From bbe79e5009985d38349843ee0eaf1b700f837dfb Mon Sep 17 00:00:00 2001 From: Geoffrey Poore Date: Mon, 28 Sep 2020 23:07:11 -0500 Subject: [PATCH] added text2qti_tk graphical application, plus Windows build scripts under make_gui_exe/ (#27) --- CHANGELOG.md | 3 + README.md | 30 ++-- make_gui_exe/README.md | 35 ++++ make_gui_exe/make_tk_exe.bat | 47 +++++ make_gui_exe/text2qti_tk.pyw | 2 + setup.py | 7 +- text2qti/gui/__init__.py | 0 text2qti/gui/tk.py | 320 +++++++++++++++++++++++++++++++++++ text2qti/markdown.py | 39 ++++- text2qti/quiz.py | 294 +++++++++++++++++--------------- text2qti/version.py | 2 +- 11 files changed, 611 insertions(+), 168 deletions(-) create mode 100644 make_gui_exe/README.md create mode 100644 make_gui_exe/make_tk_exe.bat create mode 100644 make_gui_exe/text2qti_tk.pyw create mode 100644 text2qti/gui/__init__.py create mode 100644 text2qti/gui/tk.py diff --git a/CHANGELOG.md b/CHANGELOG.md index af29099..04d6591 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## v0.5.0 (dev) +* Added `text2qti_tk` executable, which provides a basic graphical user + interface (GUI) via `tkinter`. Added build scripts in `make_gui_exe/` for + creating a standalone GUI executable under Windows with PyInstaller (#27). * In executable code blocks, `.python` now invokes `python3` on systems where `python` is equivalent to `python2` as well as on systems that lack a `python` executable. The README now suggests using `.python3` and diff --git a/README.md b/README.md index acd6d11..d6d59c7 100644 --- a/README.md +++ b/README.md @@ -214,20 +214,24 @@ editor like Notepad or gedit, or a code editor like [VS Code](https://code.visualstudio.com/). You can even use Microsoft Word, as long as you save your file as plain text (*.txt). -text2qti is a command-line application. Open a command line in the same -folder or directory as your quiz file. Under Windows, you can hold the SHIFT -button down on the keyboard, then right click next to your file, and select -"Open PowerShell window here" or "Open command window here". You can also -launch "Command Prompt" or "PowerShell" through the Start menu, and then -navigate to your file using `cd`. +text2qti includes a graphical application and a command-line application. -Run the `text2qti` application using a command like this: -``` -text2qti quiz.txt -``` -Replace "quiz.txt" with the name of your file. This will create a file like -`quiz.zip` (with "quiz" replaced by the name of your file) which is the -converted quiz in QTI format. +* To use the graphical application, open a command line and run `text2qti_tk`. + +* To use the command-line application, open a command line in the same folder + or directory as your quiz file. Under Windows, you can hold the SHIFT + button down on the keyboard, then right click next to your file, and select + "Open PowerShell window here" or "Open command window here". You can also + launch "Command Prompt" or "PowerShell" through the Start menu, and then + navigate to your file using `cd`. + + Run the `text2qti` application using a command like this: + ``` + text2qti quiz.txt + ``` + Replace "quiz.txt" with the name of your file. This will create a file like + `quiz.zip` (with "quiz" replaced by the name of your file) which is the + converted quiz in QTI format. Instructions for using the QTI file with Canvas: * Go to the course in which you want to use the quiz. diff --git a/make_gui_exe/README.md b/make_gui_exe/README.md new file mode 100644 index 0000000..8d90dce --- /dev/null +++ b/make_gui_exe/README.md @@ -0,0 +1,35 @@ +# Create Standalone GUI Executable for Windows + +This directory contains scripts for creating a standalone GUI executable under +Windows with PyInstaller. + + + +## Requirements + +* Windows +* [conda](https://docs.conda.io/projects/conda/en/latest/user-guide/install/) + + + +## Directions + +If you do not already have a local copy of the text2qti source, download +`make_tk_exe.bat` and `text2qti_tk.pyw`, and place them in the same directory. +Double-click on `make_tk_exe.bat` to run it. Or open a command prompt, +navigate to `make_gui_exe/` (or wherever the batch file is located), and run +the batch file. Under PowerShell, run something like +`cmd /c make_gui_exe.bat`. + +The batch file performs these steps: +* Create a new conda environment for building the executable. +* Activate the conda environment. +* Install needed Python packages in the environment: bespon, markdown, + pyinstaller, and text2qti. If the batch file detects that it is part of a + local copy of the text2qti source, then this local version of text2qti will + be used. Otherwise, text2qti will be installed from PyPI via pip. +* Build executable `text2qti_tk_VERSION.exe` using PyInstaller. +* Deactivate the conda environment. +* Remove the conda environment. +* Move the executable to the working directory. +* Remove all temp files and build files. diff --git a/make_gui_exe/make_tk_exe.bat b/make_gui_exe/make_tk_exe.bat new file mode 100644 index 0000000..4e2baab --- /dev/null +++ b/make_gui_exe/make_tk_exe.bat @@ -0,0 +1,47 @@ +REM This is intended to be run with the .bat file directory as the working dir +if not exist make_tk_exe.bat ( + echo Missing make_tk_exe.bat in working directory + pause + exit +) +if not exist text2qti_tk.pyw ( + echo Missing text2qti_tk.pyw in working directory + pause + exit +) + +REM Create and activate a conda env for packaging the .exe +call conda create -y --name make_text2qti_gui_exe python=3.8 --no-default-packages +call conda activate make_text2qti_gui_exe +REM List conda envs -- useful for debugging +call conda info --envs +REM Install dependencies +pip install bespon +pip install markdown +pip install pyinstaller +if exist ..\setup.py ( + if exist ..\text2qti ( + cd .. + pip install . + cd make_gui_exe + ) else ( + pip install text2qti + ) +) else ( + pip install text2qti +) +REM Build .exe +FOR /F "tokens=* USEBACKQ" %%g IN (`python -c "import text2qti; print(text2qti.__version__)"`) do (SET "TEXT2QTI_VERSION=%%g") +pyinstaller -F --name text2qti_tk_%TEXT2QTI_VERSION% text2qti_tk.pyw +REM Deactivate and delete conda env +call conda deactivate +call conda remove -y --name make_text2qti_gui_exe --all +REM List conda envs -- useful for debugging +call conda info --envs +REM Cleanup +move dist\text2qti_tk_%TEXT2QTI_VERSION%.exe text2qti_tk_%TEXT2QTI_VERSION%.exe +rd /s /q "__pycache__" +rd /s /q "build" +rd /s /q "dist" +del *.spec +pause diff --git a/make_gui_exe/text2qti_tk.pyw b/make_gui_exe/text2qti_tk.pyw new file mode 100644 index 0000000..42fda4f --- /dev/null +++ b/make_gui_exe/text2qti_tk.pyw @@ -0,0 +1,2 @@ +import text2qti.gui.tk +text2qti.gui.tk.main() diff --git a/setup.py b/setup.py index fbdd9b0..54e29fe 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ if sys.version_info < (3, 6): sys.exit('text2qti requires Python 3.6+') import pathlib -from setuptools import setup +from setuptools import setup, find_packages @@ -30,9 +30,7 @@ setup(name='text2qti', version=version, py_modules=[], - packages=[ - 'text2qti' - ], + packages=find_packages(), package_data = {}, description='Create quizzes in QTI format from Markdown-based plain text', long_description=long_description, @@ -62,5 +60,6 @@ ], entry_points = { 'console_scripts': ['text2qti = text2qti.cmdline:main'], + 'gui_scripts': ['text2qti_tk = text2qti.gui.tk:main'], }, ) diff --git a/text2qti/gui/__init__.py b/text2qti/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/text2qti/gui/tk.py b/text2qti/gui/tk.py new file mode 100644 index 0000000..429f05a --- /dev/null +++ b/text2qti/gui/tk.py @@ -0,0 +1,320 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2020, Geoffrey M. Poore +# All rights reserved. +# +# Licensed under the BSD 3-Clause License: +# http://opensource.org/licenses/BSD-3-Clause +# + + +import os +import pathlib +import shutil +import time +import tkinter as tk +import tkinter.filedialog +import webbrowser +from ..config import Config +from ..err import Text2qtiError +from ..qti import QTI +from ..quiz import Quiz +from .. import version + + + +def main(): + config = Config() + config.load() + file_name = '' + + window = tk.Tk() + window.title('text2qti') + # Bring window to front and put in focus + window.iconify() + window.update() + window.deiconify() + + # Window grid setup + current_row = 0 + column_count = 4 + + + header_label = tk.Label( + window, + text='text2qti – Create quizzes in QTI format from Markdown-based plain text', + font=(None, 16), + ) + header_label.grid( + row=current_row, column=0, columnspan=column_count, padx=(30, 30), + sticky='nsew', + ) + current_row += 1 + header_link_label = tk.Label( + window, + text='github.com/gpoore/text2qti', + font=(None, 14), fg='blue', cursor='hand2', + ) + header_link_label.bind('', lambda x: webbrowser.open_new('https://github.com/gpoore/text2qti')) + header_link_label.grid( + row=current_row, column=0, columnspan=column_count, padx=(30, 30), + sticky='nsew', + ) + current_row += 1 + version_label = tk.Label( + window, + text=f'Version {version.__version__}', + ) + version_label.grid( + row=current_row, column=0, columnspan=column_count, padx=(30, 30), pady=(0, 30), + sticky='nsew', + ) + current_row += 1 + + + file_browser_label = tk.Label( + window, + text='Quiz file:\n(plain text file)', + justify='right', + ) + file_browser_label.grid( + row=current_row, column=0, padx=(30, 5), pady=(5, 25), + sticky='nse', + ) + last_dir = None + def browse_files(): + nonlocal file_name + nonlocal last_dir + if last_dir is None: + initialdir = pathlib.Path('~').expanduser() + else: + initialdir = last_dir + file_name = tkinter.filedialog.askopenfilename( + initialdir=initialdir, + title='Select a quiz file', + filetypes=[('Quiz files', '*.md;*.txt')], + ) + if file_name: + if last_dir is None: + last_dir = pathlib.Path(file_name).parent + file_browser_button.config(text=f'"{file_name}"', fg='green') + else: + file_browser_button.config(text=f'', fg='red') + + file_browser_button = tk.Button( + window, + text='', + fg='red', + command=browse_files, + ) + file_browser_button.grid( + row=current_row, column=1, columnspan=column_count-1, padx=(0, 30), pady=(5, 25), + sticky='nsew', + ) + current_row += 1 + + + advanced_options_label = tk.Label( + window, + text='Advanced options – LaTeX math & executable code', + justify='right', + ) + advanced_options_label.grid( + row=current_row, column=1, columnspan=2, padx=(0, 0), pady=(5, 5), + sticky='nsw', + ) + current_row += 1 + + + latex_url_label = tk.Label( + window, + text='LaTeX math rendering URL:\n(for Canvas and similar systems)', + justify='right', + ) + latex_url_label.grid( + row=current_row, column=0, padx=(30, 5), pady=(5, 5), + sticky='nse', + ) + latex_url_entry = tk.Entry(window, width=100) + latex_url_entry.grid( + row=current_row, column=1, columnspan=column_count-1, padx=(0, 30), pady=(5, 5), + sticky='nsew', + ) + if 'latex_render_url' in config: + latex_url_entry.insert(1, f"{config['latex_render_url']}") + current_row += 1 + + + pandoc_exists = bool(shutil.which('pandoc')) + pandoc_mathml_label = tk.Label( + window, + text='Convert LaTeX math to MathML:\n(requires Pandoc; ignores rendering URL)', + justify='right', + ) + if not pandoc_exists: + pandoc_mathml_label['fg'] = 'gray' + pandoc_mathml_label.grid( + row=current_row, column=0, padx=(30, 5), pady=(5, 5), + sticky='nse', + ) + pandoc_mathml_bool = tk.BooleanVar() + def pandoc_mathml_command(): + if pandoc_mathml_bool.get(): + latex_url_label['fg'] = 'gray' + latex_url_entry['fg'] = 'gray' + else: + latex_url_label['fg'] = 'black' + latex_url_entry['fg'] = 'black' + if pandoc_exists: + pandoc_mathml_button = tk.Checkbutton( + window, + variable=pandoc_mathml_bool, + command=pandoc_mathml_command, + ) + pandoc_mathml_bool.set(config['pandoc_mathml']) + else: + pandoc_mathml_button = tk.Checkbutton( + window, + state=tk.DISABLED, + ) + pandoc_mathml_button.grid( + row=current_row, column=1, sticky='w', + ) + current_row += 1 + + + run_code_blocks_label = tk.Label( + window, + text='Allow executable code blocks:\n(only use for trusted code)', + justify='right', + ) + run_code_blocks_label.grid( + row=current_row, column=0, padx=(30, 5), pady=(5, 5), + sticky='nse', + ) + run_code_blocks_bool = tk.BooleanVar() + run_code_blocks_bool.set(config['run_code_blocks']) + def run_code_blocks_command(): + if run_code_blocks_bool.get(): + run_code_blocks_label['fg'] = 'red' + else: + run_code_blocks_label['fg'] = 'black' + run_code_blocks_button = tk.Checkbutton( + window, + variable=run_code_blocks_bool, + command=run_code_blocks_command, + ) + run_code_blocks_button.grid( + row=current_row, column=1, sticky='w', + ) + current_row += 1 + + + def run(): + run_message_text.delete(1.0, tk.END) + run_message_text['fg'] = 'gray' + run_message_text.insert(tk.INSERT, 'Starting...') + run_message_text.update() + error_message = None + if not file_name: + error_message = 'Must select a quiz file' + run_message_text.delete(1.0, tk.END) + run_message_text.insert(tk.INSERT, error_message) + run_message_text['fg'] = 'red' + return + if latex_url_entry.get(): + config['latex_render_url'] = latex_url_entry.get() + config['run_code_blocks'] = run_code_blocks_bool.get() + config['pandoc_mathml'] = pandoc_mathml_bool.get() + + file_path = pathlib.Path(file_name) + try: + text = file_path.read_text(encoding='utf-8-sig') # Handle BOM for Windows + except FileNotFoundError: + error_message = f'File "{file_path}" does not exist.' + except PermissionError as e: + error_message = f'File "{file_path}" cannot be read due to permission error. Technical details:\n\n{e}' + except UnicodeDecodeError as e: + error_message = f'File "{file_path}" is not encoded in valid UTF-8. Technical details:\n\n{e}' + except Exception as e: + error_message = f'An error occurred in reading the quiz file. Technical details:\n\n{e}' + if error_message: + run_message_text.delete(1.0, tk.END) + run_message_text.insert(tk.INSERT, error_message) + run_message_text['fg'] = 'red' + return + cwd = pathlib.Path.cwd() + os.chdir(file_path.parent) + try: + quiz = Quiz(text, config=config, source_name=file_path.as_posix()) + qti = QTI(quiz) + qti.save(f'{file_path.stem}.zip') + except Text2qtiError as e: + error_message = f'Quiz creation failed:\n\n{e}' + except Exception as e: + error_message = f'Quiz creation failed unexpectedly. Technical details:\n\n{e}' + finally: + os.chdir(cwd) + if error_message: + run_message_text.delete(1.0, tk.END) + run_message_text.insert(tk.INSERT, error_message) + run_message_text['fg'] = 'red' + else: + run_message_text.delete(1.0, tk.END) + run_message_text.insert(tk.INSERT, f'Created quiz "{file_path.parent.as_posix()}/{file_path.stem}.zip"') + run_message_text['fg'] = 'green' + run_button = tk.Button( + window, + text='RUN', + font=(None, 14), + command=run, + ) + run_button.grid( + row=current_row, column=1, columnspan=2, padx=(0, 0), pady=(30, 30), + sticky='nsew', + ) + current_row += 1 + + + run_message_label = tk.Label( + window, + text='\nRun Summary:\n', + relief='ridge', + width=120, + ) + run_message_label.grid( + row=current_row, column=0, columnspan=column_count, padx=(30, 30), pady=(0, 0), + sticky='nsew', + ) + current_row += 1 + + + run_message_frame = tk.Frame( + window, + width=120, height=40, + borderwidth=1, relief='sunken', bg='white', + ) + run_message_frame.grid( + row=current_row, column=0, columnspan=column_count, padx=(30, 30), pady=(0, 30), + sticky='nsew', + ) + run_message_scrollbar = tk.Scrollbar(run_message_frame) + run_message_scrollbar.pack( + side='right', fill='y', + ) + run_message_text = tk.Text( + run_message_frame, + width=10, height=10, borderwidth=0, highlightthickness=0, + wrap='word', + yscrollcommand=run_message_scrollbar.set, + ) + run_message_text.insert(tk.INSERT, 'Waiting...') + run_message_text['fg'] = 'gray' + run_message_scrollbar.config(command=run_message_text.yview) + run_message_text.pack( + side='left', fill='both', expand=True, + padx=(5, 5), pady=(5, 5), + ) + + + window.mainloop() diff --git a/text2qti/markdown.py b/text2qti/markdown.py index b70e6a8..abcdbb9 100644 --- a/text2qti/markdown.py +++ b/text2qti/markdown.py @@ -12,6 +12,7 @@ import hashlib import json import pathlib +import platform import re import subprocess import time @@ -19,8 +20,29 @@ from typing import Dict, Set import urllib.parse import zipfile + import markdown +# Markdown extensions are imported and initialized explicitly to ensure that +# pyinstaller identifies them. +import markdown.extensions +import markdown.extensions.smarty +import markdown.extensions.sane_lists +import markdown.extensions.def_list +import markdown.extensions.fenced_code +import markdown.extensions.footnotes +import markdown.extensions.tables +import markdown.extensions.md_in_html +md_extensions = [ + markdown.extensions.smarty.makeExtension(), + markdown.extensions.sane_lists.makeExtension(), + markdown.extensions.def_list.makeExtension(), + markdown.extensions.fenced_code.makeExtension(), + markdown.extensions.footnotes.makeExtension(), + markdown.extensions.tables.makeExtension(), + markdown.extensions.md_in_html.makeExtension(), +] from markdown.inlinepatterns import ImageInlineProcessor, IMAGE_LINK_RE + from .config import Config from .err import Text2qtiError from .version import __version__ as version @@ -108,15 +130,6 @@ class Markdown(object): def __init__(self, config: Config): self.config = config - md_extensions = [ - 'smarty', - 'sane_lists', - 'def_list', - 'fenced_code', - 'footnotes', - 'tables', - 'md_in_html', - ] markdown_processor = markdown.Markdown(extensions=md_extensions) markdown_image_processor = Text2qtiImagePattern(IMAGE_LINK_RE, markdown_processor, self) markdown_processor.inlinePatterns.register(markdown_image_processor, 'image_link', 150) @@ -135,6 +148,7 @@ def __init__(self, config: Config): def finalize(self): if self.config['pandoc_mathml']: self._save_cache() + self._cache_lock_path.unlink() def _prep_cache(self): @@ -247,10 +261,17 @@ def latex_to_pandoc_mathml(self, latex: str) -> str: mathml = data['mathml'] data['unused_count'] = 0 else: + if platform.system() == 'Windows': + # Prevent console from appearing for an instant + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + else: + startupinfo = None try: proc = subprocess.run(['pandoc', '-f', 'markdown', '-t', 'html', '--mathml'], input='${0}$'.format(latex), encoding='utf8', stdout=subprocess.PIPE, stderr=subprocess.PIPE, + startupinfo=startupinfo, check=True) except FileNotFoundError as e: raise Text2qtiError(f'Could not find Pandoc:\n{e}') diff --git a/text2qti/quiz.py b/text2qti/quiz.py index 37b683e..9833dc1 100644 --- a/text2qti/quiz.py +++ b/text2qti/quiz.py @@ -19,6 +19,7 @@ import itertools import locale import pathlib +import platform import re import shutil import subprocess @@ -584,165 +585,176 @@ def __init__(self, string: str, *, config: Config, else: python_executable = 'python' - parse_actions = {} - for k in start_patterns: - parse_actions[k] = getattr(self, f'append_{k}') - parse_actions[None] = self.append_unknown - start_multiline_comment_pattern = comment_patterns['start_multiline_comment'] - end_multiline_comment_pattern = comment_patterns['end_multiline_comment'] - line_comment_pattern = comment_patterns['line_comment'] - n_line_iter = iter(x for x in enumerate(string.splitlines())) - n, line = next(n_line_iter, (0, None)) - lookahead = False - n_code_start = 0 - while line is not None: - match = start_re.match(line) - if match: - action = match.lastgroup - text = line[match.end():].strip() - if action == 'start_code': - info = line.lstrip('`').strip() - info_match = start_code_supported_info_re.match(info) - if info_match is None: - pass - else: - executable = info_match.group('executable') - if executable is not None: - if executable.startswith('"'): - executable = executable[1:-1] - executable = pathlib.Path(executable).expanduser().as_posix() + try: + parse_actions = {} + for k in start_patterns: + parse_actions[k] = getattr(self, f'append_{k}') + parse_actions[None] = self.append_unknown + start_multiline_comment_pattern = comment_patterns['start_multiline_comment'] + end_multiline_comment_pattern = comment_patterns['end_multiline_comment'] + line_comment_pattern = comment_patterns['line_comment'] + n_line_iter = iter(x for x in enumerate(string.splitlines())) + n, line = next(n_line_iter, (0, None)) + lookahead = False + n_code_start = 0 + while line is not None: + match = start_re.match(line) + if match: + action = match.lastgroup + text = line[match.end():].strip() + if action == 'start_code': + info = line.lstrip('`').strip() + info_match = start_code_supported_info_re.match(info) + if info_match is None: + pass else: - executable = info_match.group('lang') - if executable == 'python': - executable = python_executable - delim = '`'*(len(line) - len(line.lstrip('`'))) - n_code_start = n - code_lines = [] - n, line = next(n_line_iter, (0, None)) - # No lookahead here; all lines are consumed - while line is not None and not (line.startswith(delim) and line[len(delim):] == line.lstrip('`')): - code_lines.append(line) - n, line = next(n_line_iter, (0, None)) - if line is None: - raise Text2qtiError(f'In {self.source_name} on line {n}:\nCode closing fence is missing') - if line.lstrip('`').strip(): - raise Text2qtiError(f'In {self.source_name} on line {n+1}:\nCode closing fence is missing') - code_lines.append('\n') - code = '\n'.join(code_lines) - try: - stdout = self._run_code(executable, code) - except Exception as e: - raise Text2qtiError(f'In {self.source_name} on line {n_code_start+1}:\n{e}') - code_n_line_iter = ((n_code_start, stdout_line) for stdout_line in stdout.splitlines()) - n_line_iter = itertools.chain(code_n_line_iter, n_line_iter) - n, line = next(n_line_iter, (0, None)) - continue - elif action in multi_line: - if start_patterns[action].endswith(':'): - indent_expandtabs = None - else: - indent_expandtabs = ' '*len(line[:match.end()].expandtabs(4)) - text_lines = [text] - n, line = next(n_line_iter, (0, None)) - line_expandtabs = line.expandtabs(4) if line is not None else None - lookahead = True - while (line is not None and - (not line or line.isspace() or - indent_expandtabs is None or line_expandtabs.startswith(indent_expandtabs))): - if not line or line.isspace(): - if action in multi_para: - text_lines.append('') + executable = info_match.group('executable') + if executable is not None: + if executable.startswith('"'): + executable = executable[1:-1] + executable = pathlib.Path(executable).expanduser().as_posix() else: - break + executable = info_match.group('lang') + if executable == 'python': + executable = python_executable + delim = '`'*(len(line) - len(line.lstrip('`'))) + n_code_start = n + code_lines = [] + n, line = next(n_line_iter, (0, None)) + # No lookahead here; all lines are consumed + while line is not None and not (line.startswith(delim) and line[len(delim):] == line.lstrip('`')): + code_lines.append(line) + n, line = next(n_line_iter, (0, None)) + if line is None: + raise Text2qtiError(f'In {self.source_name} on line {n}:\nCode closing fence is missing') + if line.lstrip('`').strip(): + raise Text2qtiError(f'In {self.source_name} on line {n+1}:\nCode closing fence is missing') + code_lines.append('\n') + code = '\n'.join(code_lines) + try: + stdout = self._run_code(executable, code) + except Exception as e: + raise Text2qtiError(f'In {self.source_name} on line {n_code_start+1}:\n{e}') + code_n_line_iter = ((n_code_start, stdout_line) for stdout_line in stdout.splitlines()) + n_line_iter = itertools.chain(code_n_line_iter, n_line_iter) + n, line = next(n_line_iter, (0, None)) + continue + elif action in multi_line: + if start_patterns[action].endswith(':'): + indent_expandtabs = None else: - if indent_expandtabs is None: - if not line.startswith(' '): - break - indent_expandtabs = ' '*(len(line_expandtabs)-len(line_expandtabs.lstrip(' '))) - if len(indent_expandtabs) < 2: - raise Text2qtiError(f'In {self.source_name} on line {n+1}:\nIndentation must be at least 2 spaces or 1 tab here') - # The `rstrip()` prevents trailing double - # spaces from becoming `
`. - text_lines.append(line_expandtabs[len(indent_expandtabs):].rstrip()) + indent_expandtabs = ' '*len(line[:match.end()].expandtabs(4)) + text_lines = [text] n, line = next(n_line_iter, (0, None)) line_expandtabs = line.expandtabs(4) if line is not None else None - text = '\n'.join(text_lines) - elif line.startswith(line_comment_pattern): - n, line = next(n_line_iter, (0, None)) - continue - elif line.startswith(start_multiline_comment_pattern): - if line.strip() != start_multiline_comment_pattern: - raise Text2qtiError(f'In {self.source_name} on line {n+1}:\nUnexpected content after "{start_multiline_comment_pattern}"') - n, line = next(n_line_iter, (0, None)) - while line is not None and not line.startswith(end_multiline_comment_pattern): + lookahead = True + while (line is not None and + (not line or line.isspace() or + indent_expandtabs is None or line_expandtabs.startswith(indent_expandtabs))): + if not line or line.isspace(): + if action in multi_para: + text_lines.append('') + else: + break + else: + if indent_expandtabs is None: + if not line.startswith(' '): + break + indent_expandtabs = ' '*(len(line_expandtabs)-len(line_expandtabs.lstrip(' '))) + if len(indent_expandtabs) < 2: + raise Text2qtiError(f'In {self.source_name} on line {n+1}:\nIndentation must be at least 2 spaces or 1 tab here') + # The `rstrip()` prevents trailing double + # spaces from becoming `
`. + text_lines.append(line_expandtabs[len(indent_expandtabs):].rstrip()) + n, line = next(n_line_iter, (0, None)) + line_expandtabs = line.expandtabs(4) if line is not None else None + text = '\n'.join(text_lines) + elif line.startswith(line_comment_pattern): n, line = next(n_line_iter, (0, None)) - if line is None: - raise Text2qtiError(f'In {self.source_name} on line {n+1}:\nf"{start_multiline_comment_pattern}" without following "{end_multiline_comment_pattern}"') - if line.strip() != end_multiline_comment_pattern: - raise Text2qtiError(f'In {self.source_name} on line {n+1}:\nUnexpected content after "{end_multiline_comment_pattern}"') - n, line = next(n_line_iter, (0, None)) - continue - elif line.startswith(end_multiline_comment_pattern): - raise Text2qtiError(f'In {self.source_name} on line {n+1}:\n"{end_multiline_comment_pattern}" without preceding "{start_multiline_comment_pattern}"') - else: - action = None - text = line - try: - parse_actions[action](text) - except Text2qtiError as e: - if lookahead and n != n_code_start: - raise Text2qtiError(f'In {self.source_name} on line {n}:\n{e}') - raise Text2qtiError(f'In {self.source_name} on line {n+1}:\n{e}') - if not lookahead: - n, line = next(n_line_iter, (0, None)) - lookahead = False - if not self.questions_and_delims: - raise Text2qtiError('No questions were found') - if self._current_group is not None: - raise Text2qtiError(f'In {self.source_name} on line {len(string.splitlines())}:\nQuestion group never ended') - last_question_or_delim = self.questions_and_delims[-1] - if isinstance(last_question_or_delim, Question): - try: - last_question_or_delim.finalize() - except Text2qtiError as e: - raise Text2qtiError(f'In {self.source_name} on line {len(string.splitlines())}:\n{e}') - - points_possible = 0 - digests = [] - for x in self.questions_and_delims: - if isinstance(x, Question): - points_possible += x.points_possible - digests.append(x.hash_digest) - elif isinstance(x, GroupStart): - points_possible += x.group.points_per_question*len(x.group.questions) - digests.append(x.group.hash_digest) - elif isinstance(x, GroupEnd): - pass - elif isinstance(x, TextRegion): - pass - else: - raise TypeError - self.points_possible = points_possible - h = hashlib.blake2b() - for digest in sorted(digests): - h.update(digest) - self.hash_digest = h.digest() - self.id = h.hexdigest()[:64] - - self.md.finalize() + continue + elif line.startswith(start_multiline_comment_pattern): + if line.strip() != start_multiline_comment_pattern: + raise Text2qtiError(f'In {self.source_name} on line {n+1}:\nUnexpected content after "{start_multiline_comment_pattern}"') + n, line = next(n_line_iter, (0, None)) + while line is not None and not line.startswith(end_multiline_comment_pattern): + n, line = next(n_line_iter, (0, None)) + if line is None: + raise Text2qtiError(f'In {self.source_name} on line {n+1}:\nf"{start_multiline_comment_pattern}" without following "{end_multiline_comment_pattern}"') + if line.strip() != end_multiline_comment_pattern: + raise Text2qtiError(f'In {self.source_name} on line {n+1}:\nUnexpected content after "{end_multiline_comment_pattern}"') + n, line = next(n_line_iter, (0, None)) + continue + elif line.startswith(end_multiline_comment_pattern): + raise Text2qtiError(f'In {self.source_name} on line {n+1}:\n"{end_multiline_comment_pattern}" without preceding "{start_multiline_comment_pattern}"') + else: + action = None + text = line + try: + parse_actions[action](text) + except Text2qtiError as e: + if lookahead and n != n_code_start: + raise Text2qtiError(f'In {self.source_name} on line {n}:\n{e}') + raise Text2qtiError(f'In {self.source_name} on line {n+1}:\n{e}') + if not lookahead: + n, line = next(n_line_iter, (0, None)) + lookahead = False + if not self.questions_and_delims: + raise Text2qtiError('No questions were found') + if self._current_group is not None: + raise Text2qtiError(f'In {self.source_name} on line {len(string.splitlines())}:\nQuestion group never ended') + last_question_or_delim = self.questions_and_delims[-1] + if isinstance(last_question_or_delim, Question): + try: + last_question_or_delim.finalize() + except Text2qtiError as e: + raise Text2qtiError(f'In {self.source_name} on line {len(string.splitlines())}:\n{e}') + + points_possible = 0 + digests = [] + for x in self.questions_and_delims: + if isinstance(x, Question): + points_possible += x.points_possible + digests.append(x.hash_digest) + elif isinstance(x, GroupStart): + points_possible += x.group.points_per_question*len(x.group.questions) + digests.append(x.group.hash_digest) + elif isinstance(x, GroupEnd): + pass + elif isinstance(x, TextRegion): + pass + else: + raise TypeError + self.points_possible = points_possible + h = hashlib.blake2b() + for digest in sorted(digests): + h.update(digest) + self.hash_digest = h.digest() + self.id = h.hexdigest()[:64] + finally: + self.md.finalize() def _run_code(self, executable: str, code: str) -> str: if not self.config['run_code_blocks']: raise Text2qtiError('Code execution for code blocks is not enabled; use --run-code-blocks, or set run_code_blocks = true in config') h = hashlib.blake2b() h.update(code.encode('utf8')) + if platform.system() == 'Windows': + # Prevent console from appearing for an instant + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + else: + startupinfo = None with tempfile.TemporaryDirectory() as tempdir: tempdir_path = pathlib.Path(tempdir) code_path = tempdir_path / f'{h.hexdigest()[:16]}.code' code_path.write_text(code, encoding='utf8') cmd = [executable, code_path.as_posix()] try: - proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + # stdin is needed for GUI because standard file handles can't + # be inherited + proc = subprocess.run(cmd, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE, + startupinfo=startupinfo) except FileNotFoundError as e: raise Text2qtiError(f'Failed to execute code (missing executable "{executable}"?):\n{e}') except Exception as e: diff --git a/text2qti/version.py b/text2qti/version.py index 73b2c0f..8009188 100644 --- a/text2qti/version.py +++ b/text2qti/version.py @@ -1,4 +1,4 @@ # -*- coding: utf-8 -*- from .fmtversion import get_version_plus_info -__version__, __version_info__ = get_version_plus_info(0, 5, 0, 'dev', 3) +__version__, __version_info__ = get_version_plus_info(0, 5, 0, 'dev', 4)