diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..37b54ba --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# Change Log + + +## v0.1.0 (2020-04-01) + +* Initial release. diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..40835cd --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2020, Geoffrey M. Poore +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..0e891c0 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include setup.cfg setup.py README.md LICENSE.txt CHANGELOG.md +recursive-include text2qti *.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..53fb8d2 --- /dev/null +++ b/README.md @@ -0,0 +1,206 @@ +# text2qti – Create quizzes in QTI format from Markdown-based plain text + +text2qti converts +[Markdown](https://daringfireball.net/projects/markdown/)-based plain text +files into quizzes in QTI format, which can be imported by +[Canvas](https://www.instructure.com/canvas/) and other educational software. +It includes basic support for LaTeX math within Markdown, and allows a limited +subset of [siunitx](https://ctan.org/pkg/siunitx) notation for units and for +numbers in scientific notation. + + +## Example + +text2qti allows quick and efficient quiz creation. Example plain-text quiz +question that can be converted to QTI and then imported by Canvas: + +``` +1. What is 2+3? +a) 6 +b) 1 +*c) 5 +``` + +A **question** is created by a line that starts with a number followed by a +period and one or more spaces or tabs ("`1. `"). Possible **choices** are +created by lines that start with a letter followed by a closing parenthesis +and one or more spaces or tabs ("`a) `"). Numbers and letters do not have to +be ordered or unique. The **correct** choice is designated with an asterisk +("`*c) `"). All question and choice text is processed as +[Markdown](https://daringfireball.net/projects/markdown/). + +There is also support for a quiz title, a quiz description, and feedback: + +``` +Quiz title: Addition +Quiz description: Checking addition. + +1. What is 2+3? +... General question feedback. ++ Feedback for correct answer. +- Feedback for incorrect answer. +a) 6 +... Feedback for this particular answer. +b) 1 +... Feedback for this particular answer. +*c) 5 +... Feedback for this particular answer. +``` + +Currently there are three major limitations: + * Images are not yet supported. + * Only multiple-choice and true/false questions are supported at present. + * All titles, descriptions, questions, choices, and feedback are limited to + a single paragraph each. If this paragraph is wrapped over multiple + lines, all lines after the first must be indented to the same level as the + start of the paragraph text on the initial line (so, indented as much as + the "`1. `" or "`a) `", etc.). All tabs are expanded to 4 spaces before + indentation is compared, following the typical Markdown approach. + Multiple paragraphs will likely be enabled at some point in the future. + ``` + 1. A question paragraph that is long enough to wrap onto a second line. + The second line must be indented to match up with the start of the + paragraph text on the first line. Multiple paragraphs are not yet + supported. + ``` + + +## Installation + +Install **Python 3.6+** if it is not already available on your machine. See +https://www.python.org/, or use the package manager or app store for your +operating system. Depending on your use case, you may want to consider a +Python distribution like [Anaconda](https://www.anaconda.com/distribution/) +instead. + +Install +[setuptools](https://packaging.python.org/tutorials/installing-packages/) +for Python if it is not already installed. This can be accomplished by +running +``` +python -m pip install setuptools +``` +on the command line. Depending on your system, you may need to use `python3` +instead of `python`. This will often be the case for Linux and OS X. + +Install text2qti by running this on the command line: +``` +python -m pip install text2qti +``` +Depending on your system, you may need to use `python3` instead of `python`. +This will often be the case for Linux and OS X. + + +## Usage + +text2qti has been designed to create QTI files for use with +[Canvas](https://www.instructure.com/canvas/). Some features may not be +supported by other educational software. You should **always preview** +quizzes or assessments after converting them to QTI and importing them. + +Write your quiz or assessment in a plain text file. You can use a basic +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`. + +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. + * Go to Settings, click on "Import Course Content", select "QTI .zip file", + choose your file, and click "Import". Typically you should not need to + select a question bank; that should be managed automatically. + * While the quiz upload will often be very fast, there is an additional + processing step that can take up to several minutes. The status will + probably appear under "Current Jobs" after upload. + * Once the quiz import is marked as "Completed", the imported quiz should be + available under Quizzes. + * You should **always preview the quiz before use**. text2qui can detect a + number of potential issues, but not everything. + +Typically, you should start your quizzes with a title, like this: +``` +Quiz title: Title here +``` +Otherwise, all quizzes will have the default title "Quiz", so it will be +difficult to tell them apart. Another option is to rename quizzes after +importing them. + +When you run `text2qti` for the first time, it will attempt to create a +configuration file called `.text2qti.bespon` in your home or user directory. +It will also ask for an institutional LaTeX rendering URL. This is only +needed if you plan to use LaTeX math; if not, simply press ENTER to continue. + * If you use Canvas, log into your account and look in the browser address + bar. You will typically see an address that starts with something like + `institution.instructure.com/`, with `institution` replaced by the name of + your school or an abbreviation for it. The LateX rendering URL that you + want to use will then be + `https://institution.instructure.com/equation_images/`, with `institution` + replaced by the appropriate value for your school. + * If you use other educational software that handles LaTeX in a manner + compatible with Canvas, consult the documentation for your software. Or + perhaps create a simple quiz within the software using its built-in tools, + then export the quiz to QTI and look through the resulting output to find + the URL. + * If you are using educational software that does not handle LaTeX in a + manner compatible with Canvas, please open an issue requesting support for + that software, and include as much information as possible about how that + software processes LaTeX. + + +## Details for writing quiz text + +text2qti processes all text as +[Markdown](https://daringfireball.net/projects/markdown/), using +[Python-Markdown](https://python-markdown.github.io/). For example, +`*emphasized*` produces emphasized text, which typically appears as italics. +Text can be styled using Markdown notation, or with HTML. Remember to preview +quizzes after conversion to QTI, especially when using any significant amount +of HTML. + +All titles, descriptions, questions, choices, and feedback are limited to a +single paragraph each. If this paragraph is wrapped over multiple lines, all +lines after the first must be indented to the same level as the start of the +paragraph text on the initial line + +text2qti supports inline LaTeX math within dollar signs `$`. There must be a +non-space character immediately after the opening `$` and immediately before +the closing `$`. For example, `$F = ma$`. LaTeX math is limited to what is +supported by Canvas or whatever other educational software you are using. It +is usually a good idea to preview imported quizzes before assigning them, +because text2qti cannot detect LaTeX incompatibilities or limitations. There +is currently not support for block LaTeX math; only inline math is supported. + +When using Canvas with LaTeX math, be aware that in some cases Canvas's +vertical alignment of math leaves much to be desired. Sometimes this can be +improved by including `\vphantom{fg}` or `\strut` at the beginning of an +equation. An alternative is simply to use LaTeX for all question or choice +text (via `\text`, etc.). + +text2tqi supports a limited subset of LaTeX +[siunitx](https://ctan.org/pkg/siunitx) notation. You can use notation like +`\num{1.23e5}` to enter numbers in scientific notation. This would result in +`1.23×10⁵`. You can use notation like `\si{m/s}` or `\si{N.m}` to enter +units. These would result in `m/s` and `N·m`. Unit macros currently are not +supported, with these exceptions: `\degree`, `\celsius`, `\fahrenheit`, +`\ohm`, `\micro`. Finally, numbers and units can be combined with notation +like `\SI{1.23e5}{m/s}`. All of these can be used inside or outside LaTeX +math. + +Technical note: LaTeX and siunitx support are currently implemented as +preprocessors that are used separately from Python-Markdown. In rare cases, +this may lead to conflicts with Markdown syntax. These features may be +reimplemented as Python-Markdown extensions in the future. diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..0c2d1b0 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,6 @@ +[metadata] +license_file = LICENSE.txt + + +[tool:pytest] +norecursedirs = build diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..b604a10 --- /dev/null +++ b/setup.py @@ -0,0 +1,66 @@ +# -*- 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 sys +if sys.version_info < (3, 6): + sys.exit('text2qti requires Python 3.6+') +import pathlib +from setuptools import setup + + + + +# Extract the version from version.py, using functions in fmtversion.py +fmtversion_path = pathlib.Path(__file__).parent / 'text2qti' / 'fmtversion.py' +exec(compile(fmtversion_path.read_text(encoding='utf8'), 'text2qti/fmtversion.py', 'exec')) +version_path = pathlib.Path(__file__).parent / 'text2qti' / 'version.py' +version = get_version_from_version_py_str(version_path.read_text(encoding='utf8')) + +readme_path = pathlib.Path(__file__).parent / 'README.md' +long_description = readme_path.read_text(encoding='utf8') + + +setup(name='text2qti', + version=version, + py_modules=[], + packages=[ + 'text2qti' + ], + package_data = {}, + description='Create quizzes in QTI format from Markdown-based plain text', + long_description=long_description, + long_description_content_type='text/markdown', + author='Geoffrey M. Poore', + author_email='gpoore@gmail.com', + url='http://github.com/gpoore/text2qti', + license='BSD', + keywords=['QTI', 'IMS Question & Test Interoperability', 'quiz', 'test', + 'exam', 'assessment', 'markdown', 'LaTeX', 'plain text'], + python_requires='>=3.6', + install_requires=[ + 'bespon>=0.4', + 'markdown', + ], + # https://pypi.python.org/pypi?:action=list_classifiers + classifiers=[ + 'Development Status :: 4 - Beta', + 'Environment :: Console', + 'Intended Audience :: Education', + 'License :: OSI Approved :: BSD License', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Topic :: Education :: Testing', + ], + entry_points = { + 'console_scripts': ['text2qti = text2qti.cmdline:main'], + }, +) diff --git a/text2qti/__init__.py b/text2qti/__init__.py new file mode 100644 index 0000000..5ef424e --- /dev/null +++ b/text2qti/__init__.py @@ -0,0 +1,14 @@ +# -*- 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 +# + + +from .version import __version__, __version_info__ +from .config import Config +from .quiz import Quiz +from .qti import QTI diff --git a/text2qti/cmdline.py b/text2qti/cmdline.py new file mode 100644 index 0000000..9c2e3f9 --- /dev/null +++ b/text2qti/cmdline.py @@ -0,0 +1,69 @@ +# -*- 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 argparse +import pathlib +import textwrap +from .version import __version__ as version +from .err import Text2qtiError +from .config import Config +from .quiz import Quiz +from .qti import QTI + + + + +def main(): + ''' + text2qti executable main function. + ''' + parser = argparse.ArgumentParser(prog='text2qti') + parser.set_defaults(func=lambda x: parser.print_help()) + parser.add_argument('--version', action='version', version=f'text2qti {version}') + parser.add_argument('--latex-render-url', + help='URL for rendering LaTeX equations') + parser.add_argument('file', + help='File to convert from text to QTI') + args = parser.parse_args() + + config = Config() + config.load() + if not config.loaded_config_file: + latex_render_url = input(textwrap.dedent('''\ + It looks like text2qti has not been installed on this machine + before. Would you like to set a default LaTeX rendering URL? If + no, press ENTER. If yes, provide the URL and press ENTER. + + If you use Canvas, the URL will have the form + https://.instructure.com/equation_images/ + with "" replaced with the name or abbreviation for + your institution. You can determine "" by logging + into Canvas and then looking in the browser address bar for + something like ".instructure.com/". + + LaTeX rendering URL: ''')) + latex_render_url = latex_render_url.strip() + if latex_render_url: + config['latex_render_url'] = latex_render_url + config.save() + if args.latex_render_url is not None: + config['latex_render_url'] = args.latex_render_url + + file_path = pathlib.Path(args.file) + try: + text = file_path.read_text(encoding='utf-8-sig') # Handle BOM for Windows + except FileNotFoundError: + raise Text2qtiError(f'File "{file_path}" does not exit') + except UnicodeDecodeError as e: + raise Text2qtiError(f'File "{file_path}" was not encoded in valid UTF-8:\n{e}') + + quiz = Quiz(text, config=config) + qti = QTI(quiz, config=config) + qti.save(file_path.parent / f'{file_path.stem}.zip') diff --git a/text2qti/config.py b/text2qti/config.py new file mode 100644 index 0000000..35580d0 --- /dev/null +++ b/text2qti/config.py @@ -0,0 +1,114 @@ +# -*- 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 bespon +import pathlib +import textwrap +import warnings +from .err import Text2qtiError + + + + +class Config(dict): + ''' + Dict-like configuration that raises an error when invalid keys are set. + If `.load()` is invoked, a config file in BespON format is loaded if it + exists, and otherwise is created if possible. + ''' + def __init__(self, *args, **kwargs): + self.loaded_config_file = False + self.update(dict(*args, **kwargs)) + + + _key_check = {'latex_render_url': lambda x: isinstance(x, str)} + _config_path = pathlib.Path('~/.text2qti.bespon').expanduser() + + def __setitem__(self, key, value): + if key not in self._key_check: + raise Text2qtiError(f'Invalid configuration option "{key}"') + if not self._key_check[key](value): + raise Text2qtiError(f'Configuration option "{key}" has invalid value "{value}"') + super().__setitem__(key, value) + + + def update(self, other: dict): + ''' + Send all keys through __setitem__ so that they are checked for + validity. + ''' + for k, v in other.items(): + self[k] = v + + + def __missing__(self, key): + if self.loaded_config_file: + raise Text2qtiError(textwrap.dedent(f'''\ + Configuration option "{key}" has not been set. + Open "{self._config_path}" to edit config manually. + ''')) + raise Text2qtiError(f'Configuration option "{key}" has not been set.') + + + _default_config_template = textwrap.dedent('''\ + # To set a default LaTeX rendering URL for Canvas, uncomment the + # config line below and replace with the name or + # abbreviation for your institution. You can find this by looking at + # your browser address bar when logged into Canvas. + + # latex_render_url = "https://.instructure.com/equation_images/" + ''') + + def load(self): + ''' + Load config file. + ''' + config_path = self._config_path + config_text = None + try: + config_text = config_path.read_text('utf8') + self.loaded_config_file = True + except FileNotFoundError: + try: + config_path.write_text(self._default_config_template, encoding='utf8') + except FileNotFoundError: + warnings.warn(f'Could not create default text2qti config file "{config_path}" due to FileNotFoundError (directory does not exist).') + except PermissionError: + warnings.warn(f'Could not create default text2qti config file "{config_path}" due to PermissionError.') + except PermissionError: + raise Text2qtiError(f'Could not open text2qti config file "{config_path}" due to PermissionError.') + except UnicodeDecodeError: + raise Text2qtiError(f'Could not open text2qti config file "{config_path}" due to UnicodeDecodeError. File may be corrupt.') + + if config_text is not None: + try: + config_dict = bespon.loads(config_text, empty_default=dict) + except Exception as e: + raise Text2qtiError(f'Failed to load config file "{config_path}":\n{e}') + try: + self.update(config_dict) + except Text2qtiError as e: + raise Text2qtiError(f'Failed to load config file "{config_path}":\n{e}') + + def save(self): + ''' + Save config file. + ''' + config_path = self._config_path + try: + bespon_text = bespon.dumps(dict(self)) + except Exception as e: + raise Text2qtiError(f'Failed to convert config data to config file format (invalid data?):\n{e}') + try: + config_path.write_text(bespon_text, 'utf8') + except FileNotFoundError: + raise Text2qtiError(f'Could not create text2qti config file "{config_path}" due to FileNotFoundError (directory does not exist).') + except PermissionError: + raise Text2qtiError(f'Could not create text2qti config file "{config_path}" due to PermissionError.') diff --git a/text2qti/err.py b/text2qti/err.py new file mode 100644 index 0000000..22e0c55 --- /dev/null +++ b/text2qti/err.py @@ -0,0 +1,12 @@ +# -*- 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 +# + + +class Text2qtiError(Exception): + pass diff --git a/text2qti/fmtversion.py b/text2qti/fmtversion.py new file mode 100644 index 0000000..d12e38e --- /dev/null +++ b/text2qti/fmtversion.py @@ -0,0 +1,217 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2015-2018, Geoffrey M. Poore +# All rights reserved. +# +# Licensed under the BSD 3-Clause License: +# http://opensource.org/licenses/BSD-3-Clause +# + + +''' +============================================================= +``fmtversion``: Simple version variables for Python packages +============================================================= + +:Author: Geoffrey M. Poore +:License: `BSD 3-Clause `_ + +Converts version information into a string ``__version__`` and a namedtuple +``__version_info__`` suitable for Python packages. The approach is inspired +by PEP 440 and ``sys.version_info``: + +* https://www.python.org/dev/peps/pep-0440 +* https://docs.python.org/3/library/sys.html + +Versions of the form "major.minor.micro" are supported, with an optional, +numbered dev/alpha/beta/candidate/final/post status. The module does not +support more complicated version numbers like "1.0b2.post345.dev456", since +this does not fit into a namedtuple of the form used by ``sys.version_info``. +The code is written as a single module, so that it may be bundled into +packages, rather than needing to be installed as a separate dependency. + +Typical usage in a package's ``version.py`` with a bundled ``fmtversion.py``:: + + from .fmtversion import get_version_plus_info + __version__, __version_info__ = get_version_plus_info(1, 1, 0, 'final', 0) + +Following ``sys.version_info``, ``get_version_plus_info()`` takes arguments +for a five-component version number: major, minor, micro, releaselevel, and +serial. The releaselevel may be any of dev, alpha, beta, candidate, final, or +post, or common variations/abbreviations thereof. All arguments but +releaselevel must be convertable to integers. + +If only ``__version__`` or ``__version_info__`` is desired, then the functions +``get_version()`` or ``get_version_info()`` may be used instead. If a micro +version is not needed (``..``), then set the optional +keyword argument ``usemicro=False``. This will omit a micro version from the +string ``__version__``, while the namedtuple ``__version_info__`` will still +have a field ``micro`` that is set to zero to simplify comparisons. If each +releaselevel will only have one release, then set ``useserial=False``. This +will omit a serial number from the string ``__version__``, while the +namedtuple ``__version_info__`` will still have a field ``serial`` that is set +to zero. + +A function ``get_version_from_version_py_str()`` is included for extracting +the version from a ``version.py`` file that has been read into a string. This +is intended for use in ``setup.py`` files. +''' + + + + +from __future__ import (division, print_function, absolute_import, + unicode_literals) +import sys +if sys.version_info.major == 2: + str = basestring +import collections + + +# Version of this module, which will later be converted into a `__version__` +# and `__version_info__` once the necessary functions are created +__version_tuple__ = (1, 1, 0, 'final', 0) + +__docformat__ = 'restructuredtext en' + + + + +VersionInfo = collections.namedtuple('VersionInfo', ['major', 'minor', 'micro', + 'releaselevel', 'serial']) + + +def get_version_info(major, minor, micro, releaselevel, serial, + usemicro=True, useserial=True): + ''' + Create a VersionInfo instance suitable for use as `__version_info__`. + + Perform all type and value checking that is needed for arguments; assume + that no previous checks have been performed. This allows all checks to be + centralized in this single function. + ''' + if not all(isinstance(x, int) or isinstance(x, str) + for x in (major, minor, micro, serial)): + raise TypeError('major, minor, micro, and serial must be integers or strings') + if not isinstance(releaselevel, str): + raise TypeError('releaselevel must be a string') + if not all(isinstance(x, bool) for x in (usemicro, useserial)): + raise TypeError('usemicro and useserial must be bools') + + try: + major = int(major) + minor = int(minor) + micro = int(micro) + serial = int(serial) + except ValueError: + raise ValueError('major, minor, micro, and serial must be convertable to integers') + if any(x < 0 for x in (major, minor, micro, serial)): + raise ValueError('major, minor, micro, and serial must correspond to non-negative integers') + if not usemicro and micro != 0: + raise ValueError('usemicro=False, but a micro value "{0}" has been set'.format(micro)) + if not useserial and serial != 0: + raise ValueError('useserial=False, but a serial value "{0}" has been set'.format(serial)) + + releaselevel_dict = {'dev': 'dev', + 'a': 'a', 'alpha': 'a', + 'b': 'b', 'beta': 'b', + 'c': 'c', 'rc': 'c', + 'candidate': 'c', 'releasecandidate': 'c', + 'pre': 'c', 'preview': 'c', + 'final': 'final', + 'post': 'post', 'r': 'post', 'rev': 'post'} + try: + releaselevel = releaselevel_dict[releaselevel.lower()] + except KeyError: + raise ValueError('Invalid releaselevel "{0}"'.format(releaselevel)) + if releaselevel == 'final' and serial != 0: + raise ValueError('final release must not have non-zero serial') + + return VersionInfo(major, minor, micro, releaselevel, serial) + + +def get_version(*args, **kwargs): + ''' + Create a version string suitable for use as `__version__`. + + Make sure arguments are appropriate, but leave all actual processing and + value and type checking to `get_version_info()`. + ''' + usemicro = kwargs.pop('usemicro', True) + useserial = kwargs.pop('useserial', True) + if kwargs: + raise TypeError('Unexpected keyword(s): {0}'.format(', '.join('{0}'.format(k) for k in kwargs))) + + if len(args) == 1: + version_info = args[0] + if not isinstance(version_info, VersionInfo): + raise TypeError('Positional argument must have 5 components, or be a VersionInfo instance') + elif len(args) == 5: + version_info = get_version_info(*args, usemicro=usemicro, useserial=useserial) + else: + raise TypeError('Positional argument must have 5 components, or be a VersionInfo instance') + + version = '{0}.{1}'.format(version_info.major, version_info.minor) + if usemicro: + version += '.{0}'.format(version_info.micro) + if version_info.releaselevel != 'final': + if version_info.releaselevel in ('dev', 'post'): + version += '.{0}'.format(version_info.releaselevel) + else: + version += '{0}'.format(version_info.releaselevel) + if useserial: + version += '{0}'.format(version_info.serial) + + return version + + +def get_version_plus_info(*args, **kwargs): + ''' + Create a tuple consisting of a version string and a VersionInfo instance. + ''' + usemicro = kwargs.pop('usemicro', True) + useserial = kwargs.pop('useserial', True) + if kwargs: + raise TypeError('Unexpected keyword(s): {0}'.format(', '.join('{0}'.format(k) for k in kwargs))) + + version_info = get_version_info(*args, usemicro=usemicro, useserial=useserial) + version = get_version(version_info, usemicro=usemicro, useserial=useserial) + return (version, version_info) + + + + +# Now that the required functions exist, process `__version_tuple__` into +# `__version__` and `__version_info__` for the module +__version__, __version_info__ = get_version_plus_info(*__version_tuple__) + + + + + +def get_version_from_version_py_str(string): + ''' + Extract version from version.py that has been read into a string. + + This assumes a simple, straightforward version.py. It is meant for use + in setup.py files. + ''' + if not isinstance(string, str): + raise TypeError('Argument must be a string') + if string.count('__version__') != 1: + raise RuntimeError('Failed to extract version from string') + exec_locals = {} + for line in string.splitlines(): + if line.startswith('__version__'): + if not any(x in line for x in ['get_version(', 'get_version_plus_info(']): + raise RuntimeError('Failed to extract version from string') + if 'fmtversion.get_version' in line: + line = line.replace('fmtversion.get_version', 'get_version') + try: + exec(compile(line, 'version.py', 'exec'), globals(), exec_locals) + except Exception: + raise RuntimeError('Failed to extract version from string') + break + if '__version__' not in exec_locals: + raise RuntimeError('Failed to extract version from string') + return exec_locals['__version__'] diff --git a/text2qti/markdown.py b/text2qti/markdown.py new file mode 100644 index 0000000..79d99be --- /dev/null +++ b/text2qti/markdown.py @@ -0,0 +1,265 @@ +# -*- 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 markdown +import re +import urllib.parse +import typing +from .config import Config +from .err import Text2qtiError + + + + +class Markdown(object): + r''' + Convert text from Markdown to HTML. Then escape the HTML for insertion + into XML templates. + + During the Markdown to HTML conversion, LaTeX math is converted to Canvas + img tags. A subset of siunitx (https://ctan.org/pkg/siunitx) LaTeX macros + are also supported, with limited features: `\SI`, `\si`, and `\num`. + siunitx macros are extracted via regex and then converted into plain + LaTeX, since Canvas LaTeX support does not cover siunitx. + ''' + def __init__(self, config: Config): + self.config = config + + + XML_ESCAPES = (('&', '&'), + ('<', '<'), + ('>', '>'), + ('"', '"'), + ("'", ''')) + XML_ESCAPES_LESS_QUOTES = tuple(x for x in XML_ESCAPES if x[0] not in ("'", '"')) + XML_ESCAPES_LESS_SQUOTE = tuple(x for x in XML_ESCAPES if x[0] != "'") + XML_ESCAPES_LESS_DQUOTE = tuple(x for x in XML_ESCAPES if x[0] != '"') + + def xml_escape(self, string: str, *, squotes: bool=True, dquotes: bool=True) -> str: + ''' + Escape a string for XML insertion, with options not to escape quotes. + ''' + if squotes and dquotes: + escapes = self.XML_ESCAPES + elif squotes: + escapes = self.XML_ESCAPES_LESS_DQUOTE + elif dquotes: + escapes = self.XML_ESCAPES_LESS_SQUOTE + else: + escapes = self.XML_ESCAPES_LESS_QUOTES + for char, esc in escapes: + string = string.replace(char, esc) + return string + + + CANVAS_EQUATION_TEMPLATE = 'LaTeX: {latex_xml_escaped}' + + def latex_to_canvas_img(self, latex: str) -> str: + ''' + Convert a LaTeX equation into an img tag suitable for Canvas. + + Requires an institutional LaTeX equation rendering URL. The URL is stored + in the text2qti config file or can be passed with flag --latex-render-url. + It will typically be of the form + + https://.instructure.com/equation_images/ + ''' + latex_render_url = self.config['latex_render_url'].rstrip('/') + latex_xml_escaped = self.xml_escape(latex) + # Double url escaping is required + latex_url_escaped = urllib.parse.quote(urllib.parse.quote(latex)) + return self.CANVAS_EQUATION_TEMPLATE.format(latex_render_url=latex_render_url, + latex_xml_escaped=latex_xml_escaped, + latex_url_escaped=latex_url_escaped) + + + siunitx_num_number_re = re.compile(r'[+-]?(?:0|(?:[1-9][0-9]*(?:\.[0-9]+)?|0?\.[0-9]+)(?:[eE][+-]?[1-9][0-9]*)?)$') + + def siunitx_num_to_plain_latex(self, number: str, in_math: bool=False) -> str: + r''' + Convert a basic subset of siunitx \num{} syntax into plain LaTeX. + If `in_math` is true, covert the plain LaTeX to a Canvas img tag. + ''' + number = number.strip() + if number.startswith('.'): + number = f'0{number}' + if not self.siunitx_num_number_re.match(number): + raise Text2qtiError(f'Invalid or unsupported LaTeX number "{number}"') + number = number.lower() + if 'e' in number: + significand, magnitude = number.split('e', 1) + latex_number = f'{significand}\\times 10^{{{magnitude}}}' + else: + latex_number = number + if in_math: + return latex_number + return self.latex_to_canvas_img(latex_number) + + + def siunitx_si_to_plain_latex(self, unit: str, in_math: bool=False) -> str: + r''' + Convert a basic subset of siunitx \si{} syntax into plain LaTeX. + If `in_math` is true, covert the plain LaTeX to a Canvas img tag. + ''' + unit = unit.strip() + unit_list = [] + unit_iter = iter(unit) + char = next(unit_iter, '') + while True: + if char == '' or char == ' ': + pass + elif char == '.': + unit_list.append(r'\!\cdot\!') # Alternative: r'\,' + elif char == '^': + char = next(unit_iter, '') + if char.isdigit(): + unit_list.append(f'^{{{char}}}') + elif char == '\\': + unit_list.append('^') + continue + else: + raise Text2qtiError(f'Invalid or unsupported LaTeX unit "{unit}"') + elif char == '/': + unit_list.append(r'/') # Alternative: r'\big/' + elif char == '\\': + macro = char + char = next(unit_iter, '') + while char.isalpha(): + macro += char + char = next(unit_iter, '') + if macro == r'\degree': + unit_list.append(r'^\circ') + elif macro == r'\celsius': + unit_list.append(r'^\circ\textrm{C}') + elif macro == r'\fahrenheit': + unit_list.append(r'^\circ\textrm{F}') + elif macro == r'\ohm': + unit_list.append(r'\Omega') + elif macro == r'\micro': + # Ideally, this would be an upright rather than slanted mu + unit_list.append(r'\mu') + else: + unit_list.append(macro) + continue + elif char.isalpha(): + unit_list.append(r'\text{') + unit_list.append(char) + char = next(unit_iter, '') + while char.isalpha(): + unit_list.append(char) + char = next(unit_iter, '') + unit_list.append('}') + continue + else: + raise Text2qtiError(f'Invalid or unsupported LaTeX unit "{unit}"') + try: + char = next(unit_iter) + except StopIteration: + break + latex_unit = '{' + ''.join(unit_list) + '}' # wrapping {} may prevent line breaks + if in_math: + return latex_unit + return self.latex_to_canvas_img(latex_unit) + + + def siunitx_SI_to_plain_latex(self, number: str, unit: str, in_math: bool=False) -> str: + r''' + Convert a basic subset of siunitx \SI{}{} syntax into plain + LaTeX. If `in_math` is true, covert the plain LaTeX to a Canvas img tag. + ''' + latex_number = self.siunitx_num_to_plain_latex(number, in_math=True) + latex_unit = self.siunitx_si_to_plain_latex(unit, in_math=True) + if latex_unit.startswith(r'^\circ'): + unit_sep = '' + else: + unit_sep = r'\,' # Alternative: `\>` + latex = f'{latex_number}{unit_sep}{latex_unit}' + if in_math: + return latex + return self.latex_to_canvas_img(latex) + + + siunitx_num_macro_pattern = r'\\num\{(?P[^{}]+)\}' + siunitx_si_macro_pattern = r'\\si\{(?P[^{}]+)\}' + siunitx_SI_macro_pattern = r'\\SI\{(?P[^{}]+)\}\{(?P[^{}]+)\}' + siunitx_latex_macros_pattern = '|'.join([siunitx_num_macro_pattern, siunitx_si_macro_pattern, siunitx_SI_macro_pattern]) + siunitx_latex_macros_re = re.compile(siunitx_latex_macros_pattern) + + def _siunitx_dispatch(self, match: typing.Match[str], in_math: bool) -> str: + ''' + Convert an siunitx regex match to plain LaTeX. If `in_math` is true, + covert the plain LaTeX to a Canvas img tag. + ''' + lastgroup = match.lastgroup + if lastgroup == 'SI_unit': + return self.siunitx_SI_to_plain_latex(match.group('SI_number'), match.group('SI_unit'), in_math) + if lastgroup == 'num_number': + return self.siunitx_num_to_plain_latex(match.group('num_number'), in_math) + if lastgroup == 'si_unit': + return self.siunitx_si_to_plain_latex(match.group('si_unit'), in_math) + raise ValueError + + def sub_siunitx_to_plain_latex(self, string: str, in_math: bool=False) -> str: + ''' + Convert all siunitx macros in a string to plain LaTeX. If `in_math` is + true, covert the plain LaTeX to a Canvas img tag. + ''' + return self.siunitx_latex_macros_re.sub(lambda match: self._siunitx_dispatch(match, in_math), string) + + + inline_code_pattern = r'(?P(?`+)(?!`).+?(?[^ $][^$]*)(? str: + ''' + Process LaTeX math and siunitx regex matches into Canvas image tags, while + leaving inline code matches unchanged. + ''' + lastgroup = match.lastgroup + if lastgroup == 'code_delim': + return match.group('code') + if lastgroup == 'math': + math = match.group('math') + math = self.sub_siunitx_to_plain_latex(math, in_math=True) + return self.latex_to_canvas_img(math) + if lastgroup == 'SI_unit': + return self.siunitx_SI_to_plain_latex(match.group('SI_number'), match.group('SI_unit'), in_math=False) + if lastgroup == 'num_number': + return self.siunitx_num_to_plain_latex(match.group('num_number'), in_math=False) + if lastgroup == 'si_unit': + return self.siunitx_si_to_plain_latex(match.group('si_unit'), in_math=False) + raise ValueError + + def sub_math_siunitx_to_canvas_img(self, string: str) -> str: + ''' + Convert all siunitx macros in a string into plain LaTeX. Then convert + this LaTeX and all $-delimited LaTeX into Canvas img tags. + ''' + return self.inline_code_math_siunitx_re.sub(self._inline_code_math_siunitx_dispatch, string) + + + markdown_processor = markdown.Markdown(extensions=['smarty', 'sane_lists']) + + def md_to_xml(self, markdown_string: str, strip_p_tags: bool=False) -> str: + markdown_string_processed_latex = self.sub_math_siunitx_to_canvas_img(markdown_string) + try: + html = self.markdown_processor.reset().convert(markdown_string_processed_latex) + except Exception as e: + raise Text2qtiError(f'Conversion from Markdown to HTML failed:\n{e}') + if strip_p_tags: + if html.startswith('

