diff --git a/CHANGES.rst b/CHANGES.rst index 04d296b99..1c73e4a25 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -15,6 +15,35 @@ Release History all his continuing hard work on MicroPython for the micro:bit. * Inclusion of tkinter, turtle, gpiozero, guizero, pigpio, pillow and requests libraries as built-in modules. +* Update to latest version of Pygame Zero. +* Fix plotter axis label bug which wouldn't display numbers if value was a + float. +* Separate session and settings into two different files. Session includes + user defined changes to configuration whereas settings contains sys-admin-y + configuration. +* Update the CSS for the three themes so they display consistently on all + supported platforms. Thanks to Zander Brown for his efforts on this. +* Move the mode selection to the "Mode" button in the top left of the window. +* Support for different encodings and default to UTF-8 where possible. Many + thanks to Tim Golden for all the hard work on this rather involved fix. +* Consistent end of line support on all platforms. Once again, many thanks to + Tim Golden for his work on this difficult problem. +* Use ``mu-editor`` instead of ``mu`` to launch the editor from the command + line. +* More sanity when dealing with cross platform paths and ensure filetypes are + treated in a case insensitive manner. +* Add support for minification of Python scripts to be flashed onto a micro:bit + thanks to Zander Brown's nudatus module. +* Clean up logging about device discovery (it's much less verbose). +* Drag and drop files onto Mu to open them. Thanks to Zander Brown for this + *really useful* feature. +* The old logs dialog is now an admin dialog which allows users to inspect the + logs, but also make various user defined configuration changes to Mu. +* Plotter now works in Python 3 mode. +* Fix problem in OSX with the ``mount`` command when detecting Circuit Python + boards. Thanks to Frank Morton for finding and fixing this. +* Add data flood avoidance to the plotter. +* A huge number of minor bug fixes, UI clean-ups and simplifications. 1.0.0.beta.15 ============= diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 02436bfc1..4c2b89b36 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -47,6 +47,10 @@ yet robust argument is most welcome. Checklist +++++++++ +* If your contribution includes non-obvious technical decision making please + make sure you document this in the + `design decisions `_ + section. * Your code should be commented in *plain English* (British spelling). * If your contribution is for a major block of work and you've not done so already, add yourself to the AUTHORS file following the convention found @@ -57,4 +61,4 @@ Checklist make check * If in doubt, ask a question. The only stupid question is the one that's never asked. -* Most importantly, **Have fun!** +* Most importantly, **Have fun!** :-) diff --git a/docs/design.rst b/docs/design.rst new file mode 100644 index 000000000..d9727be5a --- /dev/null +++ b/docs/design.rst @@ -0,0 +1,13 @@ +Design Decisions +---------------- + +The following documents concern the decision making aspects behind various +aspects of Mu. This is a recent practice (started by Tim Golden) so these +documents do not cover many aspects of Mu. However, moving forward newer +technical decisions will be documented in this way. + +.. toctree:: + :maxdepth: 2 + + design/file_reading_and_writing.rst + design/line-endings diff --git a/docs/design/line-endings.rst b/docs/design/line-endings.rst index be8dfc23b..d80c418c2 100644 --- a/docs/design/line-endings.rst +++ b/docs/design/line-endings.rst @@ -14,21 +14,22 @@ Background ---------- Mu is designed to run on any platform which supports Python / Qt. This includes -Windows and any *nix variant, including OS/X. Windows traditionally uses \r\n -(ASCII 13 + ASCII 10) for line-endings while *nix usually recognises a single \n -(ASCII 10). Although many editors now detect and adapt to either convention, +Windows and any \*nix variant, including OS/X. Windows traditionally uses \r\n +(ASCII 13 + ASCII 10) for line-endings while \*nix usually recognises a single +\n (ASCII 10). Although many editors now detect and adapt to either convention, it's common enough for beginners to use, eg, Windows notepad which only honours and only generates the \r\n convention. When reading / writing files, Python offers several forms of line-ending manipulation via the newline= parameter in the built-in open() function. -Mu originally used Universal newlines (newline=None; the default), but then switched -to retaining newlines (newline="") in PR #133 +Mu originally used Universal newlines (newline=None; the default), but then +switched to retaining newlines (newline="") in PR #133 The effect of this last change is to retain whatever convention or mix of -conventions is present in the source file. In effect it is overriding any newline -manipulation to present to the editor control the characters originally present -in the file. When the file is saved, the same characters are written out. +conventions is present in the source file. In effect it is overriding any +newline manipulation to present to the editor control the characters originally +present in the file. When the file is saved, the same characters are written +out. However this creates a quandary when programatically manipulating the editor text: do we use the most widespread \n as a line-ending; or do we use the diff --git a/docs/design/template-decision.txt b/docs/design/template-decision.txt new file mode 100644 index 000000000..55c294335 --- /dev/null +++ b/docs/design/template-decision.txt @@ -0,0 +1,33 @@ +Meaningful Title +================ + +Decision +-------- + +A brief summary of what Mu does given the context of the title. + + +Background +---------- + +As many paragraphs as needed to describe the situation that caused this +decision to be made. + + +Discussion and Implementation +----------------------------- + +As many paragraphs as needed to explain the approach taken and implementation +thereof. + +Implemented via: +~~~~~~~~~~~~~~~~ + +* (A list of URLs to GitHub pull requests or Git commits.) +* https://github.com/mu-editor/mu/pull/390 + +Discussion in: +~~~~~~~~~~~~~~ + +* (A list of URLs to GitHub issues, mailing list discussions etc...) +* https://github.com/mu-editor/mu/issues/380 diff --git a/docs/index.rst b/docs/index.rst index f87ee0f2b..1a7d21595 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,6 +17,9 @@ Mu: A Python Code Editor For tutorials, how-to guides and user related discussion, please see the project's website for users of Mu at: https://codewith.mu/ + If you're interested in the fun, educational, inspiring and sometimes + hilarious ways in which people use Mu, check out: https://madewith.mu/ + What? ----- @@ -120,6 +123,7 @@ Contents: packaging.rst website.rst api.rst + design.rst authors.rst changes.rst license.rst diff --git a/docs/swag/t-shirts/arrr.svg b/docs/swag/t-shirts/arrr.svg new file mode 100644 index 000000000..b9d39d41d --- /dev/null +++ b/docs/swag/t-shirts/arrr.svg @@ -0,0 +1,989 @@ + + + + A4 Copy 3 + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/swag/t-shirts/arrr.svg.png b/docs/swag/t-shirts/arrr.svg.png new file mode 100644 index 000000000..a572bf7a6 Binary files /dev/null and b/docs/swag/t-shirts/arrr.svg.png differ diff --git a/mu/contrib/nudatus.py b/mu/contrib/nudatus.py deleted file mode 100644 index 9d5f98e6e..000000000 --- a/mu/contrib/nudatus.py +++ /dev/null @@ -1,145 +0,0 @@ -# -*- coding: utf-8 -*- - -# Python 3 style print() -from __future__ import print_function - -import sys -import token -import tokenize -import argparse -from io import BytesIO -# tokenize.tokenize exists in python 2 but actually does something -# different, import the current function for this version -if sys.version_info < (3, 0): - from tokenize import generate_tokens as tokenizer - # This was introduced in Python 3 - tokenize.ENCODING = None -else: - from tokenize import tokenize as tokenizer # pragma: no cover - -_VERSION = (0, 0, 2, ) - - -def get_version(): - return '.'.join([str(i) for i in _VERSION]) - - -def mangle(text): - """ - Takes a script and mangles it to fit inside 8192 bytes if necessary - """ - - text_bytes = text.encode('utf-8') - - # Wrap the input script as a byte stream - buff = BytesIO(text_bytes) - # Byte stream for the mangled script - mangled = BytesIO() - - last_tok = token.INDENT - last_line = -1 - last_col = 0 - last_line_text = '' - open_list_dicts = 0 - - # Build tokens from the script - tokens = tokenizer(buff.readline) - for t, text, (line_s, col_s), (line_e, col_e), line in tokens: - # If this is a new line (except the very first) - if line_s > last_line and last_line != -1: - # Reset the column - last_col = 0 - # If the last line ended in a '\' (continuation) - if last_line_text.rstrip()[-1:] == '\\': - # Recreate it - mangled.write(b' \\\n') - - # We don't want to be calling the this multiple times - striped = text.strip() - - # Tokens or charecters for opening or closing a list/dict - list_dict_open = [token.LSQB, token.LBRACE, '[', '{'] - list_dict_close = [token.RSQB, token.RBRACE, ']', '}'] - - # If this is a list or dict - if t in list_dict_open or striped in list_dict_open: - # Increase the dict / list level - open_list_dicts += 1 - elif t in list_dict_close or striped in list_dict_close: - # Decrease the dict / list level - open_list_dicts -= 1 - - # If this is a docstring comment - if t == token.STRING and (last_tok == token.INDENT or ( - (last_tok == token.NEWLINE or last_tok == tokenize.NL) - and open_list_dicts == 0)): - # Output number of lines corresponding those in - # the docstring comment - mangled.write(b'\n' * (len(text.split('\n')) - 1)) - # Or is it a standard comment - elif t == tokenize.COMMENT: - # Plain comment, just don't write it - pass - else: - # Recreate indentation, ideally we should use tabs - if col_s > last_col: - mangled.write(b' ' * (col_s - last_col)) - # On Python 3 the first token specifies the encoding - # but we alredy know it's utf-8 and writing it just - # gives us an invalid script - if t != tokenize.ENCODING: - mangled.write(text.encode('utf-8')) - - # Store the previus state - last_tok = t - last_col = col_e - last_line = line_e - last_line_text = line - - # Return a string - return mangled.getvalue().decode('utf-8') - - -_HELP_TEXT = """ -Strip comments from a Python script. - -Please note nudatus only supports the syntax of the Python version it's running -on so nudatus running on Python 2 only supports scripts in valid Python 2 and -again for Python 3 -""" - - -def main(argv=None): - """ - Command line entry point - """ - if not argv: - argv = sys.argv[1:] - - parser = argparse.ArgumentParser(description=_HELP_TEXT) - parser.add_argument('input', nargs='?', default=None) - parser.add_argument('output', nargs='?', default=None) - parser.add_argument('--version', action='version', - version='%(prog)s ' + get_version()) - args = parser.parse_args(argv) - - if not args.input: - print("No file specified", file=sys.stderr) - sys.exit(1) - - try: - with open(args.input, 'r') as f: - res = mangle(f.read()) - if not args.output: - print(res, end='') - else: - with open(args.output, 'w') as o: - o.write(res) - except Exception as ex: - print("Error mangling {}: {!s}".format(args.input, ex), - file=sys.stderr) - sys.exit(1) - - -if __name__ == '__main__': # pragma: no cover - main(sys.argv[1:]) diff --git a/mu/interface/dialogs.py b/mu/interface/dialogs.py index 2f1bfb427..2efcbafeb 100644 --- a/mu/interface/dialogs.py +++ b/mu/interface/dialogs.py @@ -20,7 +20,7 @@ from PyQt5.QtCore import QSize from PyQt5.QtWidgets import (QVBoxLayout, QListWidget, QLabel, QListWidgetItem, QDialog, QDialogButtonBox, QPlainTextEdit, - QTabWidget, QWidget) + QTabWidget, QWidget, QCheckBox, QLineEdit) from mu.resources import load_icon from mu.interface.themes import NIGHT_STYLE, DAY_STYLE, CONTRAST_STYLE @@ -138,13 +138,38 @@ def setup(self, envars): widget_layout.addWidget(self.text_area) +class MicrobitSettingsWidget(QWidget): + """ + Used for configuring how to interact with the micro:bit: + + * Minification flag. + * Override runtime version to use. + """ + + def setup(self, minify, custom_runtime_path): + widget_layout = QVBoxLayout() + self.setLayout(widget_layout) + 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.setWordWrap(True) + widget_layout.addWidget(label) + self.runtime_path = QLineEdit() + self.runtime_path.setText(custom_runtime_path) + widget_layout.addWidget(self.runtime_path) + widget_layout.addStretch() + + class AdminDialog(QDialog): """ Displays administrative related information and settings (logs, environment variables etc...). """ - def setup(self, log, envars, theme): + def setup(self, log, settings, theme): if theme == 'day': self.setStyleSheet(DAY_STYLE) elif theme == 'night': @@ -165,13 +190,22 @@ def setup(self, log, envars, theme): self.log_widget.setup(log) self.tabs.addTab(self.log_widget, _("Current Log")) self.envar_widget = EnvironmentVariablesWidget() - self.envar_widget.setup(envars) + 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')) - def envars(self): + def settings(self): """ - Return the raw textual definition of the environment variables created - by the user. + Return a dictionary representation of the raw settings information + generated by this dialog. Such settings will need to be processed / + checked in the "logic" layer of Mu. """ - return self.envar_widget.text_area.toPlainText() + return { + 'envars': self.envar_widget.text_area.toPlainText(), + 'minify': self.microbit_widget.minify.isChecked(), + 'microbit_runtime': self.microbit_widget.runtime_path.text(), + } diff --git a/mu/interface/main.py b/mu/interface/main.py index 8d45283de..9238856de 100644 --- a/mu/interface/main.py +++ b/mu/interface/main.py @@ -650,16 +650,16 @@ def set_theme(self, theme): if hasattr(self, 'plotter') and self.plotter: self.plotter_pane.set_theme(theme) - def show_admin(self, log, envars, theme): + def show_admin(self, log, settings, theme): """ Display the administrivite dialog with referenced content of the log - and envars. Return the raw string representation of the environment - variables to be used whenever a (regular) Python script is run. + and settings. Return a dictionary of the settings that may have been + changed by the admin dialog. """ admin_box = AdminDialog() - admin_box.setup(log, envars, theme) + admin_box.setup(log, settings, theme) admin_box.exec() - return admin_box.envars() + return admin_box.settings() def show_message(self, message, information=None, icon=None): """ diff --git a/mu/interface/panes.py b/mu/interface/panes.py index df702dd08..bacc0c664 100644 --- a/mu/interface/panes.py +++ b/mu/interface/panes.py @@ -63,8 +63,8 @@ def __init__(self, theme='day', parent=None): self.set_theme(theme) self.console_height = 10 - def _append_plain_text(self, text, **kwargs): - super()._append_plain_text(text, **kwargs) + def _append_plain_text(self, text, *args, **kwargs): + super()._append_plain_text(text, *args, **kwargs) self.on_append_text.emit(text.encode('utf-8')) def set_font_size(self, new_size=DEFAULT_FONT_SIZE): @@ -581,6 +581,7 @@ def __init__(self, parent=None): self.setUndoRedoEnabled(False) self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self.context_menu) + self.running = False # Flag to show the child process is running. self.setObjectName('PythonRunner') self.process = None # Will eventually reference the running process. self.input_history = [] # history of inputs entered in this session. @@ -653,11 +654,13 @@ def start_process(self, script_name, working_directory, interactive=True, # Just run the command with no additional flags. args = [self.script, ] + command_args self.process.start(python_exec, args) + self.running = True def finished(self, code, status): """ Handle when the child process finishes. """ + self.running = False cursor = self.textCursor() cursor.movePosition(cursor.End) cursor.insertText('\n\n---------- FINISHED ----------\n') @@ -744,7 +747,7 @@ def parse_input(self, key, text, modifiers): (platform.system() != 'Darwin' and modifiers == Qt.ControlModifier): # Handle CTRL-C and CTRL-D - if self.process: + if self.process and self.running: pid = self.process.processId() # NOTE: Windows related constraints don't allow us to send a # CTRL-C, rather, the process will just terminate. @@ -790,6 +793,8 @@ def parse_input(self, key, text, modifiers): # active buffer and display it. msg = bytes(text, 'utf8') if key == Qt.Key_Backspace: + self.backspace() + if key == Qt.Key_Delete: self.delete() if not self.isReadOnly() and msg: self.insert(msg) @@ -800,6 +805,7 @@ def parse_input(self, key, text, modifiers): if line.strip(): self.input_history.append(line.replace(b'\n', b'')) self.history_position = 0 + self.start_of_current_line = self.textCursor().position() def history_back(self): """ @@ -869,9 +875,9 @@ def insert(self, msg): cursor.insertText(msg.decode('utf-8')) self.setTextCursor(cursor) - def delete(self): + def backspace(self): """ - Removes a character from the current buffer. + Removes a character from the current buffer -- to the left of cursor. """ cursor = self.textCursor() if cursor.position() > self.start_of_current_line: @@ -879,6 +885,15 @@ def delete(self): cursor.deletePreviousChar() self.setTextCursor(cursor) + def delete(self): + """ + Removes a character from the current buffer -- to the right of cursor. + """ + cursor = self.textCursor() + if cursor.position() >= self.start_of_current_line: + cursor.deleteChar() + self.setTextCursor(cursor) + def clear_input_line(self): """ Remove all the characters currently in the input buffer line. diff --git a/mu/logic.py b/mu/logic.py index 2dbb46c73..bcf21217e 100644 --- a/mu/logic.py +++ b/mu/logic.py @@ -551,6 +551,8 @@ def __init__(self, view, status_bar=None): self.mode = 'python' self.modes = {} # See set_modes. self.envars = [] # See restore session and show_admin + self.minify = False + self.microbit_runtime = '' self.connected_devices = set() if not os.path.exists(DATA_DIR): logger.debug('Creating directory: {}'.format(DATA_DIR)) @@ -639,6 +641,20 @@ def restore_session(self, paths=None): 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)) + 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.') # handle os passed file last, # so it will not be focused over by another tab if paths and len(paths) > 0: @@ -944,6 +960,8 @@ def quit(self, *args, **kwargs): 'mode': self.mode, 'paths': paths, 'envars': self.envars, + 'minify': self.minify, + 'microbit_runtime': self.microbit_runtime, } session_path = get_session_path() with open(session_path, 'w') as out: @@ -962,9 +980,26 @@ def show_admin(self, event=None): logger.info('Showing logs from {}'.format(LOG_FILE)) envars = '\n'.join(['{}={}'.format(name, value) for name, value in self.envars]) - with open(LOG_FILE, 'r') as logfile: - envars = self._view.show_admin(logfile.read(), envars, self.theme) - self.envars = extract_envars(envars) + settings = { + '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.theme) + 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._view.show_message(message, information) + else: + self.microbit_runtime = runtime def select_mode(self, event=None): """ diff --git a/mu/modes/debugger.py b/mu/modes/debugger.py index 32a4d2c89..0faf53ec8 100644 --- a/mu/modes/debugger.py +++ b/mu/modes/debugger.py @@ -150,7 +150,7 @@ def finished(self): Called when the debugged Python process is finished. """ buttons = {action['name']: False for action in self.actions() - if action['name'] is not 'stop'} + if action['name'] != 'stop'} self.set_buttons(**buttons) self.editor.show_status_message(_("Your script has finished running.")) for tab in self.view.widgets: diff --git a/mu/modes/microbit.py b/mu/modes/microbit.py index 223362134..91c201006 100644 --- a/mu/modes/microbit.py +++ b/mu/modes/microbit.py @@ -17,18 +17,24 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import json import os import sys import os.path import logging -from mu.logic import get_settings_path, HOME_DIRECTORY +from tokenize import TokenError +from mu.logic import HOME_DIRECTORY from mu.contrib import uflash, microfs from mu.modes.api import MICROBIT_APIS, SHARED_APIS from mu.modes.base import MicroPythonMode from mu.interface.panes import CHARTS from PyQt5.QtCore import QObject, QThread, pyqtSignal, QTimer +# We can run without nudatus +can_minify = True +try: + import nudatus +except ImportError: # pragma: no cover + can_minify = False logger = logging.getLogger(__name__) @@ -232,11 +238,45 @@ def flash(self): python_script = tab.text().encode('utf-8') logger.debug('Python script:') logger.debug(python_script) + # Check minification status. + minify = False + if uflash.get_minifier(): + minify = self.editor.minify if len(python_script) >= 8192: message = _('Unable to flash "{}"').format(tab.label) - information = _("Your script is too long!") - self.view.show_message(message, information, 'Warning') - return + if minify and can_minify: + orginal = len(python_script) + script = python_script.decode('utf-8') + try: + mangled = nudatus.mangle(script).encode('utf-8') + except TokenError as e: + msg, (line, col) = e.args + logger.debug('Minify failed') + logger.exception(e) + message = _("Problem with script") + information = _("{} [{}:{}]").format(msg, line, col) + self.view.show_message(message, information, 'Warning') + return + saved = orginal - len(mangled) + percent = saved / orginal * 100 + logger.debug('Script minified, {} bytes ({:.2f}%) saved:' + .format(saved, percent)) + logger.debug(mangled) + python_script = mangled + if len(python_script) >= 8192: + information = _("Our minifier tried but your " + "script is too long!") + self.view.show_message(message, information, 'Warning') + return + elif minify and not can_minify: + information = _("Your script is too long and the minifier" + " isn't available") + self.view.show_message(message, information, 'Warning') + return + else: + information = _("Your script is too long!") + self.view.show_message(message, information, 'Warning') + return # Determine the location of the BBC micro:bit. If it can't be found # fall back to asking the user to locate it. path_to_microbit = uflash.find_microbit() @@ -256,11 +296,14 @@ def flash(self): logger.debug('Path to micro:bit: {}'.format(path_to_microbit)) if path_to_microbit and os.path.exists(path_to_microbit): logger.debug('Flashing to device.') - # Flash the microbit - rt_hex_path = self.get_hex_path() + # Check use of custom runtime. + rt_hex_path = self.editor.microbit_runtime.strip() message = _('Flashing "{}" onto the micro:bit.').format(tab.label) - if (rt_hex_path is not None and os.path.exists(rt_hex_path)): + if (rt_hex_path and os.path.exists(rt_hex_path)): message = message + _(" Runtime: {}").format(rt_hex_path) + else: + rt_hex_path = None + self.editor.microbit_runtime = '' self.editor.show_status_message(message, 10) self.set_buttons(flash=False) self.flash_thread = DeviceFlasher([path_to_microbit], @@ -310,7 +353,7 @@ def flash_failed(self, error): problem. """ logger.error(error) - message = _("There was a problem flashing the micro:bit") + message = _("There was a problem flashing the micro:bit.") information = _("Please do not disconnect the device until flashing" " has completed.") self.view.show_message(message, information, 'Warning') @@ -421,37 +464,6 @@ def remove_fs(self): self.file_manager_thread = None self.fs = None - def get_hex_path(self): - """ - Returns the path to the hex runtime file - if this has been - specified under element 'microbit_runtime_hex' in settings.json. - This can be a fully-qualified file path, or just a file name - in which case the file should be located in the workspace directory. - Returns None if no path is specified or if the file is not present. - """ - runtime_hex_path = None - sp = get_settings_path() - settings = {} - try: - with open(sp) as f: - settings = json.load(f) - except FileNotFoundError: - logger.error('Settings file {} does not exist.'.format(sp)) - except ValueError: - logger.error('Settings file {} could not be parsed.'.format(sp)) - else: - if 'microbit_runtime_hex' in settings and \ - settings['microbit_runtime_hex'] is not None: - if os.path.exists(settings['microbit_runtime_hex']): - runtime_hex_path = settings['microbit_runtime_hex'] - else: - expected_path = settings['microbit_runtime_hex'] - runtime_hex_path = os.path.join(self.workspace_dir(), - expected_path) - if not os.path.exists(runtime_hex_path): - runtime_hex_path = None - return runtime_hex_path - def on_data_flood(self): """ Ensure the Files button is active before the REPL is killed off when diff --git a/mu/resources/css/contrast.css b/mu/resources/css/contrast.css index 3821979ac..cfa35242a 100644 --- a/mu/resources/css/contrast.css +++ b/mu/resources/css/contrast.css @@ -135,3 +135,18 @@ QMenu::separator { padding: 0px; margin: 0px; } + +QLineEdit { + background: #FFF; + color: #000; +} + +QCheckBox::indicator { + border: 1px solid #FFF; + width: 16px; + height: 16px; +} + +QCheckBox::indicator:checked { + image: url(images:checked.png); +} diff --git a/mu/resources/css/day.css b/mu/resources/css/day.css index 764c9858f..f5be50fa6 100644 --- a/mu/resources/css/day.css +++ b/mu/resources/css/day.css @@ -124,3 +124,17 @@ QMenu::separator { padding: 0px; margin: 0px; } + +QLineEdit { + background: #FFF; +} + +QCheckBox::indicator { + border: 1px solid #222; + width: 16px; + height: 16px; +} + +QCheckBox::indicator:checked { + image: url(images:checked.png); +} diff --git a/mu/resources/css/night.css b/mu/resources/css/night.css index 9403a6fcf..443c02363 100644 --- a/mu/resources/css/night.css +++ b/mu/resources/css/night.css @@ -122,3 +122,18 @@ QMenu::separator { padding: 0px; margin: 0px; } + +QLineEdit { + background-color: #373737; + color: white; +} + +QCheckBox::indicator { + border: 1px solid #6b6b6b; + width: 16px; + height: 16px; +} + +QCheckBox::indicator:checked { + image: url(images:checked.png); +} diff --git a/mu/resources/images/checked.png b/mu/resources/images/checked.png new file mode 100644 index 000000000..c77ee44aa Binary files /dev/null and b/mu/resources/images/checked.png differ diff --git a/requirements.txt b/requirements.txt index f80a5bdda..c5cc13813 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ coverage gpiozero guizero matplotlib +nudatus qscintilla qtconsole pgzero diff --git a/requirements_pi.txt b/requirements_pi.txt index cff1efc5f..5114d1c79 100644 --- a/requirements_pi.txt +++ b/requirements_pi.txt @@ -5,6 +5,7 @@ coverage guizero pigpio matplotlib +nudatus qtconsole Pillow pycodestyle @@ -16,4 +17,4 @@ pytest-random-order requests scrapy setuptools -sphinx +sphinx \ No newline at end of file diff --git a/tests/interface/test_dialogs.py b/tests/interface/test_dialogs.py index 6509a4c96..b0a98c18c 100644 --- a/tests/interface/test_dialogs.py +++ b/tests/interface/test_dialogs.py @@ -158,18 +158,35 @@ def test_EnvironmentVariablesWidget_setup(): assert not evw.text_area.isReadOnly() +def test_MicrobitSettingsWidget_setup(): + """ + Ensure the widget for editing settings related to the BBC microbit + displays the referenced settings data in the expected way. + """ + minify = True + 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' + + 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' - envars = 'name=value' + settings = { + 'envars': 'name=value', + 'minify': True, + 'microbit_runtime': '/foo/bar', + } ad = mu.interface.dialogs.AdminDialog() ad.setStyleSheet = mock.MagicMock() - ad.setup(log, envars, 'day') + ad.setup(log, settings, 'day') assert ad.log_widget.log_text_area.toPlainText() == log - assert ad.envars() == envars + assert ad.settings() == settings ad.setStyleSheet.assert_called_once_with(mu.interface.themes.DAY_STYLE) @@ -178,10 +195,14 @@ def test_AdminDialog_setup_night(): Ensure the admin dialog can start with the night theme. """ log = 'this is the contents of a log file' - envars = 'name=value' + settings = { + 'envars': 'name=value', + 'minify': True, + 'microbit_runtime': '/foo/bar', + } ad = mu.interface.dialogs.AdminDialog() ad.setStyleSheet = mock.MagicMock() - ad.setup(log, envars, 'night') + ad.setup(log, settings, 'night') ad.setStyleSheet.assert_called_once_with(mu.interface.themes.NIGHT_STYLE) @@ -190,9 +211,13 @@ def test_LogDisplay_setup_contrast(): Ensure the log display dialog can start with the high contrast theme. """ log = 'this is the contents of a log file' - envars = 'name=value' + settings = { + 'envars': 'name=value', + 'minify': True, + 'microbit_runtime': '/foo/bar', + } ad = mu.interface.dialogs.AdminDialog() ad.setStyleSheet = mock.MagicMock() - ad.setup(log, envars, 'contrast') + ad.setup(log, settings, 'contrast') ad.setStyleSheet.\ assert_called_once_with(mu.interface.themes.CONTRAST_STYLE) diff --git a/tests/interface/test_main.py b/tests/interface/test_main.py index 6519ea4e8..b41d4f158 100644 --- a/tests/interface/test_main.py +++ b/tests/interface/test_main.py @@ -1099,7 +1099,7 @@ def test_Window_show_admin(): """ mock_admin_display = mock.MagicMock() mock_admin_box = mock.MagicMock() - mock_admin_box.envars.return_value = 'this is the expected result' + mock_admin_box.settings.return_value = 'this is the expected result' mock_admin_display.return_value = mock_admin_box with mock.patch('mu.interface.main.AdminDialog', mock_admin_display): w = mu.interface.main.Window() diff --git a/tests/interface/test_panes.py b/tests/interface/test_panes.py index 1f9e2430a..963aaa7c3 100644 --- a/tests/interface/test_panes.py +++ b/tests/interface/test_panes.py @@ -881,6 +881,7 @@ def test_PythonProcessPane_init(): assert ppp.input_history == [] assert ppp.start_of_current_line == 0 assert ppp.history_position == 0 + assert ppp.running is False def test_PythonProcessPane_start_process(): @@ -906,6 +907,7 @@ def test_PythonProcessPane_start_process(): runner = sys.executable expected_args = ['-i', expected_script, ] # called with interactive flag. ppp.process.start.assert_called_once_with(runner, expected_args) + assert ppp.running is True def test_PythonProcessPane_start_process_command_args(): @@ -1189,6 +1191,7 @@ def test_PythonProcessPane_parse_input_ctrl_c(): ppp = mu.interface.panes.PythonProcessPane() ppp.process = mock.MagicMock() ppp.process.processId.return_value = 123 + ppp.running = True key = Qt.Key_C text = '' modifiers = Qt.ControlModifier @@ -1206,6 +1209,7 @@ def test_PythonProcessPane_parse_input_ctrl_d(): """ ppp = mu.interface.panes.PythonProcessPane() ppp.process = mock.MagicMock() + ppp.running = True key = Qt.Key_D text = '' modifiers = Qt.ControlModifier @@ -1215,6 +1219,41 @@ def test_PythonProcessPane_parse_input_ctrl_d(): ppp.process.kill.assert_called_once_with() +def test_PythonProcessPane_parse_input_ctrl_c_after_process_finished(): + """ + Control-C (SIGINT / KeyboardInterrupt) character is typed. + """ + ppp = mu.interface.panes.PythonProcessPane() + ppp.process = mock.MagicMock() + ppp.process.processId.return_value = 123 + ppp.running = False + key = Qt.Key_C + text = '' + modifiers = Qt.ControlModifier + mock_kill = mock.MagicMock() + with mock.patch('mu.interface.panes.os.kill', mock_kill), \ + mock.patch('mu.interface.panes.platform.system', + return_value='win32'): + ppp.parse_input(key, text, modifiers) + assert mock_kill.call_count == 0 + + +def test_PythonProcessPane_parse_input_ctrl_d_after_process_finished(): + """ + Control-D (Kill process) character is typed. + """ + ppp = mu.interface.panes.PythonProcessPane() + ppp.process = mock.MagicMock() + ppp.running = False + key = Qt.Key_D + text = '' + modifiers = Qt.ControlModifier + with mock.patch('mu.interface.panes.platform.system', + return_value='win32'): + ppp.parse_input(key, text, modifiers) + assert ppp.process.kill.call_count == 0 + + def test_PythonProcessPane_parse_input_up_arrow(): """ Up Arrow causes the input line to be replaced with movement back in @@ -1360,14 +1399,28 @@ def test_PythonProcessPane_parse_input_copy(): def test_PythonProcessPane_parse_input_backspace(): """ - Backspace deletes the character at the cursor position. + Backspace call causes a backspace from the character at the cursor + position. """ ppp = mu.interface.panes.PythonProcessPane() - ppp.delete = mock.MagicMock() + ppp.backspace = mock.MagicMock() key = Qt.Key_Backspace text = '\b' modifiers = None ppp.parse_input(key, text, modifiers) + ppp.backspace.assert_called_once_with() + + +def test_PythonProcessPane_parse_input_delete(): + """ + Delete deletes the character to the right of the cursor position. + """ + ppp = mu.interface.panes.PythonProcessPane() + ppp.delete = mock.MagicMock() + key = Qt.Key_Delete + text = '\b' + modifiers = None + ppp.parse_input(key, text, modifiers) ppp.delete.assert_called_once_with() @@ -1378,6 +1431,9 @@ def test_PythonProcessPane_parse_input_newline(): ppp = mu.interface.panes.PythonProcessPane() ppp.toPlainText = mock.MagicMock(return_value='abc\n') ppp.start_of_current_line = 0 + ppp.textCursor = mock.MagicMock() + ppp.textCursor().position.return_value = 666 + ppp.insert = mock.MagicMock() ppp.write_to_stdin = mock.MagicMock() key = Qt.Key_Enter text = '\r' @@ -1386,6 +1442,8 @@ def test_PythonProcessPane_parse_input_newline(): ppp.write_to_stdin.assert_called_once_with(b'abc\n') assert b'abc' in ppp.input_history assert ppp.history_position == 0 + # On newline, the start of the current line should be set correctly. + assert ppp.start_of_current_line == 666 def test_PythonProcessPane_parse_input_newline_ignore_empty_input_in_history(): @@ -1543,10 +1601,42 @@ def test_PythonProcessPane_insert(): mock_cursor.insertText.assert_called_once_with('hello') +def test_PythonProcessPane_backspace(): + """ + Make sure that removing a character to the left of the current cursor + position works as expected. + """ + ppp = mu.interface.panes.PythonProcessPane() + ppp.start_of_current_line = 123 + mock_cursor = mock.MagicMock() + mock_cursor.position.return_value = 124 + mock_cursor.deletePreviousChar = mock.MagicMock() + ppp.setTextCursor = mock.MagicMock() + ppp.textCursor = mock.MagicMock(return_value=mock_cursor) + ppp.backspace() + mock_cursor.deletePreviousChar.assert_called_once_with() + ppp.setTextCursor.assert_called_once_with(mock_cursor) + + +def test_PythonProcessPane_backspace_at_start_of_input_line(): + """ + Make sure that removing a character will not work if the cursor is at the + left-hand boundary of the input line. + """ + ppp = mu.interface.panes.PythonProcessPane() + ppp.start_of_current_line = 123 + mock_cursor = mock.MagicMock() + mock_cursor.position.return_value = 123 + mock_cursor.deletePreviousChar = mock.MagicMock() + ppp.textCursor = mock.MagicMock(return_value=mock_cursor) + ppp.backspace() + assert mock_cursor.deletePreviousChar.call_count == 0 + + def test_PythonProcessPane_delete(): """ - Make sure that removing a character at the current cursor position works as - expected. + Make sure that removing a character to the right of the current cursor + position works as expected. """ ppp = mu.interface.panes.PythonProcessPane() ppp.start_of_current_line = 123 @@ -1556,7 +1646,7 @@ def test_PythonProcessPane_delete(): ppp.setTextCursor = mock.MagicMock() ppp.textCursor = mock.MagicMock(return_value=mock_cursor) ppp.delete() - mock_cursor.deletePreviousChar.assert_called_once_with() + mock_cursor.deleteChar.assert_called_once_with() ppp.setTextCursor.assert_called_once_with(mock_cursor) @@ -1568,11 +1658,11 @@ def test_PythonProcessPane_delete_at_start_of_input_line(): ppp = mu.interface.panes.PythonProcessPane() ppp.start_of_current_line = 123 mock_cursor = mock.MagicMock() - mock_cursor.position.return_value = 123 + mock_cursor.position.return_value = 122 mock_cursor.deletePreviousChar = mock.MagicMock() ppp.textCursor = mock.MagicMock(return_value=mock_cursor) ppp.delete() - assert mock_cursor.deletePreviousChar.call_count == 0 + assert mock_cursor.deleteChar.call_count == 0 def test_PythonProcessPane_clear_input_line(): diff --git a/tests/modes/test_microbit.py b/tests/modes/test_microbit.py index 43bca3361..e5daf95a0 100644 --- a/tests/modes/test_microbit.py +++ b/tests/modes/test_microbit.py @@ -9,6 +9,7 @@ from mu.modes.microbit import MicrobitMode, FileManager, DeviceFlasher from mu.modes.api import MICROBIT_APIS, SHARED_APIS from unittest import mock +from tokenize import TokenError TEST_ROOT = os.path.split(os.path.dirname(__file__))[0] @@ -215,39 +216,6 @@ def test_microbit_mode_no_charts(): assert actions[2]['handler'] == mm.toggle_repl -def test_custom_hex_read(): - """ - Test that a custom hex file path can be read - """ - editor = mock.MagicMock() - view = mock.MagicMock() - mm = MicrobitMode(editor, view) - with mock.patch('mu.modes.microbit.get_settings_path', - return_value='tests/settingswithcustomhex.json'), \ - mock.patch('mu.modes.microbit.os.path.exists', return_value=True),\ - mock.patch('mu.modes.base.BaseMode.workspace_dir', - return_value=TEST_ROOT): - assert "customhextest.hex" in mm.get_hex_path() - """ - Test that a corrupt settings file returns None for the - runtime hex path - """ - with mock.patch('mu.modes.microbit.get_settings_path', - return_value='tests/settingscorrupt.json'), \ - mock.patch('mu.modes.base.BaseMode.workspace_dir', - return_value=TEST_ROOT): - assert mm.get_hex_path() is None - """ - Test that a missing settings file returns None for the - runtime hex path - """ - with mock.patch('mu.modes.microbit.get_settings_path', - return_value='tests/settingswithmissingcustomhex.json'), \ - mock.patch('mu.modes.base.BaseMode.workspace_dir', - return_value=TEST_ROOT): - assert mm.get_hex_path() is None - - def test_flash_no_tab(): """ If there are no active tabs simply return. @@ -276,13 +244,16 @@ def test_flash_with_attached_device_as_windows(): view.current_tab.text = mock.MagicMock(return_value='foo') view.show_message = mock.MagicMock() editor = mock.MagicMock() + editor.minify = False + editor.microbit_runtime = '/foo/bar' mm = MicrobitMode(editor, view) mm.set_buttons = mock.MagicMock() mm.flash() assert mm.flash_thread == mock_flasher assert editor.show_status_message.call_count == 1 mm.set_buttons.assert_called_once_with(flash=False) - mock_flasher_class.assert_called_once_with(['bar', ], b'foo', None) + mock_flasher_class.assert_called_once_with(['bar', ], b'foo', + '/foo/bar') mock_flasher.finished.connect.\ assert_called_once_with(mm.flash_finished) mock_flasher.on_flash_fail.connect.\ @@ -310,6 +281,8 @@ def test_flash_with_attached_device_as_not_windows(): view.current_tab.text = mock.MagicMock(return_value='foo') view.show_message = mock.MagicMock() editor = mock.MagicMock() + editor.minify = False + editor.microbit_runtime = '' mm = MicrobitMode(editor, view) mm.set_buttons = mock.MagicMock() mm.flash() @@ -332,10 +305,8 @@ def test_flash_with_attached_device_and_custom_runtime(): """ mock_flasher = mock.MagicMock() mock_flasher_class = mock.MagicMock(return_value=mock_flasher) - with mock.patch('mu.modes.microbit.get_settings_path', - return_value='tests/settingswithcustomhex.json'), \ - mock.patch('mu.modes.base.BaseMode.workspace_dir', - return_value=TEST_ROOT), \ + with mock.patch('mu.modes.base.BaseMode.workspace_dir', + return_value=TEST_ROOT), \ mock.patch('mu.modes.microbit.DeviceFlasher', mock_flasher_class), \ mock.patch('mu.modes.microbit.sys.platform', 'win32'): @@ -343,6 +314,8 @@ def test_flash_with_attached_device_and_custom_runtime(): view.current_tab.text = mock.MagicMock(return_value='foo') view.show_message = mock.MagicMock() editor = mock.MagicMock() + editor.minify = True + editor.microbit_runtime = os.path.join('tests', 'customhextest.hex') mm = MicrobitMode(editor, view) mm.flash() assert editor.show_status_message.call_count == 1 @@ -369,6 +342,8 @@ def test_flash_user_specified_device_path(): view.current_tab.text = mock.MagicMock(return_value='foo') view.show_message = mock.MagicMock() editor = mock.MagicMock() + editor.minify = False + editor.microbit_runtime = '' mm = MicrobitMode(editor, view) mm.flash() home = HOME_DIRECTORY @@ -396,12 +371,15 @@ def test_flash_existing_user_specified_device_path(): view.get_microbit_path = mock.MagicMock(return_value='bar') view.show_message = mock.MagicMock() editor = mock.MagicMock() + editor.minify = False + editor.microbit_runtime = '/foo/bar' mm = MicrobitMode(editor, view) mm.user_defined_microbit_path = 'baz' mm.flash() assert view.get_microbit_path.call_count == 0 assert editor.show_status_message.call_count == 1 - mock_flasher_class.assert_called_once_with(['baz', ], b'foo', None) + mock_flasher_class.assert_called_once_with(['baz', ], b'foo', + '/foo/bar') def test_flash_path_specified_does_not_exist(): @@ -473,8 +451,29 @@ def test_flash_script_too_big(): view.current_tab.label = 'foo' view.show_message = mock.MagicMock() editor = mock.MagicMock() + editor.minify = True mm = MicrobitMode(editor, view) - mm.flash() + with mock.patch('mu.modes.microbit.can_minify', True): + mm.flash() + view.show_message.assert_called_once_with('Unable to flash "foo"', + 'Our minifier tried but your ' + 'script is too long!', + 'Warning') + + +def test_flash_script_too_big_no_minify(): + """ + If the script in the current tab is too big, abort in the expected way. + """ + view = mock.MagicMock() + view.current_tab.text = mock.MagicMock(return_value='x' * 8193) + view.current_tab.label = 'foo' + view.show_message = mock.MagicMock() + editor = mock.MagicMock() + editor.minify = False + mm = MicrobitMode(editor, view) + with mock.patch('mu.modes.microbit.can_minify', False): + mm.flash() view.show_message.assert_called_once_with('Unable to flash "foo"', 'Your script is too long!', 'Warning') @@ -516,6 +515,48 @@ def test_flash_failed(): mock_timer.stop.assert_called_once_with() +def test_flash_minify(): + view = mock.MagicMock() + script = '#' + ('x' * 8193) + '\n' + view.current_tab.text = mock.MagicMock(return_value=script) + view.show_message = mock.MagicMock() + editor = mock.MagicMock() + editor.minify = True + mm = MicrobitMode(editor, view) + mm.set_buttons = mock.MagicMock() + with mock.patch('mu.modes.microbit.DeviceFlasher'): + with mock.patch('nudatus.mangle', return_value='') as m: + mm.flash() + m.assert_called_once_with(script) + + ex = TokenError('Bad', (1, 0)) + with mock.patch('nudatus.mangle', side_effect=ex) as m: + mm.flash() + view.show_message.assert_called_once_with('Problem with script', + 'Bad [1:0]', 'Warning') + + +def test_flash_minify_no_minify(): + view = mock.MagicMock() + view.current_tab.label = 'foo' + view.show_message = mock.MagicMock() + script = '#' + ('x' * 8193) + '\n' + view.current_tab.text = mock.MagicMock(return_value=script) + editor = mock.MagicMock() + editor.minify = True + mm = MicrobitMode(editor, view) + mm.set_buttons = mock.MagicMock() + with mock.patch('mu.modes.microbit.can_minify', False): + with mock.patch('nudatus.mangle', return_value='') as m: + mm.flash() + assert m.call_count == 0 + view.show_message.assert_called_once_with('Unable to flash "foo"', + 'Your script is too long' + ' and the minifier ' + 'isn\'t available', + 'Warning') + + def test_add_fs(): """ It's possible to add the file system pane if the REPL is inactive. diff --git a/tests/test_logic.py b/tests/test_logic.py index 3f41d701f..b02b1af0e 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -88,14 +88,9 @@ def generate_python_file(text="", dirpath=None): @contextlib.contextmanager -def generate_session( - theme="day", - mode="python", - file_contents=None, - filepath=None, - envars=[['name', 'value'], ], - **kwargs -): +def generate_session(theme="day", mode="python", file_contents=None, + filepath=None, envars=[['name', 'value'], ], minify=False, + microbit_runtime=None, **kwargs): """Generate a temporary session file for one test By default, the session file will be created inside a temporary directory @@ -133,6 +128,10 @@ def generate_session( session_data['paths'] = list(paths) if envars: session_data['envars'] = envars + if minify is not None: + session_data['minify'] = minify + if microbit_runtime: + session_data['microbit_runtime'] = microbit_runtime session_data.update(**kwargs) if filepath is None: @@ -630,21 +629,44 @@ def test_editor_setup(): assert e.modes == mock_modes -def test_editor_restore_session(): +def test_editor_restore_session_existing_runtime(): """ A correctly specified session is restored properly. """ 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'): + ed.restore_session() - with generate_session(theme, mode, file_contents): + 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.minify is False + assert ed.microbit_runtime == '/foo' + + +def test_editor_restore_session_missing_runtime(): + """ + If the referenced microbit_runtime file doesn't exist, reset to '' so Mu + uses the built-in runtime. + """ + mode, theme = "python", "night" + file_contents = ["", ""] + ed = mocked_editor(mode) + + 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.minify is False + assert ed.microbit_runtime == '' # File does not exist so set to '' def test_editor_restore_session_missing_files(): @@ -1580,12 +1602,55 @@ def test_show_admin(): view = mock.MagicMock() ed = mu.logic.Editor(view) ed.envars = [['name', 'value'], ] + ed.minify = True + ed.microbit_runtime = '/foo/bar' + settings = { + 'envars': 'name=value', + 'minify': True, + 'microbit_runtime': '/foo/bar' + } + view.show_admin.return_value = settings mock_open = mock.mock_open() - with mock.patch('builtins.open', mock_open): + with mock.patch('builtins.open', mock_open), \ + mock.patch('os.path.isfile', return_value=True): + ed.show_admin(None) + 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.minify is True + assert ed.microbit_runtime == '/foo/bar' + + +def test_show_admin_missing_microbit_runtime(): + """ + Ensure the microbit_runtime result is '' and a warning message is displayed + if the specified microbit_runtime doesn't actually exist. + """ + view = mock.MagicMock() + ed = mu.logic.Editor(view) + ed.envars = [['name', 'value'], ] + ed.minify = True + ed.microbit_runtime = '/foo/bar' + settings = { + 'envars': 'name=value', + 'minify': True, + 'microbit_runtime': '/foo/bar' + } + view.show_admin.return_value = settings + mock_open = mock.mock_open() + with mock.patch('builtins.open', mock_open), \ + mock.patch('os.path.isfile', return_value=False): ed.show_admin(None) - mock_open.assert_called_once_with(mu.logic.LOG_FILE, 'r') + 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] == 'name=value' + assert view.show_admin.call_args[0][1] == settings + assert ed.envars == [['name', 'value']] + assert ed.minify is True + assert ed.microbit_runtime == '' + assert view.show_message.call_count == 1 def test_select_mode(): diff --git a/win_installer32.cfg b/win_installer32.cfg index 392d6809e..87f6d8f42 100644 --- a/win_installer32.cfg +++ b/win_installer32.cfg @@ -70,5 +70,6 @@ packages= tkinter _tkinter turtle + nudatus files=lib diff --git a/win_installer64.cfg b/win_installer64.cfg index 03e743c3f..45eb772d6 100644 --- a/win_installer64.cfg +++ b/win_installer64.cfg @@ -70,5 +70,6 @@ packages= tkinter _tkinter turtle + nudatus files=lib