From d9971738ddf54e86176c9968f3ec54eb3dbc4791 Mon Sep 17 00:00:00 2001 From: fmorton Date: Thu, 19 Apr 2018 22:31:34 -0400 Subject: [PATCH 01/29] add get_view/get_pathname support --- mu/logic.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/mu/logic.py b/mu/logic.py index bcf21217e..b8641582a 100644 --- a/mu/logic.py +++ b/mu/logic.py @@ -351,7 +351,14 @@ def get_settings_path(): """ return get_admin_file_path('settings.json') - +def get_view(self): + return self.view + +def get_pathname(self): + view = get_view(self) + tab = get_view(self).current_tab + return tab.path + def extract_envars(raw): """ Returns a list of environment variables given a string containing @@ -453,7 +460,6 @@ def check_pycodestyle(code): }) return style_feedback - class MuFlakeCodeReporter: """ The class instantiates a reporter that creates structured data about From 4407b0b5339361d40d4427d232b10597c97ac02d Mon Sep 17 00:00:00 2001 From: fmorton Date: Wed, 13 Jun 2018 20:08:15 -0400 Subject: [PATCH 02/29] add circuit python "run" support --- mu/interface/main.py | 2 ++ mu/logic.py | 16 +++++++++++++--- mu/modes/adafruit.py | 35 +++++++++++++++++++++++++++++++++-- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/mu/interface/main.py b/mu/interface/main.py index 9238856de..d61858a96 100644 --- a/mu/interface/main.py +++ b/mu/interface/main.py @@ -65,6 +65,8 @@ def reset(self): self.clear() def change_mode(self, mode): + print("DEBUG: get the mode") + print(mode) self.reset() self.addAction(name="modes", display_name=_("Mode"), tool_text=_("Change Mu's mode of behaviour.")) diff --git a/mu/logic.py b/mu/logic.py index b8641582a..3520b1d48 100644 --- a/mu/logic.py +++ b/mu/logic.py @@ -354,10 +354,15 @@ def get_settings_path(): def get_view(self): return self.view +def get_tab(self): + #########view = get_view(self) + return get_view(self).current_tab + def get_pathname(self): - view = get_view(self) - tab = get_view(self).current_tab - return tab.path + #########view = get_view(self) + ####tab = get_view(self).current_tab + return get_view(self).current_tab.path + ###return tab.path def extract_envars(raw): """ @@ -776,6 +781,8 @@ def load(self): path = self._view.get_load_path(self.modes[self.mode].workspace_dir()) if path: self._load(path) + print("DEBUG: DONE LOADING!!!!!!!!!!") + self.change_mode(self.mode) ####DEBUG def direct_load(self, path): """ for loading files passed from command line or the OS launch""" @@ -872,6 +879,7 @@ def get_tab(self, path): self._view.focus_tab(tab) return tab self.direct_load(path) + print("DEBUG: get_tab called!!!!!!!!!!!!") return self._view.current_tab def zoom_in(self): @@ -1023,6 +1031,8 @@ def select_mode(self, event=None): self.mode.capitalize())) def change_mode(self, mode): + print("DEBUG: MODE IN CHANGE_MODE IS ") + print(mode) """ Given the name of a mode, will make the necessary changes to put the editor into the new mode. diff --git a/mu/modes/adafruit.py b/mu/modes/adafruit.py index e3e48c54b..50dfee162 100644 --- a/mu/modes/adafruit.py +++ b/mu/modes/adafruit.py @@ -18,11 +18,12 @@ """ import os import ctypes +from shutil import copyfile from subprocess import check_output from mu.modes.base import MicroPythonMode from mu.modes.api import ADAFRUIT_APIS, SHARED_APIS from mu.interface.panes import CHARTS - +from mu.logic import get_pathname class AdafruitMode(MicroPythonMode): """ @@ -50,6 +51,7 @@ class AdafruitMode(MicroPythonMode): ] def actions(self): + print("DEBUG: INDEED LOOKING UP THE ACTIONS") """ Return an ordered list of actions provided by this module. An action is a name (also used to identify the icon) , description, and handler. @@ -62,6 +64,14 @@ def actions(self): 'handler': self.toggle_repl, 'shortcut': 'CTRL+Shift+I', }, ] + if not self.workspace_dir_on_circuitpy() and self.workspace_circuitpy_available(): + buttons.append({ + 'name': 'run', + 'display_name': _('Run'), + 'description': _('Run your current file on CIRCUITPY'), + 'handler': self.run, + 'shortcut': 'CTRL+Shift+R', + }) if CHARTS: buttons.append({ 'name': 'plotter', @@ -73,6 +83,9 @@ def actions(self): return buttons def workspace_dir(self): + ####DEBUG + #self.editor.change_mode("adafruit") + """ Return the default location on the filesystem for opening and closing files. @@ -93,7 +106,6 @@ def workspace_dir(self): next elif os.name == 'nt': # We're on Windows. - def get_volume_name(disk_name): """ Each disk or external device connected to windows has an @@ -148,6 +160,25 @@ def get_volume_name(disk_name): self.connected = False return wd + def workspace_dir_on_circuitpy(self): + return "CIRCUITPY" in str(get_pathname(self)) + + def workspace_circuitpy_available(self): + return "CIRCUITPY" in str(self.workspace_dir()) + + def run(self, event): + if not self.workspace_dir_on_circuitpy() and self.workspace_circuitpy_available(): + save_result = self.editor.save() + print("DEBUG: saved again") + + pathname = get_pathname(self) + print("SEE IF PATHNAME AVAILABLE") + print(pathname) + if pathname: + print("DEBUG: copying the pathname") + destination = self.workspace_dir() + "/code.py" + copyfile(pathname, destination) + def api(self): """ Return a list of API specifications to be used by auto-suggest and call From 9d276dc64cc1b779c119133c2a5a2563d388984f Mon Sep 17 00:00:00 2001 From: fmorton Date: Wed, 13 Jun 2018 22:51:04 -0400 Subject: [PATCH 03/29] add actions_dynamic support --- mu/interface/main.py | 2 -- mu/logic.py | 21 +++++++++------------ mu/modes/adafruit.py | 31 ++++++++++++++----------------- mu/modes/base.py | 3 +++ 4 files changed, 26 insertions(+), 31 deletions(-) diff --git a/mu/interface/main.py b/mu/interface/main.py index 981d42ffb..def25945a 100644 --- a/mu/interface/main.py +++ b/mu/interface/main.py @@ -65,8 +65,6 @@ def reset(self): self.clear() def change_mode(self, mode): - print("DEBUG: get the mode") - print(mode) self.reset() self.addAction(name="modes", display_name=_("Mode"), tool_text=_("Change Mu's mode of behaviour.")) diff --git a/mu/logic.py b/mu/logic.py index 307c1ad90..019293475 100644 --- a/mu/logic.py +++ b/mu/logic.py @@ -350,14 +350,10 @@ def get_view(self): return self.view def get_tab(self): - #########view = get_view(self) - return get_view(self).current_tab + return get_view(self).current_tab def get_pathname(self): - #########view = get_view(self) - ####tab = get_view(self).current_tab return get_view(self).current_tab.path - ###return tab.path def extract_envars(raw): """ @@ -806,8 +802,9 @@ def load(self): extensions) if path: self._load(path) - print("DEBUG: DONE LOADING!!!!!!!!!!") - self.change_mode(self.mode) ####DEBUG + + if self.modes[self.mode].actions_dynamic(): + self.change_mode(self.mode) def direct_load(self, path): """ for loading files passed from command line or the OS launch""" @@ -892,6 +889,8 @@ def save(self): else: # The user cancelled the filename selection. tab.path = None + if self.modes[self.mode].actions_dynamic(): + self.change_mode(self.mode) def get_tab(self, path): """ @@ -903,7 +902,6 @@ def get_tab(self, path): self._view.focus_tab(tab) return tab self.direct_load(path) - print("DEBUG: get_tab called!!!!!!!!!!!!") return self._view.current_tab def zoom_in(self): @@ -1056,8 +1054,6 @@ def select_mode(self, event=None): self.change_mode(new_mode) def change_mode(self, mode): - print("DEBUG: MODE IN CHANGE_MODE IS ") - print(mode) """ Given the name of a mode, will make the necessary changes to put the editor into the new mode. @@ -1099,8 +1095,9 @@ def change_mode(self, mode): for tab in self._view.widgets: tab.breakpoint_lines = set() tab.reset_annotations() - self.show_status_message(_('Changed to {} mode.').format( - mode.capitalize())) + if not self.modes[self.mode].actions_dynamic(): + self.show_status_message(_('Changed to {} mode.').format( + mode.capitalize())) def autosave(self): """ diff --git a/mu/modes/adafruit.py b/mu/modes/adafruit.py index f430c8ed1..f8376bfaa 100644 --- a/mu/modes/adafruit.py +++ b/mu/modes/adafruit.py @@ -54,8 +54,10 @@ class AdafruitMode(MicroPythonMode): (0x239A, 0x802E), # Adafruit CRICKit M0 ] + def actions_dynamic(self): + return True + def actions(self): - print("DEBUG: INDEED LOOKING UP THE ACTIONS") """ Return an ordered list of actions provided by this module. An action is a name (also used to identify the icon) , description, and handler. @@ -87,9 +89,6 @@ def actions(self): return buttons def workspace_dir(self): - ####DEBUG - #self.editor.change_mode("adafruit") - """ Return the default location on the filesystem for opening and closing files. @@ -152,15 +151,17 @@ def get_volume_name(disk_name): # after warning the user. wd = super().workspace_dir() if self.connected: - m = _('Could not find an attached Adafruit CircuitPython' - ' device.') - info = _("Python files for Adafruit CircuitPython devices" - " are stored on the device. Therefore, to edit" - " these files you need to have the device plugged in." - " Until you plug in a device, Mu will use the" - " directory found here:\n\n" - " {}\n\n...to store your code.") - self.view.show_message(m, info.format(wd)) + if self.workspace_dir_on_circuitpy() and not self.workspace_circuitpy_available(): + m = _('Could not find an attached Adafruit CircuitPython' + ' device.') + info = _("Python files for Adafruit CircuitPython devices" + " are stored on the device. Therefore, to edit" + " these files you need to have the device plugged in." + " Until you plug in a device, Mu will use the" + " directory found here:\n\n" + " {}\n\n...to store your code.") + self.view.show_message(m, info.format(wd)) + self.connected = False return wd @@ -173,13 +174,9 @@ def workspace_circuitpy_available(self): def run(self, event): if not self.workspace_dir_on_circuitpy() and self.workspace_circuitpy_available(): save_result = self.editor.save() - print("DEBUG: saved again") pathname = get_pathname(self) - print("SEE IF PATHNAME AVAILABLE") - print(pathname) if pathname: - print("DEBUG: copying the pathname") destination = self.workspace_dir() + "/code.py" copyfile(pathname, destination) diff --git a/mu/modes/base.py b/mu/modes/base.py index f1f51f856..48c91c68f 100644 --- a/mu/modes/base.py +++ b/mu/modes/base.py @@ -93,6 +93,9 @@ def __init__(self, editor, view): self.view = view super().__init__() + def actions_dynamic(self): + return False + def actions(self): """ Return an ordered list of actions provided by this module. An action From 930854819f4762c5c6d34d10870c9b838329b589 Mon Sep 17 00:00:00 2001 From: fmorton Date: Thu, 14 Jun 2018 12:03:03 -0400 Subject: [PATCH 04/29] add adafruit run support --- mu/logic.py | 11 ++++++++--- mu/modes/adafruit.py | 17 +++++++++-------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/mu/logic.py b/mu/logic.py index 85493375a..ecd5565ea 100644 --- a/mu/logic.py +++ b/mu/logic.py @@ -346,15 +346,19 @@ def get_settings_path(): """ return get_admin_file_path('settings.json') + def get_view(self): return self.view + def get_tab(self): return get_view(self).current_tab - + + def get_pathname(self): return get_view(self).current_tab.path - + + def extract_envars(raw): """ Returns a list of environment variables given a string containing @@ -456,6 +460,7 @@ def check_pycodestyle(code): }) return style_feedback + class MuFlakeCodeReporter: """ The class instantiates a reporter that creates structured data about @@ -931,7 +936,7 @@ def check_code(self): if tab.has_annotations: logger.info('Checking code.') self._view.reset_annotations() - filename = tab.path if tab.path else _('untitled') + filename = tab.path if tab.path else 'untitled' builtins = self.modes[self.mode].builtins flake = check_flake(filename, tab.text(), builtins) if flake: diff --git a/mu/modes/adafruit.py b/mu/modes/adafruit.py index f8376bfaa..b192777b5 100644 --- a/mu/modes/adafruit.py +++ b/mu/modes/adafruit.py @@ -25,6 +25,7 @@ from mu.interface.panes import CHARTS from mu.logic import get_pathname + class AdafruitMode(MicroPythonMode): """ Represents the functionality required by the Adafruit mode. @@ -70,7 +71,7 @@ def actions(self): 'handler': self.toggle_repl, 'shortcut': 'CTRL+Shift+S', }, ] - if not self.workspace_dir_on_circuitpy() and self.workspace_circuitpy_available(): + if not self.workspace_dir_cp() and self.workspace_cp_avail(): buttons.append({ 'name': 'run', 'display_name': _('Run'), @@ -151,13 +152,13 @@ def get_volume_name(disk_name): # after warning the user. wd = super().workspace_dir() if self.connected: - if self.workspace_dir_on_circuitpy() and not self.workspace_circuitpy_available(): + if self.workspace_dir_cp() and not self.workspace_cp_avail(): m = _('Could not find an attached Adafruit CircuitPython' ' device.') info = _("Python files for Adafruit CircuitPython devices" " are stored on the device. Therefore, to edit" - " these files you need to have the device plugged in." - " Until you plug in a device, Mu will use the" + " these files you need to have the device plugged" + " in. Until you plug in a device, Mu will use the" " directory found here:\n\n" " {}\n\n...to store your code.") self.view.show_message(m, info.format(wd)) @@ -165,15 +166,15 @@ def get_volume_name(disk_name): self.connected = False return wd - def workspace_dir_on_circuitpy(self): + def workspace_dir_cp(self): return "CIRCUITPY" in str(get_pathname(self)) - def workspace_circuitpy_available(self): + def workspace_cp_avail(self): return "CIRCUITPY" in str(self.workspace_dir()) def run(self, event): - if not self.workspace_dir_on_circuitpy() and self.workspace_circuitpy_available(): - save_result = self.editor.save() + if not self.workspace_dir_cp() and self.workspace_cp_avail(): + self.editor.save() pathname = get_pathname(self) if pathname: From c1a6c93c3d44c44e88c5d7ba260b78ea7f8657b7 Mon Sep 17 00:00:00 2001 From: fmorton Date: Wed, 20 Jun 2018 15:52:27 -0400 Subject: [PATCH 05/29] add adafruit run support without actions_dynamic support --- mu/logic.py | 10 ++------- mu/modes/adafruit.py | 51 +++++++++++++++++++++++--------------------- mu/modes/base.py | 3 --- 3 files changed, 29 insertions(+), 35 deletions(-) diff --git a/mu/logic.py b/mu/logic.py index 857f13b28..f63b3a64c 100644 --- a/mu/logic.py +++ b/mu/logic.py @@ -809,9 +809,6 @@ def load(self): if path: self._load(path) - if self.modes[self.mode].actions_dynamic(): - self.change_mode(self.mode) - def direct_load(self, path): """ for loading files passed from command line or the OS launch""" self._load(path) @@ -895,8 +892,6 @@ def save(self): else: # The user cancelled the filename selection. tab.path = None - if self.modes[self.mode].actions_dynamic(): - self.change_mode(self.mode) def get_tab(self, path): """ @@ -1104,9 +1099,8 @@ def change_mode(self, mode): for tab in self._view.widgets: tab.breakpoint_handles = set() tab.reset_annotations() - if not self.modes[self.mode].actions_dynamic(): - self.show_status_message(_('Changed to {} mode.').format( - mode.capitalize())) + self.show_status_message(_('Changed to {} mode.').format( + mode.capitalize())) def autosave(self): """ diff --git a/mu/modes/adafruit.py b/mu/modes/adafruit.py index b192777b5..69a32e211 100644 --- a/mu/modes/adafruit.py +++ b/mu/modes/adafruit.py @@ -55,9 +55,6 @@ class AdafruitMode(MicroPythonMode): (0x239A, 0x802E), # Adafruit CRICKit M0 ] - def actions_dynamic(self): - return True - def actions(self): """ Return an ordered list of actions provided by this module. An action @@ -71,14 +68,13 @@ def actions(self): 'handler': self.toggle_repl, 'shortcut': 'CTRL+Shift+S', }, ] - if not self.workspace_dir_cp() and self.workspace_cp_avail(): - buttons.append({ - 'name': 'run', - 'display_name': _('Run'), - 'description': _('Run your current file on CIRCUITPY'), - 'handler': self.run, - 'shortcut': 'CTRL+Shift+R', - }) + buttons.append({ + 'name': 'run', + 'display_name': _('Run'), + 'description': _('Save and run your current file on CIRCUITPY'), + 'handler': self.run, + 'shortcut': 'CTRL+Shift+R', + }) if CHARTS: buttons.append({ 'name': 'plotter', @@ -152,30 +148,37 @@ def get_volume_name(disk_name): # after warning the user. wd = super().workspace_dir() if self.connected: - if self.workspace_dir_cp() and not self.workspace_cp_avail(): - m = _('Could not find an attached Adafruit CircuitPython' - ' device.') - info = _("Python files for Adafruit CircuitPython devices" - " are stored on the device. Therefore, to edit" - " these files you need to have the device plugged" - " in. Until you plug in a device, Mu will use the" - " directory found here:\n\n" - " {}\n\n...to store your code.") - self.view.show_message(m, info.format(wd)) - + m = _('Could not find an attached Adafruit CircuitPython' + ' device.') + info = _("Python files for Adafruit CircuitPython devices" + " are stored on the device. Therefore, to edit" + " these files you need to have the device plugged" + " in. Until you plug in a device, Mu will use the" + " directory found here to store new files:\n\n" + " {}\n\n...to store your code.") + self.view.show_message(m, info.format(wd)) self.connected = False return wd def workspace_dir_cp(self): + """ + Is the file currently being edited located on CIRCUITPY. + """ return "CIRCUITPY" in str(get_pathname(self)) def workspace_cp_avail(self): + """ + Is CIRCUITPY available. + """ return "CIRCUITPY" in str(self.workspace_dir()) def run(self, event): - if not self.workspace_dir_cp() and self.workspace_cp_avail(): - self.editor.save() + """ + Save the file and copy to CIRCUITPY if not already there and available. + """ + self.editor.save() + if not self.workspace_dir_cp() and self.workspace_cp_avail(): pathname = get_pathname(self) if pathname: destination = self.workspace_dir() + "/code.py" diff --git a/mu/modes/base.py b/mu/modes/base.py index 48c91c68f..f1f51f856 100644 --- a/mu/modes/base.py +++ b/mu/modes/base.py @@ -93,9 +93,6 @@ def __init__(self, editor, view): self.view = view super().__init__() - def actions_dynamic(self): - return False - def actions(self): """ Return an ordered list of actions provided by this module. An action From 17d9b25724bea005f800e38e20dafd84ddf07a91 Mon Sep 17 00:00:00 2001 From: fmorton Date: Mon, 25 Jun 2018 10:15:55 -0400 Subject: [PATCH 06/29] add adafruit "run" admin support --- mu/interface/dialogs.py | 22 ++++++++++++++++++++++ mu/logic.py | 8 ++++++++ mu/modes/adafruit.py | 28 +++++++++++++++------------- tests/interface/test_dialogs.py | 12 ++++++++++++ tests/modes/test_adafruit.py | 12 +++++++----- tests/test_logic.py | 11 +++++++++-- 6 files changed, 73 insertions(+), 20 deletions(-) diff --git a/mu/interface/dialogs.py b/mu/interface/dialogs.py index 2efcbafeb..9212481fa 100644 --- a/mu/interface/dialogs.py +++ b/mu/interface/dialogs.py @@ -163,6 +163,24 @@ def setup(self, minify, custom_runtime_path): widget_layout.addStretch() +class AdafruitSettingsWidget(QWidget): + """ + Used for configuring how to interact with adafruit mode: + + * Enable the "Run" button. + """ + + def setup(self, adafruit_run): + widget_layout = QVBoxLayout() + self.setLayout(widget_layout) + self.adafruit_run = QCheckBox(_('Enable the "Run" button to ' + 'save and copy the current ' + 'file to CIRCUITPY?')) + self.adafruit_run.setChecked(adafruit_run) + widget_layout.addWidget(self.adafruit_run) + widget_layout.addStretch() + + class AdminDialog(QDialog): """ Displays administrative related information and settings (logs, environment @@ -197,6 +215,9 @@ def setup(self, log, settings, theme): self.microbit_widget.setup(settings.get('minify', False), settings.get('microbit_runtime', '')) self.tabs.addTab(self.microbit_widget, _('BBC micro:bit Settings')) + self.adafruit_widget = AdafruitSettingsWidget() + self.adafruit_widget.setup(settings.get('adafruit_run', False)) + self.tabs.addTab(self.adafruit_widget, _('Adafruit Settings')) def settings(self): """ @@ -208,4 +229,5 @@ def settings(self): 'envars': self.envar_widget.text_area.toPlainText(), 'minify': self.microbit_widget.minify.isChecked(), 'microbit_runtime': self.microbit_widget.runtime_path.text(), + 'adafruit_run': self.adafruit_widget.adafruit_run.isChecked() } diff --git a/mu/logic.py b/mu/logic.py index f63b3a64c..04e27fe81 100644 --- a/mu/logic.py +++ b/mu/logic.py @@ -561,6 +561,7 @@ def __init__(self, view, status_bar=None): self.envars = [] # See restore session and show_admin self.minify = False self.microbit_runtime = '' + self.adafruit_run = False self.connected_devices = set() if not os.path.exists(DATA_DIR): logger.debug('Creating directory: {}'.format(DATA_DIR)) @@ -671,6 +672,10 @@ def restore_session(self, paths=None): logger.warning('The specified micro:bit runtime ' 'does not exist. Using default ' 'runtime instead.') + if 'adafruit_run' in old_session: + self.adafruit_run = old_session['adafruit_run'] + logger.info('Enable Adafruit "Run" button? ' + '{}'.format(self.adafruit_run)) # handle os passed file last, # so it will not be focused over by another tab if paths and len(paths) > 0: @@ -1005,6 +1010,7 @@ def quit(self, *args, **kwargs): 'envars': self.envars, 'minify': self.minify, 'microbit_runtime': self.microbit_runtime, + 'adafruit_run': self.adafruit_run, } session_path = get_session_path() with open(session_path, 'w') as out: @@ -1027,12 +1033,14 @@ def show_admin(self, event=None): 'envars': envars, 'minify': self.minify, 'microbit_runtime': self.microbit_runtime, + 'adafruit_run': self.adafruit_run, } with open(LOG_FILE, 'r', encoding='utf8') as logfile: new_settings = self._view.show_admin(logfile.read(), settings, self.theme) self.envars = extract_envars(new_settings['envars']) self.minify = new_settings['minify'] + self.adafruit_run = new_settings['adafruit_run'] runtime = new_settings['microbit_runtime'].strip() if runtime and not os.path.isfile(runtime): self.microbit_runtime = '' diff --git a/mu/modes/adafruit.py b/mu/modes/adafruit.py index 69a32e211..98d6b6b8f 100644 --- a/mu/modes/adafruit.py +++ b/mu/modes/adafruit.py @@ -60,20 +60,22 @@ def actions(self): Return an ordered list of actions provided by this module. An action is a name (also used to identify the icon) , description, and handler. """ - buttons = [ - { - 'name': 'serial', - 'display_name': _('Serial'), - 'description': _('Open a serial connection to your device.'), - 'handler': self.toggle_repl, - 'shortcut': 'CTRL+Shift+S', - }, ] + buttons = [] + if self.editor.adafruit_run: + buttons.append({ + 'name': 'run', + 'display_name': _('Run'), + 'description': _('Save and run your current file ' + 'on CIRCUITPY'), + 'handler': self.run, + 'shortcut': 'CTRL+Shift+R', + }) buttons.append({ - 'name': 'run', - 'display_name': _('Run'), - 'description': _('Save and run your current file on CIRCUITPY'), - 'handler': self.run, - 'shortcut': 'CTRL+Shift+R', + 'name': 'serial', + 'display_name': _('Serial'), + 'description': _('Open a serial connection to your device.'), + 'handler': self.toggle_repl, + 'shortcut': 'CTRL+Shift+S', }) if CHARTS: buttons.append({ diff --git a/tests/interface/test_dialogs.py b/tests/interface/test_dialogs.py index b0a98c18c..919b9c56f 100644 --- a/tests/interface/test_dialogs.py +++ b/tests/interface/test_dialogs.py @@ -171,6 +171,17 @@ def test_MicrobitSettingsWidget_setup(): assert mbsw.runtime_path.text() == '/foo/bar' +def test_AdafruitSettingsWidget_setup(): + """ + Ensure the widget for editing settings related to adafruit mode + displays the referenced settings data in the expected way. + """ + adafruit_run = True + mbsw = mu.interface.dialogs.AdafruitSettingsWidget() + mbsw.setup(adafruit_run) + assert mbsw.adafruit_run.isChecked() + + def test_AdminDialog_setup(): """ Ensure the admin dialog is setup properly given the content of a log @@ -181,6 +192,7 @@ def test_AdminDialog_setup(): 'envars': 'name=value', 'minify': True, 'microbit_runtime': '/foo/bar', + 'adafruit_run': True } ad = mu.interface.dialogs.AdminDialog() ad.setStyleSheet = mock.MagicMock() diff --git a/tests/modes/test_adafruit.py b/tests/modes/test_adafruit.py index 1c581ecbd..e2dcf742d 100644 --- a/tests/modes/test_adafruit.py +++ b/tests/modes/test_adafruit.py @@ -23,11 +23,13 @@ def test_adafruit_mode(): assert am.view == view actions = am.actions() - assert len(actions) == 2 - assert actions[0]['name'] == 'serial' - assert actions[0]['handler'] == am.toggle_repl - assert actions[1]['name'] == 'plotter' - assert actions[1]['handler'] == am.toggle_plotter + assert len(actions) == 3 + assert actions[0]['name'] == 'run' + assert actions[0]['handler'] == am.run + assert actions[1]['name'] == 'serial' + assert actions[1]['handler'] == am.toggle_repl + assert actions[2]['name'] == 'plotter' + assert actions[3]['handler'] == am.toggle_plotter def test_adafruit_mode_no_charts(): diff --git a/tests/test_logic.py b/tests/test_logic.py index 6d2c8fe07..383afa6e6 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -89,7 +89,7 @@ def generate_python_file(text="", dirpath=None): @contextlib.contextmanager def generate_session(theme="day", mode="python", file_contents=None, filepath=None, envars=[['name', 'value'], ], minify=False, - microbit_runtime=None, **kwargs): + microbit_runtime=None, adafruit_run=False, **kwargs): """Generate a temporary session file for one test By default, the session file will be created inside a temporary directory @@ -131,6 +131,8 @@ def generate_session(theme="day", mode="python", file_contents=None, session_data['minify'] = minify if microbit_runtime: session_data['microbit_runtime'] = microbit_runtime + if adafruit_run is not None: + session_data['adafruit_run'] = adafruit_run session_data.update(**kwargs) if filepath is None: @@ -655,6 +657,7 @@ def test_editor_restore_session_existing_runtime(): assert ed.envars == [['name', 'value'], ] assert ed.minify is False assert ed.microbit_runtime == '/foo' + assert ed.adafruit_run is False def test_editor_restore_session_missing_runtime(): @@ -675,6 +678,7 @@ def test_editor_restore_session_missing_runtime(): assert ed.envars == [['name', 'value'], ] assert ed.minify is False assert ed.microbit_runtime == '' # File does not exist so set to '' + assert ed.adafruit_run is False def test_editor_restore_session_missing_files(): @@ -1720,10 +1724,12 @@ def test_show_admin(): ed.envars = [['name', 'value'], ] ed.minify = True ed.microbit_runtime = '/foo/bar' + ed.adafruit_run = True settings = { 'envars': 'name=value', 'minify': True, - 'microbit_runtime': '/foo/bar' + 'microbit_runtime': '/foo/bar', + 'adafruit_run': True } view.show_admin.return_value = settings mock_open = mock.mock_open() @@ -1737,6 +1743,7 @@ def test_show_admin(): assert ed.envars == [['name', 'value']] assert ed.minify is True assert ed.microbit_runtime == '/foo/bar' + assert ed.adafruit_run is True def test_show_admin_missing_microbit_runtime(): From 1af1117ec37079c58d1d12a8af5c1e0f1bae5b53 Mon Sep 17 00:00:00 2001 From: fmorton Date: Tue, 26 Jun 2018 09:58:55 -0400 Subject: [PATCH 07/29] fix typo on test_adafruit.py --- tests/modes/test_adafruit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/modes/test_adafruit.py b/tests/modes/test_adafruit.py index e2dcf742d..26aa7e908 100644 --- a/tests/modes/test_adafruit.py +++ b/tests/modes/test_adafruit.py @@ -29,7 +29,7 @@ def test_adafruit_mode(): assert actions[1]['name'] == 'serial' assert actions[1]['handler'] == am.toggle_repl assert actions[2]['name'] == 'plotter' - assert actions[3]['handler'] == am.toggle_plotter + assert actions[2]['handler'] == am.toggle_plotter def test_adafruit_mode_no_charts(): From f7bae4cf94945765df233a70b980609a46b031a8 Mon Sep 17 00:00:00 2001 From: fmorton Date: Tue, 26 Jun 2018 10:06:00 -0400 Subject: [PATCH 08/29] add adafruit "run" admin support/fix test --- tests/modes/test_adafruit.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/modes/test_adafruit.py b/tests/modes/test_adafruit.py index 26aa7e908..05bc51899 100644 --- a/tests/modes/test_adafruit.py +++ b/tests/modes/test_adafruit.py @@ -41,9 +41,11 @@ def test_adafruit_mode_no_charts(): am = AdafruitMode(editor, view) with mock.patch('mu.modes.adafruit.CHARTS', False): actions = am.actions() - assert len(actions) == 1 - assert actions[0]['name'] == 'serial' - assert actions[0]['handler'] == am.toggle_repl + assert len(actions) == 2 + assert actions[0]['name'] == 'run' + assert actions[0]['handler'] == am.run + assert actions[1]['name'] == 'serial' + assert actions[1]['handler'] == am.toggle_repl def test_workspace_dir_posix_exists(): From fe4de5db35a5e9a91970b22b79817da679dfde10 Mon Sep 17 00:00:00 2001 From: fmorton Date: Tue, 26 Jun 2018 10:14:43 -0400 Subject: [PATCH 09/29] add adafruit "run" admin support/fix test --- tests/test_logic.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_logic.py b/tests/test_logic.py index 383afa6e6..d22a14493 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -1756,10 +1756,12 @@ def test_show_admin_missing_microbit_runtime(): ed.envars = [['name', 'value'], ] ed.minify = True ed.microbit_runtime = '/foo/bar' + ed.adafruit_run = True settings = { 'envars': 'name=value', 'minify': True, - 'microbit_runtime': '/foo/bar' + 'microbit_runtime': '/foo/bar', + 'adafruit_run': True } view.show_admin.return_value = settings mock_open = mock.mock_open() From 9ee45c92b7b9687d16e200376762216cd4cb4737 Mon Sep 17 00:00:00 2001 From: fmorton Date: Thu, 20 Sep 2018 11:33:40 -0400 Subject: [PATCH 10/29] add adafruit run support --- tests/modes/test_adafruit.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/tests/modes/test_adafruit.py b/tests/modes/test_adafruit.py index ab62cb810..907339782 100644 --- a/tests/modes/test_adafruit.py +++ b/tests/modes/test_adafruit.py @@ -23,7 +23,7 @@ def test_adafruit_mode(): assert am.view == view actions = am.actions() -# <<<<<<< HEAD + assert len(actions) == 3 assert actions[0]['name'] == 'run' assert actions[0]['handler'] == am.run @@ -31,14 +31,6 @@ def test_adafruit_mode(): assert actions[1]['handler'] == am.toggle_repl assert actions[2]['name'] == 'plotter' assert actions[2]['handler'] == am.toggle_plotter -# ======= -# assert len(actions) == 2 -# assert actions[0]['name'] == 'serial' -# assert actions[0]['handler'] == am.toggle_repl -# assert actions[1]['name'] == 'plotter' -# assert actions[1]['handler'] == am.toggle_plotter -# assert 'code' not in am.module_names -# >>>>>>> bd722c72f63bd0d6f51847e82852bf0638c22002 def test_adafruit_mode_no_charts(): From a9c8856f64ee3bfe4a8525f50e9fd110323ce52c Mon Sep 17 00:00:00 2001 From: fmorton Date: Thu, 20 Sep 2018 13:06:08 -0400 Subject: [PATCH 11/29] add adafruit run support --- mu/modes/adafruit.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/mu/modes/adafruit.py b/mu/modes/adafruit.py index cfd74be10..e6ca2b464 100644 --- a/mu/modes/adafruit.py +++ b/mu/modes/adafruit.py @@ -68,14 +68,6 @@ def actions(self): is a name (also used to identify the icon) , description, and handler. """ buttons = [ - { - 'name': 'run', - 'display_name': _('Run'), - 'description': _('Save and run your current file ' - 'on CIRCUITPY'), - 'handler': self.run, - 'shortcut': 'CTRL+Shift+R', - }, { 'name': 'serial', 'display_name': _('Serial'), @@ -83,6 +75,15 @@ def actions(self): 'handler': self.toggle_repl, 'shortcut': 'CTRL+Shift+U', }, ] + if self.editor.adafruit_run: + buttons.insert(0, { + 'name': 'run', + 'display_name': _('Run'), + 'description': _('Save and run your current file ' + 'on CIRCUITPY'), + 'handler': self.run, + 'shortcut': 'CTRL+Shift+R', + }) if CHARTS: buttons.append({ 'name': 'plotter', From 63d2d2e782d14aacc5a7053cf2a52a24fd64658f Mon Sep 17 00:00:00 2001 From: fmorton Date: Thu, 20 Sep 2018 13:42:21 -0400 Subject: [PATCH 12/29] show/hide adafruit "run" button after admin changes --- mu/logic.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mu/logic.py b/mu/logic.py index eb9e45a18..9e454c192 100644 --- a/mu/logic.py +++ b/mu/logic.py @@ -1066,6 +1066,9 @@ def show_admin(self, event=None): self.envars = extract_envars(new_settings['envars']) self.minify = new_settings['minify'] self.adafruit_run = new_settings['adafruit_run'] + # show/hide adafruit "run" button potentially changed in admin + if self.mode == 'adafruit': + self.change_mode(self.mode) runtime = new_settings['microbit_runtime'].strip() if runtime and not os.path.isfile(runtime): self.microbit_runtime = '' From 639e08af7d232c07ff17e996765c1e656d8da950 Mon Sep 17 00:00:00 2001 From: fmorton Date: Mon, 15 Oct 2018 22:35:26 -0400 Subject: [PATCH 13/29] add adafruit library support --- mu/modes/adafruit.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/mu/modes/adafruit.py b/mu/modes/adafruit.py index e6ca2b464..93800ce9b 100644 --- a/mu/modes/adafruit.py +++ b/mu/modes/adafruit.py @@ -17,6 +17,7 @@ along with this program. If not, see . """ import os +import time import ctypes from shutil import copyfile from subprocess import check_output @@ -190,7 +191,36 @@ def run(self, event): if not self.workspace_dir_cp() and self.workspace_cp_avail(): pathname = get_pathname(self) if pathname: - destination = self.workspace_dir() + "/code.py" + dst_dir = self.workspace_dir() + destination = dst_dir + "/code.py" + + # copy library files on to device if not working on the device + lib_dir = os.path.dirname(pathname) + "/lib" + if os.path.isdir(lib_dir): + replace_cnt = 0 + for root, dirs, files in os.walk(lib_dir): + for filename in files: + src_lib = lib_dir + "/" + filename + dst_lib_dir = dst_dir + "/lib" + dst_lib = dst_lib_dir + "/" + filename + if not os.path.exists(dst_lib): + replace_lib = True + else: + src_tm = time.ctime(os.path.getmtime(src_lib)) + dst_tm = time.ctime(os.path.getmtime(dst_lib)) + replace_lib = (src_tm > dst_tm) + if replace_lib: + if replace_cnt == 0: + if not os.path.exists(dst_lib_dir): + os.makedirs(dst_lib_dir) + copyfile(src_lib, dst_lib) + replace_cnt = replace_cnt + 1 + + # let libraries load before copying source main source file + if replace_cnt > 0: + time.sleep(5) + + # copy edited source file on to device copyfile(pathname, destination) def api(self): From ba220e5ce3505e28686c2e69d593516e61eb9890 Mon Sep 17 00:00:00 2001 From: fmorton Date: Thu, 18 Oct 2018 12:03:51 -0400 Subject: [PATCH 14/29] add run_adafruit_lib_copy support --- mu/interface/dialogs.py | 12 +++++-- mu/logic.py | 8 +++++ mu/modes/adafruit.py | 55 +++++++++++++++++++-------------- tests/interface/test_dialogs.py | 7 +++-- tests/test_logic.py | 16 ++++++++-- 5 files changed, 66 insertions(+), 32 deletions(-) diff --git a/mu/interface/dialogs.py b/mu/interface/dialogs.py index d2fce2a53..437859c7c 100644 --- a/mu/interface/dialogs.py +++ b/mu/interface/dialogs.py @@ -168,7 +168,7 @@ class AdafruitSettingsWidget(QWidget): * Enable the "Run" button. """ - def setup(self, adafruit_run): + def setup(self, adafruit_run, adafruit_lib): widget_layout = QVBoxLayout() self.setLayout(widget_layout) self.adafruit_run = QCheckBox(_('Enable the "Run" button to ' @@ -176,6 +176,10 @@ def setup(self, adafruit_run): 'file to CIRCUITPY?')) self.adafruit_run.setChecked(adafruit_run) widget_layout.addWidget(self.adafruit_run) + self.adafruit_lib = QCheckBox(_('Enable the copy library to ' + 'CIRCUITPY function?')) + self.adafruit_lib.setChecked(adafruit_lib) + widget_layout.addWidget(self.adafruit_lib) widget_layout.addStretch() @@ -211,7 +215,8 @@ def setup(self, log, settings): settings.get('microbit_runtime', '')) self.tabs.addTab(self.microbit_widget, _('BBC micro:bit Settings')) self.adafruit_widget = AdafruitSettingsWidget() - self.adafruit_widget.setup(settings.get('adafruit_run', False)) + self.adafruit_widget.setup(settings.get('adafruit_run', False), + settings.get('adafruit_lib', False)) self.tabs.addTab(self.adafruit_widget, _('Adafruit Settings')) def settings(self): @@ -224,7 +229,8 @@ def settings(self): 'envars': self.envar_widget.text_area.toPlainText(), 'minify': self.microbit_widget.minify.isChecked(), 'microbit_runtime': self.microbit_widget.runtime_path.text(), - 'adafruit_run': self.adafruit_widget.adafruit_run.isChecked() + 'adafruit_run': self.adafruit_widget.adafruit_run.isChecked(), + 'adafruit_lib': self.adafruit_widget.adafruit_lib.isChecked() } diff --git a/mu/logic.py b/mu/logic.py index f17adf3e1..79d37505c 100644 --- a/mu/logic.py +++ b/mu/logic.py @@ -560,6 +560,7 @@ def __init__(self, view, status_bar=None): self.minify = False self.microbit_runtime = '' self.adafruit_run = False + self.adafruit_lib = False self.connected_devices = set() self.find = '' self.replace = '' @@ -678,6 +679,10 @@ def restore_session(self, paths=None): self.adafruit_run = old_session['adafruit_run'] logger.info('Enable Adafruit "Run" button? ' '{}'.format(self.adafruit_run)) + if 'adafruit_lib' in old_session: + self.adafruit_lib = old_session['adafruit_lib'] + logger.info('Enable Adafruit copy library function? ' + '{}'.format(self.adafruit_lib)) # handle os passed file last, # so it will not be focused over by another tab if paths and len(paths) > 0: @@ -1039,6 +1044,7 @@ def quit(self, *args, **kwargs): 'minify': self.minify, 'microbit_runtime': self.microbit_runtime, 'adafruit_run': self.adafruit_run, + 'adafruit_lib': self.adafruit_lib, } session_path = get_session_path() with open(session_path, 'w') as out: @@ -1062,12 +1068,14 @@ def show_admin(self, event=None): 'minify': self.minify, 'microbit_runtime': self.microbit_runtime, 'adafruit_run': self.adafruit_run, + 'adafruit_lib': self.adafruit_lib, } with open(LOG_FILE, 'r', encoding='utf8') as logfile: new_settings = self._view.show_admin(logfile.read(), settings) self.envars = extract_envars(new_settings['envars']) self.minify = new_settings['minify'] self.adafruit_run = new_settings['adafruit_run'] + self.adafruit_lib = new_settings['adafruit_lib'] # show/hide adafruit "run" button potentially changed in admin if self.mode == 'adafruit': self.change_mode(self.mode) diff --git a/mu/modes/adafruit.py b/mu/modes/adafruit.py index 93800ce9b..5ffde68e3 100644 --- a/mu/modes/adafruit.py +++ b/mu/modes/adafruit.py @@ -182,6 +182,35 @@ def workspace_cp_avail(self): """ return "CIRCUITPY" in str(self.workspace_dir()) + def run_adafruit_lib_copy(self, pathname, dst_dir): + """ + Optionally copy lib files to CIRCUITPY. + """ + lib_dir = os.path.dirname(pathname) + "/lib" + if not os.path.isdir(lib_dir): + return + replace_cnt = 0 + for root, dirs, files in os.walk(lib_dir): + for filename in files: + src_lib = lib_dir + "/" + filename + dst_lib_dir = dst_dir + "/lib" + dst_lib = dst_lib_dir + "/" + filename + if not os.path.exists(dst_lib): + replace_lib = True + else: + src_tm = time.ctime(os.path.getmtime(src_lib)) + dst_tm = time.ctime(os.path.getmtime(dst_lib)) + replace_lib = (src_tm > dst_tm) + if replace_lib: + if replace_cnt == 0: + if not os.path.exists(dst_lib_dir): + os.makedirs(dst_lib_dir) + copyfile(src_lib, dst_lib) + replace_cnt = replace_cnt + 1 + # let libraries load before copying source main source file + if replace_cnt > 0: + time.sleep(3) + def run(self, event): """ Save the file and copy to CIRCUITPY if not already there and available. @@ -195,30 +224,8 @@ def run(self, event): destination = dst_dir + "/code.py" # copy library files on to device if not working on the device - lib_dir = os.path.dirname(pathname) + "/lib" - if os.path.isdir(lib_dir): - replace_cnt = 0 - for root, dirs, files in os.walk(lib_dir): - for filename in files: - src_lib = lib_dir + "/" + filename - dst_lib_dir = dst_dir + "/lib" - dst_lib = dst_lib_dir + "/" + filename - if not os.path.exists(dst_lib): - replace_lib = True - else: - src_tm = time.ctime(os.path.getmtime(src_lib)) - dst_tm = time.ctime(os.path.getmtime(dst_lib)) - replace_lib = (src_tm > dst_tm) - if replace_lib: - if replace_cnt == 0: - if not os.path.exists(dst_lib_dir): - os.makedirs(dst_lib_dir) - copyfile(src_lib, dst_lib) - replace_cnt = replace_cnt + 1 - - # let libraries load before copying source main source file - if replace_cnt > 0: - time.sleep(5) + if self.editor.adafruit_lib: + self.run_adafruit_lib_copy(pathname, dst_dir) # copy edited source file on to device copyfile(pathname, destination) diff --git a/tests/interface/test_dialogs.py b/tests/interface/test_dialogs.py index 97c835be8..d550c89bf 100644 --- a/tests/interface/test_dialogs.py +++ b/tests/interface/test_dialogs.py @@ -139,9 +139,11 @@ def test_AdafruitSettingsWidget_setup(): displays the referenced settings data in the expected way. """ adafruit_run = True + adafruit_lib = True mbsw = mu.interface.dialogs.AdafruitSettingsWidget() - mbsw.setup(adafruit_run) + mbsw.setup(adafruit_run, adafruit_lib) assert mbsw.adafruit_run.isChecked() + assert mbsw.adafruit_lib.isChecked() def test_AdminDialog_setup(): @@ -154,7 +156,8 @@ def test_AdminDialog_setup(): 'envars': 'name=value', 'minify': True, 'microbit_runtime': '/foo/bar', - 'adafruit_run': True + 'adafruit_run': True, + 'adafruit_lib': True } mock_window = QWidget() ad = mu.interface.dialogs.AdminDialog(mock_window) diff --git a/tests/test_logic.py b/tests/test_logic.py index 557b6f058..635941206 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -89,7 +89,8 @@ def generate_python_file(text="", dirpath=None): @contextlib.contextmanager def generate_session(theme="day", mode="python", file_contents=None, filepath=None, envars=[['name', 'value'], ], minify=False, - microbit_runtime=None, adafruit_run=False, **kwargs): + microbit_runtime=None, adafruit_run=False, + adafruit_lib=False, **kwargs): """Generate a temporary session file for one test By default, the session file will be created inside a temporary directory @@ -133,6 +134,8 @@ def generate_session(theme="day", mode="python", file_contents=None, session_data['microbit_runtime'] = microbit_runtime if adafruit_run is not None: session_data['adafruit_run'] = adafruit_run + if adafruit_lib is not None: + session_data['adafruit_lib'] = adafruit_lib session_data.update(**kwargs) if filepath is None: @@ -684,6 +687,7 @@ def test_editor_restore_session_existing_runtime(): assert ed.minify is False assert ed.microbit_runtime == '/foo' assert ed.adafruit_run is False + assert ed.adafruit_lib is False def test_editor_restore_session_missing_runtime(): @@ -705,6 +709,7 @@ def test_editor_restore_session_missing_runtime(): assert ed.minify is False assert ed.microbit_runtime == '' # File does not exist so set to '' assert ed.adafruit_run is False + assert ed.adafruit_lib is False def test_editor_restore_session_missing_files(): @@ -1789,11 +1794,13 @@ def test_show_admin(): ed.minify = True ed.microbit_runtime = '/foo/bar' ed.adafruit_run = True + ed.adafruit_lib = True settings = { 'envars': 'name=value', 'minify': True, 'microbit_runtime': '/foo/bar', - 'adafruit_run': True + 'adafruit_run': True, + 'adafruit_lib': True } view.show_admin.return_value = settings mock_open = mock.mock_open() @@ -1808,6 +1815,7 @@ def test_show_admin(): assert ed.minify is True assert ed.microbit_runtime == '/foo/bar' assert ed.adafruit_run is True + assert ed.adafruit_lib is True def test_show_admin_missing_microbit_runtime(): @@ -1821,11 +1829,13 @@ def test_show_admin_missing_microbit_runtime(): ed.minify = True ed.microbit_runtime = '/foo/bar' ed.adafruit_run = True + ed.adafruit_lib = True settings = { 'envars': 'name=value', 'minify': True, 'microbit_runtime': '/foo/bar', - 'adafruit_run': True + 'adafruit_run': True, + 'adafruit_lib': True } view.show_admin.return_value = settings mock_open = mock.mock_open() From 020e4a92361d3b75e9f8af17dffebddbda1f89e6 Mon Sep 17 00:00:00 2001 From: fmorton Date: Fri, 2 Nov 2018 21:16:19 -0400 Subject: [PATCH 15/29] add run_adafruit_lib_copy support --- mu/modes/adafruit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mu/modes/adafruit.py b/mu/modes/adafruit.py index 5ffde68e3..22687bd11 100644 --- a/mu/modes/adafruit.py +++ b/mu/modes/adafruit.py @@ -209,7 +209,7 @@ def run_adafruit_lib_copy(self, pathname, dst_dir): replace_cnt = replace_cnt + 1 # let libraries load before copying source main source file if replace_cnt > 0: - time.sleep(3) + time.sleep(4) def run(self, event): """ From b0e0e70083d2140636a96078c1b0589108f1239a Mon Sep 17 00:00:00 2001 From: fmorton Date: Mon, 10 Dec 2018 22:19:36 -0500 Subject: [PATCH 16/29] add adafruit copy lib source file support --- mu/modes/adafruit.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mu/modes/adafruit.py b/mu/modes/adafruit.py index 22687bd11..542d0c47b 100644 --- a/mu/modes/adafruit.py +++ b/mu/modes/adafruit.py @@ -221,7 +221,10 @@ def run(self, event): pathname = get_pathname(self) if pathname: dst_dir = self.workspace_dir() - destination = dst_dir + "/code.py" + if pathname.find("/lib/") == -1: + destination = dst_dir + "/code.py" + else: + destination = dst_dir + "/lib/" + os.path.basename(pathname) # copy library files on to device if not working on the device if self.editor.adafruit_lib: From 96cda2a55625f2373bd9a11c4f774b51f49a6823 Mon Sep 17 00:00:00 2001 From: fmorton Date: Mon, 10 Dec 2018 22:31:00 -0500 Subject: [PATCH 17/29] shorten one line for pylint --- mu/modes/adafruit.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mu/modes/adafruit.py b/mu/modes/adafruit.py index 542d0c47b..7fac4d65f 100644 --- a/mu/modes/adafruit.py +++ b/mu/modes/adafruit.py @@ -222,16 +222,16 @@ def run(self, event): if pathname: dst_dir = self.workspace_dir() if pathname.find("/lib/") == -1: - destination = dst_dir + "/code.py" + dst = dst_dir + "/code.py" else: - destination = dst_dir + "/lib/" + os.path.basename(pathname) + dst = dst_dir + "/lib/" + os.path.basename(pathname) # copy library files on to device if not working on the device if self.editor.adafruit_lib: self.run_adafruit_lib_copy(pathname, dst_dir) # copy edited source file on to device - copyfile(pathname, destination) + copyfile(pathname, dst) def api(self): """ From 97dd53490079c70970dacc0e83ee8c3487a2d969 Mon Sep 17 00:00:00 2001 From: fmorton Date: Thu, 14 Nov 2019 13:50:12 -0500 Subject: [PATCH 18/29] catch up from june 2018 --- mu/interface/dialogs.py | 370 +++++- mu/logic.py | 1062 +++++++++------ mu/modes/adafruit.py | 241 ---- mu/modes/circuitpython.py | 193 +++ tests/interface/test_dialogs.py | 416 +++++- tests/test_logic.py | 2139 ++++++++++++++++++++----------- 6 files changed, 2965 insertions(+), 1456 deletions(-) delete mode 100644 mu/modes/adafruit.py create mode 100644 mu/modes/circuitpython.py diff --git a/mu/interface/dialogs.py b/mu/interface/dialogs.py index 437859c7c..2aebc9af1 100644 --- a/mu/interface/dialogs.py +++ b/mu/interface/dialogs.py @@ -16,11 +16,26 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ +import os +import sys import logging -from PyQt5.QtCore import QSize -from PyQt5.QtWidgets import (QVBoxLayout, QListWidget, QLabel, QListWidgetItem, - QDialog, QDialogButtonBox, QPlainTextEdit, - QTabWidget, QWidget, QCheckBox, QLineEdit) +import csv +import shutil +from PyQt5.QtCore import QSize, QProcess, QTimer +from PyQt5.QtWidgets import ( + QVBoxLayout, + QListWidget, + QLabel, + QListWidgetItem, + QDialog, + QDialogButtonBox, + QPlainTextEdit, + QTabWidget, + QWidget, + QCheckBox, + QLineEdit, +) +from PyQt5.QtGui import QTextCursor from mu.resources import load_icon @@ -52,10 +67,14 @@ def __init__(self, parent=None): def setup(self, modes, current_mode): self.setMinimumSize(600, 400) - self.setWindowTitle(_('Select Mode')) + self.setWindowTitle(_("Select Mode")) widget_layout = QVBoxLayout() - label = QLabel(_('Please select the desired mode then click "OK". ' - 'Otherwise, click "Cancel".')) + label = QLabel( + _( + 'Please select the desired mode then click "OK". ' + 'Otherwise, click "Cancel".' + ) + ) label.setWordWrap(True) widget_layout.addWidget(label) self.setLayout(widget_layout) @@ -65,17 +84,23 @@ def setup(self, modes, current_mode): self.mode_list.setIconSize(QSize(48, 48)) for name, item in modes.items(): if not item.is_debugger: - litem = ModeItem(item.name, item.description, item.icon, - self.mode_list) + litem = ModeItem( + item.name, item.description, item.icon, self.mode_list + ) if item.icon == current_mode: self.mode_list.setCurrentItem(litem) self.mode_list.sortItems() - instructions = QLabel(_('Change mode at any time by clicking ' - 'the "Mode" button containing Mu\'s logo.')) + instructions = QLabel( + _( + "Change mode at any time by clicking " + 'the "Mode" button containing Mu\'s logo.' + ) + ) instructions.setWordWrap(True) widget_layout.addWidget(instructions) - button_box = QDialogButtonBox(QDialogButtonBox.Ok | - QDialogButtonBox.Cancel) + button_box = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) button_box.accepted.connect(self.accept) button_box.rejected.connect(self.reject) widget_layout.addWidget(button_box) @@ -93,7 +118,7 @@ def get_mode(self): if self.result() == QDialog.Accepted: return self.mode_list.currentItem().icon else: - raise RuntimeError('Mode change cancelled.') + raise RuntimeError("Mode change cancelled.") class LogWidget(QWidget): @@ -104,8 +129,12 @@ class LogWidget(QWidget): def setup(self, log): widget_layout = QVBoxLayout() self.setLayout(widget_layout) - label = QLabel(_('When reporting a bug, copy and paste the content of ' - 'the following log file.')) + label = QLabel( + _( + "When reporting a bug, copy and paste the content of " + "the following log file." + ) + ) label.setWordWrap(True) widget_layout.addWidget(label) self.log_text_area = QPlainTextEdit() @@ -124,10 +153,14 @@ class EnvironmentVariablesWidget(QWidget): def setup(self, envars): widget_layout = QVBoxLayout() self.setLayout(widget_layout) - label = QLabel(_('The environment variables shown below will be ' - 'set each time you run a Python 3 script.\n\n' - 'Each separate enviroment variable should be on a ' - 'new line and of the form:\nNAME=VALUE')) + label = QLabel( + _( + "The environment variables shown below will be " + "set each time you run a Python 3 script.\n\n" + "Each separate enviroment variable should be on a " + "new line and of the form:\nNAME=VALUE" + ) + ) label.setWordWrap(True) widget_layout.addWidget(label) self.text_area = QPlainTextEdit() @@ -147,12 +180,16 @@ class MicrobitSettingsWidget(QWidget): def setup(self, minify, custom_runtime_path): widget_layout = QVBoxLayout() self.setLayout(widget_layout) - self.minify = QCheckBox(_('Minify Python code before flashing?')) + self.minify = QCheckBox(_("Minify Python code before flashing?")) self.minify.setChecked(minify) widget_layout.addWidget(self.minify) - label = QLabel(_('Override the built-in MicroPython runtime with ' - 'the following hex file (empty means use the ' - 'default):')) + label = QLabel( + _( + "Override the built-in MicroPython runtime with " + "the following hex file (empty means use the " + "default):" + ) + ) label.setWordWrap(True) widget_layout.addWidget(label) self.runtime_path = QLineEdit() @@ -161,63 +198,71 @@ def setup(self, minify, custom_runtime_path): widget_layout.addStretch() -class AdafruitSettingsWidget(QWidget): +class PackagesWidget(QWidget): """ - Used for configuring how to interact with adafruit mode: - - * Enable the "Run" button. + Used for editing and displaying 3rd party packages installed via pip to be + used with Python 3 mode. """ - def setup(self, adafruit_run, adafruit_lib): + def setup(self, packages): widget_layout = QVBoxLayout() self.setLayout(widget_layout) - self.adafruit_run = QCheckBox(_('Enable the "Run" button to ' - 'save and copy the current ' - 'file to CIRCUITPY?')) - self.adafruit_run.setChecked(adafruit_run) - widget_layout.addWidget(self.adafruit_run) - self.adafruit_lib = QCheckBox(_('Enable the copy library to ' - 'CIRCUITPY function?')) - self.adafruit_lib.setChecked(adafruit_lib) - widget_layout.addWidget(self.adafruit_lib) - widget_layout.addStretch() + self.text_area = QPlainTextEdit() + self.text_area.setLineWrapMode(QPlainTextEdit.NoWrap) + label = QLabel( + _( + "The packages shown below will be available to " + "import in Python 3 mode. Delete a package from " + "the list to remove its availability.\n\n" + "Each separate package name should be on a new " + "line. Packages are installed from PyPI " + "(see: https://pypi.org/)." + ) + ) + label.setWordWrap(True) + widget_layout.addWidget(label) + self.text_area.setPlainText(packages) + widget_layout.addWidget(self.text_area) class AdminDialog(QDialog): """ Displays administrative related information and settings (logs, environment - variables etc...). + variables, third party packages etc...). """ def __init__(self, parent=None): super().__init__(parent) - def setup(self, log, settings): + def setup(self, log, settings, packages): self.setMinimumSize(600, 400) - self.setWindowTitle(_('Mu Administration')) + self.setWindowTitle(_("Mu Administration")) widget_layout = QVBoxLayout() self.setLayout(widget_layout) self.tabs = QTabWidget() widget_layout.addWidget(self.tabs) - button_box = QDialogButtonBox(QDialogButtonBox.Ok) + button_box = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) widget_layout.addWidget(button_box) # Tabs self.log_widget = LogWidget() self.log_widget.setup(log) self.tabs.addTab(self.log_widget, _("Current Log")) self.envar_widget = EnvironmentVariablesWidget() - self.envar_widget.setup(settings.get('envars', '')) - self.tabs.addTab(self.envar_widget, _('Python3 Environment')) + self.envar_widget.setup(settings.get("envars", "")) + self.tabs.addTab(self.envar_widget, _("Python3 Environment")) self.log_widget.log_text_area.setFocus() self.microbit_widget = MicrobitSettingsWidget() - self.microbit_widget.setup(settings.get('minify', False), - settings.get('microbit_runtime', '')) - self.tabs.addTab(self.microbit_widget, _('BBC micro:bit Settings')) - self.adafruit_widget = AdafruitSettingsWidget() - self.adafruit_widget.setup(settings.get('adafruit_run', False), - settings.get('adafruit_lib', False)) - self.tabs.addTab(self.adafruit_widget, _('Adafruit Settings')) + self.microbit_widget.setup( + settings.get("minify", False), settings.get("microbit_runtime", "") + ) + self.tabs.addTab(self.microbit_widget, _("BBC micro:bit Settings")) + self.package_widget = PackagesWidget() + self.package_widget.setup(packages) + self.tabs.addTab(self.package_widget, _("Third Party Packages")) def settings(self): """ @@ -226,11 +271,10 @@ def settings(self): checked in the "logic" layer of Mu. """ return { - 'envars': self.envar_widget.text_area.toPlainText(), - 'minify': self.microbit_widget.minify.isChecked(), - 'microbit_runtime': self.microbit_widget.runtime_path.text(), - 'adafruit_run': self.adafruit_widget.adafruit_run.isChecked(), - 'adafruit_lib': self.adafruit_widget.adafruit_lib.isChecked() + "envars": self.envar_widget.text_area.toPlainText(), + "minify": self.microbit_widget.minify.isChecked(), + "microbit_runtime": self.microbit_widget.runtime_path.text(), + "packages": self.package_widget.text_area.toPlainText(), } @@ -248,27 +292,29 @@ def __init__(self, parent=None): def setup(self, find=None, replace=None, replace_flag=False): self.setMinimumSize(600, 200) - self.setWindowTitle(_('Find / Replace')) + self.setWindowTitle(_("Find / Replace")) widget_layout = QVBoxLayout() self.setLayout(widget_layout) # Find. - find_label = QLabel(_('Find:')) + find_label = QLabel(_("Find:")) self.find_term = QLineEdit() self.find_term.setText(find) + self.find_term.selectAll() widget_layout.addWidget(find_label) widget_layout.addWidget(self.find_term) # Replace - replace_label = QLabel(_('Replace (optional):')) + replace_label = QLabel(_("Replace (optional):")) self.replace_term = QLineEdit() self.replace_term.setText(replace) widget_layout.addWidget(replace_label) widget_layout.addWidget(self.replace_term) # Global replace. - self.replace_all_flag = QCheckBox(_('Replace all?')) + self.replace_all_flag = QCheckBox(_("Replace all?")) self.replace_all_flag.setChecked(replace_flag) widget_layout.addWidget(self.replace_all_flag) - button_box = QDialogButtonBox(QDialogButtonBox.Ok | - QDialogButtonBox.Cancel) + button_box = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) button_box.accepted.connect(self.accept) button_box.rejected.connect(self.reject) widget_layout.addWidget(button_box) @@ -290,3 +336,201 @@ def replace_flag(self): Return the value of the global replace flag. """ return self.replace_all_flag.isChecked() + + +class PackageDialog(QDialog): + """ + Display a dialog to indicate the status of the packaging related changes + currently run by pip. + """ + + def __init__(self, parent=None): + super().__init__(parent) + + def setup(self, to_remove, to_add, module_dir): + """ + Create the UI for the dialog. + """ + self.to_remove = to_remove + self.to_add = to_add + self.module_dir = module_dir + self.pkg_dirs = {} # To hold locations of to-be-removed packages. + self.process = None + # Basic layout. + self.setMinimumSize(600, 400) + self.setWindowTitle(_("Third Party Package Status")) + widget_layout = QVBoxLayout() + self.setLayout(widget_layout) + # Text area for pip output. + self.text_area = QPlainTextEdit() + self.text_area.setReadOnly(True) + self.text_area.setLineWrapMode(QPlainTextEdit.NoWrap) + widget_layout.addWidget(self.text_area) + # Buttons. + self.button_box = QDialogButtonBox(QDialogButtonBox.Ok) + self.button_box.button(QDialogButtonBox.Ok).setEnabled(False) + self.button_box.accepted.connect(self.accept) + widget_layout.addWidget(self.button_box) + # Kick off processing of packages. + if self.to_remove: + self.remove_packages() + if self.to_add: + self.run_pip() + + def remove_packages(self): + """ + Work out which packages need to be removed and then kick off their + removal. + """ + dirs = [ + os.path.join(self.module_dir, d) + for d in os.listdir(self.module_dir) + if d.endswith("dist-info") or d.endswith("egg-info") + ] + self.pkg_dirs = {} + for pkg in self.to_remove: + for d in dirs: + # Assets on the filesystem use a normalised package name. + pkg_name = pkg.replace("-", "_").lower() + if os.path.basename(d).lower().startswith(pkg_name + "-"): + self.pkg_dirs[pkg] = d + if self.pkg_dirs: + # If there are packages to remove, schedule removal. + QTimer.singleShot(2, self.remove_package) + + def remove_package(self): + """ + Take a package from the pending packages to be removed, delete all its + assets and schedule the removal of the remaining packages. If there are + no packages to remove, move to the finished state. + """ + if self.pkg_dirs: + package, info = self.pkg_dirs.popitem() + if info.endswith("dist-info"): + # Modern + record = os.path.join(info, "RECORD") + with open(record) as f: + files = csv.reader(f) + for row in files: + to_delete = os.path.join(self.module_dir, row[0]) + try: + os.remove(to_delete) + except Exception as ex: + logger.error("Unable to remove: " + to_delete) + logger.error(ex) + shutil.rmtree(info, ignore_errors=True) + # Some modules don't use the module name for the module + # directory (they use a lower case variant thereof). E.g. + # "Fom" vs. "fom". + normal_module = os.path.join(self.module_dir, package) + lower_module = os.path.join(self.module_dir, package.lower()) + shutil.rmtree(normal_module, ignore_errors=True) + shutil.rmtree(lower_module, ignore_errors=True) + self.append_data("Removed {}\n".format(package)) + else: + # Egg + try: + record = os.path.join(info, "installed-files.txt") + with open(record) as f: + files = f.readlines() + for row in files: + to_delete = os.path.join(info, row.strip()) + try: + os.remove(to_delete) + except Exception as ex: + logger.error("Unable to remove: " + to_delete) + logger.error(ex) + shutil.rmtree(info, ignore_errors=True) + # Some modules don't use the module name for the module + # directory (they use a lower case variant thereof). E.g. + # "Fom" vs. "fom". + normal_module = os.path.join(self.module_dir, package) + lower_module = os.path.join( + self.module_dir, package.lower() + ) + shutil.rmtree(normal_module, ignore_errors=True) + shutil.rmtree(lower_module, ignore_errors=True) + self.append_data("Removed {}\n".format(package)) + except Exception as ex: + msg = ( + "UNABLE TO REMOVE PACKAGE: {} (check the logs for" + " more information.)" + ).format(package) + self.append_data(msg) + logger.error("Unable to remove package: " + package) + logger.error(ex) + QTimer.singleShot(2, self.remove_package) + else: + # Clean any directories not containing files. + dirs = [ + os.path.join(self.module_dir, d) + for d in os.listdir(self.module_dir) + ] + for d in dirs: + keep = False + for entry in os.walk(d): + if entry[2]: + keep = True + if not keep: + shutil.rmtree(d, ignore_errors=True) + # Remove the bin directory (and anything in it) since we don't + # use these assets. + shutil.rmtree( + os.path.join(self.module_dir, "bin"), ignore_errors=True + ) + # Check for end state. + if not (self.to_add or self.process): + self.end_state() + + def end_state(self): + """ + Set the UI to a valid end state. + """ + self.append_data("\nFINISHED") + self.button_box.button(QDialogButtonBox.Ok).setEnabled(True) + + def run_pip(self): + """ + Run a pip command in a subprocess and pipe the output to the dialog's + text area. + """ + package = self.to_add.pop() + args = ["-m", "pip", "install", package, "--target", self.module_dir] + self.process = QProcess(self) + self.process.setProcessChannelMode(QProcess.MergedChannels) + self.process.readyRead.connect(self.read_process) + self.process.finished.connect(self.finished) + logger.info("{} {}".format(sys.executable, " ".join(args))) + self.process.start(sys.executable, args) + + def finished(self): + """ + Called when the subprocess that uses pip to install a package is + finished. + """ + if self.to_add: + self.process = None + self.run_pip() + else: + if not self.pkg_dirs: + self.end_state() + + def read_process(self): + """ + Read data from the child process and append it to the text area. Try + to keep reading until there's no more data from the process. + """ + data = self.process.readAll() + if data: + self.append_data(data.data().decode("utf-8")) + QTimer.singleShot(2, self.read_process) + + def append_data(self, msg): + """ + Add data to the end of the text area. + """ + cursor = self.text_area.textCursor() + cursor.movePosition(QTextCursor.End) + cursor.insertText(msg) + cursor.movePosition(QTextCursor.End) + self.text_area.setTextCursor(cursor) diff --git a/mu/logic.py b/mu/logic.py index 79d37505c..52df9d190 100644 --- a/mu/logic.py +++ b/mu/logic.py @@ -31,7 +31,9 @@ import locale import shutil import appdirs +import site from PyQt5.QtWidgets import QMessageBox +from PyQt5.QtCore import QLocale from pyflakes.api import check from pycodestyle import StyleGuide, Checker from mu.resources import path @@ -40,59 +42,95 @@ # The user's home directory. -HOME_DIRECTORY = os.path.expanduser('~') +HOME_DIRECTORY = os.path.expanduser("~") # Name of the directory within the home folder to use by default -WORKSPACE_NAME = 'mu_code' +WORKSPACE_NAME = "mu_code" # The default directory for application data (i.e., configuration). -DATA_DIR = appdirs.user_data_dir(appname='mu', appauthor='python') +DATA_DIR = appdirs.user_data_dir(appname="mu", appauthor="python") +# The directory containing user installed third party modules. +MODULE_DIR = os.path.join(DATA_DIR, "site-packages") +sys.path.append(MODULE_DIR) # The default directory for application logs. -LOG_DIR = appdirs.user_log_dir(appname='mu', appauthor='python') +LOG_DIR = appdirs.user_log_dir(appname="mu", appauthor="python") # The path to the log file for the application. -LOG_FILE = os.path.join(LOG_DIR, 'mu.log') +LOG_FILE = os.path.join(LOG_DIR, "mu.log") # Regex to match pycodestyle (PEP8) output. -STYLE_REGEX = re.compile(r'.*:(\d+):(\d+):\s+(.*)') +STYLE_REGEX = re.compile(r".*:(\d+):(\d+):\s+(.*)") # Regex to match flake8 output. -FLAKE_REGEX = re.compile(r'.*:(\d+):\s+(.*)') +FLAKE_REGEX = re.compile(r".*:(\d+):\s+(.*)") # Regex to match false positive flake errors if microbit.* is expanded. EXPAND_FALSE_POSITIVE = re.compile(r"^'microbit\.(\w+)' imported but unused$") # The text to which "from microbit import \*" should be expanded. -EXPANDED_IMPORT = ("from microbit import pin15, pin2, pin0, pin1, " - " pin3, pin6, pin4, i2c, pin5, pin7, pin8, Image, " - "pin9, pin14, pin16, reset, pin19, temperature, " - "sleep, pin20, button_a, button_b, running_time, " - "accelerometer, display, uart, spi, panic, pin13, " - "pin12, pin11, pin10, compass") +EXPANDED_IMPORT = ( + "from microbit import pin15, pin2, pin0, pin1, " + " pin3, pin6, pin4, i2c, pin5, pin7, pin8, Image, " + "pin9, pin14, pin16, reset, pin19, temperature, " + "sleep, pin20, button_a, button_b, running_time, " + "accelerometer, display, uart, spi, panic, pin13, " + "pin12, pin11, pin10, compass" +) # Port number for debugger. DEBUGGER_PORT = 31415 +# Default images to copy over for use in PyGameZero demo apps. +DEFAULT_IMAGES = [ + "alien.png", + "alien_hurt.png", + "cat1.png", + "cat2.png", + "cat3.png", + "cat4.png", + "splat.png", +] +# Default sound effects to copy over for use in PyGameZero demo apps. +DEFAULT_SOUNDS = [ + "eep.wav", + "meow1.wav", + "meow2.wav", + "meow3.wav", + "meow4.wav", + "splat.wav", +] MOTD = [ # Candidate phrases for the message of the day (MOTD). - _('Hello, World!'), - _("This editor is free software written in Python. You can modify it, " - "add features or fix bugs if you like."), + _("Hello, World!"), + _( + "This editor is free software written in Python. You can modify it, " + "add features or fix bugs if you like." + ), _("This editor is called Mu (you say it 'mew' or 'moo')."), _("Google, Facebook, NASA, Pixar, Disney and many more use Python."), - _("Programming is a form of magic. Learn to cast the right spells with " - "code and you'll be a wizard."), - _("REPL stands for read, evaluate, print, loop. It's a fun way to talk to " - "your computer! :-)"), - _('Be brave, break things, learn and have fun!'), + _( + "Programming is a form of magic. Learn to cast the right spells with " + "code and you'll be a wizard." + ), + _( + "REPL stands for read, evaluate, print, loop. It's a fun way to talk " + "to your computer! :-)" + ), + _("Be brave, break things, learn and have fun!"), _("Make your software both useful AND fun. Empower your users."), - _('For the Zen of Python: import this'), - _('Diversity promotes creativity.'), + _("For the Zen of Python: import this"), + _("Diversity promotes creativity."), _("An open mind, spirit of adventure and respect for diversity are key."), - _("Don't worry if it doesn't work. Learn the lesson, fix it and try " - "again! :-)"), + _( + "Don't worry if it doesn't work. Learn the lesson, fix it and try " + "again! :-)" + ), _("Coding is collaboration."), _("Compliment and amplify the good things with code."), - _("In theory, theory and practice are the same. In practice, they're " - "not. ;-)"), + _( + "In theory, theory and practice are the same. In practice, they're " + "not. ;-)" + ), _("Debugging is twice as hard as writing the code in the first place."), _("It's fun to program."), _("Programming has more to do with problem solving than writing code."), _("Start with your users' needs."), _("Try to see things from your users' point of view."), _("Put yourself in your users' shoes."), - _("Explaining a programming problem to a friend often reveals the " - "solution. :-)"), + _( + "Explaining a programming problem to a friend often reveals the " + "solution. :-)" + ), _("If you don't know, ask. Nobody to ask? Just look it up."), _("Complexity is the enemy. KISS - keep it simple, stupid!"), _("Beautiful is better than ugly."), @@ -101,20 +139,26 @@ _("Flat is better than nested."), _("Sparse is better than dense."), _("Readability counts."), - _("Special cases aren't special enough to break the rules. " - "Although practicality beats purity."), + _( + "Special cases aren't special enough to break the rules. " + "Although practicality beats purity." + ), _("Errors should never pass silently. Unless explicitly silenced."), _("In the face of ambiguity, refuse the temptation to guess."), _("There should be one-- and preferably only one --obvious way to do it."), - _("Now is better than never. Although never is often better than " - "*right* now."), + _( + "Now is better than never. Although never is often better than " + "*right* now." + ), _("If the implementation is hard to explain, it's a bad idea."), _("If the implementation is easy to explain, it may be a good idea."), _("Namespaces are one honking great idea -- let's do more of those!"), _("Mu was created by Nicholas H.Tollervey."), _("To understand what recursion is, you must first understand recursion."), - _("Algorithm: a word used by programmers when they don't want to explain " - "what they did."), + _( + "Algorithm: a word used by programmers when they don't want to " + "explain what they did." + ), _("Programmers count from zero."), _("Simplicity is the ultimate sophistication."), _("A good programmer is humble."), @@ -137,11 +181,42 @@ # ENCODING = "utf-8" ENCODING_COOKIE_RE = re.compile( - "^[ \t\v]*#.*?coding[:=][ \t]*([-_.a-zA-Z0-9]+)") + "^[ \t\v]*#.*?coding[:=][ \t]*([-_.a-zA-Z0-9]+)" +) logger = logging.getLogger(__name__) +def installed_packages(): + """ + List all the third party modules installed by the user. + """ + result = [] + pkg_dirs = [ + os.path.join(MODULE_DIR, d) + for d in os.listdir(MODULE_DIR) + if d.endswith("dist-info") or d.endswith("egg-info") + ] + logger.info("Packages found: {}".format(pkg_dirs)) + for pkg in pkg_dirs: + if pkg.endswith("dist-info"): + # Modern. + metadata_file = os.path.join(pkg, "METADATA") + else: + # Legacy (eggs). + metadata_file = os.path.join(pkg, "PKG-INFO") + try: + with open(metadata_file, "rb") as f: + lines = f.readlines() + name = lines[1].rsplit(b":")[-1].strip() + result.append(name.decode("utf-8")) + except Exception as ex: + # Just log any errors. + logger.error("Unable to get metadata for package: " + pkg) + logger.error(ex) + return sorted(result) + + def write_and_flush(fileobj, content): """ Write content to the fileobj then flush and fsync to ensure the data is, @@ -176,8 +251,11 @@ def save_and_encode(text, filepath, newline=os.linesep): else: encoding = ENCODING - with open(filepath, "w", encoding=encoding, newline='') as f: - write_and_flush(f, newline.join(text.splitlines())) + with open(filepath, "w", encoding=encoding, newline="") as f: + text_to_write = ( + newline.join(l.rstrip(" ") for l in text.splitlines()) + newline + ) + write_and_flush(f, text_to_write) def sniff_encoding(filepath): @@ -235,7 +313,7 @@ def sniff_newline_convention(text): ("\r\n", "\r\n"), # Match \n at the start of the string # or \n preceded by any character other than \r - ("\n", "^\n|[^\r]\n") + ("\n", "^\n|[^\r]\n"), ] # # If no lines are present, default to the platform newline @@ -298,23 +376,23 @@ def get_admin_file_path(filename): empty one is created in the default location. """ # App location depends on being interpreted by normal Python or bundled - app_path = sys.executable if getattr(sys, 'frozen', False) else sys.argv[0] + app_path = sys.executable if getattr(sys, "frozen", False) else sys.argv[0] app_dir = os.path.dirname(os.path.abspath(app_path)) # The os x bundled application is placed 3 levels deep in the .app folder - if platform.system() == 'Darwin' and getattr(sys, 'frozen', False): + if platform.system() == "Darwin" and getattr(sys, "frozen", False): app_dir = os.path.dirname(os.path.dirname(os.path.dirname(app_dir))) file_path = os.path.join(app_dir, filename) if not os.path.exists(file_path): file_path = os.path.join(DATA_DIR, filename) if not os.path.exists(file_path): try: - with open(file_path, 'w') as f: - logger.debug('Creating admin file: {}'.format( - file_path)) + with open(file_path, "w") as f: + logger.debug("Creating admin file: {}".format(file_path)) json.dump({}, f) except FileNotFoundError: - logger.error('Unable to create admin file: {}'.format( - file_path)) + logger.error( + "Unable to create admin file: {}".format(file_path) + ) return file_path @@ -330,7 +408,7 @@ def get_session_path(): If no session file is detected a blank one in the default location is automatically created. """ - return get_admin_file_path('session.json') + return get_admin_file_path("session.json") def get_settings_path(): @@ -345,19 +423,7 @@ def get_settings_path(): If no settings file is detected a blank one in the default location is automatically created. """ - return get_admin_file_path('settings.json') - - -def get_view(self): - return self.view - - -def get_tab(self): - return get_view(self).current_tab - - -def get_pathname(self): - return get_view(self).current_tab.path + return get_admin_file_path("settings.json") def extract_envars(raw): @@ -366,8 +432,8 @@ def extract_envars(raw): NAME=VALUE definitions on separate lines. """ result = [] - for line in raw.split('\n'): - definition = line.split('=', 1) + for line in raw.split("\n"): + definition = line.split("=", 1) if len(definition) == 2: result.append([definition[0].strip(), definition[1].strip()]) return result @@ -391,25 +457,26 @@ def check_flake(filename, code, builtins=None): reporter = MuFlakeCodeReporter() check(code, filename, reporter) if builtins: - builtins_regex = re.compile(r"^undefined name '(" + - '|'.join(builtins) + r")'") + builtins_regex = re.compile( + r"^undefined name '(" + "|".join(builtins) + r")'" + ) feedback = {} for log in reporter.log: if import_all: # Guard to stop unwanted "microbit.* imported but unused" messages. - message = log['message'] + message = log["message"] if EXPAND_FALSE_POSITIVE.match(message): continue if builtins: - if builtins_regex.match(log['message']): + if builtins_regex.match(log["message"]): continue - if log['line_no'] not in feedback: - feedback[log['line_no']] = [] - feedback[log['line_no']].append(log) + if log["line_no"] not in feedback: + feedback[log["line_no"]] = [] + feedback[log["line_no"]].append(log) return feedback -def check_pycodestyle(code): +def check_pycodestyle(code, config_file=False): """ Given some code, uses the PyCodeStyle module (was PEP8) to return a list of items describing issues of coding style. See: @@ -422,10 +489,31 @@ def check_pycodestyle(code): os.close(code_fd) save_and_encode(code, code_filename) # Configure which PEP8 rules to ignore. - ignore = ('E121', 'E123', 'E126', 'E226', 'E302', 'E305', 'E24', 'E704', - 'W291', 'W292', 'W293', 'W391', 'W503', ) - style = StyleGuide(parse_argv=False, config_file=False) - style.options.ignore = ignore + ignore = ( + "E121", + "E123", + "E126", + "E226", + "E203", + "E302", + "E305", + "E24", + "E704", + "W291", + "W292", + "W293", + "W391", + "W503", + ) + style = StyleGuide(parse_argv=False, config_file=config_file) + + # StyleGuide() returns pycodestyle module's own ignore list. That list may + # be a default list or a custom list provided by the user + # merge the above ignore list with StyleGuide() returned list, then + # remove duplicates with set(), convert back to tuple() + ignore = style.options.ignore + ignore + style.options.ignore = tuple(set(ignore)) + checker = Checker(code_filename, options=style.options) # Re-route stdout to a temporary buffer to be parsed below. temp_out = io.StringIO() @@ -441,22 +529,24 @@ def check_pycodestyle(code): os.remove(code_filename) # Parse the output from the tool into a dictionary of structured data. style_feedback = {} - for result in results.split('\n'): + for result in results.split("\n"): matcher = STYLE_REGEX.match(result) if matcher: line_no, col, msg = matcher.groups() line_no = int(line_no) - 1 - code, description = msg.split(' ', 1) - if code == 'E303': - description += _(' above this line') + code, description = msg.split(" ", 1) + if code == "E303": + description += _(" above this line") if line_no not in style_feedback: style_feedback[line_no] = [] - style_feedback[line_no].append({ - 'line_no': line_no, - 'column': int(col) - 1, - 'message': description.capitalize(), - 'code': code, - }) + style_feedback[line_no].append( + { + "line_no": line_no, + "column": int(col) - 1, + "message": description.capitalize(), + "code": code, + } + ) return style_feedback @@ -478,11 +568,9 @@ def unexpectedError(self, filename, message): called filename. The message parameter contains a description of the problem. """ - self.log.append({ - 'line_no': 0, - 'filename': filename, - 'message': str(message) - }) + self.log.append( + {"line_no": 0, "filename": filename, "message": str(message)} + ) def syntaxError(self, filename, message, line_no, column, source): """ @@ -493,14 +581,18 @@ def syntaxError(self, filename, message, line_no, column, source): indicates the column on which the error occurred and source is the source code containing the syntax error. """ - msg = _('Syntax error. Python cannot understand this line. Check for ' - 'missing characters!') - self.log.append({ - 'message': msg, - 'line_no': int(line_no) - 1, # Zero based counting in Mu. - 'column': column - 1, - 'source': source - }) + msg = _( + "Syntax error. Python cannot understand this line. Check for " + "missing characters!" + ) + self.log.append( + { + "message": msg, + "line_no": int(line_no) - 1, # Zero based counting in Mu. + "column": column - 1, + "source": source, + } + ) def flake(self, message): """ @@ -509,17 +601,17 @@ def flake(self, message): matcher = FLAKE_REGEX.match(str(message)) if matcher: line_no, msg = matcher.groups() - self.log.append({ - 'line_no': int(line_no) - 1, # Zero based counting in Mu. - 'column': 0, - 'message': msg, - }) + self.log.append( + { + "line_no": int(line_no) - 1, # Zero based counting in Mu. + "column": 0, + "message": msg, + } + ) else: - self.log.append({ - 'line_no': 0, - 'column': 0, - 'message': str(message), - }) + self.log.append( + {"line_no": 0, "column": 0, "message": str(message)} + ) class REPL: @@ -531,16 +623,16 @@ class REPL: """ def __init__(self, port): - if os.name == 'posix': + if os.name == "posix": # If we're on Linux or OSX reference the port is like this... self.port = "/dev/{}".format(port) - elif os.name == 'nt': + elif os.name == "nt": # On Windows simply return the port (e.g. COM0). self.port = port else: # No idea how to deal with other OS's so fail. - raise NotImplementedError('OS not supported.') - logger.info('Created new REPL object with port: {}'.format(self.port)) + raise NotImplementedError("OS not supported.") + logger.info("Created new REPL object with port: {}".format(self.port)) class Editor: @@ -549,30 +641,32 @@ class Editor: """ def __init__(self, view, status_bar=None): - logger.info('Setting up editor.') + logger.info("Setting up editor.") self._view = view self._status_bar = status_bar self.fs = None - self.theme = 'day' - self.mode = 'python' + self.theme = "day" + self.mode = "python" self.modes = {} # See set_modes. self.envars = [] # See restore session and show_admin self.minify = False - self.microbit_runtime = '' - self.adafruit_run = False - self.adafruit_lib = False + self.microbit_runtime = "" self.connected_devices = set() - self.find = '' - self.replace = '' + self.find = "" + self.replace = "" + self.current_path = "" # Directory of last loaded file. self.global_replace = False self.selecting_mode = False # Flag to stop auto-detection of modes. if not os.path.exists(DATA_DIR): - logger.debug('Creating directory: {}'.format(DATA_DIR)) + logger.debug("Creating directory: {}".format(DATA_DIR)) os.makedirs(DATA_DIR) - logger.info('Settings path: {}'.format(get_settings_path())) - logger.info('Session path: {}'.format(get_session_path())) - logger.info('Log directory: {}'.format(LOG_DIR)) - logger.info('Data directory: {}'.format(DATA_DIR)) + if not os.path.exists(MODULE_DIR): + logger.debug("Creating directory: {}".format(MODULE_DIR)) + os.makedirs(MODULE_DIR) + logger.info("Settings path: {}".format(get_settings_path())) + logger.info("Session path: {}".format(get_session_path())) + logger.info("Log directory: {}".format(LOG_DIR)) + logger.info("Data directory: {}".format(DATA_DIR)) @view.open_file.connect def on_open_file(file): @@ -585,35 +679,47 @@ def setup(self, modes): directory. """ self.modes = modes - logger.info('Available modes: {}'.format(', '.join(self.modes.keys()))) + logger.info("Available modes: {}".format(", ".join(self.modes.keys()))) # Ensure there is a workspace directory. - wd = self.modes['python'].workspace_dir() + wd = self.modes["python"].workspace_dir() if not os.path.exists(wd): - logger.debug('Creating directory: {}'.format(wd)) + logger.debug("Creating directory: {}".format(wd)) os.makedirs(wd) # Ensure PyGameZero assets are copied over. - images_path = os.path.join(wd, 'images') - fonts_path = os.path.join(wd, 'fonts') - sounds_path = os.path.join(wd, 'sounds') - music_path = os.path.join(wd, 'music') + images_path = os.path.join(wd, "images") + fonts_path = os.path.join(wd, "fonts") + sounds_path = os.path.join(wd, "sounds") + music_path = os.path.join(wd, "music") if not os.path.exists(images_path): - logger.debug('Creating directory: {}'.format(images_path)) + logger.debug("Creating directory: {}".format(images_path)) os.makedirs(images_path) - shutil.copy(path('alien.png', 'pygamezero/'), - os.path.join(images_path, 'alien.png')) - shutil.copy(path('alien_hurt.png', 'pygamezero/'), - os.path.join(images_path, 'alien_hurt.png')) + for img in DEFAULT_IMAGES: + shutil.copy( + path(img, "pygamezero/"), os.path.join(images_path, img) + ) if not os.path.exists(fonts_path): - logger.debug('Creating directory: {}'.format(fonts_path)) + logger.debug("Creating directory: {}".format(fonts_path)) os.makedirs(fonts_path) if not os.path.exists(sounds_path): - logger.debug('Creating directory: {}'.format(sounds_path)) + logger.debug("Creating directory: {}".format(sounds_path)) os.makedirs(sounds_path) - shutil.copy(path('eep.wav', 'pygamezero/'), - os.path.join(sounds_path, 'eep.wav')) + for sfx in DEFAULT_SOUNDS: + shutil.copy( + path(sfx, "pygamezero/"), os.path.join(sounds_path, sfx) + ) if not os.path.exists(music_path): - logger.debug('Creating directory: {}'.format(music_path)) + logger.debug("Creating directory: {}".format(music_path)) os.makedirs(music_path) + # Ensure Web based assets are copied over. + template_path = os.path.join(wd, "templates") + static_path = os.path.join(wd, "static") + if not os.path.exists(template_path): + logger.debug("Creating directory: {}".format(template_path)) + shutil.copytree(path("templates", "web/"), template_path) + if not os.path.exists(static_path): + logger.debug("Creating directory: {}".format(static_path)) + shutil.copytree(path("static", "web/"), static_path) + # Copy all the static directories. # Start the timer to poll every second for an attached or removed # USB device. self._view.set_usb_checker(1, self.check_usb) @@ -631,91 +737,103 @@ def restore_session(self, paths=None): try: old_session = json.load(f) except ValueError: - logger.error('Settings file {} could not be parsed.'.format( - settings_path)) + logger.error( + "Settings file {} could not be parsed.".format( + settings_path + ) + ) else: - logger.info('Restoring session from: {}'.format(settings_path)) + logger.info("Restoring session from: {}".format(settings_path)) logger.debug(old_session) - if 'theme' in old_session: - self.theme = old_session['theme'] - if 'mode' in old_session: - old_mode = old_session['mode'] + if "theme" in old_session: + self.theme = old_session["theme"] + if "mode" in old_session: + old_mode = old_session["mode"] if old_mode in self.modes: - self.mode = old_session['mode'] + self.mode = old_session["mode"] else: # Unknown mode (perhaps an old version?) self.select_mode(None) else: # So ask for the desired mode. self.select_mode(None) - if 'paths' in old_session: - old_paths = self._abspath(old_session['paths']) + if "paths" in old_session: + old_paths = self._abspath(old_session["paths"]) launch_paths = self._abspath(paths) if paths else set() for old_path in old_paths: # if the os passed in a file, defer loading it now if old_path in launch_paths: continue self.direct_load(old_path) - logger.info('Loaded files.') - if 'envars' in old_session: - self.envars = old_session['envars'] - logger.info('User defined environment variables: ' - '{}'.format(self.envars)) - if 'minify' in old_session: - self.minify = old_session['minify'] - logger.info('Minify scripts on micro:bit? ' - '{}'.format(self.minify)) - if 'microbit_runtime' in old_session: - self.microbit_runtime = old_session['microbit_runtime'] + logger.info("Loaded files.") + if "envars" in old_session: + self.envars = old_session["envars"] + logger.info( + "User defined environment variables: " + "{}".format(self.envars) + ) + if "minify" in old_session: + self.minify = old_session["minify"] + logger.info( + "Minify scripts on micro:bit? " + "{}".format(self.minify) + ) + if "microbit_runtime" in old_session: + self.microbit_runtime = old_session["microbit_runtime"] if self.microbit_runtime: - logger.info('Custom micro:bit runtime path: ' - '{}'.format(self.microbit_runtime)) + logger.info( + "Custom micro:bit runtime path: " + "{}".format(self.microbit_runtime) + ) if not os.path.isfile(self.microbit_runtime): - self.microbit_runtime = '' - logger.warning('The specified micro:bit runtime ' - 'does not exist. Using default ' - 'runtime instead.') - if 'adafruit_run' in old_session: - self.adafruit_run = old_session['adafruit_run'] - logger.info('Enable Adafruit "Run" button? ' - '{}'.format(self.adafruit_run)) - if 'adafruit_lib' in old_session: - self.adafruit_lib = old_session['adafruit_lib'] - logger.info('Enable Adafruit copy library function? ' - '{}'.format(self.adafruit_lib)) + self.microbit_runtime = "" + logger.warning( + "The specified micro:bit runtime " + "does not exist. Using default " + "runtime instead." + ) + if "zoom_level" in old_session: + self._view.zoom_position = old_session["zoom_level"] + self._view.set_zoom() + old_window = old_session.get("window", {}) + self._view.size_window(**old_window) # handle os passed file last, # so it will not be focused over by another tab if paths and len(paths) > 0: self.load_cli(paths) - if not self._view.tab_count: - py = _('# Write your code here :-)') + NEWLINE - tab = self._view.add_tab(None, py, self.modes[self.mode].api(), - NEWLINE) - tab.setCursorPosition(len(py.split(NEWLINE)), 0) - logger.info('Starting with blank file.') self.change_mode(self.mode) self._view.set_theme(self.theme) self.show_status_message(random.choice(MOTD), 10) + if not self._view.tab_count: + py = self.modes[self.mode].code_template + NEWLINE + tab = self._view.add_tab( + None, py, self.modes[self.mode].api(), NEWLINE + ) + tab.setCursorPosition(len(py.split(NEWLINE)), 0) + logger.info("Starting with blank file.") def toggle_theme(self): """ Switches between themes (night, day or high-contrast). """ - if self.theme == 'day': - self.theme = 'night' - elif self.theme == 'night': - self.theme = 'contrast' + if self.theme == "day": + self.theme = "night" + elif self.theme == "night": + self.theme = "contrast" else: - self.theme = 'day' - logger.info('Toggle theme to: {}'.format(self.theme)) + self.theme = "day" + logger.info("Toggle theme to: {}".format(self.theme)) self._view.set_theme(self.theme) def new(self): """ Adds a new tab to the editor. """ - logger.info('Added a new tab.') - self._view.add_tab(None, '', self.modes[self.mode].api(), NEWLINE) + logger.info("Added a new tab.") + default_text = self.modes[self.mode].code_template + NEWLINE + self._view.add_tab( + None, default_text, self.modes[self.mode].api(), NEWLINE + ) def _load(self, path): """ @@ -727,30 +845,42 @@ def _load(self, path): to cleanly handle / report / log errors when encountered in a helpful manner. """ - logger.info('Loading script from: {}'.format(path)) - error = _("The file contains characters Mu expects to be encoded as " - "{0} or as the computer's default encoding {1}, but which " - "are encoded in some other way.\n\nIf this file was saved " - "in another application, re-save the file via the " - "'Save as' option and set the encoding to {0}") + logger.info("Loading script from: {}".format(path)) + error = _( + "The file contains characters Mu expects to be encoded as " + "{0} or as the computer's default encoding {1}, but which " + "are encoded in some other way.\n\nIf this file was saved " + "in another application, re-save the file via the " + "'Save as' option and set the encoding to {0}" + ) error = error.format(ENCODING, locale.getpreferredencoding()) # Does the file even exist? if not os.path.isfile(path): - logger.info('The file {} does not exist.'.format(path)) + logger.info("The file {} does not exist.".format(path)) return # see if file is open first for widget in self._view.widgets: if widget.path is None: # this widget is an unsaved buffer continue + # The widget could be for a file on a MicroPython device that + # has since been unplugged. We should ignore it and assume that + # folks understand this file is no longer available (there's + # nothing else we can do). + if not os.path.isfile(widget.path): + logger.info( + "The file {} no longer exists.".format(widget.path) + ) + continue + # Check for duplication of open file. if os.path.samefile(path, widget.path): - logger.info('Script already open.') + logger.info("Script already open.") msg = _('The file "{}" is already open.') self._view.show_message(msg.format(os.path.basename(path))) self._view.focus_tab(widget) return name, text, newline, file_mode = None, None, None, None try: - if path.lower().endswith('.py'): + if path.lower().endswith(".py"): # Open the file, read the textual content and set the name as # the path to the file. try: @@ -763,64 +893,107 @@ def _load(self, path): name = path else: # Delegate the open operation to the Mu modes. Leave the name - # as None, thus forcing the user to work out what to name the - # recovered script. + # as None if handling a hex file, thus forcing the user to work + # out what to name the recovered script. for mode_name, mode in self.modes.items(): try: - text = mode.open_file(path) + text, newline = mode.open_file(path) + if not path.endswith(".hex"): + name = path except Exception as exc: # No worries, log it and try the next mode - logger.warning('Error when mode {} try to open the ' - '{} file.'.format(mode_name, path), - exc_info=exc) + logger.warning( + "Error when mode {} try to open the " + "{} file.".format(mode_name, path), + exc_info=exc, + ) else: if text: - newline = sniff_newline_convention(text) file_mode = mode_name break else: - message = _('Mu was not able to open this file') - info = _('Currently Mu only works with Python source ' - 'files or hex files created with embedded ' - 'MicroPython code.') + message = _("Mu was not able to open this file") + info = _( + "Currently Mu only works with Python source " + "files or hex files created with embedded " + "MicroPython code." + ) self._view.show_message(message, info) return except OSError: message = _("Could not load {}").format(path) - logger.exception('Could not load {}'.format(path)) - info = _("Does this file exist?\nIf it does, do you have " - "permission to read it?\n\nPlease check and try again.") + logger.exception("Could not load {}".format(path)) + info = _( + "Does this file exist?\nIf it does, do you have " + "permission to read it?\n\nPlease check and try again." + ) self._view.show_message(message, info) else: if file_mode and self.mode != file_mode: device_name = self.modes[file_mode].name - message = _('Is this a {} file?').format(device_name) - info = _('It looks like this could be a {} file.\n\n' - 'Would you like to change Mu to the {}' - 'mode?').format(device_name, device_name) - if self._view.show_confirmation( - message, info, icon='Question') == QMessageBox.Ok: + message = _("Is this a {} file?").format(device_name) + info = _( + "It looks like this could be a {} file.\n\n" + "Would you like to change Mu to the {}" + "mode?" + ).format(device_name, device_name) + if ( + self._view.show_confirmation( + message, info, icon="Question" + ) + == QMessageBox.Ok + ): self.change_mode(file_mode) logger.debug(text) self._view.add_tab( - name, text, self.modes[self.mode].api(), newline) + name, text, self.modes[self.mode].api(), newline + ) + + def get_dialog_directory(self, default=None): + """ + Return the directory folder in which a load/save dialog box should + open into. In order of precedence this function will return: + + 0) If not None, the value of default. + 1) The last location used by a load/save dialog. + 2) The directory containing the current file. + 3) The mode's reported workspace directory. + """ + if default is not None: + folder = default + elif self.current_path and os.path.isdir(self.current_path): + folder = self.current_path + else: + current_file_path = "" + workspace_path = self.modes[self.mode].workspace_dir() + tab = self._view.current_tab + if tab and tab.path: + current_file_path = os.path.dirname(os.path.abspath(tab.path)) + folder = current_file_path if current_file_path else workspace_path + logger.info("Using path for file dialog: {}".format(folder)) + return folder - def load(self): + def load(self, *args, default_path=None): """ - Loads a Python file from the file system or extracts a Python script - from a hex file. + Loads a Python (or other supported) file from the file system or + extracts a Python script from a hex file. """ # Get all supported extensions from the different modes - extensions = ['py'] + extensions = ["py"] for mode_name, mode in self.modes.items(): if mode.file_extensions: extensions += mode.file_extensions extensions = set([e.lower() for e in extensions]) - extensions = '*.{} *.{}'.format(' *.'.join(extensions), - ' *.'.join(extensions).upper()) - path = self._view.get_load_path(self.modes[self.mode].workspace_dir(), - extensions) + extensions = "*.{} *.{}".format( + " *.".join(extensions), " *.".join(extensions).upper() + ) + folder = self.get_dialog_directory(default_path) + allow_previous = not bool(default_path) + path = self._view.get_load_path( + folder, extensions, allow_previous=allow_previous + ) if path: + self.current_path = os.path.dirname(os.path.abspath(path)) self._load(path) def direct_load(self, path): @@ -835,51 +1008,60 @@ def load_cli(self, paths): """ for p in paths: try: - logger.info('Passed-in filename: {}'.format(p)) + logger.info("Passed-in filename: {}".format(p)) # abspath will fail for non-paths self.direct_load(os.path.abspath(p)) except Exception as e: - logging.warning('Can\'t open file from command line {}'. - format(p), exc_info=e) + logging.warning( + "Can't open file from command line {}".format(p), + exc_info=e, + ) def _abspath(self, paths): """ Safely convert an arrary of paths to their absolute forms and remove duplicate items. """ - result = set() + result = [] for p in paths: try: - result.add(os.path.abspath(p)) + abspath = os.path.abspath(p) except Exception as ex: - logger.error('Could not get path for {}: {}'.format(p, ex)) + logger.error("Could not get path for {}: {}".format(p, ex)) + else: + if abspath not in result: + result.append(abspath) return result - def save_tab_to_file(self, tab): + def save_tab_to_file(self, tab, show_error_messages=True): """ Given a tab, will attempt to save the script in the tab to the path associated with the tab. If there's a problem this will be logged and reported and the tab status will continue to show as Modified. """ - logger.info('Saving script to: {}'.format(tab.path)) + logger.info("Saving script to: {}".format(tab.path)) logger.debug(tab.text()) try: save_and_encode(tab.text(), tab.path, tab.newline) except OSError as e: logger.error(e) - error_message = _('Could not save file (disk problem)') - information = _("Error saving file to disk. Ensure you have " - "permission to write the file and " - "sufficient disk space.") + error_message = _("Could not save file (disk problem)") + information = _( + "Error saving file to disk. Ensure you have " + "permission to write the file and " + "sufficient disk space." + ) except UnicodeEncodeError: error_message = _("Could not save file (encoding problem)") logger.exception(error_message) - information = _("Unable to convert all the characters. If you " - "have an encoding line at the top of the file, " - "remove it and try again.") + information = _( + "Unable to convert all the characters. If you " + "have an encoding line at the top of the file, " + "remove it and try again." + ) else: error_message = information = None - if error_message: + if error_message and show_error_messages: self._view.show_message(error_message, information) else: tab.setModified(False) @@ -896,10 +1078,10 @@ def check_for_shadow_module(self, path): return False. """ logger.info('Checking path "{}" for shadow module.'.format(path)) - filename = os.path.basename(path).replace('.py', '') + filename = os.path.basename(path).replace(".py", "") return filename in self.modes[self.mode].module_names - def save(self): + def save(self, *args, default=None): """ Save the content of the currently active editor tab. """ @@ -909,23 +1091,23 @@ def save(self): return if not tab.path: # Unsaved file. - workspace = self.modes[self.mode].workspace_dir() - path = self._view.get_save_path(workspace) + folder = self.get_dialog_directory(default) + path = self._view.get_save_path(folder) if path and self.check_for_shadow_module(path): - message = _('You cannot use the filename ' - '"{}"').format(os.path.basename(path)) - info = _('This name is already used by another part of ' - 'Python. If you use this name, things are ' - 'likely to break. Please try again with a ' - 'different filename.') + message = _("You cannot use the filename " '"{}"').format( + os.path.basename(path) + ) + info = _( + "This name is already used by another part of " + "Python. If you use this name, things are " + "likely to break. Please try again with a " + "different filename." + ) self._view.show_message(message, info) return tab.path = path if tab.path: # The user specified a path to a file. - if os.path.splitext(tab.path)[1] == '': - # the user didn't specify an extension, default to .py - tab.path += '.py' self.save_tab_to_file(tab) else: # The user cancelled the filename selection. @@ -950,14 +1132,14 @@ def zoom_in(self): """ Make the editor's text bigger """ - logger.info('Zoom in') + logger.info("Zoom in") self._view.zoom_in() def zoom_out(self): """ Make the editor's text smaller. """ - logger.info('Zoom out') + logger.info("Zoom out") self._view.zoom_out() def check_code(self): @@ -969,33 +1151,39 @@ def check_code(self): if tab is None: # There is no active text editor so abort. return + if tab.path and not tab.path.endswith(".py"): + # Only works on Python files, so abort. + return tab.has_annotations = not tab.has_annotations if tab.has_annotations: - logger.info('Checking code.') + logger.info("Checking code.") self._view.reset_annotations() - filename = tab.path if tab.path else 'untitled' + filename = tab.path if tab.path else _("untitled") builtins = self.modes[self.mode].builtins flake = check_flake(filename, tab.text(), builtins) if flake: logger.info(flake) - self._view.annotate_code(flake, 'error') + self._view.annotate_code(flake, "error") pep8 = check_pycodestyle(tab.text()) if pep8: logger.info(pep8) - self._view.annotate_code(pep8, 'style') + self._view.annotate_code(pep8, "style") self._view.show_annotations() tab.has_annotations = bool(flake or pep8) if not tab.has_annotations: # No problems detected, so confirm this with a friendly # message. ok_messages = [ - _('Good job! No problems found.'), - _('Hurrah! Checker turned up no problems.'), - _('Nice one! Zero problems detected.'), - _('Well done! No problems here.'), - _('Awesome! Zero problems found.'), + _("Good job! No problems found."), + _("Hurrah! Checker turned up no problems."), + _("Nice one! Zero problems detected."), + _("Well done! No problems here."), + _("Awesome! Zero problems found."), ] self.show_status_message(random.choice(ok_messages)) + self._view.set_checker_icon("check-good.png") + else: + self._view.set_checker_icon("check-bad.png") else: self._view.reset_annotations() @@ -1003,15 +1191,12 @@ def show_help(self): """ Display browser based help about Mu. """ - logger.info('Showing help.') - try: - current_locale, encoding = locale.getdefaultlocale() - language_code = current_locale[:2] - except (TypeError, ValueError): - language_code = 'en' - major_version = '.'.join(__version__.split('.')[:2]) - url = 'https://codewith.mu/{}/help/{}'.format(language_code, - major_version) + language_code = QLocale.system().name()[:2] + major_version = ".".join(__version__.split(".")[:2]) + url = "https://codewith.mu/{}/help/{}".format( + language_code, major_version + ) + logger.info("Showing help at %r.", url) webbrowser.open_new(url) def quit(self, *args, **kwargs): @@ -1020,11 +1205,13 @@ def quit(self, *args, **kwargs): """ if self._view.modified: # Alert the user to handle unsaved work. - msg = _('There is un-saved work, exiting the application will' - ' cause you to lose it.') + msg = _( + "There is un-saved work, exiting the application will" + " cause you to lose it." + ) result = self._view.show_confirmation(msg) if result == QMessageBox.Cancel: - if args and hasattr(args[0], 'ignore'): + if args and hasattr(args[0], "ignore"): # The function is handling an event, so ignore it. args[0].ignore() return @@ -1032,26 +1219,42 @@ def quit(self, *args, **kwargs): for widget in self._view.widgets: if widget.path: paths.append(os.path.abspath(widget.path)) - if self.modes[self.mode].is_debugger: - # If quitting while debugging, make sure everything is cleaned - # up. - self.modes[self.mode].stop() + # Make sure the mode's stop method is called so + # everything is cleaned up. + self.modes[self.mode].stop() session = { - 'theme': self.theme, - 'mode': self.mode, - 'paths': paths, - 'envars': self.envars, - 'minify': self.minify, - 'microbit_runtime': self.microbit_runtime, - 'adafruit_run': self.adafruit_run, - 'adafruit_lib': self.adafruit_lib, + "theme": self.theme, + "mode": self.mode, + "paths": paths, + "envars": self.envars, + "minify": self.minify, + "microbit_runtime": self.microbit_runtime, + "zoom_level": self._view.zoom_position, + "window": { + "x": self._view.x(), + "y": self._view.y(), + "w": self._view.width(), + "h": self._view.height(), + }, } session_path = get_session_path() - with open(session_path, 'w') as out: - logger.debug('Session: {}'.format(session)) - logger.debug('Saving session to: {}'.format(session_path)) + with open(session_path, "w") as out: + logger.debug("Session: {}".format(session)) + logger.debug("Saving session to: {}".format(session_path)) json.dump(session, out, indent=2) - logger.info('Quitting.\n\n') + # Clean up temporary mu.pth file if needed (Windows only). + if sys.platform == "win32" and "pythonw.exe" in sys.executable: + if site.ENABLE_USER_SITE: + site_path = site.USER_SITE + path_file = os.path.join(site_path, "mu.pth") + if os.path.exists(path_file): + try: + os.remove(path_file) + logger.info("{} removed.".format(path_file)) + except Exception as ex: + logger.error("Unable to delete {}".format(path_file)) + logger.error(ex) + logger.info("Quitting.\n\n") sys.exit(0) def show_admin(self, event=None): @@ -1060,35 +1263,64 @@ def show_admin(self, event=None): Ensure any changes to the envars is updated. """ - logger.info('Showing logs from {}'.format(LOG_FILE)) - envars = '\n'.join(['{}={}'.format(name, value) for name, value in - self.envars]) + logger.info("Showing admin with logs from {}".format(LOG_FILE)) + envars = "\n".join( + ["{}={}".format(name, value) for name, value in self.envars] + ) settings = { - 'envars': envars, - 'minify': self.minify, - 'microbit_runtime': self.microbit_runtime, - 'adafruit_run': self.adafruit_run, - 'adafruit_lib': self.adafruit_lib, + "envars": envars, + "minify": self.minify, + "microbit_runtime": self.microbit_runtime, } - with open(LOG_FILE, 'r', encoding='utf8') as logfile: - new_settings = self._view.show_admin(logfile.read(), settings) - self.envars = extract_envars(new_settings['envars']) - self.minify = new_settings['minify'] - self.adafruit_run = new_settings['adafruit_run'] - self.adafruit_lib = new_settings['adafruit_lib'] - # show/hide adafruit "run" button potentially changed in admin - if self.mode == 'adafruit': - self.change_mode(self.mode) - runtime = new_settings['microbit_runtime'].strip() + packages = installed_packages() + with open(LOG_FILE, "r", encoding="utf8") as logfile: + new_settings = self._view.show_admin( + logfile.read(), settings, "\n".join(packages) + ) + if new_settings: + self.envars = extract_envars(new_settings["envars"]) + self.minify = new_settings["minify"] + runtime = new_settings["microbit_runtime"].strip() if runtime and not os.path.isfile(runtime): - self.microbit_runtime = '' - message = _('Could not find MicroPython runtime.') - information = _("The micro:bit runtime you specified ('{}') " - "does not exist. " - "Please try again.").format(runtime) + self.microbit_runtime = "" + message = _("Could not find MicroPython runtime.") + information = _( + "The micro:bit runtime you specified " + "('{}') does not exist. " + "Please try again." + ).format(runtime) self._view.show_message(message, information) else: self.microbit_runtime = runtime + new_packages = [ + p + for p in new_settings["packages"].lower().split("\n") + if p.strip() + ] + old_packages = [p.lower() for p in packages] + self.sync_package_state(old_packages, new_packages) + else: + logger.info("No admin settings changed.") + + def sync_package_state(self, old_packages, new_packages): + """ + Given the state of the old third party packages, compared to the new + third party packages, ensure that pip uninstalls and installs the + packages so the currently available third party packages reflects the + new state. + """ + old = set(old_packages) + new = set(new_packages) + logger.info("Synchronize package states...") + logger.info("Old: {}".format(old)) + logger.info("New: {}".format(new)) + to_remove = old.difference(new) + to_add = new.difference(old) + if to_remove or to_add: + logger.info("To add: {}".format(to_add)) + logger.info("To remove: {}".format(to_remove)) + logger.info("Site packages: {}".format(MODULE_DIR)) + self._view.sync_packages(to_remove, to_add, MODULE_DIR) def select_mode(self, event=None): """ @@ -1096,13 +1328,14 @@ def select_mode(self, event=None): """ if self.modes[self.mode].is_debugger: return - logger.info('Showing available modes: {}'.format( - list(self.modes.keys()))) + logger.info( + "Showing available modes: {}".format(list(self.modes.keys())) + ) self.selecting_mode = True # Flag to stop auto-detection of modes. new_mode = self._view.select_mode(self.modes, self.mode) self.selecting_mode = False if new_mode and new_mode != self.mode: - logger.info('New mode selected: {}'.format(new_mode)) + logger.info("New mode selected: {}".format(new_mode)) self.change_mode(new_mode) def change_mode(self, mode): @@ -1112,11 +1345,11 @@ def change_mode(self, mode): """ # Remove the old mode's REPL / filesystem / plotter if required. old_mode = self.modes[self.mode] - if hasattr(old_mode, 'remove_repl'): + if hasattr(old_mode, "remove_repl"): old_mode.remove_repl() - if hasattr(old_mode, 'remove_fs'): + if hasattr(old_mode, "remove_fs"): old_mode.remove_fs() - if hasattr(old_mode, 'remove_plotter'): + if hasattr(old_mode, "remove_plotter"): if old_mode.plotter: old_mode.remove_plotter() # Re-assign to new mode. @@ -1124,23 +1357,29 @@ def change_mode(self, mode): # Update buttons. self._view.change_mode(self.modes[mode]) button_bar = self._view.button_bar - button_bar.connect('modes', self.select_mode, 'Ctrl+Shift+M') + button_bar.connect("modes", self.select_mode, "Ctrl+Shift+M") button_bar.connect("new", self.new, "Ctrl+N") button_bar.connect("load", self.load, "Ctrl+O") button_bar.connect("save", self.save, "Ctrl+S") for action in self.modes[mode].actions(): - button_bar.connect(action['name'], action['handler'], - action['shortcut']) + button_bar.connect( + action["name"], action["handler"], action["shortcut"] + ) button_bar.connect("zoom-in", self.zoom_in, "Ctrl++") button_bar.connect("zoom-out", self.zoom_out, "Ctrl+-") button_bar.connect("theme", self.toggle_theme, "F1") button_bar.connect("check", self.check_code, "F2") + if sys.version_info[:2] >= (3, 6): + button_bar.connect("tidy", self.tidy_code, "F10") button_bar.connect("help", self.show_help, "Ctrl+H") button_bar.connect("quit", self.quit, "Ctrl+Q") - self._view.status_bar.set_mode(mode) + self._view.status_bar.set_mode(self.modes[mode].name) # Update references to default file locations. - logger.info('Workspace directory: {}'.format( - self.modes[mode].workspace_dir())) + logger.info( + "Workspace directory: {}".format(self.modes[mode].workspace_dir()) + ) + # Reset remembered current path for load/save dialogs. + self.current_path = "" # Ensure auto-save timeouts are set. if self.modes[mode].save_timeout > 0: # Start the timer @@ -1153,8 +1392,9 @@ def change_mode(self, mode): for tab in self._view.widgets: tab.breakpoint_handles = set() tab.reset_annotations() - self.show_status_message(_('Changed to {} mode.').format( - mode.capitalize())) + self.show_status_message( + _("Changed to {} mode.").format(self.modes[mode].name) + ) def autosave(self): """ @@ -1164,9 +1404,12 @@ def autosave(self): # Something has changed, so save it! for tab in self._view.widgets: if tab.path and tab.isModified(): - self.save_tab_to_file(tab) - logger.info('Autosave detected and saved ' - 'changes in {}.'.format(tab.path)) + # Suppress error message on autosave attempts + self.save_tab_to_file(tab, show_error_messages=False) + logger.info( + "Autosave detected and saved " + "changes in {}.".format(tab.path) + ) def check_usb(self): """ @@ -1179,7 +1422,7 @@ def check_usb(self): device_types = set() # Detect connected devices. for name, mode in self.modes.items(): - if hasattr(mode, 'find_device'): + if hasattr(mode, "find_device"): # The mode can detect an attached device. port, serial = mode.find_device(with_logging=False) if port: @@ -1198,17 +1441,25 @@ def check_usb(self): self.connected_devices.add(device) mode_name = device[0] device_name = self.modes[mode_name].name - msg = _('Detected new {} device.').format(device_name) + msg = _("Detected new {} device.").format(device_name) self.show_status_message(msg) # Only ask to switch mode if a single device type is connected # and we're not already trying to select a new mode via the - # dialog. - if (len(device_types) == 1 and self.mode != mode_name and not - self.selecting_mode): - msg_body = _('Would you like to change Mu to the {} ' - 'mode?').format(device_name) + # dialog. Cannot change mode if a script is already being run + # by the current mode. + m = self.modes[self.mode] + running = hasattr(m, "runner") and m.runner + if ( + len(device_types) == 1 + and self.mode != mode_name + and not self.selecting_mode + ) and not running: + msg_body = _( + "Would you like to change Mu to the {} " "mode?" + ).format(device_name) change_confirmation = self._view.show_confirmation( - msg, msg_body, icon='Question') + msg, msg_body, icon="Question" + ) if change_confirmation == QMessageBox.Ok: self.change_mode(mode_name) @@ -1222,14 +1473,16 @@ def debug_toggle_breakpoint(self, margin, line, modifiers): """ How to handle the toggling of a breakpoint. """ - if (self.modes[self.mode].has_debugger or - self.modes[self.mode].is_debugger): + if ( + self.modes[self.mode].has_debugger + or self.modes[self.mode].is_debugger + ): tab = self._view.current_tab code = tab.text(line) - if self.mode == 'debugger': + if self.mode == "debugger": # The debugger is running. if is_breakpoint_line(code): - self.modes['debugger'].toggle_breakpoint(line, tab) + self.modes["debugger"].toggle_breakpoint(line, tab) return else: # The debugger isn't running. @@ -1240,9 +1493,11 @@ def debug_toggle_breakpoint(self, margin, line, modifiers): handle = tab.markerAdd(line, tab.BREAKPOINT_MARKER) tab.breakpoint_handles.add(handle) return - msg = _('Cannot Set Breakpoint.') - info = _("Lines that are comments or some multi-line " - "statements cannot have breakpoints.") + msg = _("Cannot Set Breakpoint.") + info = _( + "Lines that are comments or some multi-line " + "statements cannot have breakpoints." + ) self._view.show_message(msg, info) def rename_tab(self, tab_id=None): @@ -1259,33 +1514,40 @@ def rename_tab(self, tab_id=None): new_path = self._view.get_save_path(tab.path) if new_path and new_path != tab.path: if self.check_for_shadow_module(new_path): - message = _('You cannot use the filename ' - '"{}"').format(os.path.basename(new_path)) - info = _('This name is already used by another part of ' - 'Python. If you use that name, things are ' - 'likely to break. Please try again with a ' - 'different filename.') + message = _("You cannot use the filename " '"{}"').format( + os.path.basename(new_path) + ) + info = _( + "This name is already used by another part of " + "Python. If you use that name, things are " + "likely to break. Please try again with a " + "different filename." + ) self._view.show_message(message, info) return - logger.info('Attempting to rename {} to {}'.format(tab.path, - new_path)) + logger.info( + "Attempting to rename {} to {}".format(tab.path, new_path) + ) # The user specified a path to a file. - if not os.path.basename(new_path).endswith('.py'): + if not os.path.basename(new_path).endswith(".py"): # No extension given, default to .py - new_path += '.py' + new_path += ".py" # Check for duplicate path with currently open tab. for other_tab in self._view.widgets: if other_tab.path == new_path: - logger.info('Cannot rename, a file of that name is ' - 'already open in Mu') - message = _('Could not rename file.') - information = _("A file of that name is already open " - "in Mu.") + logger.info( + "Cannot rename, a file of that name is " + "already open in Mu" + ) + message = _("Could not rename file.") + information = _( + "A file of that name is already open " "in Mu." + ) self._view.show_message(message, information) return # Finally rename tab.path = new_path - logger.info('Renamed file to: {}'.format(tab.path)) + logger.info("Renamed file to: {}".format(tab.path)) self.save() def find_replace(self): @@ -1299,23 +1561,26 @@ def find_replace(self): If there is, find (and, optionally, replace) then confirm outcome with a status message. """ - result = self._view.show_find_replace(self.find, self.replace, - self.global_replace) + result = self._view.show_find_replace( + self.find, self.replace, self.global_replace + ) if result: self.find, self.replace, self.global_replace = result if self.find: if self.replace: - replaced = self._view.replace_text(self.find, self.replace, - self.global_replace) + replaced = self._view.replace_text( + self.find, self.replace, self.global_replace + ) if replaced == 1: msg = _('Replaced "{}" with "{}".') - self.show_status_message(msg.format(self.find, - self.replace)) + self.show_status_message( + msg.format(self.find, self.replace) + ) elif replaced > 1: msg = _('Replaced {} matches of "{}" with "{}".') - self.show_status_message(msg.format(replaced, - self.find, - self.replace)) + self.show_status_message( + msg.format(replaced, self.find, self.replace) + ) else: msg = _('Could not find "{}".') self.show_status_message(msg.format(self.find)) @@ -1327,9 +1592,11 @@ def find_replace(self): msg = _('Could not find "{}".') self.show_status_message(msg.format(self.find)) else: - message = _('You must provide something to find.') - information = _("Please try again, this time with something " - "in the find box.") + message = _("You must provide something to find.") + information = _( + "Please try again, this time with something " + "in the find box." + ) self._view.show_message(message, information) def toggle_comments(self): @@ -1337,3 +1604,40 @@ def toggle_comments(self): Ensure all highlighted lines are toggled between comments/uncommented. """ self._view.toggle_comments() + + def tidy_code(self): + """ + Prettify code with Black. + """ + tab = self._view.current_tab + if not tab or sys.version_info[:2] < (3, 6): + return + # Only works on Python, so abort. + if tab.path and not tab.path.endswith(".py"): + return + from black import format_str, FileMode, PY36_VERSIONS + + try: + source_code = tab.text() + logger.info("Tidy code.") + logger.info(source_code) + filemode = FileMode(target_versions=PY36_VERSIONS, line_length=88) + tidy_code = format_str(source_code, mode=filemode) + # The following bypasses tab.setText which resets the undo history. + # Doing it this way means the user can use CTRL-Z to undo the + # reformatting from black. + tab.SendScintilla(tab.SCI_SETTEXT, tidy_code.encode("utf-8")) + self.show_status_message( + _("Successfully cleaned the code. " "Use CTRL-Z to undo.") + ) + except Exception as ex: + # The user's code is problematic. Recover with a modal dialog + # containing a helpful message. + logger.error(ex) + message = _("Your code contains problems.") + information = _( + "These must be fixed before tidying will work. " + "Please use the 'Check' button to highlight " + "these problems." + ) + self._view.show_message(message, information) diff --git a/mu/modes/adafruit.py b/mu/modes/adafruit.py deleted file mode 100644 index 7fac4d65f..000000000 --- a/mu/modes/adafruit.py +++ /dev/null @@ -1,241 +0,0 @@ -""" -A mode for working with Adafuit's line of Circuit Python boards. - -Copyright (c) 2015-2017 Nicholas H.Tollervey and others (see the AUTHORS file). - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" -import os -import time -import ctypes -from shutil import copyfile -from subprocess import check_output -from mu.modes.base import MicroPythonMode -from mu.modes.api import ADAFRUIT_APIS, SHARED_APIS -from mu.interface.panes import CHARTS -from mu.logic import get_pathname - - -class AdafruitMode(MicroPythonMode): - """ - Represents the functionality required by the Adafruit mode. - """ - - name = _('Adafruit CircuitPython') - description = _("Use CircuitPython on Adafruit's line of boards.") - icon = 'adafruit' - save_timeout = 0 #: Don't autosave on Adafruit boards. Casues a restart. - connected = True #: is the Adafruit board connected. - force_interrupt = False #: NO keyboard interrupt on serial connection. - valid_boards = [ - (0x239A, 0x8015), # Adafruit Feather M0 CircuitPython - (0x239A, 0x8023), # Adafruit Feather M0 Express CircuitPython - (0x239A, 0x801B), # Adafruit Feather M0 Express CircuitPython - (0x239A, 0x8014), # Adafruit Metro M0 CircuitPython - (0x239A, 0x8019), # Adafruit CircuitPlayground Express CircuitPython - (0x239A, 0x801D), # Adafruit Gemma M0 - (0x239A, 0x801F), # Adafruit Trinket M0 - (0x239A, 0x8012), # Adafruit ItsyBitsy M0 - (0x239A, 0x8021), # Adafruit Metro M4 - (0x239A, 0x8025), # Adafruit Feather RadioFruit - (0x239A, 0x8026), # Adafruit Feather M4 - (0x239A, 0x8028), # Adafruit pIRKey M0 - (0x239A, 0x802A), # Adafruit Feather 52840 - (0x239A, 0x802C), # Adafruit Itsy M4 - (0x239A, 0x802E), # Adafruit CRICKit M0 - (0x239A, 0xD1ED), # Adafruit HalloWing M0 - ] - # Modules built into CircuitPython which mustn't be used as file names - # for source code. - module_names = {'storage', 'os', 'touchio', 'microcontroller', 'bitbangio', - 'digitalio', 'audiobusio', 'multiterminal', 'nvm', - 'pulseio', 'usb_hid', 'analogio', 'time', 'busio', - 'random', 'audioio', 'sys', 'math', 'builtins'} - - def actions(self): - """ - Return an ordered list of actions provided by this module. An action - is a name (also used to identify the icon) , description, and handler. - """ - buttons = [ - { - 'name': 'serial', - 'display_name': _('Serial'), - 'description': _('Open a serial connection to your device.'), - 'handler': self.toggle_repl, - 'shortcut': 'CTRL+Shift+U', - }, ] - if self.editor.adafruit_run: - buttons.insert(0, { - 'name': 'run', - 'display_name': _('Run'), - 'description': _('Save and run your current file ' - 'on CIRCUITPY'), - 'handler': self.run, - 'shortcut': 'CTRL+Shift+R', - }) - if CHARTS: - buttons.append({ - 'name': 'plotter', - 'display_name': _('Plotter'), - 'description': _('Plot incoming REPL data.'), - 'handler': self.toggle_plotter, - 'shortcut': 'CTRL+Shift+P', - }) - return buttons - - def workspace_dir(self): - """ - Return the default location on the filesystem for opening and closing - files. - """ - device_dir = None - # Attempts to find the path on the filesystem that represents the - # plugged in CIRCUITPY board. - if os.name == 'posix': - # We're on Linux or OSX - for mount_command in ['mount', '/sbin/mount']: - try: - mount_output = check_output(mount_command).splitlines() - mounted_volumes = [x.split()[2] for x in mount_output] - for volume in mounted_volumes: - if volume.endswith(b'CIRCUITPY'): - device_dir = volume.decode('utf-8') - except FileNotFoundError: - next - elif os.name == 'nt': - # We're on Windows. - def get_volume_name(disk_name): - """ - Each disk or external device connected to windows has an - attribute called "volume name". This function returns the - volume name for the given disk/device. - - Code from http://stackoverflow.com/a/12056414 - """ - vol_name_buf = ctypes.create_unicode_buffer(1024) - ctypes.windll.kernel32.GetVolumeInformationW( - ctypes.c_wchar_p(disk_name), vol_name_buf, - ctypes.sizeof(vol_name_buf), None, None, None, None, 0) - return vol_name_buf.value - - # - # In certain circumstances, volumes are allocated to USB - # storage devices which cause a Windows popup to raise if their - # volume contains no media. Wrapping the check in SetErrorMode - # with SEM_FAILCRITICALERRORS (1) prevents this popup. - # - old_mode = ctypes.windll.kernel32.SetErrorMode(1) - try: - for disk in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ': - path = '{}:\\'.format(disk) - if (os.path.exists(path) and - get_volume_name(path) == 'CIRCUITPY'): - return path - finally: - ctypes.windll.kernel32.SetErrorMode(old_mode) - else: - # No support for unknown operating systems. - raise NotImplementedError('OS "{}" not supported.'.format(os.name)) - - if device_dir: - # Found it! - self.connected = True - return device_dir - else: - # Not plugged in? Just return Mu's regular workspace directory - # after warning the user. - wd = super().workspace_dir() - if self.connected: - m = _('Could not find an attached Adafruit CircuitPython' - ' device.') - info = _("Python files for Adafruit CircuitPython devices" - " are stored on the device. Therefore, to edit" - " these files you need to have the device plugged" - " in. Until you plug in a device, Mu will use the" - " directory found here to store new files:\n\n" - " {}\n\n...to store your code.") - self.view.show_message(m, info.format(wd)) - self.connected = False - return wd - - def workspace_dir_cp(self): - """ - Is the file currently being edited located on CIRCUITPY. - """ - return "CIRCUITPY" in str(get_pathname(self)) - - def workspace_cp_avail(self): - """ - Is CIRCUITPY available. - """ - return "CIRCUITPY" in str(self.workspace_dir()) - - def run_adafruit_lib_copy(self, pathname, dst_dir): - """ - Optionally copy lib files to CIRCUITPY. - """ - lib_dir = os.path.dirname(pathname) + "/lib" - if not os.path.isdir(lib_dir): - return - replace_cnt = 0 - for root, dirs, files in os.walk(lib_dir): - for filename in files: - src_lib = lib_dir + "/" + filename - dst_lib_dir = dst_dir + "/lib" - dst_lib = dst_lib_dir + "/" + filename - if not os.path.exists(dst_lib): - replace_lib = True - else: - src_tm = time.ctime(os.path.getmtime(src_lib)) - dst_tm = time.ctime(os.path.getmtime(dst_lib)) - replace_lib = (src_tm > dst_tm) - if replace_lib: - if replace_cnt == 0: - if not os.path.exists(dst_lib_dir): - os.makedirs(dst_lib_dir) - copyfile(src_lib, dst_lib) - replace_cnt = replace_cnt + 1 - # let libraries load before copying source main source file - if replace_cnt > 0: - time.sleep(4) - - def run(self, event): - """ - Save the file and copy to CIRCUITPY if not already there and available. - """ - self.editor.save() - - if not self.workspace_dir_cp() and self.workspace_cp_avail(): - pathname = get_pathname(self) - if pathname: - dst_dir = self.workspace_dir() - if pathname.find("/lib/") == -1: - dst = dst_dir + "/code.py" - else: - dst = dst_dir + "/lib/" + os.path.basename(pathname) - - # copy library files on to device if not working on the device - if self.editor.adafruit_lib: - self.run_adafruit_lib_copy(pathname, dst_dir) - - # copy edited source file on to device - copyfile(pathname, dst) - - def api(self): - """ - Return a list of API specifications to be used by auto-suggest and call - tips. - """ - return SHARED_APIS + ADAFRUIT_APIS diff --git a/mu/modes/circuitpython.py b/mu/modes/circuitpython.py new file mode 100644 index 000000000..627971a51 --- /dev/null +++ b/mu/modes/circuitpython.py @@ -0,0 +1,193 @@ +""" +A mode for working with Circuit Python boards. + +Copyright (c) 2015-2017 Nicholas H.Tollervey and others (see the AUTHORS file). + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" +import os +import ctypes +from subprocess import check_output +from mu.modes.base import MicroPythonMode +from mu.modes.api import ADAFRUIT_APIS, SHARED_APIS +from mu.interface.panes import CHARTS + + +class CircuitPythonMode(MicroPythonMode): + """ + Represents the functionality required by the CircuitPython mode. + """ + + name = _("CircuitPython") + description = _("Write code for boards running CircuitPython.") + icon = "circuitpython" + save_timeout = 0 #: No auto-save on CP boards. Will restart. + connected = True #: is the board connected. + force_interrupt = False #: NO keyboard interrupt on serial connection. + valid_boards = [ + (0x2B04, 0xC00C), # Particle Argon + (0x2B04, 0xC00D), # Particle Boron + (0x2B04, 0xC00E), # Particle Xenon + (0x239A, None), # Any Adafruit Boards + # Non-Adafruit boards + (0x1209, 0xBAB1), # Electronic Cats Meow Meow + (0x1209, 0xBAB2), # Electronic Cats CatWAN USBStick + (0x1209, 0xBAB3), # Electronic Cats Bast Pro Mini M0 + (0x1B4F, 0x8D22), # SparkFun SAMD21 Mini Breakout + (0x1B4F, 0x8D23), # SparkFun SAMD21 Dev Breakout + (0x1209, 0x2017), # Mini SAM M4 + (0x1209, 0x7102), # Mini SAM M0 + ] + # Modules built into CircuitPython which mustn't be used as file names + # for source code. + module_names = { + "storage", + "os", + "touchio", + "microcontroller", + "bitbangio", + "digitalio", + "audiobusio", + "multiterminal", + "nvm", + "pulseio", + "usb_hid", + "analogio", + "time", + "busio", + "random", + "audioio", + "sys", + "math", + "builtins", + } + + def actions(self): + """ + Return an ordered list of actions provided by this module. An action + is a name (also used to identify the icon) , description, and handler. + """ + buttons = [ + { + "name": "serial", + "display_name": _("Serial"), + "description": _("Open a serial connection to your device."), + "handler": self.toggle_repl, + "shortcut": "CTRL+Shift+U", + } + ] + if CHARTS: + buttons.append( + { + "name": "plotter", + "display_name": _("Plotter"), + "description": _("Plot incoming REPL data."), + "handler": self.toggle_plotter, + "shortcut": "CTRL+Shift+P", + } + ) + return buttons + + def workspace_dir(self): + """ + Return the default location on the filesystem for opening and closing + files. + """ + device_dir = None + # Attempts to find the path on the filesystem that represents the + # plugged in CIRCUITPY board. + if os.name == "posix": + # We're on Linux or OSX + for mount_command in ["mount", "/sbin/mount"]: + try: + mount_output = check_output(mount_command).splitlines() + mounted_volumes = [x.split()[2] for x in mount_output] + for volume in mounted_volumes: + if volume.endswith(b"CIRCUITPY"): + device_dir = volume.decode("utf-8") + except FileNotFoundError: + next + elif os.name == "nt": + # We're on Windows. + + def get_volume_name(disk_name): + """ + Each disk or external device connected to windows has an + attribute called "volume name". This function returns the + volume name for the given disk/device. + + Code from http://stackoverflow.com/a/12056414 + """ + vol_name_buf = ctypes.create_unicode_buffer(1024) + ctypes.windll.kernel32.GetVolumeInformationW( + ctypes.c_wchar_p(disk_name), + vol_name_buf, + ctypes.sizeof(vol_name_buf), + None, + None, + None, + None, + 0, + ) + return vol_name_buf.value + + # + # In certain circumstances, volumes are allocated to USB + # storage devices which cause a Windows popup to raise if their + # volume contains no media. Wrapping the check in SetErrorMode + # with SEM_FAILCRITICALERRORS (1) prevents this popup. + # + old_mode = ctypes.windll.kernel32.SetErrorMode(1) + try: + for disk in "ABCDEFGHIJKLMNOPQRSTUVWXYZ": + path = "{}:\\".format(disk) + if ( + os.path.exists(path) + and get_volume_name(path) == "CIRCUITPY" + ): + return path + finally: + ctypes.windll.kernel32.SetErrorMode(old_mode) + else: + # No support for unknown operating systems. + raise NotImplementedError('OS "{}" not supported.'.format(os.name)) + + if device_dir: + # Found it! + self.connected = True + return device_dir + else: + # Not plugged in? Just return Mu's regular workspace directory + # after warning the user. + wd = super().workspace_dir() + if self.connected: + m = _("Could not find an attached CircuitPython device.") + info = _( + "Python files for CircuitPython devices" + " are stored on the device. Therefore, to edit" + " these files you need to have the device plugged in." + " Until you plug in a device, Mu will use the" + " directory found here:\n\n" + " {}\n\n...to store your code." + ) + self.view.show_message(m, info.format(wd)) + self.connected = False + return wd + + def api(self): + """ + Return a list of API specifications to be used by auto-suggest and call + tips. + """ + return SHARED_APIS + ADAFRUIT_APIS diff --git a/tests/interface/test_dialogs.py b/tests/interface/test_dialogs.py index d550c89bf..259a756b4 100644 --- a/tests/interface/test_dialogs.py +++ b/tests/interface/test_dialogs.py @@ -2,11 +2,13 @@ """ Tests for the user interface elements of Mu. """ -from PyQt5.QtWidgets import QApplication, QDialog, QWidget -from unittest import mock -from mu.modes import PythonMode, AdafruitMode, MicrobitMode, DebugMode -import mu.interface.dialogs +import sys +import os import pytest +import mu.interface.dialogs +from PyQt5.QtWidgets import QApplication, QDialog, QWidget, QDialogButtonBox +from unittest import mock +from mu.modes import PythonMode, CircuitPythonMode, MicrobitMode, DebugMode # Required so the QWidget tests don't abort with the message: @@ -19,22 +21,24 @@ def test_ModeItem_init(): """ Ensure that ModeItem objects are setup correctly. """ - name = 'item_name' - description = 'item_description' - icon = 'icon_name' + name = "item_name" + description = "item_description" + icon = "icon_name" mock_text = mock.MagicMock() mock_icon = mock.MagicMock() mock_load = mock.MagicMock(return_value=icon) - with mock.patch('mu.interface.dialogs.QListWidgetItem.setText', - mock_text), \ - mock.patch('mu.interface.dialogs.QListWidgetItem.setIcon', - mock_icon), \ - mock.patch('mu.interface.dialogs.load_icon', mock_load): + with mock.patch( + "mu.interface.dialogs.QListWidgetItem.setText", mock_text + ), mock.patch( + "mu.interface.dialogs.QListWidgetItem.setIcon", mock_icon + ), mock.patch( + "mu.interface.dialogs.load_icon", mock_load + ): mi = mu.interface.dialogs.ModeItem(name, description, icon) assert mi.name == name assert mi.description == description assert mi.icon == icon - mock_text.assert_called_once_with('{}\n{}'.format(name, description)) + mock_text.assert_called_once_with("{}\n{}".format(name, description)) mock_load.assert_called_once_with(icon) mock_icon.assert_called_once_with(icon) @@ -49,16 +53,16 @@ def test_ModeSelector_setup(): editor = mock.MagicMock() view = mock.MagicMock() modes = { - 'python': PythonMode(editor, view), - 'adafruit': AdafruitMode(editor, view), - 'microbit': MicrobitMode(editor, view), - 'debugger': DebugMode(editor, view), + "python": PythonMode(editor, view), + "circuitpython": CircuitPythonMode(editor, view), + "microbit": MicrobitMode(editor, view), + "debugger": DebugMode(editor, view), } - current_mode = 'python' + current_mode = "python" mock_item = mock.MagicMock() - with mock.patch('mu.interface.dialogs.ModeItem', mock_item): - with mock.patch('mu.interface.dialogs.QVBoxLayout'): - with mock.patch('mu.interface.dialogs.QListWidget'): + with mock.patch("mu.interface.dialogs.ModeItem", mock_item): + with mock.patch("mu.interface.dialogs.QVBoxLayout"): + with mock.patch("mu.interface.dialogs.QListWidget"): ms = mu.interface.dialogs.ModeSelector() ms.setLayout = mock.MagicMock() ms.setup(modes, current_mode) @@ -86,11 +90,11 @@ def test_ModeSelector_get_mode(): ms = mu.interface.dialogs.ModeSelector(mock_window) ms.result = mock.MagicMock(return_value=QDialog.Accepted) item = mock.MagicMock() - item.icon = 'name' + item.icon = "name" ms.mode_list = mock.MagicMock() ms.mode_list.currentItem.return_value = item result = ms.get_mode() - assert result == 'name' + assert result == "name" ms.result.return_value = None with pytest.raises(RuntimeError): ms.get_mode() @@ -101,7 +105,7 @@ def test_LogWidget_setup(): Ensure the log widget displays the referenced log file string in the expected way. """ - log = 'this is the contents of a log file' + log = "this is the contents of a log file" lw = mu.interface.dialogs.LogWidget() lw.setup(log) assert lw.log_text_area.toPlainText() == log @@ -113,7 +117,7 @@ def test_EnvironmentVariablesWidget_setup(): Ensure the widget for editing user defined environment variables displays the referenced string in the expected way. """ - envars = 'name=value' + envars = "name=value" evw = mu.interface.dialogs.EnvironmentVariablesWidget() evw.setup(envars) assert evw.text_area.toPlainText() == envars @@ -126,24 +130,22 @@ def test_MicrobitSettingsWidget_setup(): displays the referenced settings data in the expected way. """ minify = True - custom_runtime_path = '/foo/bar' + custom_runtime_path = "/foo/bar" mbsw = mu.interface.dialogs.MicrobitSettingsWidget() mbsw.setup(minify, custom_runtime_path) assert mbsw.minify.isChecked() - assert mbsw.runtime_path.text() == '/foo/bar' + assert mbsw.runtime_path.text() == "/foo/bar" -def test_AdafruitSettingsWidget_setup(): +def test_PackagesWidget_setup(): """ - Ensure the widget for editing settings related to adafruit mode - displays the referenced settings data in the expected way. + Ensure the widget for editing settings related to third party packages + displays the referenced data in the expected way. """ - adafruit_run = True - adafruit_lib = True - mbsw = mu.interface.dialogs.AdafruitSettingsWidget() - mbsw.setup(adafruit_run, adafruit_lib) - assert mbsw.adafruit_run.isChecked() - assert mbsw.adafruit_lib.isChecked() + packages = "foo\nbar\nbaz" + pw = mu.interface.dialogs.PackagesWidget() + pw.setup(packages) + assert pw.text_area.toPlainText() == packages def test_AdminDialog_setup(): @@ -151,19 +153,21 @@ def test_AdminDialog_setup(): Ensure the admin dialog is setup properly given the content of a log file and envars. """ - log = 'this is the contents of a log file' + log = "this is the contents of a log file" settings = { - 'envars': 'name=value', - 'minify': True, - 'microbit_runtime': '/foo/bar', - 'adafruit_run': True, - 'adafruit_lib': True + "envars": "name=value", + "minify": True, + "microbit_runtime": "/foo/bar", } + packages = "foo\nbar\nbaz\n" mock_window = QWidget() ad = mu.interface.dialogs.AdminDialog(mock_window) - ad.setup(log, settings) + ad.setup(log, settings, packages) assert ad.log_widget.log_text_area.toPlainText() == log - assert ad.settings() == settings + s = ad.settings() + assert s["packages"] == packages + del s["packages"] + assert s == settings def test_FindReplaceDialog_setup(): @@ -173,8 +177,8 @@ def test_FindReplaceDialog_setup(): """ frd = mu.interface.dialogs.FindReplaceDialog() frd.setup() - assert frd.find() == '' - assert frd.replace() == '' + assert frd.find() == "" + assert frd.replace() == "" assert frd.replace_flag() is False @@ -183,11 +187,327 @@ def test_FindReplaceDialog_setup_with_args(): Ensure the find/replace dialog is setup properly given only the theme as an argument. """ - find = 'foo' - replace = 'bar' + find = "foo" + replace = "bar" flag = True frd = mu.interface.dialogs.FindReplaceDialog() frd.setup(find, replace, flag) assert frd.find() == find assert frd.replace() == replace assert frd.replace_flag() + + +def test_PackageDialog_setup(): + """ + Ensure the PackageDialog is set up correctly and kicks off the process of + removing and adding packages. + """ + pd = mu.interface.dialogs.PackageDialog() + pd.remove_packages = mock.MagicMock() + pd.run_pip = mock.MagicMock() + to_remove = {"foo"} + to_add = {"bar"} + module_dir = "baz" + pd.setup(to_remove, to_add, module_dir) + pd.remove_packages.assert_called_once_with() + pd.run_pip.assert_called_once_with() + assert pd.button_box.button(QDialogButtonBox.Ok).isEnabled() is False + assert pd.pkg_dirs == {} + + +def test_PackageDialog_remove_packages(): + """ + Ensure the pkg_dirs of to-be-removed packages is correctly filled and the + remove_package method is scheduled. + """ + pd = mu.interface.dialogs.PackageDialog() + pd.to_remove = {"foo", "bar-baz", "Quux"} + pd.module_dir = "wibble" + dirs = [ + "foo-1.0.0.dist-info", + "foo", + "bar_baz-1.0.0.dist-info", + "bar_baz", + "quux-1.0.0.dist-info", + "quux", + ] + with mock.patch( + "mu.interface.dialogs.os.listdir", return_value=dirs + ), mock.patch("mu.interface.dialogs.QTimer") as mock_qtimer: + pd.remove_packages() + assert pd.pkg_dirs == { + "foo": os.path.join("wibble", "foo-1.0.0.dist-info"), + "bar-baz": os.path.join("wibble", "bar_baz-1.0.0.dist-info"), + "Quux": os.path.join("wibble", "quux-1.0.0.dist-info"), + } + mock_qtimer.singleShot.assert_called_once_with(2, pd.remove_package) + + +def test_PackageDialog_remove_package_dist_info(): + """ + Ensures that if there are packages remaining to be deleted, then the next + one is deleted as expected. + """ + pd = mu.interface.dialogs.PackageDialog() + pd.append_data = mock.MagicMock() + pd.pkg_dirs = {"foo": os.path.join("bar", "foo-1.0.0.dist-info")} + pd.module_dir = "baz" + files = [["filename1", ""], ["filename2", ""], ["filename3", ""]] + mock_remove = mock.MagicMock() + mock_shutil = mock.MagicMock() + mock_qtimer = mock.MagicMock() + with mock.patch("builtins.open"), mock.patch( + "mu.interface.dialogs.csv.reader", return_value=files + ), mock.patch("mu.interface.dialogs.os.remove", mock_remove), mock.patch( + "mu.interface.dialogs.shutil", mock_shutil + ), mock.patch( + "mu.interface.dialogs.QTimer", mock_qtimer + ): + pd.remove_package() + assert pd.pkg_dirs == {} + assert mock_remove.call_count == 3 + assert mock_shutil.rmtree.call_count == 3 + pd.append_data.assert_called_once_with("Removed foo\n") + mock_qtimer.singleShot.assert_called_once_with(2, pd.remove_package) + + +def test_PackageDialog_remove_package_dist_info_cannot_delete(): + """ + Ensures that if there are packages remaining to be deleted, then the next + one is deleted and any failures are logged. + """ + pd = mu.interface.dialogs.PackageDialog() + pd.append_data = mock.MagicMock() + pd.pkg_dirs = {"foo": os.path.join("bar", "foo-1.0.0.dist-info")} + pd.module_dir = "baz" + files = [["filename1", ""], ["filename2", ""], ["filename3", ""]] + mock_remove = mock.MagicMock(side_effect=Exception("Bang")) + mock_shutil = mock.MagicMock() + mock_qtimer = mock.MagicMock() + mock_log = mock.MagicMock() + with mock.patch("builtins.open"), mock.patch( + "mu.interface.dialogs.csv.reader", return_value=files + ), mock.patch("mu.interface.dialogs.os.remove", mock_remove), mock.patch( + "mu.interface.dialogs.logger.error", mock_log + ), mock.patch( + "mu.interface.dialogs.shutil", mock_shutil + ), mock.patch( + "mu.interface.dialogs.QTimer", mock_qtimer + ): + pd.remove_package() + assert pd.pkg_dirs == {} + assert mock_remove.call_count == 3 + assert mock_log.call_count == 6 + assert mock_shutil.rmtree.call_count == 3 + pd.append_data.assert_called_once_with("Removed foo\n") + mock_qtimer.singleShot.assert_called_once_with(2, pd.remove_package) + + +def test_PackageDialog_remove_package_egg_info(): + """ + Ensures that if there are packages remaining to be deleted, then the next + one is deleted as expected. + """ + pd = mu.interface.dialogs.PackageDialog() + pd.append_data = mock.MagicMock() + pd.pkg_dirs = {"foo": os.path.join("bar", "foo-1.0.0.egg-info")} + pd.module_dir = "baz" + files = "".join(["filename1\n", "filename2\n", "filename3\n"]) + mock_remove = mock.MagicMock() + mock_shutil = mock.MagicMock() + mock_qtimer = mock.MagicMock() + with mock.patch( + "builtins.open", mock.mock_open(read_data=files) + ), mock.patch("mu.interface.dialogs.os.remove", mock_remove), mock.patch( + "mu.interface.dialogs.shutil", mock_shutil + ), mock.patch( + "mu.interface.dialogs.QTimer", mock_qtimer + ): + pd.remove_package() + assert pd.pkg_dirs == {} + assert mock_remove.call_count == 3 + assert mock_shutil.rmtree.call_count == 3 + pd.append_data.assert_called_once_with("Removed foo\n") + mock_qtimer.singleShot.assert_called_once_with(2, pd.remove_package) + + +def test_PackageDialog_remove_package_egg_info_cannot_delete(): + """ + Ensures that if there are packages remaining to be deleted, then the next + one is deleted and any failures are logged. + """ + pd = mu.interface.dialogs.PackageDialog() + pd.append_data = mock.MagicMock() + pd.pkg_dirs = {"foo": os.path.join("bar", "foo-1.0.0.egg-info")} + pd.module_dir = "baz" + files = "".join(["filename1\n", "filename2\n", "filename3\n"]) + mock_remove = mock.MagicMock(side_effect=Exception("Bang")) + mock_shutil = mock.MagicMock() + mock_qtimer = mock.MagicMock() + mock_log = mock.MagicMock() + with mock.patch( + "builtins.open", mock.mock_open(read_data=files) + ), mock.patch("mu.interface.dialogs.os.remove", mock_remove), mock.patch( + "mu.interface.dialogs.logger.error", mock_log + ), mock.patch( + "mu.interface.dialogs.shutil", mock_shutil + ), mock.patch( + "mu.interface.dialogs.QTimer", mock_qtimer + ): + pd.remove_package() + assert pd.pkg_dirs == {} + assert mock_remove.call_count == 3 + assert mock_log.call_count == 6 + assert mock_shutil.rmtree.call_count == 3 + pd.append_data.assert_called_once_with("Removed foo\n") + mock_qtimer.singleShot.assert_called_once_with(2, pd.remove_package) + + +def test_PackageDialog_remove_package_egg_info_cannot_open_record(): + """ + If the installed-files.txt file is not available (sometimes the case), then + simply raise an exception and communicate this to the user. + """ + pd = mu.interface.dialogs.PackageDialog() + pd.append_data = mock.MagicMock() + pd.pkg_dirs = {"foo": os.path.join("bar", "foo-1.0.0.egg-info")} + pd.module_dir = "baz" + mock_qtimer = mock.MagicMock() + mock_log = mock.MagicMock() + with mock.patch( + "builtins.open", mock.MagicMock(side_effect=Exception("boom")) + ), mock.patch("mu.interface.dialogs.logger.error", mock_log), mock.patch( + "mu.interface.dialogs.QTimer", mock_qtimer + ): + pd.remove_package() + assert pd.pkg_dirs == {} + assert mock_log.call_count == 2 + msg = ( + "UNABLE TO REMOVE PACKAGE: foo (check the logs for " + "more information.)" + ) + pd.append_data.assert_called_once_with(msg) + mock_qtimer.singleShot.assert_called_once_with(2, pd.remove_package) + + +def test_PackageDialog_remove_package_end_state(): + """ + If there are no more packages to remove and there's nothing to be done for + adding packages, then ensure all directories that do not contain files are + deleted and the expected end-state is called. + """ + pd = mu.interface.dialogs.PackageDialog() + pd.module_dir = "foo" + pd.pkg_dirs = {} + pd.to_add = {} + pd.process = None + pd.end_state = mock.MagicMock() + with mock.patch( + "mu.interface.dialogs.os.listdir", return_value=["bar", "baz"] + ), mock.patch( + "mu.interface.dialogs.os.walk", + side_effect=[[("bar", [], [])], [("baz", [], ["x"])]], + ), mock.patch( + "mu.interface.dialogs.shutil" + ) as mock_shutil: + pd.remove_package() + assert mock_shutil.rmtree.call_count == 2 + call_args = mock_shutil.rmtree.call_args_list + assert call_args[0][0][0] == os.path.join("foo", "bar") + assert call_args[1][0][0] == os.path.join("foo", "bin") + pd.end_state.assert_called_once_with() + + +def test_PackageDialog_end_state(): + """ + Ensure the expected end-state is correctly cofigured (for when all tasks + relating to third party packages have finished). + """ + pd = mu.interface.dialogs.PackageDialog() + pd.append_data = mock.MagicMock() + pd.button_box = mock.MagicMock() + pd.end_state() + pd.append_data.assert_called_once_with("\nFINISHED") + pd.button_box.button().setEnabled.assert_called_once_with(True) + + +def test_PackageDialog_run_pip(): + """ + Ensure the expected package to be installed is done so via the expected + correct call to "pip" in a new process (as per the recommended way to + us "pip"). + """ + pd = mu.interface.dialogs.PackageDialog() + pd.to_add = {"foo"} + pd.module_dir = "bar" + mock_process = mock.MagicMock() + with mock.patch("mu.interface.dialogs.QProcess", mock_process): + pd.run_pip() + assert pd.to_add == set() + pd.process.readyRead.connect.assert_called_once_with(pd.read_process) + pd.process.finished.connect.assert_called_once_with(pd.finished) + args = [ + "-m", # run the module + "pip", # called pip + "install", # to install + "foo", # a package called "foo" + "--target", # and the target directory for package assets is... + "bar", # ...this directory + ] + pd.process.start.assert_called_once_with(sys.executable, args) + + +def test_PackageDialog_finished_with_more_to_remove(): + """ + When the pip process is finished, check if there are more packages to + install and run again. + """ + pd = mu.interface.dialogs.PackageDialog() + pd.to_add = {"foo"} + pd.run_pip = mock.MagicMock() + pd.process = mock.MagicMock() + pd.finished() + assert pd.process is None + pd.run_pip.assert_called_once_with() + + +def test_PackageDialog_finished_to_end_state(): + """ + When the pip process is finished, if there are no more packages to install + and there's no more activity for removing packages, move to the end state. + """ + pd = mu.interface.dialogs.PackageDialog() + pd.to_add = set() + pd.pkg_dirs = {} + pd.end_state = mock.MagicMock() + pd.finished() + pd.end_state.assert_called_once_with() + + +def test_PackageDialog_read_process(): + """ + Ensure any data from the subprocess running "pip" is read and appended to + the text area. + """ + pd = mu.interface.dialogs.PackageDialog() + pd.process = mock.MagicMock() + pd.process.readAll().data.return_value = b"hello" + pd.append_data = mock.MagicMock() + mock_timer = mock.MagicMock() + with mock.patch("mu.interface.dialogs.QTimer", mock_timer): + pd.read_process() + pd.append_data.assert_called_once_with("hello") + mock_timer.singleShot.assert_called_once_with(2, pd.read_process) + + +def test_PackageDialog_append_data(): + """ + Ensure that when data is appended, it's added to the end of the text area! + """ + pd = mu.interface.dialogs.PackageDialog() + pd.text_area = mock.MagicMock() + pd.append_data("hello") + c = pd.text_area.textCursor() + assert c.movePosition.call_count == 2 + c.insertText.assert_called_once_with("hello") + pd.text_area.setTextCursor.assert_called_once_with(c) diff --git a/tests/test_logic.py b/tests/test_logic.py index 635941206..96a08a322 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -22,19 +22,17 @@ from mu import __version__ -SESSION = json.dumps({ - 'theme': 'night', - 'mode': 'python', - 'paths': [ - 'path/foo.py', - 'path/bar.py', - ], - 'envars': [ - ['name', 'value'], - ], -}) -ENCODING_COOKIE = '# -*- coding: {} -*- {}'.format(mu.logic.ENCODING, - mu.logic.NEWLINE) +SESSION = json.dumps( + { + "theme": "night", + "mode": "python", + "paths": ["path/foo.py", "path/bar.py"], + "envars": [["name", "value"]], + } +) +ENCODING_COOKIE = "# -*- coding: {} -*-{}".format( + mu.logic.ENCODING, mu.logic.NEWLINE +) # @@ -44,6 +42,7 @@ # tested from among the boilerplate setup code # + def _generate_python_files(contents, dirpath): """Generate a series of .py files, one for each element in an iterable @@ -87,10 +86,18 @@ def generate_python_file(text="", dirpath=None): @contextlib.contextmanager -def generate_session(theme="day", mode="python", file_contents=None, - filepath=None, envars=[['name', 'value'], ], minify=False, - microbit_runtime=None, adafruit_run=False, - adafruit_lib=False, **kwargs): +def generate_session( + theme="day", + mode="python", + file_contents=None, + filepath=None, + envars=[["name", "value"]], + minify=False, + microbit_runtime=None, + zoom_level=2, + window=None, + **kwargs +): """Generate a temporary session file for one test By default, the session file will be created inside a temporary directory @@ -120,22 +127,22 @@ def generate_session(theme="day", mode="python", file_contents=None, dirpath = tempfile.mkdtemp(prefix="mu-") session_data = {} if theme: - session_data['theme'] = theme + session_data["theme"] = theme if mode: - session_data['mode'] = mode + session_data["mode"] = mode if file_contents: paths = _generate_python_files(file_contents, dirpath) - session_data['paths'] = list(paths) + session_data["paths"] = list(paths) if envars: - session_data['envars'] = envars + session_data["envars"] = envars if minify is not None: - session_data['minify'] = minify + session_data["minify"] = minify if microbit_runtime: - session_data['microbit_runtime'] = microbit_runtime - if adafruit_run is not None: - session_data['adafruit_run'] = adafruit_run - if adafruit_lib is not None: - session_data['adafruit_lib'] = adafruit_lib + session_data["microbit_runtime"] = microbit_runtime + if zoom_level: + session_data["zoom_level"] = zoom_level + if window: + session_data["window"] = window session_data.update(**kwargs) if filepath is None: @@ -144,7 +151,7 @@ def generate_session(theme="day", mode="python", file_contents=None, with open(filepath, "w") as f: f.write(json.dumps(session_data)) session = dict(session_data) - session['session_filepath'] = filepath + session["session_filepath"] = filepath with mock.patch("mu.logic.get_session_path", return_value=filepath): yield session shutil.rmtree(dirpath) @@ -177,11 +184,9 @@ def mocked_editor(mode="python", text=None, path=None, newline=None): ed.select_mode = mock.MagicMock() mock_mode = mock.MagicMock() mock_mode.save_timeout = 5 - mock_mode.workspace_dir.return_value = '/fake/path' + mock_mode.workspace_dir.return_value = "/fake/path" mock_mode.api.return_value = ["API Specification"] - ed.modes = { - mode: mock_mode, - } + ed.modes = {mode: mock_mode} return ed @@ -194,6 +199,92 @@ def test_CONSTANTS(): assert mu.logic.WORKSPACE_NAME +def test_installed_packages_dist_info(): + """ + Ensure module meta-data is processed properly to give a return value of a + list containing all the installed modules currently in the MODULE_DIR. + """ + mock_listdir = mock.MagicMock( + return_value=["foo-1.0.0.dist-info", "bar-2.0.0.dist-info", "baz"] + ) + mock_open = mock.MagicMock() + mock_file = mock.MagicMock() + mock_open().__enter__ = mock.MagicMock(return_value=mock_file) + foo_metadata = [ + b"Metadata-Version: 2.1", + b"Name: foo", + b"test: \xe6\x88\x91", + ] + bar_metadata = [ + b"Metadata-Version: 2.1", + b"Name: bar", + b"test: \xe6\x88\x91", + ] + mock_file.readlines = mock.MagicMock( + side_effect=[foo_metadata, bar_metadata] + ) + with mock.patch("builtins.open", mock_open), mock.patch( + "mu.logic.os.listdir", mock_listdir + ): + mock_open.reset_mock() + result = mu.logic.installed_packages() + assert mock_open.call_args_list[0][0][0].endswith("METADATA") + assert mock_open.call_args_list[1][0][0].endswith("METADATA") + assert result == ["bar", "foo"] # ordered result. + + +def test_installed_packages_egg_info(): + """ + Ensure module meta-data is processed properly to give a return value of a + list containing all the installed modules currently in the MODULE_DIR. + """ + mock_listdir = mock.MagicMock( + return_value=["foo-1.0.0.egg-info", "bar-2.0.0.egg-info", "baz"] + ) + mock_open = mock.MagicMock() + mock_file = mock.MagicMock() + mock_open().__enter__ = mock.MagicMock(return_value=mock_file) + foo_metadata = [ + b"Metadata-Version: 2.1", + b"Name: foo", + b"test: \xe6\x88\x91", + ] + bar_metadata = [ + b"Metadata-Version: 2.1", + b"Name: bar", + b"test: \xe6\x88\x91", + ] + mock_file.readlines = mock.MagicMock( + side_effect=[foo_metadata, bar_metadata] + ) + with mock.patch("builtins.open", mock_open), mock.patch( + "mu.logic.os.listdir", mock_listdir + ): + mock_open.reset_mock() + result = mu.logic.installed_packages() + assert mock_open.call_args_list[0][0][0].endswith("PKG-INFO") + assert mock_open.call_args_list[1][0][0].endswith("PKG-INFO") + assert result == ["bar", "foo"] # ordered result. + + +def test_installed_packages_errors(): + """ + If there's an error opening the expected metadata file, then just ignore + and log. + """ + mock_listdir = mock.MagicMock( + return_value=["foo-1.0.0.egg-info", "bar-2.0.0.egg-info", "baz"] + ) + mock_open = mock.MagicMock(side_effect=Exception("Boom")) + with mock.patch("builtins.open", mock_open), mock.patch( + "mu.logic.os.listdir", mock_listdir + ), mock.patch("mu.logic.logger.error") as mock_log: + mock_open.reset_mock() + result = mu.logic.installed_packages() + assert result == [] + assert mock_log.call_count == 4 + + def test_write_and_flush(): """ Ensure the write and flush function tries to write to the filesystem and @@ -201,7 +292,7 @@ def test_write_and_flush(): """ mock_fd = mock.MagicMock() mock_content = mock.MagicMock() - with mock.patch('mu.logic.os.fsync') as fsync: + with mock.patch("mu.logic.os.fsync") as fsync: mu.logic.write_and_flush(mock_fd, mock_content) fsync.assert_called_once_with(mock_fd) mock_fd.write.assert_called_once_with(mock_content) @@ -213,39 +304,43 @@ def test_save_and_encode(): When saving, ensure that encoding cookies are honoured, otherwise fall back to the default encoding (UTF-8 -- as per Python standard practice). """ - encoding_cookie = '# -*- coding: latin-1 -*-' + encoding_cookie = "# -*- coding: latin-1 -*-" text = encoding_cookie + '\n\nprint("Hello")' mock_open = mock.MagicMock() mock_wandf = mock.MagicMock() # Valid cookie - with mock.patch('mu.logic.open', mock_open), \ - mock.patch('mu.logic.write_and_flush', mock_wandf): - mu.logic.save_and_encode(text, 'foo.py') - mock_open.assert_called_once_with('foo.py', 'w', encoding='latin-1', - newline='') + with mock.patch("mu.logic.open", mock_open), mock.patch( + "mu.logic.write_and_flush", mock_wandf + ): + mu.logic.save_and_encode(text, "foo.py") + mock_open.assert_called_once_with( + "foo.py", "w", encoding="latin-1", newline="" + ) assert mock_wandf.call_count == 1 mock_open.reset_mock() mock_wandf.reset_mock() # Invalid cookie - encoding_cookie = '# -*- coding: utf-42 -*-' + encoding_cookie = "# -*- coding: utf-42 -*-" text = encoding_cookie + '\n\nprint("Hello")' - with mock.patch('mu.logic.open', mock_open), \ - mock.patch('mu.logic.write_and_flush', mock_wandf): - mu.logic.save_and_encode(text, 'foo.py') - mock_open.assert_called_once_with('foo.py', 'w', - encoding=mu.logic.ENCODING, - newline='') + with mock.patch("mu.logic.open", mock_open), mock.patch( + "mu.logic.write_and_flush", mock_wandf + ): + mu.logic.save_and_encode(text, "foo.py") + mock_open.assert_called_once_with( + "foo.py", "w", encoding=mu.logic.ENCODING, newline="" + ) assert mock_wandf.call_count == 1 mock_open.reset_mock() mock_wandf.reset_mock() # No cookie text = 'print("Hello")' - with mock.patch('mu.logic.open', mock_open), \ - mock.patch('mu.logic.write_and_flush', mock_wandf): - mu.logic.save_and_encode(text, 'foo.py') - mock_open.assert_called_once_with('foo.py', 'w', - encoding=mu.logic.ENCODING, - newline='') + with mock.patch("mu.logic.open", mock_open), mock.patch( + "mu.logic.write_and_flush", mock_wandf + ): + mu.logic.save_and_encode(text, "foo.py") + mock_open.assert_called_once_with( + "foo.py", "w", encoding=mu.logic.ENCODING, newline="" + ) assert mock_wandf.call_count == 1 @@ -254,9 +349,10 @@ def test_sniff_encoding_from_BOM(): Ensure an expected BOM detected at the start of the referenced file is used to set the expected encoding. """ - with mock.patch('mu.logic.open', - mock.mock_open(read_data=codecs.BOM_UTF8 + b'# hello')): - assert mu.logic.sniff_encoding('foo.py') == 'utf-8-sig' + with mock.patch( + "mu.logic.open", mock.mock_open(read_data=codecs.BOM_UTF8 + b"# hello") + ): + assert mu.logic.sniff_encoding("foo.py") == "utf-8-sig" def test_sniff_encoding_from_cookie(): @@ -264,26 +360,26 @@ def test_sniff_encoding_from_cookie(): If there's a cookie present, then use that to work out the expected encoding. """ - encoding_cookie = b'# -*- coding: latin-1 -*-' + encoding_cookie = b"# -*- coding: latin-1 -*-" mock_locale = mock.MagicMock() - mock_locale.getpreferredencoding.return_value = 'UTF-8' - with mock.patch('mu.logic.open', - mock.mock_open(read_data=encoding_cookie)), \ - mock.patch('mu.logic.locale', mock_locale): - assert mu.logic.sniff_encoding('foo.py') == 'latin-1' + mock_locale.getpreferredencoding.return_value = "UTF-8" + with mock.patch( + "mu.logic.open", mock.mock_open(read_data=encoding_cookie) + ), mock.patch("mu.logic.locale", mock_locale): + assert mu.logic.sniff_encoding("foo.py") == "latin-1" def test_sniff_encoding_from_bad_cookie(): """ If there's a cookie present but we can't even read it, then return None. """ - encoding_cookie = '# -*- coding: silly-你好 -*-'.encode('utf-8') + encoding_cookie = "# -*- coding: silly-你好 -*-".encode("utf-8") mock_locale = mock.MagicMock() - mock_locale.getpreferredencoding.return_value = 'ascii' - with mock.patch('mu.logic.open', - mock.mock_open(read_data=encoding_cookie)), \ - mock.patch('mu.logic.locale', mock_locale): - assert mu.logic.sniff_encoding('foo.py') is None + mock_locale.getpreferredencoding.return_value = "ascii" + with mock.patch( + "mu.logic.open", mock.mock_open(read_data=encoding_cookie) + ), mock.patch("mu.logic.locale", mock_locale): + assert mu.logic.sniff_encoding("foo.py") is None def test_sniff_encoding_fallback_to_locale(): @@ -291,19 +387,19 @@ def test_sniff_encoding_fallback_to_locale(): If there's no encoding information in the file, just return None. """ mock_locale = mock.MagicMock() - mock_locale.getpreferredencoding.return_value = 'ascii' - with mock.patch('mu.logic.open', - mock.mock_open(read_data=b'# hello')), \ - mock.patch('mu.logic.locale', mock_locale): - assert mu.logic.sniff_encoding('foo.py') is None + mock_locale.getpreferredencoding.return_value = "ascii" + with mock.patch( + "mu.logic.open", mock.mock_open(read_data=b"# hello") + ), mock.patch("mu.logic.locale", mock_locale): + assert mu.logic.sniff_encoding("foo.py") is None def test_sniff_newline_convention(): """ Ensure sniff_newline_convention returns the expected newline convention. """ - text = 'the\r\ncat\nsat\non\nthe\r\nmat' - assert mu.logic.sniff_newline_convention(text) == '\n' + text = "the\r\ncat\nsat\non\nthe\r\nmat" + assert mu.logic.sniff_newline_convention(text) == "\n" def test_sniff_newline_convention_local(): @@ -311,7 +407,7 @@ def test_sniff_newline_convention_local(): Ensure sniff_newline_convention returns the local newline convention if it cannot determine it from the text. """ - text = 'There are no new lines here' + text = "There are no new lines here" assert mu.logic.sniff_newline_convention(text) == os.linesep @@ -321,12 +417,13 @@ def test_get_admin_file_path(): NOT frozen by PyInstaller. """ fake_app_path = os.path.dirname(__file__) - fake_app_script = os.path.join(fake_app_path, 'run.py') - wrong_fake_path = 'wrong/path/to/executable' - fake_local_settings = os.path.join(fake_app_path, 'settings.json') - with mock.patch.object(sys, 'executable', wrong_fake_path), \ - mock.patch.object(sys, 'argv', [fake_app_script]): - result = mu.logic.get_admin_file_path('settings.json') + fake_app_script = os.path.join(fake_app_path, "run.py") + wrong_fake_path = "wrong/path/to/executable" + fake_local_settings = os.path.join(fake_app_path, "settings.json") + with mock.patch.object( + sys, "executable", wrong_fake_path + ), mock.patch.object(sys, "argv", [fake_app_script]): + result = mu.logic.get_admin_file_path("settings.json") assert result == fake_local_settings @@ -336,14 +433,19 @@ def test_get_admin_file_path_frozen(): using PyInstaller. """ fake_app_path = os.path.dirname(__file__) - fake_app_script = os.path.join(fake_app_path, 'mu.exe') - wrong_fake_path = 'wrong/path/to/executable' - fake_local_settings = os.path.join(fake_app_path, 'settings.json') - with mock.patch.object(sys, 'frozen', create=True, return_value=True), \ - mock.patch('platform.system', return_value='not_Darwin'), \ - mock.patch.object(sys, 'executable', fake_app_script), \ - mock.patch.object(sys, 'argv', [wrong_fake_path]): - result = mu.logic.get_admin_file_path('settings.json') + fake_app_script = os.path.join(fake_app_path, "mu.exe") + wrong_fake_path = "wrong/path/to/executable" + fake_local_settings = os.path.join(fake_app_path, "settings.json") + with mock.patch.object( + sys, "frozen", create=True, return_value=True + ), mock.patch( + "platform.system", return_value="not_Darwin" + ), mock.patch.object( + sys, "executable", fake_app_script + ), mock.patch.object( + sys, "argv", [wrong_fake_path] + ): + result = mu.logic.get_admin_file_path("settings.json") assert result == fake_local_settings @@ -352,16 +454,20 @@ def test_get_admin_file_path_frozen_osx(): Find an admin file in the application location when it has been frozen using PyInstaller on macOS (as the path is different in the app bundle). """ - fake_app_path = os.path.join(os.path.dirname(__file__), 'a', 'b', 'c') - fake_app_script = os.path.join(fake_app_path, 'mu.exe') - wrong_fake_path = 'wrong/path/to/executable' - fake_local_settings = os.path.abspath(os.path.join( - fake_app_path, '..', '..', '..', 'settings.json')) - with mock.patch.object(sys, 'frozen', create=True, return_value=True), \ - mock.patch('platform.system', return_value='Darwin'), \ - mock.patch.object(sys, 'executable', fake_app_script), \ - mock.patch.object(sys, 'argv', [wrong_fake_path]): - result = mu.logic.get_admin_file_path('settings.json') + fake_app_path = os.path.join(os.path.dirname(__file__), "a", "b", "c") + fake_app_script = os.path.join(fake_app_path, "mu.exe") + wrong_fake_path = "wrong/path/to/executable" + fake_local_settings = os.path.abspath( + os.path.join(fake_app_path, "..", "..", "..", "settings.json") + ) + with mock.patch.object( + sys, "frozen", create=True, return_value=True + ), mock.patch("platform.system", return_value="Darwin"), mock.patch.object( + sys, "executable", fake_app_script + ), mock.patch.object( + sys, "argv", [wrong_fake_path] + ): + result = mu.logic.get_admin_file_path("settings.json") assert result == fake_local_settings @@ -373,12 +479,13 @@ def test_get_admin_file_path_with_data_path(): mock_exists = mock.MagicMock() mock_exists.side_effect = [False, True] mock_json_dump = mock.MagicMock() - with mock.patch('os.path.exists', mock_exists), \ - mock.patch('builtins.open', mock_open), \ - mock.patch('json.dump', mock_json_dump), \ - mock.patch('mu.logic.DATA_DIR', 'fake_path'): - result = mu.logic.get_admin_file_path('settings.json') - assert result == os.path.join('fake_path', 'settings.json') + with mock.patch("os.path.exists", mock_exists), mock.patch( + "builtins.open", mock_open + ), mock.patch("json.dump", mock_json_dump), mock.patch( + "mu.logic.DATA_DIR", "fake_path" + ): + result = mu.logic.get_admin_file_path("settings.json") + assert result == os.path.join("fake_path", "settings.json") assert not mock_json_dump.called @@ -388,12 +495,13 @@ def test_get_admin_file_path_no_files(): """ mock_open = mock.mock_open() mock_json_dump = mock.MagicMock() - with mock.patch('os.path.exists', return_value=False), \ - mock.patch('builtins.open', mock_open), \ - mock.patch('json.dump', mock_json_dump), \ - mock.patch('mu.logic.DATA_DIR', 'fake_path'): - result = mu.logic.get_admin_file_path('settings.json') - assert result == os.path.join('fake_path', 'settings.json') + with mock.patch("os.path.exists", return_value=False), mock.patch( + "builtins.open", mock_open + ), mock.patch("json.dump", mock_json_dump), mock.patch( + "mu.logic.DATA_DIR", "fake_path" + ): + result = mu.logic.get_admin_file_path("settings.json") + assert result == os.path.join("fake_path", "settings.json") assert mock_json_dump.call_count == 1 @@ -403,17 +511,21 @@ def test_get_admin_file_path_no_files_cannot_create(): make do. """ mock_open = mock.MagicMock() - mock_open.return_value.__enter__.side_effect = FileNotFoundError('Bang') + mock_open.return_value.__enter__.side_effect = FileNotFoundError("Bang") mock_open.return_value.__exit__ = mock.Mock() mock_json_dump = mock.MagicMock() - with mock.patch('os.path.exists', return_value=False), \ - mock.patch('builtins.open', mock_open), \ - mock.patch('json.dump', mock_json_dump), \ - mock.patch('mu.logic.DATA_DIR', 'fake_path'), \ - mock.patch('mu.logic.logger', return_value=None) as logger: - mu.logic.get_admin_file_path('settings.json') - msg = 'Unable to create admin file: ' \ - 'fake_path{}settings.json'.format(os.path.sep) + with mock.patch("os.path.exists", return_value=False), mock.patch( + "builtins.open", mock_open + ), mock.patch("json.dump", mock_json_dump), mock.patch( + "mu.logic.DATA_DIR", "fake_path" + ), mock.patch( + "mu.logic.logger", return_value=None + ) as logger: + mu.logic.get_admin_file_path("settings.json") + msg = ( + "Unable to create admin file: " + "fake_path{}settings.json".format(os.path.sep) + ) logger.error.assert_called_once_with(msg) @@ -422,10 +534,10 @@ def test_get_session_path(): Ensure the result of calling get_admin_file_path with session.json returns the expected result. """ - mock_func = mock.MagicMock(return_value='foo') - with mock.patch('mu.logic.get_admin_file_path', mock_func): - assert mu.logic.get_session_path() == 'foo' - mock_func.assert_called_once_with('session.json') + mock_func = mock.MagicMock(return_value="foo") + with mock.patch("mu.logic.get_admin_file_path", mock_func): + assert mu.logic.get_session_path() == "foo" + mock_func.assert_called_once_with("session.json") def test_get_settings_path(): @@ -433,10 +545,10 @@ def test_get_settings_path(): Ensure the result of calling get_admin_file_path with settings.json returns the expected result. """ - mock_func = mock.MagicMock(return_value='foo') - with mock.patch('mu.logic.get_admin_file_path', mock_func): - assert mu.logic.get_settings_path() == 'foo' - mock_func.assert_called_once_with('settings.json') + mock_func = mock.MagicMock(return_value="foo") + with mock.patch("mu.logic.get_admin_file_path", mock_func): + assert mu.logic.get_settings_path() == "foo" + mock_func.assert_called_once_with("settings.json") def test_extract_envars(): @@ -446,10 +558,7 @@ def test_extract_envars(): """ raw = "FOO=BAR\n BAZ = Q=X \n\n\n" expected = mu.logic.extract_envars(raw) - assert expected == [ - ['FOO', 'BAR'], - ['BAZ', 'Q=X'], - ] + assert expected == [["FOO", "BAR"], ["BAZ", "Q=X"]] def test_check_flake(): @@ -458,12 +567,13 @@ def test_check_flake(): reporter. """ mock_r = mock.MagicMock() - mock_r.log = [{'line_no': 2, 'column': 0, 'message': 'b'}] - with mock.patch('mu.logic.MuFlakeCodeReporter', return_value=mock_r), \ - mock.patch('mu.logic.check', return_value=None) as mock_check: - result = mu.logic.check_flake('foo.py', 'some code') + mock_r.log = [{"line_no": 2, "column": 0, "message": "b"}] + with mock.patch( + "mu.logic.MuFlakeCodeReporter", return_value=mock_r + ), mock.patch("mu.logic.check", return_value=None) as mock_check: + result = mu.logic.check_flake("foo.py", "some code") assert result == {2: mock_r.log} - mock_check.assert_called_once_with('some code', 'foo.py', mock_r) + mock_check.assert_called_once_with("some code", "foo.py", mock_r) def test_check_flake_needing_expansion(): @@ -473,14 +583,16 @@ def test_check_flake_needing_expansion(): """ mock_r = mock.MagicMock() msg = "'microbit.foo' imported but unused" - mock_r.log = [{'line_no': 2, 'column': 0, 'message': msg}] - with mock.patch('mu.logic.MuFlakeCodeReporter', return_value=mock_r), \ - mock.patch('mu.logic.check', return_value=None) as mock_check: - code = 'from microbit import *' - result = mu.logic.check_flake('foo.py', code) + mock_r.log = [{"line_no": 2, "column": 0, "message": msg}] + with mock.patch( + "mu.logic.MuFlakeCodeReporter", return_value=mock_r + ), mock.patch("mu.logic.check", return_value=None) as mock_check: + code = "from microbit import *" + result = mu.logic.check_flake("foo.py", code) assert result == {} - mock_check.assert_called_once_with(mu.logic.EXPANDED_IMPORT, 'foo.py', - mock_r) + mock_check.assert_called_once_with( + mu.logic.EXPANDED_IMPORT, "foo.py", mock_r + ) def test_check_flake_with_builtins(): @@ -489,14 +601,38 @@ def test_check_flake_with_builtins(): messages for them are ignored. """ mock_r = mock.MagicMock() - mock_r.log = [{'line_no': 2, 'column': 0, - 'message': "undefined name 'foo'"}] - with mock.patch('mu.logic.MuFlakeCodeReporter', return_value=mock_r), \ - mock.patch('mu.logic.check', return_value=None) as mock_check: - result = mu.logic.check_flake('foo.py', 'some code', - builtins=['foo', ]) + mock_r.log = [ + {"line_no": 2, "column": 0, "message": "undefined name 'foo'"} + ] + with mock.patch( + "mu.logic.MuFlakeCodeReporter", return_value=mock_r + ), mock.patch("mu.logic.check", return_value=None) as mock_check: + result = mu.logic.check_flake("foo.py", "some code", builtins=["foo"]) assert result == {} - mock_check.assert_called_once_with('some code', 'foo.py', mock_r) + mock_check.assert_called_once_with("some code", "foo.py", mock_r) + + +def test_check_pycodestyle_E121(): + """ + Ensure the expected result is generated from the PEP8 style validator. + Should ensure we honor a mu internal override of E123 error + """ + code = "mylist = [\n 1, 2,\n 3, 4,\n ]" # would have Generated E123 + result = mu.logic.check_pycodestyle(code) + assert len(result) == 0 + + +def test_check_pycodestyle_custom_override(): + """ + Ensure the expected result if generated from the PEP8 style validator. + For this test we have overridden the E265 error check via a custom + override "pycodestyle" file in a directory pointed to by the content of + scripts/codecheck.ini. We should "not" get and E265 error due to the + lack of space after the # + """ + code = "# OK\n#this is ok if we override the E265 check\n" + result = mu.logic.check_pycodestyle(code, "tests/scripts/pycodestyle") + assert len(result) == 0 def test_check_pycodestyle(): @@ -506,10 +642,10 @@ def test_check_pycodestyle(): code = "import foo\n\n\n\n\n\ndef bar():\n pass\n" # Generate E303 result = mu.logic.check_pycodestyle(code) assert len(result) == 1 - assert result[6][0]['line_no'] == 6 - assert result[6][0]['column'] == 0 - assert ' above this line' in result[6][0]['message'] - assert result[6][0]['code'] == 'E303' + assert result[6][0]["line_no"] == 6 + assert result[6][0]["column"] == 0 + assert " above this line" in result[6][0]["message"] + assert result[6][0]["code"] == "E303" def test_check_pycodestyle_with_non_ascii(): @@ -540,11 +676,11 @@ def test_MuFlakeCodeReporter_unexpected_error(): Check the reporter handles unexpected errors. """ r = mu.logic.MuFlakeCodeReporter() - r.unexpectedError('foo.py', 'Nobody expects the Spanish Inquisition!') + r.unexpectedError("foo.py", "Nobody expects the Spanish Inquisition!") assert len(r.log) == 1 - assert r.log[0]['line_no'] == 0 - assert r.log[0]['filename'] == 'foo.py' - assert r.log[0]['message'] == 'Nobody expects the Spanish Inquisition!' + assert r.log[0]["line_no"] == 0 + assert r.log[0]["filename"] == "foo.py" + assert r.log[0]["message"] == "Nobody expects the Spanish Inquisition!" def test_MuFlakeCodeReporter_syntax_error(): @@ -552,16 +688,19 @@ def test_MuFlakeCodeReporter_syntax_error(): Check the reporter handles syntax errors in a humane and kid friendly manner. """ - msg = ('Syntax error. Python cannot understand this line. Check for ' - 'missing characters!') + msg = ( + "Syntax error. Python cannot understand this line. Check for " + "missing characters!" + ) r = mu.logic.MuFlakeCodeReporter() - r.syntaxError('foo.py', 'something incomprehensible to kids', '2', 3, - 'source') + r.syntaxError( + "foo.py", "something incomprehensible to kids", "2", 3, "source" + ) assert len(r.log) == 1 - assert r.log[0]['line_no'] == 1 - assert r.log[0]['message'] == msg - assert r.log[0]['column'] == 2 - assert r.log[0]['source'] == 'source' + assert r.log[0]["line_no"] == 1 + assert r.log[0]["message"] == msg + assert r.log[0]["column"] == 2 + assert r.log[0]["source"] == "source" def test_MuFlakeCodeReporter_flake_matched(): @@ -573,9 +712,9 @@ def test_MuFlakeCodeReporter_flake_matched(): err = "foo.py:4: something went wrong" r.flake(err) assert len(r.log) == 1 - assert r.log[0]['line_no'] == 3 - assert r.log[0]['column'] == 0 - assert r.log[0]['message'] == 'something went wrong' + assert r.log[0]["line_no"] == 3 + assert r.log[0]["column"] == 0 + assert r.log[0]["message"] == "something went wrong" def test_MuFlakeCodeReporter_flake_un_matched(): @@ -587,36 +726,36 @@ def test_MuFlakeCodeReporter_flake_un_matched(): err = "something went wrong" r.flake(err) assert len(r.log) == 1 - assert r.log[0]['line_no'] == 0 - assert r.log[0]['column'] == 0 - assert r.log[0]['message'] == 'something went wrong' + assert r.log[0]["line_no"] == 0 + assert r.log[0]["column"] == 0 + assert r.log[0]["message"] == "something went wrong" def test_REPL_posix(): """ The port is set correctly in a posix environment. """ - with mock.patch('os.name', 'posix'): - r = mu.logic.REPL('ttyACM0') - assert r.port == '/dev/ttyACM0' + with mock.patch("os.name", "posix"): + r = mu.logic.REPL("ttyACM0") + assert r.port == "/dev/ttyACM0" def test_REPL_nt(): """ The port is set correctly in an nt (Windows) environment. """ - with mock.patch('os.name', 'nt'): - r = mu.logic.REPL('COM0') - assert r.port == 'COM0' + with mock.patch("os.name", "nt"): + r = mu.logic.REPL("COM0") + assert r.port == "COM0" def test_REPL_unsupported(): """ A NotImplementedError is raised on an unsupported OS. """ - with mock.patch('os.name', 'SPARC'): + with mock.patch("os.name", "SPARC"): with pytest.raises(NotImplementedError): - mu.logic.REPL('tty0') + mu.logic.REPL("tty0") def test_editor_init(): @@ -627,23 +766,25 @@ def test_editor_init(): view = mock.MagicMock() # Check the editor attempts to create required directories if they don't # already exist. - with mock.patch('os.path.exists', return_value=False), \ - mock.patch('os.makedirs', return_value=None) as mkd: + with mock.patch("os.path.exists", return_value=False), mock.patch( + "os.makedirs", return_value=None + ) as mkd: e = mu.logic.Editor(view) assert e._view == view - assert e.theme == 'day' - assert e.mode == 'python' + assert e.theme == "day" + assert e.mode == "python" assert e.modes == {} assert e.envars == [] assert e.minify is False - assert e.microbit_runtime == '' + assert e.microbit_runtime == "" assert e.connected_devices == set() - assert e.find == '' - assert e.replace == '' + assert e.find == "" + assert e.replace == "" assert e.global_replace is False assert e.selecting_mode is False - assert mkd.call_count == 1 + assert mkd.call_count == 2 assert mkd.call_args_list[0][0][0] == mu.logic.DATA_DIR + assert mkd.call_args_list[1][0][0] == mu.logic.MODULE_DIR def test_editor_setup(): @@ -653,17 +794,19 @@ def test_editor_setup(): view = mock.MagicMock() e = mu.logic.Editor(view) mock_mode = mock.MagicMock() - mock_mode.workspace_dir.return_value = 'foo' - mock_modes = { - 'python': mock_mode, - } - with mock.patch('os.path.exists', return_value=False), \ - mock.patch('os.makedirs', return_value=None) as mkd, \ - mock.patch('shutil.copy') as mock_shutil: + mock_mode.workspace_dir.return_value = "foo" + mock_modes = {"python": mock_mode} + with mock.patch("os.path.exists", return_value=False), mock.patch( + "os.makedirs", return_value=None + ) as mkd, mock.patch("shutil.copy") as mock_shutil_copy, mock.patch( + "shutil.copytree" + ) as mock_shutil_copytree: e.setup(mock_modes) assert mkd.call_count == 5 - assert mkd.call_args_list[0][0][0] == 'foo' - assert mock_shutil.call_count == 3 + assert mkd.call_args_list[0][0][0] == "foo" + asset_len = len(mu.logic.DEFAULT_IMAGES) + len(mu.logic.DEFAULT_SOUNDS) + assert mock_shutil_copy.call_count == asset_len + assert mock_shutil_copytree.call_count == 2 assert e.modes == mock_modes view.set_usb_checker.assert_called_once_with(1, e.check_usb) @@ -675,19 +818,19 @@ def test_editor_restore_session_existing_runtime(): mode, theme = "python", "night" file_contents = ["", ""] ed = mocked_editor(mode) - with mock.patch('os.path.isfile', return_value=True): - with generate_session(theme, mode, file_contents, - microbit_runtime='/foo'): + with mock.patch("os.path.isfile", return_value=True): + with generate_session( + theme, mode, file_contents, microbit_runtime="/foo", zoom_level=5 + ): ed.restore_session() assert ed.theme == theme assert ed._view.add_tab.call_count == len(file_contents) ed._view.set_theme.assert_called_once_with(theme) - assert ed.envars == [['name', 'value'], ] + assert ed.envars == [["name", "value"]] assert ed.minify is False - assert ed.microbit_runtime == '/foo' - assert ed.adafruit_run is False - assert ed.adafruit_lib is False + assert ed.microbit_runtime == "/foo" + assert ed._view.zoom_position == 5 def test_editor_restore_session_missing_runtime(): @@ -699,17 +842,15 @@ def test_editor_restore_session_missing_runtime(): file_contents = ["", ""] ed = mocked_editor(mode) - with generate_session(theme, mode, file_contents, microbit_runtime='/foo'): + with generate_session(theme, mode, file_contents, microbit_runtime="/foo"): ed.restore_session() assert ed.theme == theme assert ed._view.add_tab.call_count == len(file_contents) ed._view.set_theme.assert_called_once_with(theme) - assert ed.envars == [['name', 'value'], ] + assert ed.envars == [["name", "value"]] assert ed.minify is False - assert ed.microbit_runtime == '' # File does not exist so set to '' - assert ed.adafruit_run is False - assert ed.adafruit_lib is False + assert ed.microbit_runtime == "" # File does not exist so set to '' def test_editor_restore_session_missing_files(): @@ -717,22 +858,21 @@ def test_editor_restore_session_missing_files(): Missing files that were opened tabs in the previous session are safely ignored when attempting to restore them. """ - fake_session = os.path.join(os.path.dirname(__file__), 'session.json') + fake_session = os.path.join(os.path.dirname(__file__), "session.json") view = mock.MagicMock() ed = mu.logic.Editor(view) ed._view.add_tab = mock.MagicMock() mock_mode = mock.MagicMock() - mock_mode.workspace_dir.return_value = '/fake/path' + mock_mode.workspace_dir.return_value = "/fake/path" mock_mode.save_timeout = 5 - ed.modes = { - 'python': mock_mode, - } + ed.modes = {"python": mock_mode} mock_gettext = mock.MagicMock() - mock_gettext.return_value = '# Write your code here :-)' + mock_gettext.return_value = "# Write your code here :-)" get_test_session_path = mock.MagicMock() get_test_session_path.return_value = fake_session - with mock.patch('os.path.exists', return_value=True), \ - mock.patch('mu.logic.get_session_path', get_test_session_path): + with mock.patch("os.path.exists", return_value=True), mock.patch( + "mu.logic.get_session_path", get_test_session_path + ): ed.restore_session() assert ed._view.add_tab.call_count == 0 @@ -761,19 +901,17 @@ def test_editor_restore_session_no_session_file(): ed._view.add_tab = mock.MagicMock() ed.select_mode = mock.MagicMock() mock_mode = mock.MagicMock() - api = ['API specification', ] + api = ["API specification"] mock_mode.api.return_value = api - mock_mode.workspace_dir.return_value = '/fake/path' + mock_mode.workspace_dir.return_value = "/fake/path" mock_mode.save_timeout = 5 - ed.modes = { - 'python': mock_mode, - } + mock_mode.code_template = "Hello" + ed.modes = {"python": mock_mode} mock_gettext = mock.MagicMock() - mock_gettext.return_value = '# Write your code here :-)' - with mock.patch('os.path.exists', return_value=False): + mock_gettext.return_value = "# Write your code here :-)" + with mock.patch("os.path.exists", return_value=False): ed.restore_session() - py = '# Write your code here :-)'.format( - os.linesep, os.linesep) + mu.logic.NEWLINE + py = mock_mode.code_template + mu.logic.NEWLINE ed._view.add_tab.assert_called_once_with(None, py, api, mu.logic.NEWLINE) ed.select_mode.assert_called_once_with(None) @@ -788,24 +926,77 @@ def test_editor_restore_session_invalid_file(): ed = mu.logic.Editor(view) ed._view.add_tab = mock.MagicMock() mock_mode = mock.MagicMock() - api = ['API specification', ] + api = ["API specification"] mock_mode.api.return_value = api - mock_mode.workspace_dir.return_value = '/fake/path' + mock_mode.workspace_dir.return_value = "/fake/path" mock_mode.save_timeout = 5 - ed.modes = { - 'python': mock_mode, - } + mock_mode.code_template = "template code" + ed.modes = {"python": mock_mode} mock_open = mock.mock_open( - read_data='{"paths": ["path/foo.py", "path/bar.py"]}, invalid: 0}') + read_data='{"paths": ["path/foo.py", "path/bar.py"]}, invalid: 0}' + ) mock_gettext = mock.MagicMock() - mock_gettext.return_value = '# Write your code here :-)' - with mock.patch('builtins.open', mock_open), \ - mock.patch('os.path.exists', return_value=True): + mock_gettext.return_value = "# Write your code here :-)" + with mock.patch("builtins.open", mock_open), mock.patch( + "os.path.exists", return_value=True + ): ed.restore_session() - py = '# Write your code here :-)' + mu.logic.NEWLINE + py = "template code" + mu.logic.NEWLINE ed._view.add_tab.assert_called_once_with(None, py, api, mu.logic.NEWLINE) +def test_restore_session_open_tabs_in_the_same_order(): + """ + Editor.restore_session() loads editor tabs in the same order as the 'paths' + array in the session.json file. + """ + mocked_view = mock.MagicMock() + mocked_view.tab_count = 0 + ed = mu.logic.Editor(mocked_view) + + mocked_mode = mock.MagicMock() + mocked_mode.save_timeout = 5 + ed.modes = {"python": mocked_mode} + + ed.direct_load = mock.MagicMock() + + settings_paths = ["a.py", "b.py", "c.py", "d.py"] + settings_json_payload = json.dumps({"paths": settings_paths}) + + mock_open = mock.mock_open(read_data=settings_json_payload) + with mock.patch("builtins.open", mock_open): + ed.restore_session() + + direct_load_calls_args = [ + os.path.basename(args[0]) + for args, _kwargs in ed.direct_load.call_args_list + ] + assert direct_load_calls_args == settings_paths + + +def test_editor_restore_saved_window_geometry(): + """ + Window geometry specified in the session file is restored properly. + """ + ed = mocked_editor() + window = {"x": 10, "y": 20, "w": 1000, "h": 600} + with mock.patch("os.path.isfile", return_value=True): + with generate_session(window=window): + ed.restore_session() + ed._view.size_window.assert_called_once_with(**window) + + +def test_editor_restore_default_window_geometry(): + """ + Window is sized by default if no geometry exists in the session file. + """ + ed = mocked_editor() + with mock.patch("os.path.isfile", return_value=True): + with generate_session(): + ed.restore_session() + ed._view.size_window.assert_called_once_with() + + def test_editor_open_focus_passed_file(): """ A file passed in by the OS is opened @@ -814,16 +1005,14 @@ def test_editor_open_focus_passed_file(): view.tab_count = 0 ed = mu.logic.Editor(view) mock_mode = mock.MagicMock() - mock_mode.workspace_dir.return_value = '/fake/path' + mock_mode.workspace_dir.return_value = "/fake/path" mock_mode.save_timeout = 5 - ed.modes = { - 'python': mock_mode, - } + ed.modes = {"python": mock_mode} ed._load = mock.MagicMock() file_path = os.path.join( os.path.dirname(os.path.realpath(__file__)), - 'scripts', - 'contains_red.py' + "scripts", + "contains_red.py", ) ed.select_mode = mock.MagicMock() with mock.patch("builtins.open", mock.mock_open(read_data="data")): @@ -842,19 +1031,16 @@ def test_editor_session_and_open_focus_passed_file(): ed.modes = mock.MagicMock() ed.direct_load = mock.MagicMock() mock_mode = mock.MagicMock() - mock_mode.workspace_dir.return_value = '/fake/path' + mock_mode.workspace_dir.return_value = "/fake/path" mock_mode.save_timeout = 5 - ed.modes = { - 'python': mock_mode, - } + ed.modes = {"python": mock_mode} ed.select_mode = mock.MagicMock() - settings = json.dumps({ - "paths": ["path/foo.py", - "path/bar.py"]}, ) + settings = json.dumps({"paths": ["path/foo.py", "path/bar.py"]}) mock_open = mock.mock_open(read_data=settings) - with mock.patch('builtins.open', mock_open), \ - mock.patch('os.path.exists', return_value=True): - ed.restore_session(paths=['path/foo.py']) + with mock.patch("builtins.open", mock_open), mock.patch( + "os.path.exists", return_value=True + ): + ed.restore_session(paths=["path/foo.py"]) # direct_load should be called twice (once for each path) assert ed.direct_load.call_count == 2 @@ -862,9 +1048,11 @@ def test_editor_session_and_open_focus_passed_file(): # at the end so it has focus, despite being the first file listed in # the restored session. assert ed.direct_load.call_args_list[0][0][0] == os.path.abspath( - 'path/bar.py') + "path/bar.py" + ) assert ed.direct_load.call_args_list[1][0][0] == os.path.abspath( - 'path/foo.py') + "path/foo.py" + ) def test_toggle_theme_to_night(): @@ -875,9 +1063,9 @@ def test_toggle_theme_to_night(): view = mock.MagicMock() view.set_theme = mock.MagicMock() ed = mu.logic.Editor(view) - ed.theme = 'day' + ed.theme = "day" ed.toggle_theme() - assert ed.theme == 'night' + assert ed.theme == "night" view.set_theme.assert_called_once_with(ed.theme) @@ -889,9 +1077,9 @@ def test_toggle_theme_to_day(): view = mock.MagicMock() view.set_theme = mock.MagicMock() ed = mu.logic.Editor(view) - ed.theme = 'contrast' + ed.theme = "contrast" ed.toggle_theme() - assert ed.theme == 'day' + assert ed.theme == "day" view.set_theme.assert_called_once_with(ed.theme) @@ -903,9 +1091,9 @@ def test_toggle_theme_to_contrast(): view = mock.MagicMock() view.set_theme = mock.MagicMock() ed = mu.logic.Editor(view) - ed.theme = 'night' + ed.theme = "night" ed.toggle_theme() - assert ed.theme == 'contrast' + assert ed.theme == "contrast" view.set_theme.assert_called_once_with(ed.theme) @@ -916,14 +1104,14 @@ def test_new(): view = mock.MagicMock() view.add_tab = mock.MagicMock() mock_mode = mock.MagicMock() - api = ['API specification', ] + api = ["API specification"] mock_mode.api.return_value = api + mock_mode.code_template = "new code template" + mu.logic.NEWLINE ed = mu.logic.Editor(view) - ed.modes = { - 'python': mock_mode, - } + ed.modes = {"python": mock_mode} ed.new() - view.add_tab.assert_called_once_with(None, '', api, mu.logic.NEWLINE) + py = mock_mode.code_template + mu.logic.NEWLINE + view.add_tab.assert_called_once_with(None, py, api, mu.logic.NEWLINE) def test_load_checks_file_exists(): @@ -933,11 +1121,12 @@ def test_load_checks_file_exists(): """ view = mock.MagicMock() ed = mu.logic.Editor(view) - with mock.patch('os.path.isfile', return_value=False), \ - mock.patch('mu.logic.logger.info') as mock_info: - ed._load('not_a_file') - msg1 = 'Loading script from: not_a_file' - msg2 = 'The file not_a_file does not exist.' + with mock.patch("os.path.isfile", return_value=False), mock.patch( + "mu.logic.logger.info" + ) as mock_info: + ed._load("not_a_file") + msg1 = "Loading script from: not_a_file" + msg2 = "The file not_a_file does not exist." assert mock_info.call_args_list[0][0][0] == msg1 assert mock_info.call_args_list[1][0][0] == msg2 @@ -957,10 +1146,8 @@ def test_load_python_file(): mock_read.assert_called_once_with(filepath) ed._view.add_tab.assert_called_once_with( - filepath, - text, - ed.modes[ed.mode].api(), - newline) + filepath, text, ed.modes[ed.mode].api(), newline + ) def test_load_python_file_case_insensitive_file_type(): @@ -972,17 +1159,16 @@ def test_load_python_file_case_insensitive_file_type(): ed = mocked_editor() with generate_python_file(text) as filepath: ed._view.get_load_path.return_value = filepath.upper() - with mock.patch("mu.logic.read_and_decode") as mock_read, \ - mock.patch('os.path.isfile', return_value=True): + with mock.patch("mu.logic.read_and_decode") as mock_read, mock.patch( + "os.path.isfile", return_value=True + ): mock_read.return_value = text, newline ed.load() mock_read.assert_called_once_with(filepath.upper()) ed._view.add_tab.assert_called_once_with( - filepath.upper(), - text, - ed.modes[ed.mode].api(), - newline) + filepath.upper(), text, ed.modes[ed.mode].api(), newline + ) def test_load_python_unicode_error(): @@ -995,9 +1181,9 @@ def test_load_python_unicode_error(): with generate_python_file(text) as filepath: ed._view.get_load_path.return_value = filepath with mock.patch("mu.logic.read_and_decode") as mock_read: - mock_read.side_effect = UnicodeDecodeError('funnycodec', - b'\x00\x00', 1, 2, - 'A fake reason!') + mock_read.side_effect = UnicodeDecodeError( + "funnycodec", b"\x00\x00", 1, 2, "A fake reason!" + ) ed.load() assert ed._view.show_message.call_count == 1 @@ -1008,8 +1194,8 @@ def test_no_duplicate_load_python_file(): """ brown_script = os.path.join( os.path.dirname(os.path.realpath(__file__)), - 'scripts', - 'contains_brown.py' + "scripts", + "contains_brown.py", ) editor_window = mock.MagicMock() @@ -1025,48 +1211,85 @@ def test_no_duplicate_load_python_file(): editor_window.widgets = [unsaved_tab, brown_tab] editor_window.get_load_path = mock.MagicMock(return_value=brown_script) + editor_window.current_tab.path = "path" # Create the "editor" that'll control the "window". editor = mu.logic.Editor(view=editor_window) mock_mode = mock.MagicMock() - mock_mode.workspace_dir.return_value = '/fake/path' - editor.modes = { - 'python': mock_mode, - } - + mock_mode.workspace_dir.return_value = "/fake/path" + editor.modes = {"python": mock_mode} editor.load() - message = 'The file "{}" is already open.'.format(os.path.basename( - brown_script)) + message = 'The file "{}" is already open.'.format( + os.path.basename(brown_script) + ) editor_window.show_message.assert_called_once_with(message) editor_window.add_tab.assert_not_called() +def test_no_duplicate_load_python_file_widget_file_no_longer_exists(): + """ + If the user specifies a file already loaded (but which no longer exists), + ensure this is detected, logged and Mu doesn't crash..! See: + + https://github.com/mu-editor/mu/issues/774 + + for context. + """ + brown_script = os.path.join( + os.path.dirname(os.path.realpath(__file__)), + "scripts", + "contains_brown.py", + ) + + editor_window = mock.MagicMock() + editor_window.show_message = mock.MagicMock() + editor_window.focus_tab = mock.MagicMock() + editor_window.add_tab = mock.MagicMock() + + missing_tab = mock.MagicMock() + missing_tab.path = "not_a_file.py" + + editor_window.widgets = [missing_tab] + + editor_window.current_tab.path = "path" + # Create the "editor" that'll control the "window". + editor = mu.logic.Editor(view=editor_window) + mock_mode = mock.MagicMock() + mock_mode.workspace_dir.return_value = "/fake/path" + editor.modes = {"python": mock_mode} + with mock.patch("mu.logic.logger") as mock_logger: + editor._load(brown_script) + assert mock_logger.info.call_count == 3 + log = mock_logger.info.call_args_list[1][0][0] + assert log == "The file not_a_file.py no longer exists." + + def test_load_other_file(): """ If the user specifies a file supported by a Mu mode (like a .hex file) then ensure it's loaded and added as a tab. """ view = mock.MagicMock() - view.get_load_path = mock.MagicMock(return_value='foo.hex') + view.get_load_path = mock.MagicMock(return_value="foo.hex") view.add_tab = mock.MagicMock() view.show_confirmation = mock.MagicMock() + view.current_tab.path = "path" ed = mu.logic.Editor(view) ed.change_mode = mock.MagicMock() - api = ['API specification', ] - file_content = 'PYTHON CODE' + api = ["API specification"] + file_content = "PYTHON CODE" mock_py = mock.MagicMock() + mock_py.file_extensions = None mock_py.open_file.return_value = None mock_mb = mock.MagicMock() mock_mb.api.return_value = api - mock_mb.workspace_dir.return_value = '/fake/path' - mock_mb.open_file.return_value = file_content - mock_mb.file_extensions = ['hex'] - ed.modes = { - 'python': mock_py, - 'microbit': mock_mb, - } - ed.mode = 'microbit' - with mock.patch('builtins.open', mock.mock_open()), \ - mock.patch('os.path.isfile', return_value=True): + mock_mb.workspace_dir.return_value = "/fake/path" + mock_mb.open_file.return_value = (file_content, os.linesep) + mock_mb.file_extensions = ["hex"] + ed.modes = {"python": mock_py, "microbit": mock_mb} + ed.mode = "microbit" + with mock.patch("builtins.open", mock.mock_open()), mock.patch( + "os.path.isfile", return_value=True + ): ed.load() assert view.get_load_path.call_count == 1 assert view.show_confirmation.call_count == 0 @@ -1076,39 +1299,40 @@ def test_load_other_file(): def test_load_other_file_change_mode(): """ - If the user specifies a file supported by a Mu mode (like a .hex file) that - is not currently active, then ensure it's loaded, added as a tab, and it - asks the user to change mode. + If the user specifies a file supported by a Mu mode (like a .html file) + that is not currently active, then ensure it's loaded, added as a tab, andi + it asks the user to change mode. """ view = mock.MagicMock() - view.get_load_path = mock.MagicMock(return_value='foo.hex') + view.get_load_path = mock.MagicMock(return_value="foo.html") view.add_tab = mock.MagicMock() view.show_confirmation = mock.MagicMock(return_value=QMessageBox.Ok) + view.current_tab.path = "path" ed = mu.logic.Editor(view) ed.change_mode = mock.MagicMock() - api = ['API specification', ] - file_content = 'PYTHON CODE' + api = ["API specification"] + file_content = "" mock_py = mock.MagicMock() mock_py.open_file.return_value = None mock_py.api.return_value = api - mock_py.workspace_dir.return_value = '/fake/path' + mock_py.workspace_dir.return_value = "/fake/path" mock_mb = mock.MagicMock() mock_mb.api.return_value = api - mock_mb.workspace_dir.return_value = '/fake/path' - mock_mb.open_file.return_value = file_content - mock_mb.file_extensions = ['hex'] - ed.modes = { - 'python': mock_py, - 'microbit': mock_mb, - } - ed.mode = 'python' - with mock.patch('builtins.open', mock.mock_open()), \ - mock.patch('os.path.isfile', return_value=True): + mock_mb.workspace_dir.return_value = "/fake/path" + mock_mb.open_file.return_value = (file_content, os.linesep) + mock_mb.file_extensions = ["hex"] + ed.modes = {"python": mock_py, "microbit": mock_mb} + ed.mode = "python" + with mock.patch("builtins.open", mock.mock_open()), mock.patch( + "os.path.isfile", return_value=True + ): ed.load() assert view.get_load_path.call_count == 1 assert view.show_confirmation.call_count == 1 assert ed.change_mode.call_count == 1 - view.add_tab.assert_called_once_with(None, file_content, api, os.linesep) + view.add_tab.assert_called_once_with( + "foo.html", file_content, api, os.linesep + ) def test_load_other_file_with_exception(): @@ -1117,22 +1341,22 @@ def test_load_other_file_with_exception(): to open it and check it ignores it if it throws an unexpected exception. """ view = mock.MagicMock() - view.get_load_path = mock.MagicMock(return_value='foo.hex') + view.get_load_path = mock.MagicMock(return_value="foo.hex") view.add_tab = mock.MagicMock() view.show_confirmation = mock.MagicMock() + view.current_tab.path = "path" ed = mu.logic.Editor(view) ed.change_mode = mock.MagicMock() mock_mb = mock.MagicMock() - mock_mb.workspace_dir.return_value = '/fake/path' - mock_mb.open_file = mock.MagicMock(side_effect=Exception(':(')) - mock_mb.file_extensions = ['hex'] - ed.modes = { - 'microbit': mock_mb, - } - ed.mode = 'microbit' + mock_mb.workspace_dir.return_value = "/fake/path" + mock_mb.open_file = mock.MagicMock(side_effect=Exception(":(")) + mock_mb.file_extensions = ["hex"] + ed.modes = {"microbit": mock_mb} + ed.mode = "microbit" mock_open = mock.mock_open() - with mock.patch('builtins.open', mock_open), \ - mock.patch('os.path.isfile', return_value=True): + with mock.patch("builtins.open", mock_open), mock.patch( + "os.path.isfile", return_value=True + ): ed.load() assert view.get_load_path.call_count == 1 assert view.show_message.call_count == 1 @@ -1148,8 +1372,8 @@ def test_load_not_python_or_hex(): """ view = mock.MagicMock() ed = mu.logic.Editor(view) - with mock.patch('os.path.isfile', return_value=True): - ed._load('unknown_filetype.foo') + with mock.patch("os.path.isfile", return_value=True): + ed._load("unknown_filetype.foo") assert view.show_message.call_count == 1 @@ -1160,9 +1384,9 @@ def test_load_recovers_from_oserror(): """ text = "python" ed = mocked_editor() - with generate_python_file(text) as filepath, \ - mock.patch('mu.logic.read_and_decode', - side_effect=OSError('boom')): + with generate_python_file(text) as filepath, mock.patch( + "mu.logic.read_and_decode", side_effect=OSError("boom") + ): ed._view.get_load_path.return_value = filepath ed.load() assert ed._view.show_message.call_count == 1 @@ -1185,7 +1409,8 @@ def test_load_stores_newline(): editor.load() assert editor._view.add_tab.called_with( - filepath, text, editor.modes[editor.mode].api(), "\r\n") + filepath, text, editor.modes[editor.mode].api(), "\r\n" + ) def test_save_restores_newline(): @@ -1194,9 +1419,7 @@ def test_save_restores_newline(): be used. """ newline = "\r\n" - test_text = mu.logic.NEWLINE.join( - "the cat sat on the mat".split() - ) + test_text = mu.logic.NEWLINE.join("the cat sat on the mat".split()) with generate_python_file(test_text) as filepath: with mock.patch("mu.logic.save_and_encode") as mock_save: ed = mocked_editor(text=test_text, newline=newline, path=filepath) @@ -1204,26 +1427,180 @@ def test_save_restores_newline(): assert mock_save.called_with(test_text, filepath, newline) +def test_save_strips_trailing_spaces(): + """ + When a file is saved any trailing spaces should be removed from each line + leaving any newlines intact. NB we inadvertently strip trailing newlines + in any case via save_and_encode + """ + words = "the cat sat on the mat".split() + test_text = mu.logic.NEWLINE.join("%s " % w for w in words) + stripped_text = mu.logic.NEWLINE.join(words) + with generate_python_file(test_text) as filepath: + mu.logic.save_and_encode(test_text, filepath) + with open(filepath) as f: + assert f.read() == stripped_text + "\n" + + def test_load_error(): """ Ensure that anything else is just ignored. """ view = mock.MagicMock() - view.get_load_path = mock.MagicMock(return_value='foo.py') + view.get_load_path = mock.MagicMock(return_value="foo.py") view.add_tab = mock.MagicMock() + view.current_tab.path = "path" ed = mu.logic.Editor(view) mock_open = mock.MagicMock(side_effect=FileNotFoundError()) mock_mode = mock.MagicMock() - mock_mode.workspace_dir.return_value = '/fake/path' - ed.modes = { - 'python': mock_mode, - } - with mock.patch('builtins.open', mock_open): + mock_mode.workspace_dir.return_value = "/fake/path" + ed.modes = {"python": mock_mode} + with mock.patch("builtins.open", mock_open): ed.load() assert view.get_load_path.call_count == 1 assert view.add_tab.call_count == 0 +def test_load_sets_current_path(): + """ + When a path has been selected for loading by the OS's file selector, + ensure that the directory containing the selected file is set as the + self.current_path for re-use later on. + """ + view = mock.MagicMock() + view.get_load_path = mock.MagicMock( + return_value=os.path.join("path", "foo.py") + ) + view.current_tab.path = os.path.join("old_path", "foo.py") + ed = mu.logic.Editor(view) + ed._load = mock.MagicMock() + mock_mode = mock.MagicMock() + mock_mode.workspace_dir.return_value = "/fake/path" + mock_mode.file_extensions = ["html", "css"] + ed.modes = {"python": mock_mode} + ed.load() + assert ed.current_path == os.path.abspath("path") + + +def test_load_no_current_path(): + """ + If there is no self.current_path the default location to look for a file + to load is the directory containing the file currently being edited. + """ + view = mock.MagicMock() + view.get_load_path = mock.MagicMock( + return_value=os.path.join("path", "foo.py") + ) + view.current_tab.path = os.path.join("old_path", "foo.py") + ed = mu.logic.Editor(view) + ed._load = mock.MagicMock() + mock_mode = mock.MagicMock() + mock_mode.workspace_dir.return_value = "/fake/path" + mock_mode.file_extensions = [] + ed.modes = {"python": mock_mode} + ed.load() + expected = os.path.abspath("old_path") + view.get_load_path.assert_called_once_with( + expected, "*.py *.PY", allow_previous=True + ) + + +def test_load_no_current_path_no_current_tab(): + """ + If there is no self.current_path nor is there a current file being edited + then the default location to look for a file to load is the current + mode's workspace directory. This used to be the default behaviour, but now + acts as a sensible fall-back. + """ + view = mock.MagicMock() + view.get_load_path = mock.MagicMock( + return_value=os.path.join("path", "foo.py") + ) + view.current_tab = None + ed = mu.logic.Editor(view) + ed._load = mock.MagicMock() + mock_mode = mock.MagicMock() + mock_mode.workspace_dir.return_value = os.path.join("fake", "path") + mock_mode.file_extensions = [] + ed.modes = {"python": mock_mode} + ed.load() + expected = mock_mode.workspace_dir() + view.get_load_path.assert_called_once_with( + expected, "*.py *.PY", allow_previous=True + ) + + +def test_load_has_current_path_does_not_exist(): + """ + If there is a self.current_path but it doesn't exist, then use the expected + fallback as the location to look for a file to load. + """ + view = mock.MagicMock() + view.get_load_path = mock.MagicMock( + return_value=os.path.join("path", "foo.py") + ) + view.current_tab = None + ed = mu.logic.Editor(view) + ed._load = mock.MagicMock() + ed.current_path = "foo" + mock_mode = mock.MagicMock() + mock_mode.workspace_dir.return_value = os.path.join("fake", "path") + mock_mode.file_extensions = [] + ed.modes = {"python": mock_mode} + ed.load() + expected = mock_mode.workspace_dir() + view.get_load_path.assert_called_once_with( + expected, "*.py *.PY", allow_previous=True + ) + + +def test_load_has_current_path(): + """ + If there is a self.current_path then use this as the location to look for + a file to load. + """ + view = mock.MagicMock() + view.get_load_path = mock.MagicMock( + return_value=os.path.join("path", "foo.py") + ) + view.current_tab = None + ed = mu.logic.Editor(view) + ed._load = mock.MagicMock() + ed.current_path = "foo" + mock_mode = mock.MagicMock() + mock_mode.workspace_dir.return_value = os.path.join("fake", "path") + mock_mode.file_extensions = [] + ed.modes = {"python": mock_mode} + with mock.patch("os.path.isdir", return_value=True): + ed.load() + view.get_load_path.assert_called_once_with( + "foo", "*.py *.PY", allow_previous=True + ) + + +def test_load_has_default_path(): + """ + If there is a default_path argument then use this as the location to look + for a file to load. + """ + view = mock.MagicMock() + view.get_load_path = mock.MagicMock( + return_value=os.path.join("path", "foo.py") + ) + view.current_tab = None + ed = mu.logic.Editor(view) + ed._load = mock.MagicMock() + mock_mode = mock.MagicMock() + mock_mode.workspace_dir.return_value = os.path.join("fake", "path") + mock_mode.file_extensions = [] + ed.modes = {"python": mock_mode} + with mock.patch("os.path.isdir", return_value=True): + ed.load(default_path="foo") + view.get_load_path.assert_called_once_with( + "foo", "*.py *.PY", allow_previous=False + ) + + def test_check_for_shadow_module_with_match(): """ If the name of the file in the path passed into check_for_shadow_module @@ -1233,12 +1610,10 @@ def test_check_for_shadow_module_with_match(): view = mock.MagicMock() ed = mu.logic.Editor(view) mock_mode = mock.MagicMock() - mock_mode.module_names = set(['foo', 'bar', 'baz']) - ed.modes = { - 'python': mock_mode, - } - ed.mode = 'python' - assert ed.check_for_shadow_module('/a/long/path/with/foo.py') + mock_mode.module_names = set(["foo", "bar", "baz"]) + ed.modes = {"python": mock_mode} + ed.mode = "python" + assert ed.check_for_shadow_module("/a/long/path/with/foo.py") def test_save_no_tab(): @@ -1274,7 +1649,7 @@ def test_save_no_path_no_path_given(): """ text, newline = "foo", "\n" ed = mocked_editor(text=text, path=None, newline=newline) - ed._view.get_save_path.return_value = '' + ed._view.get_save_path.return_value = "" ed.save() # The path isn't the empty string returned from get_save_path. assert ed._view.current_tab.path is None @@ -1287,13 +1662,11 @@ def test_save_path_shadows_module(): """ text, newline = "foo", "\n" ed = mocked_editor(text=text, path=None, newline=newline) - ed._view.get_save_path.return_value = '/a/long/path/foo.py' + ed._view.get_save_path.return_value = "/a/long/path/foo.py" mock_mode = mock.MagicMock() - mock_mode.module_names = set(['foo', 'bar', 'baz']) - ed.modes = { - 'python': mock_mode, - } - ed.mode = 'python' + mock_mode.module_names = set(["foo", "bar", "baz"]) + ed.modes = {"python": mock_mode} + ed.mode = "python" ed.save() # The path isn't the empty string returned from get_save_path. assert ed._view.show_message.call_count == 1 @@ -1306,13 +1679,13 @@ def test_save_file_with_exception(): """ view = mock.MagicMock() view.current_tab = mock.MagicMock() - view.current_tab.path = 'foo.py' - view.current_tab.text = mock.MagicMock(return_value='foo') + view.current_tab.path = "foo.py" + view.current_tab.text = mock.MagicMock(return_value="foo") view.current_tab.setModified = mock.MagicMock(return_value=None) view.show_message = mock.MagicMock() mock_open = mock.MagicMock(side_effect=OSError()) ed = mu.logic.Editor(view) - with mock.patch('builtins.open', mock_open): + with mock.patch("builtins.open", mock_open): ed.save() assert view.current_tab.setModified.call_count == 0 assert view.show_message.call_count == 1 @@ -1326,8 +1699,9 @@ def test_save_file_with_encoding_error(): text, path, newline = "foo", "foo", "\n" ed = mocked_editor(text=text, path=path, newline=newline) with mock.patch("mu.logic.save_and_encode") as mock_save: - mock_save.side_effect = UnicodeEncodeError(mu.logic.ENCODING, "", - 0, 0, "Unable to encode") + mock_save.side_effect = UnicodeEncodeError( + mu.logic.ENCODING, "", 0, 0, "Unable to encode" + ) ed.save() assert ed._view.current_tab.setModified.call_count == 0 @@ -1355,18 +1729,6 @@ def test_save_python_file(): view.current_tab.setModified.assert_called_once_with(False) -def test_save_with_no_file_extension(): - """ - If the path doesn't end in *.py then append it to the filename. - """ - text, path, newline = "foo", "foo", "\n" - ed = mocked_editor(text=text, path=path, newline=newline) - with mock.patch('mu.logic.save_and_encode') as mock_save: - ed.save() - mock_save.assert_called_once_with(text, path + ".py", newline) - ed._view.get_save_path.call_count == 0 - - def test_save_with_non_py_file_extension(): """ If the path ends in an extension, save it using the extension @@ -1374,7 +1736,7 @@ def test_save_with_non_py_file_extension(): text, path, newline = "foo", "foo.txt", "\n" ed = mocked_editor(text=text, path=path, newline=newline) ed._view.get_save_path.return_value = path - with mock.patch('mu.logic.save_and_encode') as mock_save: + with mock.patch("mu.logic.save_and_encode") as mock_save: ed.save() mock_save.assert_called_once_with(text, path, newline) ed._view.get_save_path.call_count == 0 @@ -1386,11 +1748,11 @@ def test_get_tab_existing_tab(): """ view = mock.MagicMock() mock_tab = mock.MagicMock() - mock_tab.path = 'foo' - view.widgets = [mock_tab, ] + mock_tab.path = "foo" + view.widgets = [mock_tab] ed = mu.logic.Editor(view) view.focus_tab.reset_mock() - tab = ed.get_tab('foo') + tab = ed.get_tab("foo") assert tab == mock_tab view.focus_tab.assert_called_once_with(mock_tab) @@ -1402,12 +1764,12 @@ def test_get_tab_new_tab(): """ view = mock.MagicMock() mock_tab = mock.MagicMock() - mock_tab.path = 'foo' - view.widgets = [mock_tab, ] + mock_tab.path = "foo" + view.widgets = [mock_tab] ed = mu.logic.Editor(view) ed.direct_load = mock.MagicMock() - tab = ed.get_tab('bar') - ed.direct_load.assert_called_once_with('bar') + tab = ed.get_tab("bar") + ed.direct_load.assert_called_once_with("bar") assert tab == view.current_tab @@ -1419,11 +1781,11 @@ def test_get_tab_no_path(): view = mock.MagicMock() mock_tab = mock.MagicMock() mock_tab.path = None - view.widgets = [mock_tab, ] + view.widgets = [mock_tab] ed = mu.logic.Editor(view) ed.direct_load = mock.MagicMock() - tab = ed.get_tab('bar') - ed.direct_load.assert_called_once_with('bar') + tab = ed.get_tab("bar") + ed.direct_load.assert_called_once_with("bar") assert tab == view.current_tab @@ -1456,24 +1818,28 @@ def test_check_code_on(): view = mock.MagicMock() tab = mock.MagicMock() tab.has_annotations = False - tab.path = 'foo.py' - tab.text.return_value = 'import this\n' + tab.path = "foo.py" + tab.text.return_value = "import this\n" view.current_tab = tab - flake = {2: {'line_no': 2, 'message': 'a message', }, } - pep8 = {2: [{'line_no': 2, 'message': 'another message', }], - 3: [{'line_no': 3, 'message': 'yet another message', }]} + flake = {2: {"line_no": 2, "message": "a message"}} + pep8 = { + 2: [{"line_no": 2, "message": "another message"}], + 3: [{"line_no": 3, "message": "yet another message"}], + } mock_mode = mock.MagicMock() mock_mode.builtins = None - with mock.patch('mu.logic.check_flake', return_value=flake), \ - mock.patch('mu.logic.check_pycodestyle', return_value=pep8): + with mock.patch("mu.logic.check_flake", return_value=flake), mock.patch( + "mu.logic.check_pycodestyle", return_value=pep8 + ): ed = mu.logic.Editor(view) - ed.modes = {'python': mock_mode, } + ed.modes = {"python": mock_mode} ed.check_code() assert tab.has_annotations is True view.reset_annotations.assert_called_once_with() - view.annotate_code.assert_has_calls([mock.call(flake, 'error'), - mock.call(pep8, 'style')], - any_order=True) + view.annotate_code.assert_has_calls( + [mock.call(flake, "error"), mock.call(pep8, "style")], + any_order=True, + ) def test_check_code_no_problems(): @@ -1484,18 +1850,19 @@ def test_check_code_no_problems(): view = mock.MagicMock() tab = mock.MagicMock() tab.has_annotations = False - tab.path = 'foo.py' - tab.text.return_value = 'import this\n' + tab.path = "foo.py" + tab.text.return_value = "import this\n" view.current_tab = tab flake = {} pep8 = {} mock_mode = mock.MagicMock() mock_mode.builtins = None - with mock.patch('mu.logic.check_flake', return_value=flake), \ - mock.patch('mu.logic.check_pycodestyle', return_value=pep8): + with mock.patch("mu.logic.check_flake", return_value=flake), mock.patch( + "mu.logic.check_pycodestyle", return_value=pep8 + ): ed = mu.logic.Editor(view) ed.show_status_message = mock.MagicMock() - ed.modes = {'python': mock_mode, } + ed.modes = {"python": mock_mode} ed.check_code() assert ed.show_status_message.call_count == 1 @@ -1525,36 +1892,35 @@ def test_check_code_no_tab(): assert view.annotate_code.call_count == 0 -def test_show_help(): +def test_check_code_not_python(): """ - Help should attempt to open up the user's browser and point it to the - expected help documentation. + Checking code when the tab does not contain Python code aborts the process. """ view = mock.MagicMock() + view.current_tab = mock.MagicMock() + view.current_tab.path = "foo.html" ed = mu.logic.Editor(view) - with mock.patch('mu.logic.webbrowser.open_new', return_value=None) as wb, \ - mock.patch('mu.logic.locale.getdefaultlocale', - return_value=('en_GB', 'UTF-8')): - ed.show_help() - version = '.'.join(__version__.split('.')[:2]) - url = 'https://codewith.mu/en/help/{}'.format(version) - wb.assert_called_once_with(url) + ed.check_code() + assert view.annotate_code.call_count == 0 -def test_show_help_exploding_getdefaultlocale(): +def test_show_help(): """ - Sometimes, on OSX the getdefaultlocale method causes a TypeError or - ValueError. Ensure when this happens, Mu defaults to 'en' as the language - code. + Help should attempt to open up the user's browser and point it to the + expected help documentation. """ view = mock.MagicMock() ed = mu.logic.Editor(view) - with mock.patch('mu.logic.webbrowser.open_new', return_value=None) as wb, \ - mock.patch('mu.logic.locale.getdefaultlocale', - side_effect=TypeError('Boom!')): + qlocalesys = mock.MagicMock() + qlocalesys.name.return_value = "en_GB" + with mock.patch( + "mu.logic.webbrowser.open_new", return_value=None + ) as wb, mock.patch( + "PyQt5.QtCore.QLocale.system", return_value=qlocalesys + ): ed.show_help() - version = '.'.join(__version__.split('.')[:2]) - url = 'https://codewith.mu/en/help/{}'.format(version) + version = ".".join(__version__.split(".")[:2]) + url = "https://codewith.mu/en/help/{}".format(version) wb.assert_called_once_with(url) @@ -1571,8 +1937,9 @@ def test_quit_modified_cancelled_from_button(): mock_open.return_value.__enter__ = lambda s: s mock_open.return_value.__exit__ = mock.Mock() mock_open.return_value.write = mock.MagicMock() - with mock.patch('sys.exit', return_value=None), \ - mock.patch('builtins.open', mock_open): + with mock.patch("sys.exit", return_value=None), mock.patch( + "builtins.open", mock_open + ): ed.quit() assert view.show_confirmation.call_count == 1 assert mock_open.call_count == 0 @@ -1593,8 +1960,9 @@ def test_quit_modified_cancelled_from_event(): mock_open.return_value.write = mock.MagicMock() mock_event = mock.MagicMock() mock_event.ignore = mock.MagicMock(return_value=None) - with mock.patch('sys.exit', return_value=None), \ - mock.patch('builtins.open', mock_open): + with mock.patch("sys.exit", return_value=None), mock.patch( + "builtins.open", mock_open + ): ed.quit(mock_event) assert view.show_confirmation.call_count == 1 assert mock_event.ignore.call_count == 1 @@ -1611,24 +1979,25 @@ def test_quit_modified_ok(): view.show_confirmation = mock.MagicMock(return_value=True) ed = mu.logic.Editor(view) mock_mode = mock.MagicMock() - mock_mode.workspace_dir.return_value = 'foo/bar' - mock_mode.get_hex_path.return_value = 'foo/bar' + mock_mode.workspace_dir.return_value = "foo/bar" + mock_mode.get_hex_path.return_value = "foo/bar" mock_debug_mode = mock.MagicMock() mock_debug_mode.is_debugger = True ed.modes = { - 'python': mock_mode, - 'microbit': mock_mode, - 'debugger': mock_debug_mode, + "python": mock_mode, + "microbit": mock_mode, + "debugger": mock_debug_mode, } - ed.mode = 'debugger' + ed.mode = "debugger" mock_open = mock.MagicMock() mock_open.return_value.__enter__ = lambda s: s mock_open.return_value.__exit__ = mock.Mock() mock_open.return_value.write = mock.MagicMock() mock_event = mock.MagicMock() mock_event.ignore = mock.MagicMock(return_value=None) - with mock.patch('sys.exit', return_value=None), \ - mock.patch('builtins.open', mock_open): + with mock.patch("sys.exit", return_value=None), mock.patch( + "builtins.open", mock_open + ): ed.quit(mock_event) mock_debug_mode.stop.assert_called_once_with() assert view.show_confirmation.call_count == 1 @@ -1637,80 +2006,90 @@ def test_quit_modified_ok(): assert mock_open.return_value.write.call_count > 0 -def test_quit_save_tabs_with_paths(): +def _editor_view_mock(): """ - When saving the session, ensure those tabs with associated paths are - logged in the session file. + Return a mocked mu.interface.Window to be used as a mu.logic.Editor view + in the test_quit_save* tests. """ view = mock.MagicMock() view.modified = True + view.zoom_position = 2 view.show_confirmation = mock.MagicMock(return_value=True) + view.x.return_value = 100 + view.y.return_value = 200 + view.width.return_value = 300 + view.height.return_value = 400 + return view + + +def test_quit_save_tabs_with_paths(): + """ + When saving the session, ensure those tabs with associated paths are + logged in the session file. + """ + view = _editor_view_mock() w1 = mock.MagicMock() - w1.path = 'foo.py' - view.widgets = [w1, ] + w1.path = "foo.py" + view.widgets = [w1] ed = mu.logic.Editor(view) mock_mode = mock.MagicMock() - mock_mode.workspace_dir.return_value = 'foo/bar' - mock_mode.get_hex_path.return_value = 'foo/bar' - ed.modes = { - 'python': mock_mode, - 'microbit': mock_mode, - } + mock_mode.workspace_dir.return_value = "foo/bar" + mock_mode.get_hex_path.return_value = "foo/bar" + ed.modes = {"python": mock_mode, "microbit": mock_mode} mock_open = mock.MagicMock() mock_open.return_value.__enter__ = lambda s: s mock_open.return_value.__exit__ = mock.Mock() mock_open.return_value.write = mock.MagicMock() mock_event = mock.MagicMock() mock_event.ignore = mock.MagicMock(return_value=None) - with mock.patch('sys.exit', return_value=None), \ - mock.patch('builtins.open', mock_open): + with mock.patch("sys.exit", return_value=None), mock.patch( + "builtins.open", mock_open + ): ed.quit(mock_event) assert view.show_confirmation.call_count == 1 assert mock_event.ignore.call_count == 0 assert mock_open.call_count == 1 assert mock_open.return_value.write.call_count > 0 - recovered = ''.join([i[0][0] for i - in mock_open.return_value.write.call_args_list]) + recovered = "".join( + [i[0][0] for i in mock_open.return_value.write.call_args_list] + ) session = json.loads(recovered) - assert os.path.abspath('foo.py') in session['paths'] + assert os.path.abspath("foo.py") in session["paths"] def test_quit_save_theme(): """ When saving the session, ensure the theme is logged in the session file. """ - view = mock.MagicMock() - view.modified = True - view.show_confirmation = mock.MagicMock(return_value=True) + view = _editor_view_mock() w1 = mock.MagicMock() - w1.path = 'foo.py' - view.widgets = [w1, ] + w1.path = "foo.py" + view.widgets = [w1] ed = mu.logic.Editor(view) - ed.theme = 'night' + ed.theme = "night" mock_mode = mock.MagicMock() - mock_mode.workspace_dir.return_value = 'foo/bar' - mock_mode.get_hex_path.return_value = 'foo/bar' - ed.modes = { - 'python': mock_mode, - 'microbit': mock_mode, - } + mock_mode.workspace_dir.return_value = "foo/bar" + mock_mode.get_hex_path.return_value = "foo/bar" + ed.modes = {"python": mock_mode, "microbit": mock_mode} mock_open = mock.MagicMock() mock_open.return_value.__enter__ = lambda s: s mock_open.return_value.__exit__ = mock.Mock() mock_open.return_value.write = mock.MagicMock() mock_event = mock.MagicMock() mock_event.ignore = mock.MagicMock(return_value=None) - with mock.patch('sys.exit', return_value=None), \ - mock.patch('builtins.open', mock_open): + with mock.patch("sys.exit", return_value=None), mock.patch( + "builtins.open", mock_open + ): ed.quit(mock_event) assert view.show_confirmation.call_count == 1 assert mock_event.ignore.call_count == 0 assert mock_open.call_count == 1 assert mock_open.return_value.write.call_count > 0 - recovered = ''.join([i[0][0] for i - in mock_open.return_value.write.call_args_list]) + recovered = "".join( + [i[0][0] for i in mock_open.return_value.write.call_args_list] + ) session = json.loads(recovered) - assert session['theme'] == 'night' + assert session["theme"] == "night" def test_quit_save_envars(): @@ -1718,42 +2097,235 @@ def test_quit_save_envars(): When saving the session, ensure the user defined envars are logged in the session file. """ - view = mock.MagicMock() - view.modified = True - view.show_confirmation = mock.MagicMock(return_value=True) + view = _editor_view_mock() w1 = mock.MagicMock() - w1.path = 'foo.py' - view.widgets = [w1, ] + w1.path = "foo.py" + view.widgets = [w1] ed = mu.logic.Editor(view) - ed.theme = 'night' + ed.theme = "night" mock_mode = mock.MagicMock() - mock_mode.workspace_dir.return_value = 'foo/bar' - mock_mode.get_hex_path.return_value = 'foo/bar' - ed.modes = { - 'python': mock_mode, - 'microbit': mock_mode, - } - ed.envars = [ - ['name1', 'value1'], - ['name2', 'value2'], - ] + mock_mode.workspace_dir.return_value = "foo/bar" + mock_mode.get_hex_path.return_value = "foo/bar" + ed.modes = {"python": mock_mode, "microbit": mock_mode} + ed.envars = [["name1", "value1"], ["name2", "value2"]] + mock_open = mock.MagicMock() + mock_open.return_value.__enter__ = lambda s: s + mock_open.return_value.__exit__ = mock.Mock() + mock_open.return_value.write = mock.MagicMock() + mock_event = mock.MagicMock() + mock_event.ignore = mock.MagicMock(return_value=None) + with mock.patch("sys.exit", return_value=None), mock.patch( + "builtins.open", mock_open + ): + ed.quit(mock_event) + assert view.show_confirmation.call_count == 1 + assert mock_event.ignore.call_count == 0 + assert mock_open.call_count == 1 + assert mock_open.return_value.write.call_count > 0 + recovered = "".join( + [i[0][0] for i in mock_open.return_value.write.call_args_list] + ) + session = json.loads(recovered) + assert session["envars"] == [["name1", "value1"], ["name2", "value2"]] + + +def test_quit_save_zoom_level(): + """ + When saving the session, ensure the zoom level is logged in the session + file. + """ + view = _editor_view_mock() + w1 = mock.MagicMock() + w1.path = "foo.py" + view.widgets = [w1] + ed = mu.logic.Editor(view) + ed.theme = "night" + mock_mode = mock.MagicMock() + mock_mode.workspace_dir.return_value = "foo/bar" + mock_mode.get_hex_path.return_value = "foo/bar" + ed.modes = {"python": mock_mode, "microbit": mock_mode} + ed.envars = [["name1", "value1"], ["name2", "value2"]] + mock_open = mock.MagicMock() + mock_open.return_value.__enter__ = lambda s: s + mock_open.return_value.__exit__ = mock.Mock() + mock_open.return_value.write = mock.MagicMock() + mock_event = mock.MagicMock() + mock_event.ignore = mock.MagicMock(return_value=None) + with mock.patch("sys.exit", return_value=None), mock.patch( + "builtins.open", mock_open + ): + ed.quit(mock_event) + assert view.show_confirmation.call_count == 1 + assert mock_event.ignore.call_count == 0 + assert mock_open.call_count == 1 + assert mock_open.return_value.write.call_count > 0 + recovered = "".join( + [i[0][0] for i in mock_open.return_value.write.call_args_list] + ) + session = json.loads(recovered) + assert session["zoom_level"] == 2 + + +def test_quit_save_window_geometry(): + """ + When saving the session, ensure the window geometry is saved in the session + file. + """ + view = _editor_view_mock() + w1 = mock.MagicMock() + w1.path = "foo.py" + view.widgets = [w1] + ed = mu.logic.Editor(view) + ed.theme = "night" + mock_mode = mock.MagicMock() + mock_mode.workspace_dir.return_value = "foo/bar" + mock_mode.get_hex_path.return_value = "foo/bar" + ed.modes = {"python": mock_mode, "microbit": mock_mode} + ed.envars = [["name1", "value1"], ["name2", "value2"]] mock_open = mock.MagicMock() mock_open.return_value.__enter__ = lambda s: s mock_open.return_value.__exit__ = mock.Mock() mock_open.return_value.write = mock.MagicMock() mock_event = mock.MagicMock() mock_event.ignore = mock.MagicMock(return_value=None) - with mock.patch('sys.exit', return_value=None), \ - mock.patch('builtins.open', mock_open): + with mock.patch("sys.exit", return_value=None), mock.patch( + "builtins.open", mock_open + ): ed.quit(mock_event) assert view.show_confirmation.call_count == 1 assert mock_event.ignore.call_count == 0 assert mock_open.call_count == 1 assert mock_open.return_value.write.call_count > 0 - recovered = ''.join([i[0][0] for i - in mock_open.return_value.write.call_args_list]) + recovered = "".join( + [i[0][0] for i in mock_open.return_value.write.call_args_list] + ) session = json.loads(recovered) - assert session['envars'] == [['name1', 'value1'], ['name2', 'value2'], ] + assert session["window"] == {"x": 100, "y": 200, "w": 300, "h": 400} + + +def test_quit_cleans_temporary_pth_file_on_windows(): + """ + If the platform is Windows and Mu is running as installed by the official + Windows installer, then check for the existence of mu.pth, and if found, + delete it. + """ + view = _editor_view_mock() + w1 = mock.MagicMock() + w1.path = "foo.py" + view.widgets = [w1] + ed = mu.logic.Editor(view) + ed.theme = "night" + ed.modes = {"python": mock.MagicMock(), "microbit": mock.MagicMock()} + mock_open = mock.MagicMock() + mock_open.return_value.__enter__ = lambda s: s + mock_open.return_value.__exit__ = mock.Mock() + mock_open.return_value.write = mock.MagicMock() + mock_event = mock.MagicMock() + mock_event.ignore = mock.MagicMock(return_value=None) + mock_sys = mock.MagicMock() + mock_sys.platform = "win32" + mock_sys.executable = "C:\\Program Files\\Mu\\Python\\pythonw.exe" + mock_os_p_e = mock.MagicMock(return_value=True) + mock_os_remove = mock.MagicMock() + mock_site = mock.MagicMock() + mock_site.ENABLE_USER_SITE = True + mock_site.USER_SITE = ( + "C:\\Users\\foo\\AppData\\Roaming\\Python\\" "Python36\\site-packages" + ) + with mock.patch("sys.exit", return_value=None), mock.patch( + "builtins.open", mock_open + ), mock.patch("json.dump"), mock.patch( + "mu.logic.sys", mock_sys + ), mock.patch( + "mu.logic.os.path.exists", mock_os_p_e + ), mock.patch( + "mu.logic.os.remove", mock_os_remove + ), mock.patch( + "mu.logic.site", mock_site + ): + ed.quit(mock_event) + expected_path = os.path.join(mock_site.USER_SITE, "mu.pth") + mock_os_remove.assert_called_once_with(expected_path) + + +def test_quit_unable_to_clean_temporary_pth_file_on_windows(): + """ + If the platform is Windows and Mu is running as installed by the official + Windows installer, then check for the existence of mu.pth, and if found, + attempt to delete it, but in the case of an error, simply log the error + for future reference / debugging. + """ + view = mock.MagicMock() + view.modified = True + view.show_confirmation = mock.MagicMock(return_value=True) + w1 = mock.MagicMock() + w1.path = "foo.py" + view.widgets = [w1] + ed = mu.logic.Editor(view) + ed.theme = "night" + ed.modes = {"python": mock.MagicMock(), "microbit": mock.MagicMock()} + mock_open = mock.MagicMock() + mock_open.return_value.__enter__ = lambda s: s + mock_open.return_value.__exit__ = mock.Mock() + mock_open.return_value.write = mock.MagicMock() + mock_event = mock.MagicMock() + mock_event.ignore = mock.MagicMock(return_value=None) + mock_sys = mock.MagicMock() + mock_sys.platform = "win32" + mock_sys.executable = "C:\\Program Files\\Mu\\Python\\pythonw.exe" + mock_os_p_e = mock.MagicMock(return_value=True) + mock_os_remove = mock.MagicMock(side_effect=PermissionError("Boom")) + mock_site = mock.MagicMock() + mock_site.ENABLE_USER_SITE = True + mock_site.USER_SITE = ( + "C:\\Users\\foo\\AppData\\Roaming\\Python\\" "Python36\\site-packages" + ) + mock_log = mock.MagicMock() + with mock.patch("sys.exit", return_value=None), mock.patch( + "builtins.open", mock_open + ), mock.patch("json.dump"), mock.patch( + "mu.logic.sys", mock_sys + ), mock.patch( + "mu.logic.os.path.exists", mock_os_p_e + ), mock.patch( + "mu.logic.os.remove", mock_os_remove + ), mock.patch( + "mu.logic.site", mock_site + ), mock.patch( + "mu.logic.logger", mock_log + ): + ed.quit(mock_event) + logs = [call[0][0] for call in mock_log.error.call_args_list] + expected_path = os.path.join(mock_site.USER_SITE, "mu.pth") + expected = "Unable to delete {}".format(expected_path) + assert expected in logs + + +def test_quit_calls_mode_stop(): + """ + Ensure that the current mode's stop method is called. + """ + view = mock.MagicMock() + view.modified = True + view.show_confirmation = mock.MagicMock(return_value=True) + w1 = mock.MagicMock() + w1.path = "foo.py" + view.widgets = [w1] + ed = mu.logic.Editor(view) + ed.theme = "night" + ed.modes = {"python": mock.MagicMock(), "microbit": mock.MagicMock()} + ed.mode = "python" + mock_open = mock.MagicMock() + mock_open.return_value.__enter__ = lambda s: s + mock_open.return_value.__exit__ = mock.Mock() + mock_open.return_value.write = mock.MagicMock() + mock_event = mock.MagicMock() + mock_event.ignore = mock.MagicMock(return_value=None) + with mock.patch("sys.exit", return_value=None), mock.patch( + "builtins.open", mock_open + ): + ed.quit(mock_event) + ed.modes[ed.mode].stop.assert_called_once_with() def test_quit_calls_sys_exit(): @@ -1764,22 +2336,20 @@ def test_quit_calls_sys_exit(): view.modified = True view.show_confirmation = mock.MagicMock(return_value=True) w1 = mock.MagicMock() - w1.path = 'foo.py' - view.widgets = [w1, ] + w1.path = "foo.py" + view.widgets = [w1] ed = mu.logic.Editor(view) - ed.theme = 'night' - ed.modes = { - 'python': mock.MagicMock(), - 'microbit': mock.MagicMock(), - } + ed.theme = "night" + ed.modes = {"python": mock.MagicMock(), "microbit": mock.MagicMock()} mock_open = mock.MagicMock() mock_open.return_value.__enter__ = lambda s: s mock_open.return_value.__exit__ = mock.Mock() mock_open.return_value.write = mock.MagicMock() mock_event = mock.MagicMock() mock_event.ignore = mock.MagicMock(return_value=None) - with mock.patch('sys.exit', return_value=None) as ex, \ - mock.patch('builtins.open', mock_open): + with mock.patch("sys.exit", return_value=None) as ex, mock.patch( + "builtins.open", mock_open + ): ed.quit(mock_event) ex.assert_called_once_with(0) @@ -1790,32 +2360,59 @@ def test_show_admin(): """ view = mock.MagicMock() ed = mu.logic.Editor(view) - ed.envars = [['name', 'value'], ] + ed.sync_package_state = mock.MagicMock() + ed.envars = [["name", "value"]] ed.minify = True - ed.microbit_runtime = '/foo/bar' - ed.adafruit_run = True - ed.adafruit_lib = True + ed.microbit_runtime = "/foo/bar" settings = { - 'envars': 'name=value', - 'minify': True, - 'microbit_runtime': '/foo/bar', - 'adafruit_run': True, - 'adafruit_lib': True + "envars": "name=value", + "minify": True, + "microbit_runtime": "/foo/bar", + } + new_settings = { + "envars": "name=value", + "minify": True, + "microbit_runtime": "/foo/bar", + "packages": "baz\n", } - view.show_admin.return_value = settings + view.show_admin.return_value = new_settings mock_open = mock.mock_open() - with mock.patch('builtins.open', mock_open), \ - mock.patch('os.path.isfile', return_value=True): + mock_ip = mock.MagicMock(return_value=["Foo", "bar"]) + with mock.patch("builtins.open", mock_open), mock.patch( + "os.path.isfile", return_value=True + ), mock.patch("mu.logic.installed_packages", mock_ip): ed.show_admin(None) - mock_open.assert_called_once_with(mu.logic.LOG_FILE, 'r', - encoding='utf8') + mock_open.assert_called_once_with( + mu.logic.LOG_FILE, "r", encoding="utf8" + ) assert view.show_admin.call_count == 1 assert view.show_admin.call_args[0][1] == settings - assert ed.envars == [['name', 'value']] + assert ed.envars == [["name", "value"]] assert ed.minify is True - assert ed.microbit_runtime == '/foo/bar' - assert ed.adafruit_run is True - assert ed.adafruit_lib is True + assert ed.microbit_runtime == "/foo/bar" + # Expect package names to be normalised to lowercase. + ed.sync_package_state.assert_called_once_with(["foo", "bar"], ["baz"]) + + +def test_show_admin_no_change(): + """ + If the dialog is cancelled, no changes are made to settings. + """ + view = mock.MagicMock() + ed = mu.logic.Editor(view) + ed.sync_package_state = mock.MagicMock() + ed.envars = [["name", "value"]] + ed.minify = True + ed.microbit_runtime = "/foo/bar" + new_settings = {} + view.show_admin.return_value = new_settings + mock_open = mock.mock_open() + mock_ip = mock.MagicMock(return_value=["foo", "bar"]) + with mock.patch("builtins.open", mock_open), mock.patch( + "os.path.isfile", return_value=True + ), mock.patch("mu.logic.installed_packages", mock_ip): + ed.show_admin(None) + assert ed.sync_package_state.call_count == 0 def test_show_admin_missing_microbit_runtime(): @@ -1825,31 +2422,53 @@ def test_show_admin_missing_microbit_runtime(): """ view = mock.MagicMock() ed = mu.logic.Editor(view) - ed.envars = [['name', 'value'], ] + ed.sync_package_state = mock.MagicMock() + ed.envars = [["name", "value"]] ed.minify = True - ed.microbit_runtime = '/foo/bar' - ed.adafruit_run = True - ed.adafruit_lib = True + ed.microbit_runtime = "/foo/bar" settings = { - 'envars': 'name=value', - 'minify': True, - 'microbit_runtime': '/foo/bar', - 'adafruit_run': True, - 'adafruit_lib': True + "envars": "name=value", + "minify": True, + "microbit_runtime": "/foo/bar", } - view.show_admin.return_value = settings + new_settings = { + "envars": "name=value", + "minify": True, + "microbit_runtime": "/foo/bar", + "packages": "baz\n", + } + view.show_admin.return_value = new_settings mock_open = mock.mock_open() - with mock.patch('builtins.open', mock_open), \ - mock.patch('os.path.isfile', return_value=False): + mock_ip = mock.MagicMock(return_value=["foo", "bar"]) + with mock.patch("builtins.open", mock_open), mock.patch( + "os.path.isfile", return_value=False + ), mock.patch("mu.logic.installed_packages", mock_ip): ed.show_admin(None) - mock_open.assert_called_once_with(mu.logic.LOG_FILE, 'r', - encoding='utf8') + mock_open.assert_called_once_with( + mu.logic.LOG_FILE, "r", encoding="utf8" + ) assert view.show_admin.call_count == 1 assert view.show_admin.call_args[0][1] == settings - assert ed.envars == [['name', 'value']] + assert ed.envars == [["name", "value"]] assert ed.minify is True - assert ed.microbit_runtime == '' + assert ed.microbit_runtime == "" assert view.show_message.call_count == 1 + ed.sync_package_state.assert_called_once_with(["foo", "bar"], ["baz"]) + + +def test_sync_package_state(): + """ + Ensure that the expected set operations are carried out so that the + view's sync_packages method is called with the correct packages. + """ + view = mock.MagicMock() + ed = mu.logic.Editor(view) + old_packages = ["foo", "bar"] + new_packages = ["bar", "baz"] + ed.sync_package_state(old_packages, new_packages) + view.sync_packages.assert_called_once_with( + {"foo"}, {"baz"}, mu.logic.MODULE_DIR + ) def test_select_mode(): @@ -1857,17 +2476,15 @@ def test_select_mode(): It's possible to select and update to a new mode. """ view = mock.MagicMock() - view.select_mode.return_value = 'foo' + view.select_mode.return_value = "foo" mode = mock.MagicMock() mode.is_debugger = False ed = mu.logic.Editor(view) - ed.modes = { - 'python': mode, - } + ed.modes = {"python": mode} ed.change_mode = mock.MagicMock() ed.select_mode(None) assert view.select_mode.call_count == 1 - ed.change_mode.assert_called_once_with('foo') + ed.change_mode.assert_called_once_with("foo") def test_select_mode_debug_mode(): @@ -1879,13 +2496,11 @@ def test_select_mode_debug_mode(): mode = mock.MagicMock() mode.debugger = True ed = mu.logic.Editor(view) - ed.modes = { - 'debugger': mode, - } - ed.mode = 'debugger' + ed.modes = {"debugger": mode} + ed.mode = "debugger" ed.change_mode = mock.MagicMock() ed.select_mode(None) - assert ed.mode == 'debugger' + assert ed.mode == "debugger" assert ed.change_mode.call_count == 0 @@ -1902,36 +2517,29 @@ def test_change_mode(): old_mode = mock.MagicMock() old_mode.save_timeout = 5 old_mode.actions.return_value = [ - { - 'name': 'name', - 'handler': 'handler', - 'shortcut': 'Ctrl+X', - }, + {"name": "name", "handler": "handler", "shortcut": "Ctrl+X"} ] mode = mock.MagicMock() mode.save_timeout = 5 + mode.name = "Python" mode.actions.return_value = [ - { - 'name': 'name', - 'handler': 'handler', - 'shortcut': 'Ctrl+X', - }, + {"name": "name", "handler": "handler", "shortcut": "Ctrl+X"} ] - ed.modes = { - 'microbit': old_mode, - 'python': mode, - } - ed.mode = 'microbit' - ed.change_mode('python') + ed.modes = {"microbit": old_mode, "python": mode} + ed.mode = "microbit" + ed.change_mode("python") # Check the old mode is closed properly. old_mode.remove_repl.assert_called_once_with() old_mode.remove_fs.assert_called_once_with() old_mode.remove_plotter.assert_called_once_with() # Check the new mode is set up correctly. - assert ed.mode == 'python' + assert ed.mode == "python" view.change_mode.assert_called_once_with(mode) - assert mock_button_bar.connect.call_count == 11 - view.status_bar.set_mode.assert_called_once_with('python') + if sys.version_info < (3, 6): + assert mock_button_bar.connect.call_count == 11 + else: + assert mock_button_bar.connect.call_count == 12 + view.status_bar.set_mode.assert_called_once_with("Python") view.set_timer.assert_called_once_with(5, ed.autosave) @@ -1947,21 +2555,19 @@ def test_change_mode_no_timer(): ed = mu.logic.Editor(view) mode = mock.MagicMock() mode.save_timeout = 0 + mode.name = "Python" mode.actions.return_value = [ - { - 'name': 'name', - 'handler': 'handler', - 'shortcut': 'Ctrl+X', - }, + {"name": "name", "handler": "handler", "shortcut": "Ctrl+X"} ] - ed.modes = { - 'python': mode, - } - ed.change_mode('python') - assert ed.mode == 'python' + ed.modes = {"python": mode} + ed.change_mode("python") + assert ed.mode == "python" view.change_mode.assert_called_once_with(mode) - assert mock_button_bar.connect.call_count == 11 - view.status_bar.set_mode.assert_called_once_with('python') + if sys.version_info < (3, 6): + assert mock_button_bar.connect.call_count == 11 + else: + assert mock_button_bar.connect.call_count == 12 + view.status_bar.set_mode.assert_called_once_with("Python") view.stop_timer.assert_called_once_with() @@ -1972,20 +2578,17 @@ def test_change_mode_reset_breakpoints(): """ view = mock.MagicMock() mock_tab = mock.MagicMock() - mock_tab.breakpoint_handles = set([1, 2, 3, ]) - view.widgets = [mock_tab, ] + mock_tab.breakpoint_handles = set([1, 2, 3]) + view.widgets = [mock_tab] ed = mu.logic.Editor(view) mode = mock.MagicMock() mode.has_debugger = False mode.is_debugger = False mode.save_timeout = 5 - ed.modes = { - 'microbit': mode, - 'debug': mock.MagicMock(), - } - ed.mode = 'debug' - ed.change_mode('microbit') - assert ed.mode == 'microbit' + ed.modes = {"microbit": mode, "debug": mock.MagicMock()} + ed.mode = "debug" + ed.change_mode("microbit") + assert ed.mode == "microbit" assert mock_tab.breakpoint_handles == set() mock_tab.reset_annotations.assert_called_once_with() @@ -1997,14 +2600,15 @@ def test_autosave(): view = mock.MagicMock() view.modified = True mock_tab = mock.MagicMock() - mock_tab.path = 'foo' + mock_tab.path = "foo" mock_tab.isModified.return_value = True - view.widgets = [mock_tab, ] + view.widgets = [mock_tab] ed = mu.logic.Editor(view) - with mock.patch('mu.logic.save_and_encode') as mock_save: - ed.autosave() - assert mock_save.call_count == 1 - mock_tab.setModified.assert_called_once_with(False) + ed.save_tab_to_file = mock.MagicMock() + ed.autosave() + ed.save_tab_to_file.assert_called_once_with( + mock_tab, show_error_messages=False + ) def test_check_usb(): @@ -2015,18 +2619,20 @@ def test_check_usb(): view.show_confirmation = mock.MagicMock(return_value=QMessageBox.Ok) ed = mu.logic.Editor(view) ed.change_mode = mock.MagicMock() + mode_py = mock.MagicMock() + mode_py.name = "Python3" + mode_py.runner = None + mode_py.find_device.return_value = (None, None) mode_mb = mock.MagicMock() - mode_mb.name = 'BBC micro:bit' - mode_mb.find_device.return_value = ('/dev/ttyUSB0', '12345') - ed.modes = { - 'microbit': mode_mb, - } + mode_mb.name = "BBC micro:bit" + mode_mb.find_device.return_value = ("/dev/ttyUSB0", "12345") + ed.modes = {"microbit": mode_mb, "python": mode_py} ed.show_status_message = mock.MagicMock() ed.check_usb() - expected = 'Detected new BBC micro:bit device.' + expected = "Detected new BBC micro:bit device." ed.show_status_message.assert_called_with(expected) assert view.show_confirmation.called - ed.change_mode.assert_called_once_with('microbit') + ed.change_mode.assert_called_once_with("microbit") def test_check_usb_change_mode_cancel(): @@ -2037,15 +2643,17 @@ def test_check_usb_change_mode_cancel(): view.show_confirmation = mock.MagicMock(return_value=QMessageBox.Cancel) ed = mu.logic.Editor(view) ed.change_mode = mock.MagicMock() + mode_py = mock.MagicMock() + mode_py.name = "Python3" + mode_py.runner = None + mode_py.find_device.return_value = (None, None) mode_cp = mock.MagicMock() - mode_cp.name = 'CircuitPlayground' - mode_cp.find_device.return_value = ('/dev/ttyUSB1', '12345') - ed.modes = { - 'circuitplayground': mode_cp, - } + mode_cp.name = "CircuitPlayground" + mode_cp.find_device.return_value = ("/dev/ttyUSB1", "12345") + ed.modes = {"circuitplayground": mode_cp, "python": mode_py} ed.show_status_message = mock.MagicMock() ed.check_usb() - expected = 'Detected new CircuitPlayground device.' + expected = "Detected new CircuitPlayground device." ed.show_status_message.assert_called_with(expected) assert view.show_confirmation.called ed.change_mode.assert_not_called() @@ -2060,15 +2668,35 @@ def test_check_usb_already_in_mode(): ed = mu.logic.Editor(view) ed.change_mode = mock.MagicMock() mode_mb = mock.MagicMock() - mode_mb.name = 'BBC micro:bit' - mode_mb.find_device.return_value = ('/dev/ttyUSB0', '12345') + mode_mb.name = "BBC micro:bit" + mode_mb.find_device.return_value = ("/dev/ttyUSB0", "12345") mode_cp = mock.MagicMock() mode_cp.find_device.return_value = (None, None) - ed.modes = { - 'microbit': mode_mb, - 'circuitplayground': mode_cp - } - ed.mode = 'microbit' + ed.modes = {"microbit": mode_mb, "circuitplayground": mode_cp} + ed.mode = "microbit" + ed.show_status_message = mock.MagicMock() + ed.check_usb() + view.show_confirmation.assert_not_called() + ed.change_mode.assert_not_called() + + +def test_check_usb_currently_running_code(): + """ + Ensure the check_usb doesn't ask to change mode if the current mode is + running code. + """ + view = mock.MagicMock() + view.show_confirmation = mock.MagicMock(return_value=QMessageBox.Ok) + ed = mu.logic.Editor(view) + ed.change_mode = mock.MagicMock() + mode_py = mock.MagicMock() + mode_py.name = "Python3" + mode_py.runner = True + mode_py.find_device.return_value = (None, None) + mode_mb = mock.MagicMock() + mode_mb.name = "BBC micro:bit" + mode_mb.find_device.return_value = ("/dev/ttyUSB0", "12345") + ed.modes = {"microbit": mode_mb, "python": mode_py} ed.show_status_message = mock.MagicMock() ed.check_usb() view.show_confirmation.assert_not_called() @@ -2083,22 +2711,28 @@ def test_check_usb_multiple_devices(): view.show_confirmation = mock.MagicMock(return_value=QMessageBox.Ok) ed = mu.logic.Editor(view) ed.change_mode = mock.MagicMock() + mode_py = mock.MagicMock() + mode_py.name = "Python3" + mode_py.runner = None + mode_py.find_device.return_value = (None, None) mode_mb = mock.MagicMock() - mode_mb.name = 'BBC micro:bit' - mode_mb.find_device.return_value = ('/dev/ttyUSB0', '12345') + mode_mb.name = "BBC micro:bit" + mode_mb.find_device.return_value = ("/dev/ttyUSB0", "12345") mode_cp = mock.MagicMock() - mode_cp.name = 'CircuitPlayground' - mode_cp.find_device.return_value = ('/dev/ttyUSB1', '54321') + mode_cp.name = "CircuitPlayground" + mode_cp.find_device.return_value = ("/dev/ttyUSB1", "54321") ed.modes = { - 'microbit': mode_mb, - 'circuitplayground': mode_cp + "microbit": mode_mb, + "circuitplayground": mode_cp, + "python": mode_py, } ed.show_status_message = mock.MagicMock() ed.check_usb() - expected_mb = mock.call('Detected new BBC micro:bit device.') - expected_cp = mock.call('Detected new CircuitPlayground device.') - ed.show_status_message.assert_has_calls((expected_mb, expected_cp), - any_order=True) + expected_mb = mock.call("Detected new BBC micro:bit device.") + expected_cp = mock.call("Detected new CircuitPlayground device.") + ed.show_status_message.assert_has_calls( + (expected_mb, expected_cp), any_order=True + ) view.show_confirmation.assert_not_called() ed.change_mode.assert_not_called() @@ -2112,16 +2746,18 @@ def test_check_usb_when_selecting_mode_is_silent(): view.show_confirmation = mock.MagicMock(return_value=QMessageBox.Cancel) ed = mu.logic.Editor(view) ed.change_mode = mock.MagicMock() + mode_py = mock.MagicMock() + mode_py.name = "Python3" + mode_py.runner = None + mode_py.find_device.return_value = (None, None) mode_cp = mock.MagicMock() - mode_cp.name = 'CircuitPlayground' - mode_cp.find_device.return_value = ('/dev/ttyUSB1', '12345') - ed.modes = { - 'circuitplayground': mode_cp, - } + mode_cp.name = "CircuitPlayground" + mode_cp.find_device.return_value = ("/dev/ttyUSB1", "12345") + ed.modes = {"circuitplayground": mode_cp, "python": mode_py} ed.show_status_message = mock.MagicMock() ed.selecting_mode = True ed.check_usb() - expected = 'Detected new CircuitPlayground device.' + expected = "Detected new CircuitPlayground device." ed.show_status_message.assert_called_with(expected) assert view.show_confirmation.call_count == 0 ed.change_mode.assert_not_called() @@ -2136,7 +2772,7 @@ def test_check_usb_remove_disconnected_devices(): ed = mu.logic.Editor(view) ed.modes = {} ed.show_status_message = mock.MagicMock() - ed.connected_devices = {('microbit', '/dev/ttyACM1')} + ed.connected_devices = {("microbit", "/dev/ttyACM1")} ed.check_usb() assert len(ed.connected_devices) == 0 @@ -2163,13 +2799,12 @@ def test_debug_toggle_breakpoint_as_debugger(): mock_debugger = mock.MagicMock() mock_debugger.has_debugger = False mock_debugger.is_debugger = True - ed.modes = { - 'debugger': mock_debugger, - } - ed.mode = 'debugger' + ed.modes = {"debugger": mock_debugger} + ed.mode = "debugger" ed.debug_toggle_breakpoint(1, 10, False) - mock_debugger.toggle_breakpoint.assert_called_once_with(10, - view.current_tab) + mock_debugger.toggle_breakpoint.assert_called_once_with( + 10, view.current_tab + ) def test_debug_toggle_breakpoint_on(): @@ -2185,14 +2820,13 @@ def test_debug_toggle_breakpoint_on(): mock_debugger = mock.MagicMock() mock_debugger.has_debugger = True mock_debugger.is_debugger = False - ed.modes = { - 'python': mock_debugger, - } - ed.mode = 'python' - with mock.patch('mu.logic.is_breakpoint_line', return_value=True): + ed.modes = {"python": mock_debugger} + ed.mode = "python" + with mock.patch("mu.logic.is_breakpoint_line", return_value=True): ed.debug_toggle_breakpoint(1, 10, False) - view.current_tab.markerAdd.\ - assert_called_once_with(10, view.current_tab.BREAKPOINT_MARKER) + view.current_tab.markerAdd.assert_called_once_with( + 10, view.current_tab.BREAKPOINT_MARKER + ) assert 999 in view.current_tab.breakpoint_handles @@ -2202,19 +2836,16 @@ def test_debug_toggle_breakpoint_off(): tab.breakpoint_handles set. """ view = mock.MagicMock() - view.current_tab.breakpoint_handles = set([10, ]) + view.current_tab.breakpoint_handles = set([10]) ed = mu.logic.Editor(view) mock_debugger = mock.MagicMock() mock_debugger.has_debugger = True mock_debugger.is_debugger = False - ed.modes = { - 'python': mock_debugger, - } - ed.mode = 'python' - with mock.patch('mu.logic.is_breakpoint_line', return_value=True): + ed.modes = {"python": mock_debugger} + ed.mode = "python" + with mock.patch("mu.logic.is_breakpoint_line", return_value=True): ed.debug_toggle_breakpoint(1, 10, False) - view.current_tab.markerDelete.\ - assert_called_once_with(10, -1) + view.current_tab.markerDelete.assert_called_once_with(10, -1) def test_debug_toggle_breakpoint_on_invalid_breakpoint_line(): @@ -2228,10 +2859,8 @@ def test_debug_toggle_breakpoint_on_invalid_breakpoint_line(): mock_debugger = mock.MagicMock() mock_debugger.has_debugger = False mock_debugger.is_debugger = True - ed.modes = { - 'debugger': mock_debugger, - } - ed.mode = 'debugger' + ed.modes = {"debugger": mock_debugger} + ed.mode = "debugger" ed.debug_toggle_breakpoint(1, 10, False) assert view.show_message.call_count == 1 @@ -2244,15 +2873,13 @@ def test_debug_toggle_breakpoint_off_invalid_breakpoint_line(): view = mock.MagicMock() view.current_tab.text.return_value = '#print("Hello")' view.current_tab.markersAtLine.return_value = True - view.current_tab.breakpoint_handles = set([10, ]) + view.current_tab.breakpoint_handles = set([10]) ed = mu.logic.Editor(view) mock_mode = mock.MagicMock() mock_mode.has_debugger = True mock_mode.is_debugger = False - ed.modes = { - 'python': mock_mode, - } - ed.mode = 'python' + ed.modes = {"python": mock_mode} + ed.mode = "python" ed.debug_toggle_breakpoint(1, 10, False) view.current_tab.markerDelete.assert_called_once_with(10, -1) @@ -2263,16 +2890,16 @@ def test_rename_tab_no_tab_id(): instead of the double-click event), then use the tab currently in focus. """ view = mock.MagicMock() - view.get_save_path.return_value = 'foo' + view.get_save_path.return_value = "foo" mock_tab = mock.MagicMock() - mock_tab.path = 'old.py' + mock_tab.path = "old.py" view.current_tab = mock_tab ed = mu.logic.Editor(view) ed.save = mock.MagicMock() ed.check_for_shadow_module = mock.MagicMock(return_value=False) ed.rename_tab() - view.get_save_path.assert_called_once_with('old.py') - assert mock_tab.path == 'foo.py' + view.get_save_path.assert_called_once_with("old.py") + assert mock_tab.path == "foo.py" ed.save.assert_called_once_with() @@ -2282,17 +2909,17 @@ def test_rename_tab(): so make sure the expected tab is grabbed from the view. """ view = mock.MagicMock() - view.get_save_path.return_value = 'foo' + view.get_save_path.return_value = "foo" mock_tab = mock.MagicMock() - mock_tab.path = 'old.py' + mock_tab.path = "old.py" view.tabs.widget.return_value = mock_tab ed = mu.logic.Editor(view) ed.save = mock.MagicMock() ed.check_for_shadow_module = mock.MagicMock(return_value=False) ed.rename_tab(1) - view.get_save_path.assert_called_once_with('old.py') + view.get_save_path.assert_called_once_with("old.py") view.tabs.widget.assert_called_once_with(1) - assert mock_tab.path == 'foo.py' + assert mock_tab.path == "foo.py" ed.save.assert_called_once_with() @@ -2302,18 +2929,18 @@ def test_rename_tab_with_shadow_module(): Python module, then a warning should appear and the process aborted. """ view = mock.MagicMock() - view.get_save_path.return_value = 'foo' + view.get_save_path.return_value = "foo" mock_tab = mock.MagicMock() - mock_tab.path = 'old.py' + mock_tab.path = "old.py" view.tabs.widget.return_value = mock_tab ed = mu.logic.Editor(view) ed.save = mock.MagicMock() ed.check_for_shadow_module = mock.MagicMock(return_value=True) ed.rename_tab(1) - view.get_save_path.assert_called_once_with('old.py') + view.get_save_path.assert_called_once_with("old.py") view.tabs.widget.assert_called_once_with(1) assert view.show_message.call_count == 1 - assert mock_tab.path == 'old.py' + assert mock_tab.path == "old.py" assert ed.save.call_count == 0 @@ -2323,20 +2950,21 @@ def test_rename_tab_avoid_duplicating_other_tab_name(): then show an error message and don't rename anything. """ view = mock.MagicMock() - view.get_save_path.return_value = 'foo' + view.get_save_path.return_value = "foo" mock_other_tab = mock.MagicMock() - mock_other_tab.path = 'foo.py' - view.widgets = [mock_other_tab, ] + mock_other_tab.path = "foo.py" + view.widgets = [mock_other_tab] mock_tab = mock.MagicMock() - mock_tab.path = 'old.py' + mock_tab.path = "old.py" view.tabs.widget.return_value = mock_tab ed = mu.logic.Editor(view) ed.check_for_shadow_module = mock.MagicMock(return_value=False) ed.rename_tab(1) - view.show_message.assert_called_once_with('Could not rename file.', - 'A file of that name is already ' - 'open in Mu.') - assert mock_tab.path == 'old.py' + view.show_message.assert_called_once_with( + "Could not rename file.", + "A file of that name is already " "open in Mu.", + ) + assert mock_tab.path == "old.py" def test_logic_independent_import_logic(): @@ -2364,6 +2992,7 @@ def test_logic_independent_import_app(): # the file out again. Internally all newlines are MU_NEWLINE # + def test_read_newline_no_text(): """If the file being loaded is empty, use the platform default newline """ @@ -2435,7 +3064,7 @@ def test_write_newline_to_unix(): with open(filepath, newline="") as f: text = f.read() assert text.count("\r\n") == 0 - assert text.count("\n") == test_string.count("\r\n") + assert text.count("\n") == test_string.count("\r\n") + 1 def test_write_newline_to_windows(): @@ -2448,7 +3077,7 @@ def test_write_newline_to_windows(): with open(filepath, newline="") as f: text = f.read() assert len(re.findall("[^\r]\n", text)) == 0 - assert text.count("\r\n") == test_string.count("\n") + assert text.count("\r\n") == test_string.count("\n") + 1 # @@ -2456,7 +3085,7 @@ def test_write_newline_to_windows(): # 7-bit characters but also an 8th-bit range which tends to # trip things up between encodings # -BYTES_TEST_STRING = bytes(range(0x20, 0x80)) + bytes(range(0xa0, 0xff)) +BYTES_TEST_STRING = bytes(range(0x20, 0x80)) + bytes(range(0xA0, 0xFF)) UNICODE_TEST_STRING = BYTES_TEST_STRING.decode("iso-8859-1") @@ -2502,8 +3131,7 @@ def test_read_utf16lebom(): def test_read_encoding_cookie(): """Successfully decode from iso-8859-1 with an encoding cookie """ - encoding_cookie = ENCODING_COOKIE.replace( - mu.logic.ENCODING, "iso-8859-1") + encoding_cookie = ENCODING_COOKIE.replace(mu.logic.ENCODING, "iso-8859-1") test_string = encoding_cookie + UNICODE_TEST_STRING with generate_python_file() as filepath: with open(filepath, "wb") as f: @@ -2564,7 +3192,7 @@ def test_write_encoding_cookie_no_cookie(): mu.logic.save_and_encode(test_string, filepath) with open(filepath, encoding=mu.logic.ENCODING) as f: for line in f: - assert line == test_string + assert line == test_string + "\n" break @@ -2579,7 +3207,7 @@ def test_write_encoding_cookie_existing_cookie(): mu.logic.save_and_encode(test_string, filepath) with open(filepath, encoding=encoding) as f: assert next(f) == cookie - assert next(f) == UNICODE_TEST_STRING + assert next(f) == UNICODE_TEST_STRING + "\n" def test_write_invalid_codec(): @@ -2593,7 +3221,7 @@ def test_write_invalid_codec(): mu.logic.save_and_encode(test_string, filepath) with open(filepath, encoding=mu.logic.ENCODING) as f: assert next(f) == cookie - assert next(f) == UNICODE_TEST_STRING + assert next(f) == UNICODE_TEST_STRING + "\n" def test_handle_open_file(): @@ -2601,14 +3229,16 @@ def test_handle_open_file(): Ensure on_open_file event handler fires as expected with the editor's direct_load when the view's open_file signal is emitted. """ + class Dummy(QObject): open_file = pyqtSignal(str) + view = Dummy() edit = mu.logic.Editor(view) m = mock.MagicMock() edit.direct_load = m - view.open_file.emit('/test/path.py') - m.assert_called_once_with('/test/path.py') + view.open_file.emit("/test/path.py") + m.assert_called_once_with("/test/path.py") def test_load_cli(): @@ -2619,8 +3249,8 @@ def test_load_cli(): ed = mu.logic.Editor(mock_view) m = mock.MagicMock() ed.direct_load = m - ed.load_cli(['test.py']) - m.assert_called_once_with(os.path.abspath('test.py')) + ed.load_cli(["test.py"]) + m.assert_called_once_with(os.path.abspath("test.py")) m = mock.MagicMock() ed.direct_load = m @@ -2634,11 +3264,11 @@ def test_abspath(): arbitrary paths. """ ed = mu.logic.Editor(mock.MagicMock()) - paths = ['foo', 'bar', 'bar'] + paths = ["foo", "bar", "bar"] result = ed._abspath(paths) assert len(result) == 2 - assert os.path.abspath('foo') in result - assert os.path.abspath('bar') in result + assert os.path.abspath("foo") in result + assert os.path.abspath("bar") in result def test_abspath_fail(): @@ -2647,13 +3277,13 @@ def test_abspath_fail(): continue to process the "good" paths. """ ed = mu.logic.Editor(mock.MagicMock()) - paths = ['foo', 'bar', 5, 'bar'] - with mock.patch('mu.logic.logger.error') as mock_error: + paths = ["foo", "bar", 5, "bar"] + with mock.patch("mu.logic.logger.error") as mock_error: result = ed._abspath(paths) assert mock_error.call_count == 1 assert len(result) == 2 - assert os.path.abspath('foo') in result - assert os.path.abspath('bar') in result + assert os.path.abspath("foo") in result + assert os.path.abspath("bar") in result def test_find_replace_cancelled(): @@ -2675,11 +3305,11 @@ def test_find_replace_no_find(): message to explain the problem. """ mock_view = mock.MagicMock() - mock_view.show_find_replace.return_value = ('', '', False) + mock_view.show_find_replace.return_value = ("", "", False) ed = mu.logic.Editor(mock_view) ed.show_message = mock.MagicMock() ed.find_replace() - msg = 'You must provide something to find.' + msg = "You must provide something to find." info = "Please try again, this time with something in the find box." mock_view.show_message.assert_called_once_with(msg, info) @@ -2690,17 +3320,18 @@ def test_find_replace_find_matched(): the expected status message should be shown. """ mock_view = mock.MagicMock() - mock_view.show_find_replace.return_value = ('foo', '', False) + mock_view.show_find_replace.return_value = ("foo", "", False) mock_view.highlight_text.return_value = True ed = mu.logic.Editor(mock_view) ed.show_status_message = mock.MagicMock() ed.find_replace() - mock_view.highlight_text.assert_called_once_with('foo') - assert ed.find == 'foo' - assert ed.replace == '' + mock_view.highlight_text.assert_called_once_with("foo") + assert ed.find == "foo" + assert ed.replace == "" assert ed.global_replace is False - ed.show_status_message.\ - assert_called_once_with('Highlighting matches for "foo".') + ed.show_status_message.assert_called_once_with( + 'Highlighting matches for "foo".' + ) def test_find_replace_find_unmatched(): @@ -2709,13 +3340,12 @@ def test_find_replace_find_unmatched(): then the expected status message should be shown. """ mock_view = mock.MagicMock() - mock_view.show_find_replace.return_value = ('foo', '', False) + mock_view.show_find_replace.return_value = ("foo", "", False) mock_view.highlight_text.return_value = False ed = mu.logic.Editor(mock_view) ed.show_status_message = mock.MagicMock() ed.find_replace() - ed.show_status_message.\ - assert_called_once_with('Could not find "foo".') + ed.show_status_message.assert_called_once_with('Could not find "foo".') def test_find_replace_replace_no_match(): @@ -2724,17 +3354,16 @@ def test_find_replace_replace_no_match(): UN-matched in the code, then the expected status message should be shown. """ mock_view = mock.MagicMock() - mock_view.show_find_replace.return_value = ('foo', 'bar', False) + mock_view.show_find_replace.return_value = ("foo", "bar", False) mock_view.replace_text.return_value = 0 ed = mu.logic.Editor(mock_view) ed.show_status_message = mock.MagicMock() ed.find_replace() - assert ed.find == 'foo' - assert ed.replace == 'bar' + assert ed.find == "foo" + assert ed.replace == "bar" assert ed.global_replace is False - mock_view.replace_text.assert_called_once_with('foo', 'bar', False) - ed.show_status_message.\ - assert_called_once_with('Could not find "foo".') + mock_view.replace_text.assert_called_once_with("foo", "bar", False) + ed.show_status_message.assert_called_once_with('Could not find "foo".') def test_find_replace_replace_single_match(): @@ -2743,17 +3372,18 @@ def test_find_replace_replace_single_match(): matched once in the code, then the expected status message should be shown. """ mock_view = mock.MagicMock() - mock_view.show_find_replace.return_value = ('foo', 'bar', False) + mock_view.show_find_replace.return_value = ("foo", "bar", False) mock_view.replace_text.return_value = 1 ed = mu.logic.Editor(mock_view) ed.show_status_message = mock.MagicMock() ed.find_replace() - assert ed.find == 'foo' - assert ed.replace == 'bar' + assert ed.find == "foo" + assert ed.replace == "bar" assert ed.global_replace is False - mock_view.replace_text.assert_called_once_with('foo', 'bar', False) - ed.show_status_message.\ - assert_called_once_with('Replaced "foo" with "bar".') + mock_view.replace_text.assert_called_once_with("foo", "bar", False) + ed.show_status_message.assert_called_once_with( + 'Replaced "foo" with "bar".' + ) def test_find_replace_replace_multi_match(): @@ -2763,17 +3393,18 @@ def test_find_replace_replace_multi_match(): shown. """ mock_view = mock.MagicMock() - mock_view.show_find_replace.return_value = ('foo', 'bar', True) + mock_view.show_find_replace.return_value = ("foo", "bar", True) mock_view.replace_text.return_value = 4 ed = mu.logic.Editor(mock_view) ed.show_status_message = mock.MagicMock() ed.find_replace() - assert ed.find == 'foo' - assert ed.replace == 'bar' + assert ed.find == "foo" + assert ed.replace == "bar" assert ed.global_replace is True - mock_view.replace_text.assert_called_once_with('foo', 'bar', True) - ed.show_status_message.\ - assert_called_once_with('Replaced 4 matches of "foo" with "bar".') + mock_view.replace_text.assert_called_once_with("foo", "bar", True) + ed.show_status_message.assert_called_once_with( + 'Replaced 4 matches of "foo" with "bar".' + ) def test_toggle_comments(): @@ -2784,3 +3415,61 @@ def test_toggle_comments(): ed = mu.logic.Editor(mock_view) ed.toggle_comments() mock_view.toggle_comments.assert_called_once_with() + + +@pytest.mark.skipif(sys.version_info < (3, 6), reason="Requires Python3.6") +def test_tidy_code_no_tab(): + """ + If there's no current tab ensure black isn't called. + """ + mock_view = mock.MagicMock() + mock_view.current_tab = None + ed = mu.logic.Editor(mock_view) + ed.show_status_message = mock.MagicMock() + ed.tidy_code() + assert ed.show_status_message.call_count == 0 + + +@pytest.mark.skipif(sys.version_info < (3, 6), reason="Requires Python3.6") +def test_tidy_code_not_python(): + """ + If the current tab doesn't contain Python, abort. + """ + mock_view = mock.MagicMock() + mock_view.current_tab = mock.MagicMock() + mock_view.current_tab.path = "foo.html" + ed = mu.logic.Editor(mock_view) + ed.show_status_message = mock.MagicMock() + ed.tidy_code() + assert ed.show_status_message.call_count == 0 + + +@pytest.mark.skipif(sys.version_info < (3, 6), reason="Requires Python3.6") +def test_tidy_code_valid_python(): + """ + Ensure the "good case" works as expected (the code is reformatted and Mu + shows a status message to confirm so). + """ + mock_view = mock.MagicMock() + mock_view.current_tab.text.return_value = "print('hello')" + ed = mu.logic.Editor(mock_view) + ed.show_status_message = mock.MagicMock() + ed.tidy_code() + tab = mock_view.current_tab + tab.SendScintilla.assert_called_once_with( + tab.SCI_SETTEXT, b'print("hello")\n' + ) + assert ed.show_status_message.call_count == 1 + + +@pytest.mark.skipif(sys.version_info < (3, 6), reason="Requires Python3.6") +def test_tidy_code_invalid_python(): + """ + If the code is incorrectly formatted so black can't do its thing, ensure + that a message is shown to the user to say so. + """ + mock_view = mock.MagicMock() + mock_view.current_tab.text.return_value = "print('hello'" + ed = mu.logic.Editor(mock_view) + ed.tidy_code() + assert mock_view.show_message.call_count == 1 From dd3646d3f216fdd63884bf5e2a56453bfe7a497b Mon Sep 17 00:00:00 2001 From: fmorton Date: Thu, 14 Nov 2019 13:55:17 -0500 Subject: [PATCH 19/29] catch up from june 2018 --- ...test_adafruit.py => test_circuitpython.py} | 127 +++++++++--------- 1 file changed, 64 insertions(+), 63 deletions(-) rename tests/modes/{test_adafruit.py => test_circuitpython.py} (50%) diff --git a/tests/modes/test_adafruit.py b/tests/modes/test_circuitpython.py similarity index 50% rename from tests/modes/test_adafruit.py rename to tests/modes/test_circuitpython.py index 907339782..b2fb969a0 100644 --- a/tests/modes/test_adafruit.py +++ b/tests/modes/test_circuitpython.py @@ -1,52 +1,48 @@ # -*- coding: utf-8 -*- """ -Tests for the Adafruit mode. +Tests for the CircuitPython mode. """ import pytest import ctypes -from mu.modes.adafruit import AdafruitMode +from mu.modes.circuitpython import CircuitPythonMode from mu.modes.api import ADAFRUIT_APIS, SHARED_APIS from unittest import mock -def test_adafruit_mode(): +def test_circuitpython_mode(): """ Sanity check for setting up the mode. """ editor = mock.MagicMock() view = mock.MagicMock() - am = AdafruitMode(editor, view) - assert am.name == 'Adafruit CircuitPython' + am = CircuitPythonMode(editor, view) + assert am.name == "CircuitPython" assert am.description is not None - assert am.icon == 'adafruit' + assert am.icon == "circuitpython" assert am.editor == editor assert am.view == view actions = am.actions() + assert len(actions) == 2 + assert actions[0]["name"] == "serial" + assert actions[0]["handler"] == am.toggle_repl + assert actions[1]["name"] == "plotter" + assert actions[1]["handler"] == am.toggle_plotter + assert "code" not in am.module_names - assert len(actions) == 3 - assert actions[0]['name'] == 'run' - assert actions[0]['handler'] == am.run - assert actions[1]['name'] == 'serial' - assert actions[1]['handler'] == am.toggle_repl - assert actions[2]['name'] == 'plotter' - assert actions[2]['handler'] == am.toggle_plotter - -def test_adafruit_mode_no_charts(): +def test_circuitpython_mode_no_charts(): """ If QCharts is not available, ensure the plotter feature is not available. """ editor = mock.MagicMock() view = mock.MagicMock() - am = AdafruitMode(editor, view) - with mock.patch('mu.modes.adafruit.CHARTS', False): + am = CircuitPythonMode(editor, view) + with mock.patch("mu.modes.circuitpython.CHARTS", False): actions = am.actions() - assert len(actions) == 2 - assert actions[0]['name'] == 'run' - assert actions[0]['handler'] == am.run - assert actions[1]['name'] == 'serial' - assert actions[1]['handler'] == am.toggle_repl + assert len(actions) == 1 + assert actions[0]["name"] == "serial" + assert actions[0]["handler"] == am.toggle_repl def test_workspace_dir_posix_exists(): @@ -56,13 +52,14 @@ def test_workspace_dir_posix_exists(): """ editor = mock.MagicMock() view = mock.MagicMock() - am = AdafruitMode(editor, view) - with open('tests/modes/mount_exists.txt', 'rb') as fixture_file: + am = CircuitPythonMode(editor, view) + with open("tests/modes/mount_exists.txt", "rb") as fixture_file: fixture = fixture_file.read() - with mock.patch('os.name', 'posix'): - with mock.patch('mu.modes.adafruit.check_output', - return_value=fixture): - assert am.workspace_dir() == '/media/ntoll/CIRCUITPY' + with mock.patch("os.name", "posix"): + with mock.patch( + "mu.modes.circuitpython.check_output", return_value=fixture + ): + assert am.workspace_dir() == "/media/ntoll/CIRCUITPY" def test_workspace_dir_posix_no_mount_command(): @@ -73,16 +70,17 @@ def test_workspace_dir_posix_no_mount_command(): """ editor = mock.MagicMock() view = mock.MagicMock() - am = AdafruitMode(editor, view) - with open('tests/modes/mount_exists.txt', 'rb') as fixture_file: + am = CircuitPythonMode(editor, view) + with open("tests/modes/mount_exists.txt", "rb") as fixture_file: fixture = fixture_file.read() mock_check = mock.MagicMock(side_effect=[FileNotFoundError, fixture]) - with mock.patch('os.name', 'posix'), \ - mock.patch('mu.modes.adafruit.check_output', mock_check): - assert am.workspace_dir() == '/media/ntoll/CIRCUITPY' + with mock.patch("os.name", "posix"), mock.patch( + "mu.modes.circuitpython.check_output", mock_check + ): + assert am.workspace_dir() == "/media/ntoll/CIRCUITPY" assert mock_check.call_count == 2 - assert mock_check.call_args_list[0][0][0] == 'mount' - assert mock_check.call_args_list[1][0][0] == '/sbin/mount' + assert mock_check.call_args_list[0][0][0] == "mount" + assert mock_check.call_args_list[1][0][0] == "/sbin/mount" def test_workspace_dir_posix_missing(): @@ -92,16 +90,17 @@ def test_workspace_dir_posix_missing(): """ editor = mock.MagicMock() view = mock.MagicMock() - am = AdafruitMode(editor, view) - with open('tests/modes/mount_missing.txt', 'rb') as fixture_file: + am = CircuitPythonMode(editor, view) + with open("tests/modes/mount_missing.txt", "rb") as fixture_file: fixture = fixture_file.read() - with mock.patch('os.name', 'posix'): - with mock.patch('mu.modes.adafruit.check_output', - return_value=fixture),\ - mock.patch('mu.modes.adafruit.' - 'MicroPythonMode.workspace_dir') as mpm: - mpm.return_value = 'foo' - assert am.workspace_dir() == 'foo' + with mock.patch("os.name", "posix"): + with mock.patch( + "mu.modes.circuitpython.check_output", return_value=fixture + ), mock.patch( + "mu.modes.circuitpython." "MicroPythonMode.workspace_dir" + ) as mpm: + mpm.return_value = "foo" + assert am.workspace_dir() == "foo" def test_workspace_dir_nt_exists(): @@ -115,14 +114,15 @@ def test_workspace_dir_nt_exists(): mock_windll.kernel32.GetVolumeInformationW.return_value = None editor = mock.MagicMock() view = mock.MagicMock() - am = AdafruitMode(editor, view) - with mock.patch('os.name', 'nt'): - with mock.patch('os.path.exists', return_value=True): - return_value = ctypes.create_unicode_buffer('CIRCUITPY') - with mock.patch('ctypes.create_unicode_buffer', - return_value=return_value): + am = CircuitPythonMode(editor, view) + with mock.patch("os.name", "nt"): + with mock.patch("os.path.exists", return_value=True): + return_value = ctypes.create_unicode_buffer("CIRCUITPY") + with mock.patch( + "ctypes.create_unicode_buffer", return_value=return_value + ): ctypes.windll = mock_windll - assert am.workspace_dir() == 'A:\\' + assert am.workspace_dir() == "A:\\" def test_workspace_dir_nt_missing(): @@ -136,17 +136,18 @@ def test_workspace_dir_nt_missing(): mock_windll.kernel32.GetVolumeInformationW.return_value = None editor = mock.MagicMock() view = mock.MagicMock() - am = AdafruitMode(editor, view) - with mock.patch('os.name', 'nt'): - with mock.patch('os.path.exists', return_value=True): + am = CircuitPythonMode(editor, view) + with mock.patch("os.name", "nt"): + with mock.patch("os.path.exists", return_value=True): return_value = ctypes.create_unicode_buffer(1024) - with mock.patch('ctypes.create_unicode_buffer', - return_value=return_value), \ - mock.patch('mu.modes.adafruit.' - 'MicroPythonMode.workspace_dir') as mpm: - mpm.return_value = 'foo' + with mock.patch( + "ctypes.create_unicode_buffer", return_value=return_value + ), mock.patch( + "mu.modes.circuitpython." "MicroPythonMode.workspace_dir" + ) as mpm: + mpm.return_value = "foo" ctypes.windll = mock_windll - assert am.workspace_dir() == 'foo' + assert am.workspace_dir() == "foo" def test_workspace_dir_unknown_os(): @@ -155,8 +156,8 @@ def test_workspace_dir_unknown_os(): """ editor = mock.MagicMock() view = mock.MagicMock() - am = AdafruitMode(editor, view) - with mock.patch('os.name', 'foo'): + am = CircuitPythonMode(editor, view) + with mock.patch("os.name", "foo"): with pytest.raises(NotImplementedError) as ex: am.workspace_dir() assert ex.value.args[0] == 'OS "foo" not supported.' @@ -168,5 +169,5 @@ def test_api(): """ editor = mock.MagicMock() view = mock.MagicMock() - am = AdafruitMode(editor, view) + am = CircuitPythonMode(editor, view) assert am.api() == SHARED_APIS + ADAFRUIT_APIS From f1f0ca13c27658b4423b8f5acab92b3cd98df013 Mon Sep 17 00:00:00 2001 From: fmorton Date: Thu, 14 Nov 2019 14:06:01 -0500 Subject: [PATCH 20/29] add circuitpython dialog --- mu/interface/dialogs.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/mu/interface/dialogs.py b/mu/interface/dialogs.py index 2aebc9af1..7066414a5 100644 --- a/mu/interface/dialogs.py +++ b/mu/interface/dialogs.py @@ -198,6 +198,28 @@ def setup(self, minify, custom_runtime_path): widget_layout.addStretch() +class CircuitPythonSettingsWidget(QWidget): + """ + Used for configuring how to interact with adafruit mode: + + * Enable the "Run" button. + """ + + def setup(self, circuitpython_run, circuitpython_lib): + widget_layout = QVBoxLayout() + self.setLayout(widget_layout) + self.circuitpython_run = QCheckBox(_('Enable the "Run" button to ' + 'save and copy the current ' + 'file to CIRCUITPY?')) + self.circuitpython_run.setChecked(circuitpython_run) + widget_layout.addWidget(self.circuitpython_run) + self.circuitpython_lib = QCheckBox(_('Enable the copy library to ' + 'CIRCUITPY function?')) + self.circuitpython_lib.setChecked(circuitpython_lib) + widget_layout.addWidget(self.circuitpython_lib) + widget_layout.addStretch() + + class PackagesWidget(QWidget): """ Used for editing and displaying 3rd party packages installed via pip to be @@ -260,6 +282,10 @@ def setup(self, log, settings, packages): settings.get("minify", False), settings.get("microbit_runtime", "") ) self.tabs.addTab(self.microbit_widget, _("BBC micro:bit Settings")) + self.circuitpython_widget = CircuitPythonSettingsWidget() + self.circuitpython_widget.setup(settings.get('circuitpython_run', False), + settings.get('circuitpython_lib', False)) + self.tabs.addTab(self.circuitpython_widget, _('CircuitPython Settings')) self.package_widget = PackagesWidget() self.package_widget.setup(packages) self.tabs.addTab(self.package_widget, _("Third Party Packages")) @@ -274,6 +300,8 @@ def settings(self): "envars": self.envar_widget.text_area.toPlainText(), "minify": self.microbit_widget.minify.isChecked(), "microbit_runtime": self.microbit_widget.runtime_path.text(), + 'circuitpython_run': self.circuitpython_widget.circuitpython_run.isChecked(), + 'circuitpython_lib': self.circuitpython_widget.circuitpython_lib.isChecked() "packages": self.package_widget.text_area.toPlainText(), } From 7273caaaf6dfa8f2d576b80a43d7774dab4f2eee Mon Sep 17 00:00:00 2001 From: fmorton Date: Thu, 14 Nov 2019 14:06:55 -0500 Subject: [PATCH 21/29] add circuitpython dialog --- mu/interface/dialogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mu/interface/dialogs.py b/mu/interface/dialogs.py index 7066414a5..71748ed9b 100644 --- a/mu/interface/dialogs.py +++ b/mu/interface/dialogs.py @@ -301,7 +301,7 @@ def settings(self): "minify": self.microbit_widget.minify.isChecked(), "microbit_runtime": self.microbit_widget.runtime_path.text(), 'circuitpython_run': self.circuitpython_widget.circuitpython_run.isChecked(), - 'circuitpython_lib': self.circuitpython_widget.circuitpython_lib.isChecked() + 'circuitpython_lib': self.circuitpython_widget.circuitpython_lib.isChecked(), "packages": self.package_widget.text_area.toPlainText(), } From ad18689aea7e32210bef5ae544e17c09c1201537 Mon Sep 17 00:00:00 2001 From: fmorton Date: Thu, 14 Nov 2019 14:43:32 -0500 Subject: [PATCH 22/29] catch up from june 2018--needs more test coverage --- mu/interface/dialogs.py | 29 ++++++++------ mu/logic.py | 30 +++++++++++++++ mu/modes/circuitpython.py | 79 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 11 deletions(-) diff --git a/mu/interface/dialogs.py b/mu/interface/dialogs.py index 71748ed9b..032daa44c 100644 --- a/mu/interface/dialogs.py +++ b/mu/interface/dialogs.py @@ -208,13 +208,18 @@ class CircuitPythonSettingsWidget(QWidget): def setup(self, circuitpython_run, circuitpython_lib): widget_layout = QVBoxLayout() self.setLayout(widget_layout) - self.circuitpython_run = QCheckBox(_('Enable the "Run" button to ' - 'save and copy the current ' - 'file to CIRCUITPY?')) + self.circuitpython_run = QCheckBox( + _( + 'Enable the "Run" button to ' + "save and copy the current " + "file to CIRCUITPY?" + ) + ) self.circuitpython_run.setChecked(circuitpython_run) widget_layout.addWidget(self.circuitpython_run) - self.circuitpython_lib = QCheckBox(_('Enable the copy library to ' - 'CIRCUITPY function?')) + self.circuitpython_lib = QCheckBox( + _("Enable the copy library to " "CIRCUITPY function?") + ) self.circuitpython_lib.setChecked(circuitpython_lib) widget_layout.addWidget(self.circuitpython_lib) widget_layout.addStretch() @@ -282,10 +287,12 @@ def setup(self, log, settings, packages): settings.get("minify", False), settings.get("microbit_runtime", "") ) self.tabs.addTab(self.microbit_widget, _("BBC micro:bit Settings")) - self.circuitpython_widget = CircuitPythonSettingsWidget() - self.circuitpython_widget.setup(settings.get('circuitpython_run', False), - settings.get('circuitpython_lib', False)) - self.tabs.addTab(self.circuitpython_widget, _('CircuitPython Settings')) + self.cp_widget = CircuitPythonSettingsWidget() + self.cp_widget.setup( + settings.get("circuitpython_run", False), + settings.get("circuitpython_lib", False), + ) + self.tabs.addTab(self.cp_widget, _("CircuitPython Settings")) self.package_widget = PackagesWidget() self.package_widget.setup(packages) self.tabs.addTab(self.package_widget, _("Third Party Packages")) @@ -300,8 +307,8 @@ def settings(self): "envars": self.envar_widget.text_area.toPlainText(), "minify": self.microbit_widget.minify.isChecked(), "microbit_runtime": self.microbit_widget.runtime_path.text(), - 'circuitpython_run': self.circuitpython_widget.circuitpython_run.isChecked(), - 'circuitpython_lib': self.circuitpython_widget.circuitpython_lib.isChecked(), + "circuitpython_run": self.cp_widget.circuitpython_run.isChecked(), + "circuitpython_lib": self.cp_widget.circuitpython_lib.isChecked(), "packages": self.package_widget.text_area.toPlainText(), } diff --git a/mu/logic.py b/mu/logic.py index 52df9d190..3828a5421 100644 --- a/mu/logic.py +++ b/mu/logic.py @@ -426,6 +426,13 @@ def get_settings_path(): return get_admin_file_path("settings.json") +def get_pathname(self): + """ + Returns the pathname of the currently edited file + """ + return self.view.current_tab.path + + def extract_envars(raw): """ Returns a list of environment variables given a string containing @@ -651,6 +658,8 @@ def __init__(self, view, status_bar=None): self.envars = [] # See restore session and show_admin self.minify = False self.microbit_runtime = "" + self.circuitpython_run = False + self.circuitpython_lib = False self.connected_devices = set() self.find = "" self.replace = "" @@ -792,6 +801,18 @@ def restore_session(self, paths=None): "does not exist. Using default " "runtime instead." ) + if "circuitpython_run" in old_session: + self.circuitpython_run = old_session["circuitpython_run"] + logger.info( + 'Enable CircuitPython "Run" button? ' + "{}".format(self.circuitpython_run) + ) + if "circuitpython_lib" in old_session: + self.circuitpython_lib = old_session["circuitpython_lib"] + logger.info( + "Enable CircuitPython copy library function? " + "{}".format(self.circuitpython_lib) + ) if "zoom_level" in old_session: self._view.zoom_position = old_session["zoom_level"] self._view.set_zoom() @@ -1229,6 +1250,8 @@ def quit(self, *args, **kwargs): "envars": self.envars, "minify": self.minify, "microbit_runtime": self.microbit_runtime, + "circuitpython_run": self.circuitpython_run, + "circuitpython_lib": self.circuitpython_lib, "zoom_level": self._view.zoom_position, "window": { "x": self._view.x(), @@ -1271,6 +1294,8 @@ def show_admin(self, event=None): "envars": envars, "minify": self.minify, "microbit_runtime": self.microbit_runtime, + "circuitpython_run": self.circuitpython_run, + "circuitpython_lib": self.circuitpython_lib, } packages = installed_packages() with open(LOG_FILE, "r", encoding="utf8") as logfile: @@ -1280,6 +1305,11 @@ def show_admin(self, event=None): if new_settings: self.envars = extract_envars(new_settings["envars"]) self.minify = new_settings["minify"] + self.circuitpython_run = new_settings["circuitpython_run"] + self.circuitpython_lib = new_settings["circuitpython_lib"] + # show/hide circuitpython "run" button possibly changed in admin + if self.mode == "circuitpython": + self.change_mode(self.mode) runtime = new_settings["microbit_runtime"].strip() if runtime and not os.path.isfile(runtime): self.microbit_runtime = "" diff --git a/mu/modes/circuitpython.py b/mu/modes/circuitpython.py index 627971a51..ccb33c3db 100644 --- a/mu/modes/circuitpython.py +++ b/mu/modes/circuitpython.py @@ -17,11 +17,14 @@ along with this program. If not, see . """ import os +import time import ctypes +from shutil import copyfile from subprocess import check_output from mu.modes.base import MicroPythonMode from mu.modes.api import ADAFRUIT_APIS, SHARED_APIS from mu.interface.panes import CHARTS +from mu.logic import get_pathname class CircuitPythonMode(MicroPythonMode): @@ -87,6 +90,19 @@ def actions(self): "shortcut": "CTRL+Shift+U", } ] + if self.editor.circuitpython_run: + buttons.insert( + 0, + { + "name": "run", + "display_name": _("Run"), + "description": _( + "Save and run your current file " "on CIRCUITPY" + ), + "handler": self.run, + "shortcut": "CTRL+Shift+R", + }, + ) if CHARTS: buttons.append( { @@ -185,6 +201,69 @@ def get_volume_name(disk_name): self.connected = False return wd + def workspace_dir_cp(self): + """ + Is the file currently being edited located on CIRCUITPY. + """ + return "CIRCUITPY" in str(get_pathname(self)) + + def workspace_cp_avail(self): + """ + Is CIRCUITPY available. + """ + return "CIRCUITPY" in str(self.workspace_dir()) + + def run_circuitpython_lib_copy(self, pathname, dst_dir): + """ + Optionally copy lib files to CIRCUITPY. + """ + lib_dir = os.path.dirname(pathname) + "/lib" + if not os.path.isdir(lib_dir): + return + replace_cnt = 0 + for root, dirs, files in os.walk(lib_dir): + for filename in files: + src_lib = lib_dir + "/" + filename + dst_lib_dir = dst_dir + "/lib" + dst_lib = dst_lib_dir + "/" + filename + if not os.path.exists(dst_lib): + replace_lib = True + else: + src_tm = time.ctime(os.path.getmtime(src_lib)) + dst_tm = time.ctime(os.path.getmtime(dst_lib)) + replace_lib = src_tm > dst_tm + if replace_lib: + if replace_cnt == 0: + if not os.path.exists(dst_lib_dir): + os.makedirs(dst_lib_dir) + copyfile(src_lib, dst_lib) + replace_cnt = replace_cnt + 1 + # let libraries load before copying source main source file + if replace_cnt > 0: + time.sleep(4) + + def run(self, event): + """ + Save the file and copy to CIRCUITPY if not already there and available. + """ + self.editor.save() + + if not self.workspace_dir_cp() and self.workspace_cp_avail(): + pathname = get_pathname(self) + if pathname: + dst_dir = self.workspace_dir() + if pathname.find("/lib/") == -1: + dst = dst_dir + "/code.py" + else: + dst = dst_dir + "/lib/" + os.path.basename(pathname) + + # copy library files on to device if not working on the device + if self.editor.circuitpython_lib: + self.run_circuitpython_lib_copy(pathname, dst_dir) + + # copy edited source file on to device + copyfile(pathname, dst) + def api(self): """ Return a list of API specifications to be used by auto-suggest and call From 1458d1280f44957bde10510d32a3489a7b5108d1 Mon Sep 17 00:00:00 2001 From: fmorton Date: Thu, 14 Nov 2019 15:06:17 -0500 Subject: [PATCH 23/29] improve circuitpython test coverage --- tests/interface/test_dialogs.py | 13 +++++++++++++ tests/modes/test_circuitpython.py | 20 ++++++++++++-------- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/tests/interface/test_dialogs.py b/tests/interface/test_dialogs.py index 259a756b4..19dfa9b41 100644 --- a/tests/interface/test_dialogs.py +++ b/tests/interface/test_dialogs.py @@ -137,6 +137,19 @@ def test_MicrobitSettingsWidget_setup(): assert mbsw.runtime_path.text() == "/foo/bar" +def test_CircuitPythonSettingsWidget_setup(): + """ + Ensure the widget for editing settings related to adafruit mode + displays the referenced settings data in the expected way. + """ + circuitpython_run = True + circuitpython_lib = True + mbsw = mu.interface.dialogs.CircuitPythonSettingsWidget() + mbsw.setup(circuitpython_run, circuitpython_lib) + assert mbsw.circuitpython_run.isChecked() + assert mbsw.circuitpython_lib.isChecked() + + def test_PackagesWidget_setup(): """ Ensure the widget for editing settings related to third party packages diff --git a/tests/modes/test_circuitpython.py b/tests/modes/test_circuitpython.py index b2fb969a0..a146ced07 100644 --- a/tests/modes/test_circuitpython.py +++ b/tests/modes/test_circuitpython.py @@ -23,11 +23,13 @@ def test_circuitpython_mode(): assert am.view == view actions = am.actions() - assert len(actions) == 2 - assert actions[0]["name"] == "serial" - assert actions[0]["handler"] == am.toggle_repl - assert actions[1]["name"] == "plotter" - assert actions[1]["handler"] == am.toggle_plotter + assert len(actions) == 3 + assert actions[0]["name"] == "run" + assert actions[0]["handler"] == am.run + assert actions[1]["name"] == "serial" + assert actions[1]["handler"] == am.toggle_repl + assert actions[2]["name"] == "plotter" + assert actions[2]["handler"] == am.toggle_plotter assert "code" not in am.module_names @@ -40,9 +42,11 @@ def test_circuitpython_mode_no_charts(): am = CircuitPythonMode(editor, view) with mock.patch("mu.modes.circuitpython.CHARTS", False): actions = am.actions() - assert len(actions) == 1 - assert actions[0]["name"] == "serial" - assert actions[0]["handler"] == am.toggle_repl + assert len(actions) == 2 + assert actions[0]["name"] == "run" + assert actions[0]["handler"] == am.run + assert actions[1]["name"] == "serial" + assert actions[1]["handler"] == am.toggle_repl def test_workspace_dir_posix_exists(): From 4523de06f6e59fd9eb50e80ba844b0fd835241d7 Mon Sep 17 00:00:00 2001 From: fmorton Date: Thu, 14 Nov 2019 15:13:57 -0500 Subject: [PATCH 24/29] improve test coverage --- tests/interface/test_dialogs.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/interface/test_dialogs.py b/tests/interface/test_dialogs.py index 19dfa9b41..6ec1ffea5 100644 --- a/tests/interface/test_dialogs.py +++ b/tests/interface/test_dialogs.py @@ -171,6 +171,8 @@ def test_AdminDialog_setup(): "envars": "name=value", "minify": True, "microbit_runtime": "/foo/bar", + "circuitpython_run": True, + "circuitpython_lib": True, } packages = "foo\nbar\nbaz\n" mock_window = QWidget() From a7e23a458f5989e3f075b39e0245fff771956c17 Mon Sep 17 00:00:00 2001 From: fmorton Date: Thu, 14 Nov 2019 15:34:17 -0500 Subject: [PATCH 25/29] improve test coverage --- tests/test_logic.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_logic.py b/tests/test_logic.py index 96a08a322..badecc117 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -94,6 +94,8 @@ def generate_session( envars=[["name", "value"]], minify=False, microbit_runtime=None, + circuitpython_run=True, + circuitpython_lib=True, zoom_level=2, window=None, **kwargs @@ -139,6 +141,10 @@ def generate_session( session_data["minify"] = minify if microbit_runtime: session_data["microbit_runtime"] = microbit_runtime + if circuitpython_run: + session_data["circuitpython_run"] = circuitpython_run + if circuitpython_lib: + session_data["circuitpython_lib"] = circuitpython_lib if zoom_level: session_data["zoom_level"] = zoom_level if window: @@ -777,6 +783,8 @@ def test_editor_init(): assert e.envars == [] assert e.minify is False assert e.microbit_runtime == "" + assert e.circuitpython_run is False + assert e.circuitpython_lib is False assert e.connected_devices == set() assert e.find == "" assert e.replace == "" @@ -830,6 +838,8 @@ def test_editor_restore_session_existing_runtime(): assert ed.envars == [["name", "value"]] assert ed.minify is False assert ed.microbit_runtime == "/foo" + assert ed.circuitpython_run is True + assert ed.circuitpython_lib is True assert ed._view.zoom_position == 5 @@ -851,6 +861,8 @@ def test_editor_restore_session_missing_runtime(): assert ed.envars == [["name", "value"]] assert ed.minify is False assert ed.microbit_runtime == "" # File does not exist so set to '' + assert ed.circuitpython_run is True + assert ed.circuitpython_lib is True def test_editor_restore_session_missing_files(): @@ -2368,11 +2380,15 @@ def test_show_admin(): "envars": "name=value", "minify": True, "microbit_runtime": "/foo/bar", + "circuitpython_run": False, + "circuitpython_lib": False, } new_settings = { "envars": "name=value", "minify": True, "microbit_runtime": "/foo/bar", + "circuitpython_run": True, + "circuitpython_lib": True, "packages": "baz\n", } view.show_admin.return_value = new_settings @@ -2426,16 +2442,22 @@ def test_show_admin_missing_microbit_runtime(): ed.envars = [["name", "value"]] ed.minify = True ed.microbit_runtime = "/foo/bar" + ed.circuitpython_run = True + ed.circuitpython_lib = True settings = { "envars": "name=value", "minify": True, "microbit_runtime": "/foo/bar", + "circuitpython_run": True, + "circuitpython_lib": True, } new_settings = { "envars": "name=value", "minify": True, "microbit_runtime": "/foo/bar", "packages": "baz\n", + "circuitpython_run": True, + "circuitpython_lib": True, } view.show_admin.return_value = new_settings mock_open = mock.mock_open() @@ -2452,6 +2474,8 @@ def test_show_admin_missing_microbit_runtime(): assert ed.envars == [["name", "value"]] assert ed.minify is True assert ed.microbit_runtime == "" + assert ed.circuitpython_run is True + assert ed.circuitpython_lib is True assert view.show_message.call_count == 1 ed.sync_package_state.assert_called_once_with(["foo", "bar"], ["baz"]) From 5c6056df6c59b15bf006169b458f975badf1dc0a Mon Sep 17 00:00:00 2001 From: fmorton Date: Mon, 3 Feb 2020 20:49:29 -0500 Subject: [PATCH 26/29] modify comment --- mu/logic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mu/logic.py b/mu/logic.py index 3828a5421..f6daa0ad5 100644 --- a/mu/logic.py +++ b/mu/logic.py @@ -1307,7 +1307,7 @@ def show_admin(self, event=None): self.minify = new_settings["minify"] self.circuitpython_run = new_settings["circuitpython_run"] self.circuitpython_lib = new_settings["circuitpython_lib"] - # show/hide circuitpython "run" button possibly changed in admin + # show/hide circuitpython "run" options possibly changed in admin if self.mode == "circuitpython": self.change_mode(self.mode) runtime = new_settings["microbit_runtime"].strip() From ac070bd1f8fd3e09973b3ab5c70c1ab82bfcdbe2 Mon Sep 17 00:00:00 2001 From: fmorton Date: Tue, 8 Sep 2020 10:35:31 -0400 Subject: [PATCH 27/29] guess at test_get_pathname --- tests/test_logic.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_logic.py b/tests/test_logic.py index 69356f939..f391bc556 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -603,6 +603,13 @@ def test_get_settings_path(): mock_func.assert_called_once_with("settings.json") +def test_get_pathname(): + mock_func = mock.MagicMock(return_value="CIRCUITPY") + with mock.patch("mu.logic.get_pathname", mock_func): + assert mu.logic.get_pathname() == "CIRCUITPY" + mock_func.assert_called_once_with() + + def test_extract_envars(): """ Given a correct textual representation, get the expected list From 0cd3cf771a3a808734daead20a19ca81315fb94a Mon Sep 17 00:00:00 2001 From: fmorton Date: Fri, 19 Mar 2021 14:33:47 -0400 Subject: [PATCH 28/29] shorten two long lines for make check --- mu/modes/circuitpython.py | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/mu/modes/circuitpython.py b/mu/modes/circuitpython.py index ce08467c8..4bc0dfdce 100644 --- a/mu/modes/circuitpython.py +++ b/mu/modes/circuitpython.py @@ -106,27 +106,23 @@ def actions(self): is a name (also used to identify the icon) , description, and handler. """ buttons = [ + { + "name": "run", + "display_name": _("Run"), + "description": _( + "Save and run your current file on CIRCUITPY." + ), + "handler": self.run, + "shortcut": "CTRL+Shift+R", + }, { "name": "serial", "display_name": _("Serial"), "description": _("Open a serial connection to your device."), "handler": self.toggle_repl, "shortcut": "CTRL+Shift+U", - } + }, ] - if True: - buttons.insert( - 0, - { - "name": "run", - "display_name": _("Run"), - "description": _( - "Save and run your current file " "on CIRCUITPY" - ), - "handler": self.run, - "shortcut": "CTRL+Shift+R", - }, - ) if CHARTS: buttons.append( { @@ -219,11 +215,9 @@ def get_volume_name(disk_name): try: for disk in "ABCDEFGHIJKLMNOPQRSTUVWXYZ": path = "{}:\\".format(disk) - if ( - os.path.exists(path) - and get_volume_name(path) == "CIRCUITPY" - ): - return path + if os.path.exists(path): + if get_volume_name(path) == "CIRCUITPY": + return path finally: ctypes.windll.kernel32.SetErrorMode(old_mode) else: From 21cfb1011c5c6ee561fc8dded5d5781184db6f79 Mon Sep 17 00:00:00 2001 From: fmorton Date: Fri, 19 Mar 2021 15:33:35 -0400 Subject: [PATCH 29/29] simplify circuitpython/remove unused methods --- mu/logic.py | 61 ----------------------------------------------------- 1 file changed, 61 deletions(-) diff --git a/mu/logic.py b/mu/logic.py index 3a357667d..49d58a293 100644 --- a/mu/logic.py +++ b/mu/logic.py @@ -22,10 +22,8 @@ import codecs import io import re -import json import logging import tempfile -import platform import webbrowser import random import locale @@ -336,65 +334,6 @@ def read_and_decode(filepath): return text, newline -def get_admin_file_path(filename): - """ - Given an admin related filename, this function will attempt to get the - most relevant version of this file (the default location is the application - data directory, although a file of the same name in the same directory as - the application itself takes preference). If this file isn't found, an - empty one is created in the default location. - """ - # App location depends on being interpreted by normal Python or bundled - app_path = sys.executable if getattr(sys, "frozen", False) else sys.argv[0] - app_dir = os.path.dirname(os.path.abspath(app_path)) - # The os x bundled application is placed 3 levels deep in the .app folder - if platform.system() == "Darwin" and getattr(sys, "frozen", False): - app_dir = os.path.dirname(os.path.dirname(os.path.dirname(app_dir))) - file_path = os.path.join(app_dir, filename) - if not os.path.exists(file_path): - file_path = os.path.join(DATA_DIR, filename) - if not os.path.exists(file_path): - try: - with open(file_path, "w") as f: - logger.debug("Creating admin file: {}".format(file_path)) - json.dump({}, f) - except FileNotFoundError: - logger.error( - "Unable to create admin file: {}".format(file_path) - ) - return file_path - - -def get_session_path(): - """ - The session file stores details about the state of Mu from the user's - perspective (tabs open, current mode etc...). - - The session file default location is the application data directory. - However, a session file in the same directory as the application itself - takes preference. - - If no session file is detected a blank one in the default location is - automatically created. - """ - return get_admin_file_path("session.json") - - -def get_settings_path(): - """ - The settings file stores details about the configuration of Mu from an - administrators' perspective (default workspace etc...). - - The settings file default location is the application data directory. - However, a settings file in the same directory as the application itself - takes preference. - - If no settings file is detected a blank one in the default location is - automatically created. - """ - return get_admin_file_path("settings.json") - - def get_pathname(self): """ Returns the pathname of the currently edited file