'): + html = html[3:] + if html.endswith('

'): + html = html[:-4] + xml = self.xml_escape(html, squotes=False, dquotes=False) + return xml diff --git a/text2qti/qti.py b/text2qti/qti.py new file mode 100644 index 0000000..7ca66d5 --- /dev/null +++ b/text2qti/qti.py @@ -0,0 +1,70 @@ +# -*- 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 io +import pathlib +from typing import Union, BinaryIO +import zipfile +from .config import Config +from .quiz import Quiz +from .xml_imsmanifest import imsmanifest +from .xml_assessment_meta import assessment_meta +from .xml_assessment import assessment + + +class QTI(object): + ''' + Create QTI from a Quiz object. + ''' + def __init__(self, quiz: Quiz, *, config: Config): + id_base = 'text2qti' + self.manifest_identifier = f'{id_base}_manifest_{quiz.id}' + self.assessment_identifier = f'{id_base}_assessment_{quiz.id}' + self.dependency_identifier = f'{id_base}_dependency_{quiz.id}' + self.assignment_identifier = f'{id_base}_assignment_{quiz.id}' + self.assignment_group_identifier = f'{id_base}_assignment-group_{quiz.id}' + self.title = quiz.title_xml + self.description = quiz.description_xml + self.points_possible = quiz.points_possible + + self.imsmanifest_xml = imsmanifest(manifest_identifier=self.manifest_identifier, + assessment_identifier=self.assessment_identifier, + dependency_identifier=self.dependency_identifier) + self.assessment_meta = assessment_meta(assessment_identifier=self.assessment_identifier, + assignment_identifier=self.assessment_identifier, + assignment_group_identifier=self.assignment_group_identifier, + title=self.title, + description=self.description, + points_possible=self.points_possible) + self.assessment = assessment(quiz=quiz, + assessment_identifier=self.assignment_identifier, + title=self.title) + + + def write(self, bytes_stream: BinaryIO): + with zipfile.ZipFile(bytes_stream, 'w', compression=zipfile.ZIP_DEFLATED) as zf: + zf.writestr('ismanifest.xml', self.imsmanifest_xml) + zf.writestr(zipfile.ZipInfo('non_cc_assessments/'), b'') + zf.writestr(f'{self.assessment_identifier}/assessment_meta.xml', self.assessment_meta) + zf.writestr(f'{self.assessment_identifier}/{self.assessment_identifier}.xml', self.assessment)\ + + + def zip_bytes(self) -> bytes: + stream = io.BytesIO() + self.write(stream) + return stream.getvalue() + + + def save(self, qti_path: Union[str, pathlib.Path]): + if isinstance(qti_path, str): + qti_path = pathlib.Path(qti_path) + elif not isinstance(qti_path, pathlib.Path): + raise TypeError + qti_path.write_bytes(self.zip_bytes()) diff --git a/text2qti/quiz.py b/text2qti/quiz.py new file mode 100644 index 0000000..85e9ae8 --- /dev/null +++ b/text2qti/quiz.py @@ -0,0 +1,304 @@ +# -*- 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 +# + + +''' +Parse text into a Quiz object that contains a list of Question objects, each +of which contains a list of Choice objects. +''' + + +import hashlib +import pathlib +import re +from typing import List, Optional, Set, Union +from .config import Config +from .err import Text2qtiError +from .markdown import Markdown + + + + +# regex patterns for parsing quiz +start_patterns = { + 'question': r'\d+\.', + 'correct_choice': r'\*[a-zA-Z]\)', + 'incorrect_choice': r'[a-zA-Z]\)', + 'feedback': r'\.\.\.', + 'correct_feedback': r'\+', + 'incorrect_feedback': r'\-', + 'quiz_title': r'[Qq]uiz [Tt]itle:', + 'quiz_description': r'[Qq]uiz [Dd]escription:', +} +start_re = re.compile('|'.join(r'(?P<{0}>{1}[ \t]+(?=\S))'.format(name, pattern) + for name, pattern in start_patterns.items())) +start_no_content_re = re.compile('|'.join(r'(?P<{0}>{1}[ \t]*$)'.format(name, pattern) + for name, pattern in start_patterns.items())) +start_no_whitespace_re = re.compile('|'.join(r'(?P<{0}>{1}(?=\S))'.format(name, pattern) + for name, pattern in start_patterns.items())) + + + + +class Choice(object): + ''' + A choice for a question plus optional feedback. + + The id is based on a hash of both the question and the choice itself. + The presence of feedback does not affect the id. + ''' + def __init__(self, text: str, *, + correct: bool, question_hash_digest: bytes, md: Markdown): + self.choice_raw = text + self.choice_xml = md.md_to_xml(text) + self.correct = correct + self.feedback_raw: Optional[str] = None + self.feedback_xml: Optional[str] = None + # ID is based on hash of choice XML as well as question XML. This + # gives different IDs for identical choices in different questions. + self.id = hashlib.blake2b(self.choice_xml.encode('utf8'), key=question_hash_digest).hexdigest()[:64] + self.md = md + + def append_feedback(self, text: str): + if self.feedback_raw is not None: + raise Text2qtiError('Feedback can only be specified once') + self.feedback_raw = text + self.feedback_xml = self.md.md_to_xml(text) + + +class Question(object): + ''' + A question, along with a list of possible choices and optional feedback of + various types. + ''' + def __init__(self, text: str, *, md: Markdown): + self.type: Optional[str] = None + self.title_raw = 'Question' + self.title_xml = 'Question' + self.question_raw = text + self.question_xml = md.md_to_xml(text) + self.choices: List[Choice] = [] + # The set for detecting duplicate choices uses the XML version of the + # choices, to avoid the issue of multiple Markdown representations of + # the same XML. + self._choice_set: Set[str] = set() + self.correct_choices = 0 + self.points_possible = 1 + self.feedback_raw: Optional[str] = None + self.feedback_xml: Optional[str] = None + self.correct_feedback_raw: Optional[str] = None + self.correct_feedback_xml: Optional[str] = None + self.incorrect_feedback_raw: Optional[str] = None + self.incorrect_feedback_xml: Optional[str] = None + h = hashlib.blake2b(self.question_xml.encode('utf8')) + self.question_hash_digest = h.digest() + self.id = h.hexdigest()[:64] + self.md = md + + def append_correct_choice(self, text: str): + choice = Choice(text, correct=True, question_hash_digest=self.question_hash_digest, md=self.md) + if choice.choice_xml in self._choice_set: + raise Text2qtiError('Duplicate choice for question') + self._choice_set.add(choice.choice_xml) + self.choices.append(choice) + self.correct_choices += 1 + + def append_incorrect_choice(self, text: str): + choice = Choice(text, correct=False, question_hash_digest=self.question_hash_digest, md=self.md) + if choice.choice_xml in self._choice_set: + raise Text2qtiError('Duplicate choice for question') + self._choice_set.add(choice.choice_xml) + self.choices.append(choice) + + def append_feedback(self, text: str): + if not self.choices: + if self.feedback_raw is not None: + raise Text2qtiError('Feedback can only be specified once') + self.feedback_raw = text + self.feedback_xml = self.md.md_to_xml(text) + else: + self.choices[-1].append_feedback(text) + + def append_correct_feedback(self, text: str): + if self.choices: + raise Text2qtiError('Correct feedback can only be specified for questions, not choices') + if self.correct_feedback_raw is not None: + raise Text2qtiError('Feedback can only be specified once') + self.correct_feedback_raw = text + self.correct_feedback_xml = self.md.md_to_xml(text) + + def append_incorrect_feedback(self, text: str): + if self.choices: + raise Text2qtiError('Incorrect feedback can only be specified for questions, not choices') + if self.incorrect_feedback_raw is not None: + raise Text2qtiError('Feedback can only be specified once') + self.incorrect_feedback_raw = text + self.incorrect_feedback_xml = self.md.md_to_xml(text) + + def finalize(self): + if not self.choices: + raise Text2qtiError('Question must provide choices') + if len(self.choices) < 2: + raise Text2qtiError('Question must provide more than one choice') + if self.correct_choices < 1: + raise Text2qtiError('Question must specify a correct choice') + if self.correct_choices > 1: + raise Text2qtiError('Question must specify only one correct choice') + # Might be useful to have an option for this sort of thing: + # self.choices = sorted(self.choices, key=lambda choice: choice.id) + if len(self.choices) == 2 and all(c.choice_raw.lower() in ('true', 'false') for c in self.choices): + self.type = 'true_false_question' + else: + self.type = 'multiple_choice_question' + + + + +class Quiz(object): + ''' + A quiz or assessment. Contains a list of questions along with possible + choices and feedback. + ''' + def __init__(self, string: str, *, config: Config, + source_name: Optional[str]=None, + resource_path: Optional[Union[str, pathlib.Path]]=None): + self.string = string + self.config = config + self.source_name = '' if source_name is None else source_name + if resource_path is not None: + if isinstance(resource_path, str): + resource_path = pathlib.Path(resource_path) + else: + raise TypeError + if not resource_path.is_dir(): + raise Text2qtiError(f'Resource path "{resource_path.as_posix()}" does not exist') + self.resource_path = resource_path + self.title_raw = None + self.title_xml = 'Quiz' + self.description_raw = None + self.description_xml = '' + self.questions: List[Question] = [] + # The set for detecting duplicate questions uses the XML version of + # the question, to avoid the issue of multiple Markdown + # representations of the same XML. + self.question_set: Set[str] = set() + self.md = Markdown(config) + + parse_actions = {} + for k in start_patterns: + parse_actions[k] = getattr(self, f'append_{k}') + parse_actions[None] = self.append_unknown + # Enhancement: revise to allow elements to span multiple paragraphs + n_line_iter = iter(x for x in enumerate(string.splitlines())) + n, line = next(n_line_iter, (0, None)) + lookahead = True + while line is not None: + match = start_re.match(line) + if match: + action = match.lastgroup + text = line[match.end():].strip() + wrapped_expanded_indent = ' '*len(line[:len(line)-len(text)].expandtabs(4)) + 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 + line_expandtabs.startswith(wrapped_expanded_indent) and + line_expandtabs[len(wrapped_expanded_indent):len(wrapped_expanded_indent)+1] not in ('', ' ', '\t')): + if not text.endswith(' '): + text += ' ' + text += line_expandtabs[len(wrapped_expanded_indent):] + n, line = next(n_line_iter, (0, None)) + line_expandtabs = line.expandtabs(4) if line is not None else None + else: + action = None + text = line + try: + parse_actions[action](text) + except Text2qtiError as 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: + raise Text2qtiError('No questions were found') + try: + self.questions[-1].finalize() + except Text2qtiError as e: + raise Text2qtiError(f'In {self.source_name} on line {len(string.splitlines())}:\n{e}') + + self.points_possible = sum(q.points_possible for q in self.questions) + + h = hashlib.blake2b() + for digest in sorted(x.question_hash_digest for x in self.questions): + h.update(digest) + self.id = h.hexdigest()[:64] + + def append_quiz_title(self, text: str): + if self.title_raw is not None: + raise Text2qtiError('Quiz title has already been given') + if self.questions: + raise Text2qtiError('Must give quiz title before questions') + if self.description_raw is not None: + raise Text2qtiError('Must give quiz title before quiz description') + self.title_raw = text + self.title_xml = self.md.md_to_xml(text, strip_p_tags=True) + + def append_quiz_description(self, text: str): + if self.description_raw is not None: + raise Text2qtiError('Quiz description has already been given') + if self.questions: + raise Text2qtiError('Must give quiz description before questions') + self.description_raw = text + self.description_xml = self.md.md_to_xml(text) + + def append_question(self, text: str): + if self.questions: + self.questions[-1].finalize() + question = Question(text, md=self.md) + if question.question_xml in self.question_set: + raise Text2qtiError('Duplicate question') + self.question_set.add(question.question_xml) + self.questions.append(question) + + def append_correct_choice(self, text: str): + if not self.questions: + raise Text2qtiError('Cannot have a choice without a question') + self.questions[-1].append_correct_choice(text) + + def append_incorrect_choice(self, text: str): + if not self.questions: + raise Text2qtiError('Cannot have a choice without a question') + self.questions[-1].append_incorrect_choice(text) + + def append_feedback(self, text: str): + if not self.questions: + raise Text2qtiError('Cannot have feedback without a question') + self.questions[-1].append_feedback(text) + + def append_correct_feedback(self, text: str): + if not self.questions: + raise Text2qtiError('Cannot have feedback without a question') + self.questions[-1].append_correct_feedback(text) + + def append_incorrect_feedback(self, text: str): + if not self.questions: + raise Text2qtiError('Cannot have feedback without a question') + self.questions[-1].append_incorrect_feedback(text) + + def append_unknown(self, text: str): + if text and not text.isspace(): + match = start_no_whitespace_re.match(text) + if match: + raise Text2qtiError(f'Missing whitespace after "{match.group().strip()}"') + match = start_no_content_re.match(text) + if match: + raise Text2qtiError(f'Missing content after "{match.group().strip()}"') + if '\t\t' in 'text': + raise Text2qtiError('Syntax error; unexpected text or incorrect indentation for a wrapped paragraph') + raise Text2qtiError('Syntax error; unexpected text or incorrect indentation for a wrapped paragraph') diff --git a/text2qti/version.py b/text2qti/version.py new file mode 100644 index 0000000..44ec943 --- /dev/null +++ b/text2qti/version.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from .fmtversion import get_version_plus_info +__version__, __version_info__ = get_version_plus_info(0, 1, 0, 'final', 0) diff --git a/text2qti/xml_assessment.py b/text2qti/xml_assessment.py new file mode 100644 index 0000000..5a90080 --- /dev/null +++ b/text2qti/xml_assessment.py @@ -0,0 +1,249 @@ +# -*- 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 +# + + +from .quiz import Quiz + + +BEFORE_ITEMS = '''\ + + + + + + cc_maxattempts + 1 + + +
+''' + +AFTER_ITEMS = '''\ +
+
+
+''' + + +START_ITEM = '''\ + +''' + +END_ITEM = '''\ + +''' + + +ITEM_METADATA = '''\ + + + + question_type + {question_type} + + + points_possible + {points_possible} + + + original_answer_ids + {original_answer_ids} + + + assessment_question_identifierref + {assessment_question_identifierref} + + + +''' + + +ITEM_PRESENTATION = '''\ + + + {question_xml} + + + +{choices} + + + +''' + +ITEM_PRESENTATION_CHOICE = '''\ + + + {choice_xml} + + ''' + + +ITEM_RESPROCESSING_START = '''\ + + + + +''' + +ITEM_RESPROCESSING_GENERAL_FEEDBACK = '''\ + + + + + + +''' + +ITEM_RESPROCESSING_CHOICE_FEEDBACK = '''\ + + + {ident} + + + +''' + +ITEM_RESPROCESSING_SET_CORRECT_WITH_FEEDBACK = '''\ + + + {ident} + + 100 + + +''' + +ITEM_RESPROCESSING_SET_CORRECT_NO_FEEDBACK = '''\ + + + {ident} + + 100 + +''' + +ITEM_RESPROCESSING_INCORRECT_FEEDBACK = '''\ + + + + + + +''' + +ITEM_RESPROCESSING_END = '''\ + +''' + + +ITEM_FEEDBACK_GENERAL = '''\ + + + + {feedback} + + + +''' + +ITEM_FEEDBACK_CORRECT = '''\ + + + + {feedback} + + + +''' + +ITEM_FEEDBACK_INCORRECT = '''\ + + + + <p>Wrong comment</p> + + + +''' + +ITEM_FEEDBACK_INDIVIDUAL = '''\ + + + + {feedback} + + + +''' + + + + +def assessment(*, quiz: Quiz, assessment_identifier: str, title: str) -> str: + ''' + Generate assessment XML from Quiz. + ''' + xml = [] + xml.append(BEFORE_ITEMS.format(assessment_identifier=assessment_identifier, + title=title)) + for question in quiz.questions: + correct_choice = None + for choice in question.choices: + if choice.correct: + correct_choice = choice + break + if correct_choice is None: + raise TypeError + + xml.append(START_ITEM.format(question_identifier=f'text2qti_question_{question.id}', + question_title=question.title_xml)) + + xml.append(ITEM_METADATA.format(question_type=question.type, + points_possible=question.points_possible, + original_answer_ids=','.join(f'text2qti_choice_{c.id}' for c in question.choices), + assessment_question_identifierref=f'text2qti_question_ref_{question.id}')) + + choices = '\n'.join(ITEM_PRESENTATION_CHOICE.format(ident=f'text2qti_choice_{c.id}', choice_xml=c.choice_xml) + for c in question.choices) + xml.append(ITEM_PRESENTATION.format(question_xml=question.question_xml, + choices=choices)) + + resprocessing = [] + resprocessing.append(ITEM_RESPROCESSING_START) + if question.feedback_raw is not None: + resprocessing.append(ITEM_RESPROCESSING_GENERAL_FEEDBACK) + for choice in question.choices: + if choice.feedback_raw is not None: + resprocessing.append(ITEM_RESPROCESSING_CHOICE_FEEDBACK.format(ident=f'text2qti_choice_{choice.id}')) + if question.correct_feedback_raw is not None: + resprocessing.append(ITEM_RESPROCESSING_SET_CORRECT_WITH_FEEDBACK.format(ident=f'text2qti_choice_{correct_choice.id}')) + else: + resprocessing.append(ITEM_RESPROCESSING_SET_CORRECT_NO_FEEDBACK.format(ident=f'text2qti_choice_{correct_choice.id}')) + if question.incorrect_feedback_raw is not None: + resprocessing.append(ITEM_RESPROCESSING_INCORRECT_FEEDBACK) + resprocessing.append(ITEM_RESPROCESSING_END) + xml.append(''.join(resprocessing)) + + if question.feedback_raw is not None: + xml.append(ITEM_FEEDBACK_GENERAL.format(feedback=question.feedback_xml)) + if question.correct_feedback_raw is not None: + xml.append(ITEM_FEEDBACK_CORRECT.format(feedback=question.correct_feedback_xml)) + if question.incorrect_feedback_raw is not None: + xml.append(ITEM_FEEDBACK_INCORRECT.format(feedback=question.incorrect_feedback_xml)) + for choice in question.choices: + if choice.feedback_raw is not None: + xml.append(ITEM_FEEDBACK_INDIVIDUAL.format(ident=f'text2qti_choice_{choice.id}', + feedback=choice.feedback_xml)) + + xml.append(END_ITEM) + + xml.append(AFTER_ITEMS) + + return ''.join(xml) diff --git a/text2qti/xml_assessment_meta.py b/text2qti/xml_assessment_meta.py new file mode 100644 index 0000000..1e6d522 --- /dev/null +++ b/text2qti/xml_assessment_meta.py @@ -0,0 +1,98 @@ +# -*- 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 +# + + +TEMPLATE = '''\ + + + {title} + {description} + false + keep_highest + + assignment + {points_possible:.1f} + false + false + false + + true + false + false + 1 + false + false + false + false + false + false + false + + {title} + + + + false + unpublished + + + {assessment_identifier} + + false + {points_possible:.1f} + points + false + online_quiz + 12 + false + false + 0 + false + false + false + false + false + false + false + false + false + false + 0 + true + false + false + true + false + + false + + + {assignment_group_identifier} + + + +''' + + +def assessment_meta(*, + assessment_identifier: str, + assignment_group_identifier: str, + assignment_identifier: str, + title: str, + description: str, + points_possible: int) -> str: + ''' + Generate `assessment_meta.xml`. + ''' + return TEMPLATE.format(assessment_identifier=assessment_identifier, + assignment_identifier=assignment_identifier, + assignment_group_identifier=assignment_group_identifier, + title=title, + description=description, + points_possible=points_possible) diff --git a/text2qti/xml_imsmanifest.py b/text2qti/xml_imsmanifest.py new file mode 100644 index 0000000..cf3a654 --- /dev/null +++ b/text2qti/xml_imsmanifest.py @@ -0,0 +1,71 @@ +# -*- 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 datetime +from typing import Optional + + +TEMPLATE = '''\ + + + + IMS Content + 1.1.3 + + + + QTI assessment generated by text2qti + + + + + + {date} + + + + + + yes + + + Private (Copyrighted) - http://en.wikipedia.org/wiki/Copyright + + + + + + + + + + + + + + + +''' + + +def imsmanifest(*, + manifest_identifier: str, + assessment_identifier: str, + dependency_identifier: str, + date: Optional[str]=None) -> str: + ''' + Generate `imsmanifest.xml`. + ''' + if date is None: + date = str(datetime.date.today()) + return TEMPLATE.format(manifest_identifier=manifest_identifier, + assessment_identifier=assessment_identifier, + dependency_identifier=dependency_identifier, + date=date)