
from IPython import get_ipython

from pyrope import config, frontends, nodes, tests, widgets
from pyrope.core import (
    Exercise, ExercisePool, ExerciseRunner, ParametrizedExercise,
    IllPosedError, ValidationError,
)
from pyrope.ipython_extension.magics import PyRopeMagics
from pyrope.logo import logo


__all__ = [
    'config', 'frontends', 'nodes', 'tests', 'widgets',
    'Exercise', 'ExercisePool', 'ExerciseRunner', 'ParametrizedExercise',
    'IllPosedError', 'ValidationError',
    'logo',
]


def load_ipython_extension(ipy):
    ipy.register_magics(PyRopeMagics)


# Load PyRope's ipython extension while PyRope gets imported.
if (ipy := get_ipython()) is not None:
    if 'pyrope' not in ipy.magics_manager.magics.get('line'):
        load_ipython_extension(ipy)


def _jupyter_nbextension_paths():
    return [
        {
            'section': 'notebook',
            'src': 'nbextension',
            'dest': 'pyrope',
            'require': 'pyrope/extension',
        }
    ]

pyrope/__init__.py
PyRope-E-Assessment/pyrope

import os
import subprocess
import sys
import unittest
from uuid import uuid4

import nbformat

from pyrope import ExercisePool, ExerciseRunner, examples
from pyrope.core import CLIParser
from pyrope.frontends import ConsoleFrontend


parser = CLIParser(prog='python3 -m pyrope')
args = parser.parse_args()

pool = ExercisePool()
if not args.patterns:
    pool.add_exercises_from_module(examples)
else:
    for pattern in args.patterns:
        pool.add_exercises_from_file(pattern)

if args.subcommand == 'run':

    if args.frontend == 'console':
        for exercise in pool:
            runner = ExerciseRunner(exercise)
            frontend = ConsoleFrontend(debug=args.debug)
            runner.set_frontend(frontend)
            frontend.set_runner(runner)
            runner.run()
            pexercise = runner.pexercise
            print('trivial input:', pexercise.trivial_input)
            print('dummy input:', pexercise.dummy_input)
            print('the solution:', pexercise.the_solution)
            print('a solution:', pexercise.a_solution)
            print('solution:', pexercise.solution)
            print('answers:', pexercise.answers)
            print('scores:', {
                ifield: '{}/{}'.format(
                    pexercise.scores[ifield], pexercise.max_scores[ifield]
                )
                for ifield in pexercise.ifields
            })
            print(
                f'total score: '
                f'{pexercise.total_score}/{pexercise.max_total_score}'
            )

    if args.frontend == 'jupyter':
        nb = nbformat.v4.new_notebook()
        filename = str(uuid4())
        file = f'{filename}.ipynb'
        try:
            assert os.path.isdir(args.path)
        except AssertionError:
            raise NotADirectoryError(
                f'{args.path} does not exist or is not a directory.'
            )
        else:
            file = os.path.join(args.path, file)
        code = (
            'import pyrope\n\n'
            f'%pyrope run {" ".join(args.patterns)}'
            f'{" --debug" if args.debug else ""}'
        )
        nb['cells'] = [nbformat.v4.new_code_cell(code)]
        nb.metadata['pyrope'] = {'autoexecute': True}
        nb = nbformat.validator.normalize(nb)[1]
        with open(file, 'w') as f:
            nbformat.write(nb, f)

        jupyter_server = subprocess.Popen(['jupyter', 'notebook', file])
        try:
            jupyter_server.wait()
        except KeyboardInterrupt:
            pass

        # Cleanup
        while True:
            try:
                try:
                    os.remove(file)
                except FileNotFoundError:
                    pass
                checkpoint_path = os.path.join(
                    args.path,
                    '.ipynb_checkpoints'
                )
                try:
                    checkpoint_file = os.path.join(
                        checkpoint_path,
                        f'{filename}-checkpoint.ipynb'
                    )
                    os.remove(checkpoint_file)
                except FileNotFoundError:
                    pass
                if (
                        os.path.isdir(checkpoint_path) and
                        not os.listdir(checkpoint_path)
                ):
                    os.rmdir(checkpoint_path)
                break
            except KeyboardInterrupt:
                print('Please wait for cleanup.')

if args.subcommand == 'test':
    test_cases = [
        test_case
        for exercise in pool
        for test_case in exercise.test_cases()
    ]
    suite = unittest.TestSuite(test_cases)
    runner = unittest.TextTestRunner()
    test_result = runner.run(suite)
    if not test_result.wasSuccessful():
        sys.exit(1)

pyrope/__main__.py
PyRope-E-Assessment/pyrope

import os

import pyrope


# Maximum length for text field input.
#
# This should be set as small as possible for secure input parsing.
maximum_input_length = 256


# Transformations for parsing symbolic expressions.
#
# https://docs.sympy.org/latest/modules/parsing.html#parsing-transformations-reference
transformations = (
    # Splits symbol names for implicit multiplication.
    #
    # Intended to let expressions like xyz be parsed as x*y*z. Does not split
    # Greek character names, so theta will not become t*h*e*t*a. Generally
    # this should be used with implicit_multiplication.
    # 'split_symbols',

    # Makes the multiplication operator optional in most cases.
    #
    # Use this before implicit_application(), otherwise expressions like sin
    # 2x will be parsed as x * sin(2) rather than sin(2*x).
    # 'implicit_multiplication',

    # Makes parentheses optional in some cases for function calls.
    #
    # Use this after implicit_multiplication(), otherwise expressions like sin
    # 2x will be parsed as x * sin(2) rather than sin(2*x).
    # 'implicit_application',

    # Allows functions to be exponentiated, e.g. cos^2(x).
    # 'function_exponentiation',

    # Allows a slightly relaxed syntax.
    #
    # * Parentheses for single-argument method calls are optional.
    # * Multiplication is implicit.
    # * Symbol names can be split (i.e. spaces are not needed between symbols).
    # * Functions can be exponentiated.
    # 'implicit_multiplication_application',

    # Treats XOR, ^, as exponentiation, **.
    'convert_xor',

    # Allows standard notation for factorial.
    'factorial_notation',

    # Inserts calls to Symbol for undefined variables.
    'auto_symbol',

    # Converts numeric literals to use SymPy equivalents.
    'auto_number',

    # Converts floats into Rational. Run AFTER auto_number.
    'rationalize',

    # Allows 0.2[1] notation to represent the repeated decimal 0.2111… (19/90)
    'repeated_decimals',
)


# Maximal number of test repetitions.
#
# Automated exercise tests are repeated with different (faked) user inputs.
# This option limits the maximal number of repetitions in order to restrict
# the testing time.
maximum_test_repetitions = 1024


# Valid representations for boolean values.
#
# Valid representations Python's boolean values False and True can be defined
# with this option. A representation pair consists of a value for False
# followed by a value for True.
boolean_representations = (
    ('0', '1'),
    ('False', 'True'),
    ('false', 'true'),
    ('f', 't'),
    ('Falsch', 'Wahr'),
    ('falsch', 'wahr'),
    ('f', 'w'),
)


# Processing scores.
#
# With the functions 'process_score' and 'process_total_score', one could
# implement another way of processing scores before they are sent to the
# frontend. Maximal (total) scores are also processed by these functions.
def process_score(score: int | float) -> int | float:
    return round(score, 1)

def process_total_score(total_score: int | float) -> int | float:  # noqa
    return round(total_score, 1)


# Path to CSS file for jupyter frontend styles.
dirname = os.path.dirname(pyrope.__file__)
filename = 'static/jupyter_frontend.css'
jupyter_frontend_css = os.path.join(dirname, filename)

pyrope/config.py
PyRope-E-Assessment/pyrope

import abc
import argparse
import collections
from functools import cache, cached_property
import glob
import importlib
import inspect
import itertools
import os
from pathlib import Path
import sys
import textwrap
import unittest

from IPython import get_ipython
import numpy

from pyrope import frontends, tests
from pyrope.config import process_total_score


score_types = (bool, int, float, numpy.bool_, numpy.int_, numpy.float_)


class Exercise(abc.ABC):

    def __init_subclass__(cls):
        cls.__init__ = Exercise.__init__

    def __init__(self, **kwargs):
        self.kwargs = kwargs

    @property
    def dir(self):
        module = sys.modules[self.__module__]
        try:
            file = module.__file__
        except AttributeError:
            return Path.cwd()
        return Path(file).parent

    def run(self, debug=False):
        runner = ExerciseRunner(self)
        if get_ipython() is not None:
            frontend = frontends.JupyterFrontend(debug=debug)
        else:
            frontend = frontends.ConsoleFrontend(debug=debug)
        runner.set_frontend(frontend)
        frontend.set_runner(runner)
        runner.run()

    def preamble(self):
        return ''

    def parameters(self):
        return {}

    @abc.abstractmethod
    def problem(self):
        ...

    def the_solution(self):
        return None

    def a_solution(self):
        return None

    def hint(self):
        return None

    def validate(self):
        return None

    def score(self):
        return None

    def feedback(self):
        return ''

    def test_cases(self):
        test_loader = unittest.TestLoader()
        method_names = test_loader.getTestCaseNames(tests.TestExercise)
        for method_name in method_names:
            yield tests.TestExercise(self, method_name)
        pexercise = ParametrizedExercise(self)
        yield from pexercise.test_cases()

    def test(self, runner=unittest.TextTestRunner()):
        suite = unittest.TestSuite(self.test_cases())
        runner.run(suite)


class ParametrizedExercise:

    def __init__(self, exercise, global_parameters={}):
        self.exercise = exercise
        self.global_parameters = global_parameters
        self._total_score = None
        self._max_total_score = None

    @staticmethod
    def apply(func, d):
        signature = inspect.signature(func)
        kwargs = {}
        for par in signature.parameters.values():
            if par.name in d:
                kwargs[par.name] = d[par.name]
            elif par.default is inspect.Parameter.empty:
                raise IllPosedError(f'Missing parameter: {par.name}.')
        return func(**kwargs)

    @cached_property
    def parameters(self):
        kwargs = self.global_parameters | self.exercise.kwargs
        pars = self.apply(self.exercise.parameters, kwargs)
        if pars is None:
            pars = {}
        return pars

    @cached_property
    def model(self):
        model = self.apply(self.exercise.problem, self.parameters)
        for ofield in model.ofields:
            if ofield not in self.parameters:
                raise IllPosedError(
                    f"No parameter for output field '{ofield}'."
                )
        return model

    @cached_property
    def template(self):
        return textwrap.dedent(str(self.model))

    @cached_property
    def preamble(self):
        preamble = self.exercise.preamble
        if callable(preamble):
            preamble = self.apply(preamble, self.parameters)
        return textwrap.dedent(preamble)

    @cached_property
    def ifields(self):
        return self.model.ifields

    @cached_property
    def widgets(self):
        return self.model.widgets

    @cached_property
    def the_solution(self):
        explicit = self.apply(self.exercise.the_solution, self.parameters)
        if explicit is None:
            explicit = {}
        elif not isinstance(explicit, dict):
            if len(self.ifields) != 1:
                raise IllPosedError(
                    'Unless there is only a single input field, '
                    'the solution must be a dictionary.'
                )
            names = list(self.ifields.keys())
            explicit = {names[0]: explicit}

        # implicit solutions from underscore naming convention
        implicit = {
            ifield: self.parameters[ifield[:-1]]
            for ifield in self.ifields
            if ifield.endswith('_')
            if ifield[:-1] in self.parameters
            if ifield not in explicit
        }

        solution = explicit | implicit

        self.model.the_solution = solution
        return {
            name: ifield.the_solution
            for name, ifield in self.model.ifields.items()
        }

    @cached_property
    def a_solution(self):
        solution = self.apply(self.exercise.a_solution, self.parameters)
        if solution is None:
            solution = {}
        elif not isinstance(solution, dict):
            if len(self.ifields) != 1:
                raise IllPosedError(
                    'Unless there is only a single input field, '
                    'a solution must be a dictionary.'
                )
            names = list(self.ifields.keys())
            solution = {names[0]: solution}
        self.model.a_solution = solution
        return {
            name: ifield.a_solution
            for name, ifield in self.model.ifields.items()
        }

    @property
    def solution(self):
        # trigger solutions via getter
        self.the_solution
        self.a_solution
        return {
            name: ifield.solution
            for name, ifield in self.model.ifields.items()
        }

    @cached_property
    def trivial_input(self):
        return {
            name: ifield.dtype.trivial_value()
            for name, ifield in self.ifields.items()
        }

    @cached_property
    def dummy_input(self):
        return {
            name: ifield.dtype.dummy_value()
            for name, ifield in self.ifields.items()
        }

    @property
    def answers(self):
        return self.model.answers

    @answers.setter
    def answers(self, answers):
        self.model.value = answers

    @cached_property
    def max_scores(self):
        solution = self.solution
        scores = self.apply(
            self.exercise.score, self.parameters | self.dummy_input
        )
        if scores is None or isinstance(scores, score_types):
            for name, value in solution.items():
                if value is None:
                    raise IllPosedError(
                        f"Unable to determine maximal score for "
                        f"input field '{name}'."
                    )
        if scores is None:
            max_scores = {
                name: float(ifield.auto_max_score)
                for name, ifield in self.ifields.items()
            }
            self._max_total_score = sum(max_scores.values())
            for name, ifield in self.ifields.items():
                ifield.displayed_max_score = max_scores[name]
            return max_scores
        if isinstance(scores, tuple):
            self._max_total_score = float(scores[1])
            if len(self.ifields) == 1:
                name = list(self.ifields.keys())[0]
                ifield = list(self.ifields.values())[0]
                ifield.displayed_max_score = self._max_total_score
                return {name: self._max_total_score}
            return {name: None for name in self.ifields}

        answer = {
            name: self.dummy_input[name]
            if value is None else value
            for name, value in solution.items()
        }
        max_scores = self.apply(
            self.exercise.score, self.parameters | answer
        )
        for name, value in solution.items():
            if value is None and name in max_scores:
                max_scores[name] = None
        if isinstance(scores, score_types):
            self._max_total_score = float(max_scores)
            if len(self.ifields) == 1:
                name = list(self.ifields.keys())[0]
                ifield = list(self.ifields.values())[0]
                ifield.displayed_max_score = self._max_total_score
                return {name: self._max_total_score}
            return {name: None for name in self.ifields}
        if isinstance(scores, dict):
            max_scores = {
                name: max_scores[name]
                if name in max_scores else None
                for name in self.ifields
            }
            for name, value in max_scores.items():
                if isinstance(value, score_types):
                    max_scores[name] = float(value)
                elif isinstance(value, tuple):
                    max_scores[name] = float(value[1])
                elif value is None or solution[name] is None:
                    max_scores[name] = self.ifields[name].auto_max_score
                self.ifields[name].displayed_max_score = max_scores[name]
            self._max_total_score = sum(max_scores.values())
            return max_scores
        raise IllPosedError(
            'If implemented, the score method must return a number, a pair '
            'of numbers or a dictionary with values of this type, where '
            'number is either an int or a float.'
        )

    @property
    def scores(self):
        self.solution
        output = self.apply(
            self.exercise.score, self.parameters | self.dummy_input
        )
        answers = self.answers
        fill_values = {
            name: self.dummy_input[name]
            for name, answer in answers.items()
            if answer is None
        }
        if isinstance(output, dict)\
                or (output is not None and len(self.ifields) == 1):
            answers |= fill_values
        scores = self.apply(
            self.exercise.score, self.parameters | answers
        )
        if isinstance(output, dict):
            for name in fill_values:
                if name not in scores:
                    continue
                elif isinstance(scores[name], tuple):
                    scores[name] = (0.0, scores[name][1])
                else:
                    scores[name] = 0.0
        if output is not None and len(self.ifields) == 1:
            for name in fill_values:
                if isinstance(output, tuple):
                    scores = {name: (0.0, scores[1])}
                else:
                    scores = {name: 0.0}
        no_scores = {name: None for name in self.ifields}
        if scores is None:
            scores = no_scores
        names = list(self.ifields.keys())
        ifields = list(self.ifields.values())
        if isinstance(scores, score_types):
            self._total_score = float(scores)
            if len(names) == 1:
                ifields[0].displayed_score = self._total_score
                return {names[0]: self._total_score}
            return no_scores
        if isinstance(scores, tuple):
            self._total_score = float(scores[0])
            if len(names) == 1:
                ifields[0].displayed_score = self._total_score
                return {names[0]: self._total_score}
            return no_scores
        for name, ifield in self.model.ifields.items():
            if name not in scores:
                scores[name] = None
            if scores[name] is None:
                scores[name] = ifield.auto_score
            if isinstance(scores[name], tuple):
                scores[name] = scores[name][0]
            scores[name] = float(scores[name])
            ifield.displayed_score = scores[name]
        self._total_score = sum([
            scores[name][0]
            if isinstance(scores[name], tuple) else scores[name]
            for name in self.ifields.keys()
        ])
        return scores

    @property
    def total_score(self):
        self.scores  # trigger total score computation
        return self._total_score

    @property
    def max_total_score(self):
        self.max_scores  # trigger maximal total score computation
        return self._max_total_score

    @property
    def feedback(self):
        kwargs = self.parameters | self.answers
        feedback = self.apply(self.exercise.feedback, kwargs)
        return textwrap.dedent(feedback)

    @cached_property
    def input_generator(self):
        answers = [
            self.trivial_input,
            self.dummy_input,
            self.solution,
        ]
        keys = self.ifields.keys()
        factors = []
        for key in keys:
            factor = [None]
            for answer in answers:
                if answer[key] is not None:
                    factor.append(answer[key])
            factors.append(factor)

        def generator():
            for values in itertools.product(*factors):
                yield dict(zip(keys, values))

        return generator

    def test_cases(self):
        test_loader = unittest.TestLoader()
        method_names = test_loader.getTestCaseNames(
            tests.TestParametrizedExercise
        )
        for method_name in method_names:
            yield tests.TestParametrizedExercise(self, method_name)


class ExerciseRunner:

    def __init__(self, exercise, global_parameters={}):
        self.observers = []
        self.pexercise = ParametrizedExercise(exercise, global_parameters)

    # TODO: enforce order of steps
    def run(self):
        self.frontend.render_preamble(self.pexercise.preamble)
        self.frontend.render_problem(self.pexercise.template)
        self.frontend.get_answers()

    def finish(self):
        self.get_solutions()
        self.notify(
            self.pexercise.__class__, 'total_max_score',
            process_total_score(self.pexercise.max_total_score)
        )
        self.notify(
            self.pexercise.__class__, 'total_score',
            process_total_score(self.pexercise.total_score)
        )
        for widget in self.pexercise.model.widgets:
            widget.show_max_score = True
            widget.show_score = True
        self.frontend.render_feedback(self.pexercise.feedback)

    def get_solutions(self):
        self.pexercise.solution
        for widget in self.pexercise.model.widgets:
            widget.show_solution = True

    def set_frontend(self, frontend):
        self.frontend = frontend

    def register_observer(self, observer):
        self.observers.append(observer)
        for widget in self.pexercise.widgets:
            widget.register_observer(observer)
            widget.observe_attributes()

    def notify(self, owner, name, value):
        for observer in self.observers:
            observer(self, owner, name, value)

    @cache
    def get_parameters(self):
        return self.pexercise.parameters

    def get_answers(self):
        return self.pexercise.answers

    @property
    def widgets(self):
        return self.pexercise.widgets

    @property
    def exercise_dir(self):
        return self.pexercise.exercise.dir

    def set_widget_value(self, widget, value):
        widget.value = value

    @property
    def empty_widgets(self):
        return [widget for widget in self.widgets if widget.value is None]

    @property
    def invalid_widgets(self):
        return [widget for widget in self.widgets if widget.valid is False]


class PyRopeError(Exception):
    pass


class IllPosedError(PyRopeError):
    pass


class ValidationError(PyRopeError):

    def __init__(self, message, ifield=None):
        self.ifield = ifield
        super().__init__(message)


class ExercisePool(collections.UserList):

    def add_exercise(self, exercise):
        self.data.append(exercise)

    def add_exercises_from_pool(self, pool):
        self.data.extend(pool)

    def add_exercises_from_module(self, module):
        # Calling the '__dir__' method instead of the 'dir' built-in
        # avoids alphabetical sorting of the exercises added to the pool.
        for name in module.__dir__():
            obj = getattr(module, name)
            if isinstance(obj, type) and issubclass(obj, Exercise):
                if not inspect.isabstract(obj):
                    self.data.append(obj())

    def add_exercises_from_file(self, filepattern):
        filepaths = glob.glob(filepattern, recursive=True)
        for filepath in filepaths:
            dirname, filename = os.path.split(filepath)
            modulename, ext = os.path.splitext(filename)
            if ext != '.py':
                raise TypeError(
                    f'File "{filepath}" does not seem to be a python script.'
                )
            sys.path.insert(0, dirname)
            importlib.invalidate_caches()
            module = importlib.import_module(modulename)
            self.add_exercises_from_module(module)


class CLIParser:

    def __init__(self, *args, default_frontend='jupyter', **kwargs):
        self._parser = argparse.ArgumentParser(*args, **kwargs)
        subparsers = self._parser.add_subparsers(
            dest='subcommand',
            required=True,
            help='subcommands'
        )

        run_parser = subparsers.add_parser(
            'run',
            help='start an interactive exercise session'
        )
        run_parser.add_argument(
            'patterns',
            nargs='*',
            type=str,
            help='pattern for python scripts with exercise definitions',
            metavar='pattern',
        )
        run_parser.add_argument(
            '--frontend',
            default=default_frontend,
            choices={'console', 'jupyter'},
            help='exercises renderer',
        )
        run_parser.add_argument(
            '--path',
            default=os.getcwd(),
            help='location to store files'
        )
        run_parser.add_argument(
            '--debug',
            default=False,
            action='store_true',
            help='enables debug mode for the frontend',
        )

        test_parser = subparsers.add_parser(
            'test',
            help='run automated unit tests on exercises'
        )
        test_parser.add_argument(
            'patterns',
            nargs='*',
            type=str,
            help='pattern for python scripts with exercise definitions',
            metavar='pattern',
        )

    def parse_args(self, args=None, namespace=None):
        return self._parser.parse_args(args=args, namespace=namespace)

pyrope/core.py
PyRope-E-Assessment/pyrope

import abc
from fractions import Fraction
import numbers

import numpy as np
import sympy

from sympy.core.numbers import Zero, One

from pyrope import config
from pyrope.core import ValidationError


class TypeChecked:

    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, obj, objtype=None):
        ifields = {
            name: getattr(obj.ifields[name], self.name)
            for name in obj.ifields
        }
        for value in ifields.values():
            if value is None:
                return None
        value = obj.assemble(**ifields)
        if value is None:
            return None
        value = obj.cast(value)
        value = obj.normalize(value)
        obj.check_type(value)
        return value

    def __set__(self, obj, value):
        if value is None:
            for ifield in obj.ifields.values():
                setattr(ifield, self.name, None)
            return
        value = obj.cast(value)
        value = obj.normalize(value)
        obj.check_type(value)
        for name, value in obj.disassemble(value).items():
            setattr(obj.ifields[name], self.name, value)


class DType(abc.ABC):

    def __init_subclass__(cls):
        def validate_trivial_value(f):
            def wrapper(self):
                try:
                    trivial_value = f(self)
                    self.check_type(trivial_value)
                except ValidationError:
                    return None
                else:
                    return trivial_value
            return wrapper
        cls.trivial_value = validate_trivial_value(cls.trivial_value)

    @property
    @abc.abstractmethod
    def dtype(self):
        ...

    @property
    @abc.abstractmethod
    def info(self):
        ...

    @abc.abstractmethod
    def trivial_value(self):
        ...

    @abc.abstractmethod
    def dummy_value(self):
        ...

    def cast(self, value):
        return value

    def check_type(self, value):
        if not isinstance(value, self.dtype):
            raise ValidationError(
                f"Bad type: '{self.dtype.__name__}' expected, "
                f"got '{type(value).__name__}'."
            )

    def compare(self, LHS, RHS):
        return LHS == RHS

    def normalize(self, value):
        return value


class BoolType(DType):

    dtype = bool

    @property
    def info(self):
        return 'a boolean'

    def trivial_value(self):
        return False

    def dummy_value(self):
        return True

    def cast(self, value):
        if isinstance(value, np.bool_):
            return value.item()
        if isinstance(value, self.dtype):
            return value
        for bool_repr in config.boolean_representations:
            if value in bool_repr:
                return [False, True][bool_repr.index(value)]
        return value


class ComplexType(DType):

    dtype = complex

    @property
    def info(self):
        return 'a complex number'

    def trivial_value(self):
        return 0j

    def dummy_value(self):
        return 1j

    def cast(self, value):
        if isinstance(value, (np.int_, np.float_, np.complex_)):
            value = value.item()
        if isinstance(value, (int, float)):
            return complex(value)
        return value


class DictType(DType):

    dtype = dict

    def __init__(self, count=None):
        if count is not None:
            if not isinstance(count, int) or count < 0:
                raise ValueError(
                    'Number of dictionary elements must be a non-negative '
                    'integer.'
                )
        self.count = count

    @property
    def info(self):
        if self.count is None:
            return 'a dictionary'
        if self.count == 0:
            return 'an empty dictionary'
        return f'a dictionary with {self.count} elements'

    def trivial_value(self):
        return {}

    def dummy_value(self):
        if self.count is None:
            return {0: True}
        return {i: True for i in range(self.count)}

    def check_type(self, value):
        DType.check_type(self, value)
        if self.count is None:
            return
        if len(value) != self.count:
            raise ValidationError(
                f'Expected dictionary with {self.count} elements, not '
                f'{len(value)}.'
            )


class ExpressionType(DType):

    dtype = sympy.Expr

    def __init__(self, symbols=None):
        if symbols is None:
            self.symbols = set()
        else:
            symbols = sympy.symbols(symbols)
            try:
                self.symbols = set(symbols)
            except TypeError:
                self.symbols = {symbols}

    @property
    def info(self):
        if not self.symbols:
            return 'an expression'
        return f'an expression in {self.symbols}'

    def trivial_value(self):
        return Zero()

    def dummy_value(self):
        return One()

    def compare(self, LHS, RHS):
        return LHS.equals(RHS)

    def check_type(self, value):
        DType.check_type(self, value)
        if not value.free_symbols <= self.symbols:
            if not self.symbols:
                raise ValidationError('Expression without variables expected.')
            else:
                raise ValidationError(
                    f'Expected an expression in {self.symbols}, '
                    f'not in {value.free_symbols}.'
                )


class EquationType(ExpressionType):

    dtype = sympy.Equality

    @property
    def info(self):
        if not self.symbols:
            return 'an equation'
        return f'an equation in {self.symbols}'

    def trivial_value(self):
        return sympy.Equality(Zero(), Zero(), evaluate=False)

    def dummy_value(self):
        return sympy.Equality(One(), Zero(), evaluate=False)

    def check_type(self, value):
        DType.check_type(self, value)
        if not value.free_symbols <= self.symbols:
            if not self.symbols:
                raise ValidationError('Equation without variables expected.')
            else:
                raise ValidationError(
                    f'Expected an equation in {self.symbols}, '
                    f'not in {value.free_symbols}.'
                )


class IntType(DType):

    dtype = int

    def __init__(self, minimum=None, maximum=None):
        if not (minimum is None or isinstance(minimum, int)):
            raise ValueError('Minimum must be integer or None.')
        if not (maximum is None or isinstance(maximum, int)):
            raise ValueError('Maximum must be integer or None.')
        if minimum is not None and maximum is not None:
            if minimum >= maximum:
                raise ValueError('Minimum must be less than maximum.')
        self.minimum = minimum
        self.maximum = maximum

    @property
    def info(self):
        if self.minimum is None and self.maximum is None:
            return 'an integer'
        if self.maximum is None:
            return f'an integer greater equal {self.minimum}'
        if self.minimum is None:
            return f'an integer less or equal {self.maximum}'
        return f'an integer between {self.minimum} and {self.maximum}'

    def trivial_value(self):
        return 0

    def dummy_value(self):
        if self.minimum is not None:
            return self.minimum
        if self.maximum is not None:
            return self.maximum
        return 1

    def cast(self, value):
        if isinstance(value, (np.int_, np.float_, np.complex_)):
            value = value.item()
        if isinstance(value, float) and int(value) == value:
            return int(value)
        elif isinstance(value, complex):
            real = value.real
            if value.imag == 0 and int(real) == real:
                return int(real)
        return value

    def check_type(self, value):
        DType.check_type(self, value)
        if self.minimum is not None and value < self.minimum:
            raise ValidationError(
                    f'Expected an integer greater or equal {self.minimum}, '
                    f'got {value}.'
                )
        if self.maximum is not None and value > self.maximum:
            raise ValidationError(
                    f'Expected an integer less or equal {self.maximum}, '
                    f'got {value}.'
                )


class MatrixType(DType):

    dtype = np.ndarray
    sub_dtypes = (
        numbers.Integral, numbers.Rational, numbers.Real, numbers.Complex,
        numbers.Number
    )

    def __init__(
        self, nrows=None, ncols=None, sub_dtype=numbers.Number,
        compare_elementwise=True, rtol=0, atol=0,
    ):
        for dim, n in (('row', nrows), ('column', ncols)):
            if n is None:
                continue
            if not isinstance(n, int):
                raise ValueError(f'Number of {dim}s must be integer.')
            if not n >= 1:
                raise ValueError(
                    f'Number of {dim}s must be positive.'
                )
        if sub_dtype not in self.sub_dtypes:
            raise ValueError(
                f"'sub_dtype' must be one of {self.sub_dtypes}."
            )
        if not isinstance(compare_elementwise, bool):
            raise ValueError("'compare_elementwise' must be boolean.")
        if isinstance(rtol, numbers.Real):
            raise ValueError("'rtol' must be real.")
        if isinstance(atol, numbers.Real):
            raise ValueError("'atol' must be real.")
        self.nrows = nrows
        self.ncols = ncols
        self.sub_dtype = sub_dtype
        self.compare_elementwise = compare_elementwise
        self.tols = dict(rtol=rtol, atol=atol)

    @property
    def info(self):
        if self.nrows is None and self.ncols is None:
            info = 'a matrix'
        elif self.nrows is None:
            info = f'a matrix with {self.ncols} columns'
        elif self.ncols is None:
            info = f'a matrix with {self.nrows} rows'
        else:
            info = f'a {self.nrows}x{self.ncols} matrix'
        if self.sub_dtype == numbers.Number:
            return info
        return f'{info} of {self.sub_dtype.__name__} numbers'

    def trivial_value(self):
        return np.zeros((1, 1))

    def dummy_value(self):
        nrows = self.nrows
        ncols = self.ncols
        if nrows is None:
            nrows = 1
        if ncols is None:
            ncols = 1
        return np.array(np.zeros((nrows, ncols)))

    def cast(self, value):
        try:
            value = np.array(value)
        except ValueError:
            return value
        if np.all(np.real(value) == value):
            value = np.real(value)
        if np.all(value.astype(int) == value):
            value = value.astype(int)
        return value

    def check_type(self, value):
        DType.check_type(self, value)
        if value.size == 0:
            raise ValidationError('Empty matrix.')
        if len(value.shape) != 2:
            raise ValidationError(
                f'Expected two dimensions, not {len(value.shape)}.'
            )
        if self.nrows is not None and self.nrows != value.shape[0]:
            raise ValidationError(
                f'Expected {self.nrows} rows, not {value.shape[0]}'
            )
        if self.ncols is not None and self.ncols != value.shape[1]:
            raise ValidationError(
                f'Expected {self.ncols} columns, not {value.shape[1]}.'
            )
        for element in value.flat:
            if not isinstance(element, self.sub_dtype):
                raise ValidationError(
                    f'Entries must be {self.sub_dtype.__name__}.'
                )

    def compare(self, LHS, RHS):
        if LHS.shape != RHS.shape:
            return 0.0
        comparison_matrix = np.isclose(LHS, RHS, **self.tols)
        if self.compare_elementwise:
            return comparison_matrix.sum() / comparison_matrix.size
        else:
            return comparison_matrix.all()


class OneOfType(DType):

    dtype = None

    def __init__(self, options):
        try:
            options = tuple(options)
        except TypeError:
            raise TypeError("'options' must be iterable.")
        if len(options) == 0:
            raise ValueError('No options given.')
        if len(options) == 1:
            raise ValueError('A single option is nonsense.')
        option_dtypes = {type(option) for option in options}
        if len(option_dtypes) != 1:
            raise TypeError(
                f'All options have to be of the same data type. '
                f'Found: {set({dtype.__name__ for dtype in option_dtypes})}.'
            )
        self.dtype = next(iter(option_dtypes))
        self.options = options

    @property
    def info(self):
        return f"one of '{self.options}'"

    def trivial_value(self):
        return self.options[0]

    def dummy_value(self):
        return self.options[-1]

    def check_type(self, value):
        DType.check_type(self, value)
        if value not in self.options:
            raise ValidationError(
                f"'{value}' is not in {self.options}."
            )


class RationalType(DType):

    dtype = Fraction

    @property
    def info(self):
        return 'a rational number'

    def trivial_value(self):
        return Fraction()

    def dummy_value(self):
        return Fraction(1, 2)

    def cast(self, value):
        if isinstance(value, (int, float)):
            return Fraction(str(value))
        return value


class RealType(DType):

    dtype = float

    @property
    def info(self):
        return 'a real number'

    def trivial_value(self):
        return 0.0

    def dummy_value(self):
        return 0.5

    def cast(self, value):
        if isinstance(value, (np.int_, np.float_, np.complex_)):
            value = value.item()
        if isinstance(value, int):
            return float(value)
        elif isinstance(value, complex) and value.imag == 0:
            return float(value.real)
        return value


class SetType(DType):

    dtype = set

    def __init__(self, count=None, compare='equality'):
        if count is not None:
            if not isinstance(count, int) or count < 0:
                raise ValueError(
                    'Number of set elements must be a non-negative integer.'
                )
        if compare not in {'equality', 'IoU'}:
            raise ValueError("'Compare' must be 'equality' or 'IoU'.")
        self.comparison = compare
        self.count = count

    @property
    def info(self):
        if self.count is None:
            return 'a set'
        if self.count == 0:
            return 'an empty set'
        return f'a set with {self.count} elements'

    def trivial_value(self):
        return set()

    def dummy_value(self):
        if self.count is None:
            return {0}
        return set(range(self.count))

    def cast(self, value):
        if value == {}:
            return set()
        return value

    def check_type(self, value):
        DType.check_type(self, value)
        if self.count is None:
            return
        if len(value) != self.count:
            raise ValidationError(
                f'Expected set with {self.count} elements, not {len(value)}.'
            )

    def compare(self, LHS, RHS):
        if self.comparison == 'equality':
            return LHS == RHS
        if self.comparison == 'IoU':
            intersection = set.intersection(LHS, RHS)
            union = set.union(LHS, RHS)
            if len(union) == 0:
                return 1.0
            return len(intersection) / len(union)


class StringType(DType):

    dtype = str

    def __init__(self, strip=False):
        self.strip = strip

    @property
    def info(self):
        return 'a string'

    def trivial_value(self):
        return ''

    def dummy_value(self):
        return 'PyRope'

    def normalize(self, value):
        if self.strip is True:
            value = value.strip()
        return value

    def cast(self, value):
        if isinstance(value, np.str_):
            return value.item()
        return str(value)


class TupleType(DType):

    dtype = tuple

    def __init__(self, count=None):
        if count is not None:
            if not isinstance(count, int) or count < 0:
                raise ValueError(
                    f'Number of {self.dtype.__name__} elements must be a '
                    f'non-negative integer.'
                )
        self.count = count

    @property
    def info(self):
        if self.count is None:
            return f'a {self.dtype.__name__}'
        if self.count == 0:
            return f'an empty {self.dtype.__name__}'
        return f'a {self.dtype.__name__} with {self.count} elements'

    def trivial_value(self):
        return tuple()

    def dummy_value(self):
        if self.count is None:
            return (0,)
        return self.count * (0,)

    def cast(self, value):
        if isinstance(value, list):
            return tuple(value)
        return value

    def check_type(self, value):
        DType.check_type(self, value)
        if self.count is None:
            return
        if len(value) != self.count:
            raise ValidationError(
                f'Expected {self.dtype.__name__} with {self.count} elements, '
                f'got {len(value)}.'
            )


class ListType(TupleType):

    dtype = list

    def trivial_value(self):
        return []

    def dummy_value(self):
        if self.count is None:
            return [0]
        return self.count * [0]

    def cast(self, value):
        if isinstance(value, tuple):
            return list(value)
        return value


class VectorType(MatrixType):

    def __init__(
        self, orientation='column', dim=None, allow_flat_list=True, **kwargs,
    ):
        if orientation not in ('row', 'column'):
            raise ValueError("'orientation' must either be 'column' or 'row'.")
        if dim is not None:
            if not isinstance(dim, int):
                raise ValueError("Dimension must be integer.")
            if not dim > 1:
                raise ValueError(f'Dimension must be positive, not {dim}.')
        if not isinstance(allow_flat_list, bool):
            raise ValueError("'allow_flat_list' must be boolean.")
        nrows, ncols = 1, 1
        if orientation == 'row':
            ncols = dim
        if orientation == 'column':
            nrows = dim
        MatrixType.__init__(self, nrows=nrows, ncols=ncols, **kwargs)
        self.orientation = orientation
        self.dim = dim
        self.allow_flat_list = allow_flat_list

    @property
    def info(self):
        info = f'a {self.dim}-dimensional {self.orientation} vector'
        if self.sub_dtype == numbers.Number:
            return info
        return f'{info} of {self.sub_dtype.__name__} numbers'

    def cast(self, value):
        try:
            value = np.array(value)
        except ValueError:
            return value
        if not (self.allow_flat_list and value.ndim == 1):
            return value
        if self.orientation == 'column':
            return np.reshape(value, (-1, 1))
        else:
            return np.reshape(value, (1, -1))

pyrope/dtypes.py
PyRope-E-Assessment/pyrope

import sympy

from fractions import Fraction
from math import prod

from pyrope.core import Exercise
from pyrope.nodes import (
    Problem, String, Int, Rational, Set, Expression, Equation
)


class EquationExample(Exercise):

    def problem(self):
        return Problem(
            """
            The Pythagorean Theorem reads <<equation>>.
            """,
            equation=Equation(symbols='a,b,c')
        )

    def the_solution(self):
        return sympy.parse_expr('Eq(a**2+b**2,c**2)')


class ExpressionExample(Exercise):

    def problem(self):
        return Problem(
            """
            Einstein's most famous formula, relating Energy $E$ and mass $m$
            via the speed of light $c$, reads $E=$<<RHS>>.
            """,
            RHS=Expression(symbols='m,c')
        )

    def the_solution(self):
        return sympy.parse_expr('m*c**2')


class IntExample(Exercise):

    def problem(self):
        return Problem(
            '''
            If there are five apples and you take away three,
            how many do you have?

            <<number>>
            ''',
            number=Int(minimum=0, maximum=5)
        )

    def the_solution(self):
        return 3

    def feedback(self, number):
        return 'You took three apples, so you have three!'


class SetExample(Exercise):
    '''
    Use a set if the order of the items you ask for does not matter.
    '''

    def preamble(self):
        return r'You know that $2+2=2\times2$.'

    def problem(self):
        return Problem(
            '''
            Find a set of three different integers whose sum is equal to their
            product.

            <<numbers>>
            ''',
            numbers=Set(count=3)
        )

    def a_solution(self):
        return {1, 2, 3}

    def score(self, numbers):
        return sum(numbers) == prod(numbers)


class RationalExample(Exercise):

    def problem(self):
        return Problem(
            '''
            A half is a third of it. What is it?

            <<number>>
            ''',
            number=Rational()
        )

    def the_solution(self):
        return Fraction(3, 2)


class StaticExample(Exercise):
    '''
    For simplicity, let us start with a static exercise, i.e. one which is the
    same every time we run it.

    Note that the user's answer is automatically scored, if we provide the
    sample solution, since PyRope scores one point per correct input field by
    default.
    '''

    preamble = __doc__

    def problem(self):
        return Problem(
            '''
            What is the answer to the Ultimate Question of Life, The Universe,
            and Everything?

            <<answer>>
            ''',
            answer=String(strip=True)
        )

    def the_solution(self):
        return 'fourty-two'


class ParsingExample(Exercise):
    '''
    PyRope accepts valid Python expressions for standard data types. So you
    can, for example, use scientific notation, such as '1e21'.
    '''

    preamble = __doc__

    def problem(self):
        return Problem(
            '''A 'Sextillion' equals <<answer>>. ''', answer=Int()
        )

    def the_solution(self):
        return 1e21


class FeedbackExample(Exercise):
    '''
    It is possible to give feedback with PyRope. To use adaptive feedback,
    you can pass your parameters and user answers to the 'feedback' method.
    '''

    preamble = __doc__

    def problem(self):
        return Problem(
            '''
            One grandmother, two mothers, two daughters and one granddaughter
            go to the cinema and buy one ticket each. How many tickets do they
            have to buy in total?

            <<number>>
            ''',
            number=Int()
        )

    def the_solution(self):
        return 3

    def feedback(self, number):
        if number == 3:
            return "You knew the riddle, didn't you?"
        return (
            "The grandmother is also a mother and the mother is also "
            "a daughter."
        )


class TrivialExample(Exercise):
    '''
    For the sake of completeness, let us finish with a trivial example, one
    without any input fields. Admittedly, this does not make much sense. But
    your students will love it, as it gives them points for free. Note that
    we have to provide scores, since they can not be deduced from input fields.
    '''

    preamble = __doc__

    def problem(self):
        return Problem('Free lunch!')

    def score(self):
        return 100

pyrope/examples.py
PyRope-E-Assessment/pyrope

import re


class TemplateFormatter:

    @classmethod
    def format(cls, template, **kwargs):
        result = []
        for literal_text, field_name, format_spec in cls.parse(template):

            if literal_text:
                result.append(literal_text)

            if field_name is not None:
                obj = cls.get_field(field_name, format_spec, kwargs)
                result.append(cls.format_field(obj, format_spec))

        return ''.join(result).replace(r'\<\<', '<<').replace(r'\>\>', '>>')

    @staticmethod
    def parse(s):
        text_start = 0
        for match in re.finditer(r'<<[^>]*>>', s):
            field_start, field_end = match.span()
            field = s[field_start + 2:field_end - 2]
            field_name, *format_spec = field.split(':', maxsplit=1)
            format_spec = format_spec[0] if format_spec else None
            literal_text = s[text_start:field_start]
            text_start = field_end
            yield literal_text, field_name, format_spec
        yield s[text_start:], None, None

    @staticmethod
    def get_field(field_name, format_spec, kwargs):
        if format_spec is None:
            field_template = f'<<{field_name}>>'
        else:
            field_template = f'<<{field_name}:{format_spec}>>'
        obj = kwargs.get(field_name, field_template)
        return str(obj)

    @staticmethod
    def format_field(obj, format_spec):
        if format_spec is None:
            return obj
        elif format_spec == 'latex':
            return obj.strip(' $')
        else:
            raise ValueError(f'Unknown format specifier "{format_spec}".')

pyrope/formatters.py
PyRope-E-Assessment/pyrope

from .console_frontend import ConsoleFrontend
from .jupyter_frontend import JupyterFrontend

__all__ = ['ConsoleFrontend', 'JupyterFrontend']

pyrope/frontends/__init__.py
PyRope-E-Assessment/pyrope

from pyrope.formatters import TemplateFormatter
from pyrope.widgets import Widget


class ConsoleFrontend:

    def __init__(self, debug=False):
        self.debug = debug

    def set_runner(self, runner):
        self.runner = runner
        self.widget_index = {
            widget: f'{index}' for index, widget in enumerate(runner.widgets)
        }
        runner.register_observer(self.observer)

    def formatter(self, template, **kwargs):
        parameters = self.runner.get_parameters()
        return TemplateFormatter.format(
            template, **(parameters | kwargs)
        )

    def render_preamble(self, preamble):
        print(78 * '-')
        if preamble != '':
            print(self.formatter(preamble))

    def render_problem(self, template):
        fields = {
            f'#{widget.ID}': f'[{index}:____]'
            for widget, index in self.widget_index.items()
        }
        print(self.formatter(template, **fields))
        print(78*'-')

    def render_feedback(self, feedback):
        print(78 * '-')
        if feedback != '':
            print(self.formatter(feedback, **self.runner.get_answers()))
            print(78 * '-')

    def get_answers(self):
        runner = self.runner
        while True:
            for widget in runner.widgets:
                value = widget.value
                if value is None:
                    value = '____'
                value = input(
                    f'[{self.widget_index[widget]}:{value}], '
                    f'{widget.info} >> '
                )
                if value == '':
                    value = None
                runner.set_widget_value(widget, value)
            submit = True
            if runner.empty_widgets:
                print('There are empty input fields.')
                submit = False
            if runner.invalid_widgets:
                print('There are invalid input fields.')
                submit = False
            if submit is False:
                answer = input('Submit? >> ')
                if answer.strip().lower() in ('y', 'yes'):
                    submit = True
            if submit:
                runner.finish()
                break

    def observer(self, obj, owner, name, value):
        if isinstance(obj, Widget):
            origin = f'{self.widget_index[obj]}: <{owner.__name__}>'
            if name == 'error':
                print(f'{origin} {value}')
            elif self.debug:
                print(f'{origin} {name} set to {value}.')
        else:
            print(obj, owner, name, value)

    def display_error(self, e):
        msg = str(e)
        if e.ifield in self.widget_index:
            msg = f'{self.widget_index[e.ifield]}: ' + msg
        print(msg)

pyrope/frontends/console_frontend.py
PyRope-E-Assessment/pyrope

from functools import cached_property
import os
from pathlib import Path
from uuid import uuid4
import warnings

from ipykernel.comm import Comm
from IPython import get_ipython
from IPython.display import display, HTML, Javascript
from ipywidgets import widgets as ipy_widgets
import matplotlib.pyplot as plt
from nbconvert.filters.pandoc import convert_pandoc

from pyrope import config
from pyrope.formatters import TemplateFormatter
from pyrope.widgets import (
    Checkbox, Dropdown, RadioButtons, Slider, Text, Textarea, Widget,
    WidgetFactory
)


class JupyterFrontend:

    def __init__(self, widget_factory=None, debug=False):
        if widget_factory is None:
            self.widget_factory = JupyterHtmlWidgetFactory()
        self.debug = debug
        self.format = TemplateFormatter.format

        self.total_max_score = None
        self.total_score = None

        # Reload all imported packages for debugging purposes.
        self.ipy = get_ipython()
        line_magics = self.ipy.magics_manager.magics.get('line')
        if self.debug and 'autoreload' not in line_magics:
            self.ipy.run_line_magic('load_ext', 'autoreload')
            self.ipy.run_line_magic('autoreload', '3')

        # Load .css file for jupyter frontend styles.
        filepath = config.jupyter_frontend_css
        try:
            with open(filepath, 'r') as f:
                content = f.read()
        except FileNotFoundError:
            warnings.warn(
                f'.css file for jupyter frontend styles not found: '
                f'{filepath}'
            )
        else:
            display(HTML(f'<style>\n{content}\n</style>'))

    def set_runner(self, runner):
        self.runner = runner
        self.submit_section = JupyterSubmitSection(frontend=self)
        self.widgets = {
            f'#{widget.ID}': self.widget_factory(widget, self.runner)
            for widget in self.runner.widgets
        }
        self.runner.register_observer(self.observer)

    def formatter(self, template, allow_widgets=False, **kwargs):
        parameters = self.runner.get_parameters()
        template = self.format_ofields(template, **(parameters | kwargs))
        if allow_widgets:
            template = self.format(template, **self.widgets)
        template = self.markdown_to_html(template)
        return template

    def format_ofields(self, template, **kwargs):
        relative_path = os.path.relpath(self.runner.exercise_dir, Path.cwd())
        ofields = {}
        for name, ofield in kwargs.items():

            # Set paths relative to the exercise directory.
            if isinstance(ofield, Path):
                ofield = str(relative_path / ofield)

            # Otherwise strings are rendered with '' or "".
            if not isinstance(ofield, str):
                ofield = self.ipython_format(ofield)

            # All output fields should be inline elements with no line breaks.
            ofield = ofield.replace('\n', '').strip()

            ofields[name] = ofield

        return self.format(template, **ofields)

    def ipython_format(self, obj):
        supported_mimetypes = [
            'text/html',
            'text/latex',
            'text/markdown',
            'image/png',
            'image/jpeg',
            'image/gif',
            'image/svg+xml',
            'text/plain',
        ]
        format = self.ipy.display_formatter.format
        format_dict, metadata = format(obj)

        # Check format_dict for one of the supported mimetypes in the specified
        # order and break the loop immediately after a representation is found.
        for mimetype in supported_mimetypes:
            representation = format_dict.get(mimetype, None)
            if representation is not None:
                match mimetype:
                    case 'image/png' | 'image/jpeg' | 'image/gif':
                        metadata = metadata.get(mimetype, {})
                        width = metadata.get('width', '')
                        height = metadata.get('height', '')
                        alt = metadata.get('alt', '')
                        return (
                            '<span class="pyrope">'
                            f'<img src="data:{mimetype};base64,'
                            f'{representation}" width="{width}" '
                            f'height="{height}" alt="{alt}">'
                            '</span>'
                        )
                    case 'text/html':
                        return (
                            '<span class="pyrope">'
                            f'{representation}'
                            '</span>'
                        )
                    case _:
                        return representation

        return repr(obj)

    def markdown_to_html(self, template):
        # Use basic markdown variant and add extensions used
        # by jupyter notebook manually.
        # documentation: https://pandoc.org/MANUAL.html
        markdown_extensions = [
            'markdown_strict',
            'backtick_code_blocks',
            'hard_line_breaks',  # Preserve linebreaks from the template.
            'pipe_tables',
            'strikeout',
            'task_lists',
        ]

        template = convert_pandoc(
            template, '+'.join(markdown_extensions), 'html',
            extra_args=[
                '--highlight-style=pygments',
                '--mathjax',
                '--wrap=none',  # Pandoc must not wrap text automatically.
            ]
        )

        return f'<div class="pyrope">{template}</div>'

    def render_preamble(self, preamble):
        if preamble:
            display(HTML(self.formatter(preamble)))

    def render_problem(self, template):
        display(HTML(self.formatter(template, allow_widgets=True)))

        # Prevent matplotlib from rendering a plot directly after an
        # exercise is rendered. This also clears the current figure
        # for the next exercise.
        plt.close()

    def render_feedback(self, feedback):
        feedback = self.formatter(feedback, **self.runner.get_answers())
        display(Javascript(
            f'PyRope.set_inner_html(\'{self.submit_section.feedback_div_ID}\','
            f' \'{feedback}\')'
        ))
        score_string = ''
        if self.total_score is not None and self.total_max_score is not None:
            percentage = round(
                (self.total_score / self.total_max_score) * 100, 2
            )
            score_string = (
                f'Total Score: {self.total_score}/{self.total_max_score} '
                f'({percentage}%)'
            )
        elif self.total_score is not None:
            score_string = f'Total Score: {self.total_score}'
        elif self.total_max_score is not None:
            score_string = (
                f'Total Maximal Score: {self.total_max_score}'
            )
        display(Javascript(
            f'PyRope.set_inner_html(\'{self.submit_section.score_div_ID}\', '
            f'\'{score_string}\')'
        ))

    def get_answers(self):
        self.submit, self.submit_anyway = True, False
        display(self.submit_section)
        if self.debug:
            self.runner.get_solutions()

    def observer(self, obj, owner, name, value):
        if isinstance(obj, Widget):
            widget = self.widgets[f'#{obj.ID}']
            if name == 'attribute':
                attr_name, attr_value = list(value.items())[0]
                if attr_name == 'value':
                    self.submit_anyway = False
                    self.submit_section.submit_btn.description = 'Submit'
                elif attr_name == 'valid' and attr_value is True:
                    widget.change_hover_text(widget.description)
                setattr(widget, attr_name, attr_value)
            elif name == 'error':
                widget.change_hover_text(value)
        elif name == 'total_max_score':
            self.total_max_score = value
        elif name == 'total_score':
            self.total_score = value

        with self.submit_section.debug:
            if name == 'error':
                print(f'{owner} {value}')
            elif name == 'attribute':
                attr_name, attr_value = list(value.items())[0]
                print(f'{repr(obj)} {attr_name} set to {attr_value}.')
            else:
                print(f'{repr(obj)} {name} set to {value}.')

    def display_error(self, e):
        try:
            with self.submit_section:
                print(e)
        except AttributeError:
            display(e)

    def disable_widgets(self):
        for widget in self.widgets.values():
            widget.disabled = True
        self.submit_section.disable_widgets()

    def display_widget_scores(self, show=True):
        for widget in self.widgets.values():
            widget.display_score(show=show)

    def display_widget_solutions(self, show=True):
        for widget in self.widgets.values():
            widget.display_solution(show=show)


def escape_markdown(s):
    # cf. https://github.com/mattcone/markdown-guide/blob/master/_basic-syntax/escaping-characters.md  # noqa
    characters = (
        '\\', '`', '*', '_', '{', '}', '[', ']', '<', '>', '(', ')', '#',
        '+', '-', '.', '!', '|'
    )
    translation = str.maketrans({char: '\\' + char for char in characters})
    return s.translate(translation)


class JupyterSubmitSection(ipy_widgets.VBox):

    def __init__(self, frontend):
        self.frontend = frontend
        self.runner = frontend.runner
        self.debug = JupyterDebugOutput()
        self.submit_output = ipy_widgets.Output()
        self.show_solutions = False
        self.feedback_div_ID = f'id_{uuid4().hex}'
        self.score_div_ID = f'id_{uuid4().hex}'

        if self.frontend.debug:
            btn_box = ipy_widgets.HBox((
                self.submit_btn, self.debug.clear_btn, self.solution_btn
            ))
        else:
            btn_box = ipy_widgets.HBox((self.submit_btn,))

        vspace = ipy_widgets.Box(layout={'height': '15px'})
        result = ipy_widgets.HTML(
            f'<div data-pyrope-id="{self.feedback_div_ID}" '
            f'class="pyrope feedback"></div>'
            f'<div data-pyrope-id="{self.score_div_ID}" '
            f'class="pyrope score"></div>'
        )
        children = (btn_box, vspace, self.submit_output, result)

        if self.frontend.debug:
            children += (vspace, self.debug, vspace)

        children += (ipy_widgets.HTML('<hr class="pyrope">'),)

        super().__init__(children)

    def __enter__(self):
        return self.submit_output.__enter__()

    def __exit__(self, *args):
        return self.submit_output.__exit__(*args)

    @cached_property
    def solution_btn(self):
        btn = ipy_widgets.Button(description='Show Solutions')

        def show(btn):
            self.show_solutions = True
            btn.description = 'Hide Solutions'
            self.frontend.display_widget_solutions(show=True)
            btn.on_click(show, remove=True)
            btn.on_click(hide)

        def hide(btn):
            self.show_solutions = False
            btn.description = 'Show Solutions'
            self.frontend.display_widget_solutions(show=False)
            btn.on_click(hide, remove=True)
            btn.on_click(show)

        btn.on_click(show)

        return btn

    @cached_property
    def submit_btn(self):
        btn = ipy_widgets.Button(description='Submit')

        def f(btn):
            self.submit_output.clear_output()

            if self.runner.invalid_widgets or self.runner.empty_widgets:
                self.submit, self.submit_anyway = False, True
                with self:
                    if self.runner.invalid_widgets:
                        print('There are invalid input fields.')
                    if self.runner.empty_widgets:
                        print('There are empty input fields.')
            else:
                self.submit = True

            if self.submit_anyway:
                btn.description = 'Submit anyway?'
                btn.on_click(f, remove=True)
                btn.on_click(f_anyway)

            if self.submit:
                self.runner.finish()
                self.frontend.disable_widgets()
                if not self.show_solutions:
                    self.solution_btn.click()
                self.frontend.display_widget_scores()

        def f_anyway(btn):
            if self.submit_anyway:
                self.runner.finish()
                self.frontend.disable_widgets()
                if not self.show_solutions:
                    self.solution_btn.click()
                self.frontend.display_widget_scores()
            else:
                btn.on_click(f_anyway, remove=True)
                btn.on_click(f)
                f(btn)

        btn.on_click(f)

        return btn

    @property
    def submit(self):
        return self.frontend.submit

    @submit.setter
    def submit(self, value):
        if value is True:
            self.frontend.submit = True
        else:
            self.frontend.submit = False

    @property
    def submit_anyway(self):
        return self.frontend.submit_anyway

    @submit_anyway.setter
    def submit_anyway(self, value):
        if value is True:
            self.frontend.submit_anyway = True
        else:
            self.frontend.submit_anyway = False

    def disable_widgets(self):
        for widget in (
                self.submit_btn, self.solution_btn,
                self.debug.clear_btn
        ):
            widget.disabled = True


class JupyterDebugOutput(ipy_widgets.Output):

    def __init__(self):
        super().__init__(layout={'border': '1px solid black'})
        self.append_stdout('Debug Messages:\n')

    @cached_property
    def clear_btn(self):
        btn = ipy_widgets.Button(description='Clear Debug')

        def f(_):
            self.clear_output()
            with self:
                print('Debug Messages:')
        btn.on_click(f)

        return btn


class JupyterWidgetResultSpan:

    def __init__(self):
        self.score_ID = f'id_{uuid4().hex}'
        self.solution_ID = f'id_{uuid4().hex}'
        self._score = ''
        self._solution = ''

    @property
    def score(self):
        return self._score

    @score.setter
    def score(self, value):
        self._score = str(value)
        display(Javascript(
            f'PyRope.set_inner_html(\'{self.score_ID}\', '
            f'\'{self._score}\')'
        ))

    @property
    def solution(self):
        return self._solution

    @solution.setter
    def solution(self, value):
        self._solution = str(value)
        display(Javascript(
            f'PyRope.set_inner_html(\'{self.solution_ID}\', '
            f'\'{self._solution}\')'
        ))

    def __str__(self):
        return (
            f'<span data-pyrope-id="{self.solution_ID}" '
            f'class="pyrope solution"></span>'
            f'<span data-pyrope-id="{self.score_ID}" '
            f'class="pyrope score"></span>'
        )


class JupyterHtmlWidget:

    def __init__(self, widget, runner):
        self.ipy = get_ipython()
        self.runner = runner
        self.widget = widget
        self.result_span = JupyterWidgetResultSpan()
        self.description = ''
        self.displayed_max_score = None
        self.displayed_score = None
        self.solution = ''
        self._disabled = False
        self._valid = 'valid'
        self._value = None

        if widget is not None:
            self.ID = f'id_{widget.ID.hex}'
        else:
            self.ID = f'id_{uuid4().hex}'

        display(Javascript(
            f'PyRope.create_widget_comm("{self.ID}");'
        ))
        self.comm = Comm(target_name=self.ID)

        def comm_target(comm, _):
            @comm.on_msg
            def receive(msg):
                self.value = msg['content']['data']['value']

        self.ipy.kernel.comm_manager.register_target(self.ID, comm_target)

    def __init_subclass__(cls):
        sub_cls_str = cls.__str__

        def new_str(self):
            return sub_cls_str(self) + str(self.result_span)

        cls.__str__ = new_str

    def __setattr__(self, name, value):
        if callable(getattr(self, name, None)):
            # The frontend could observe an attribute name which is identical
            # to a method name. This would cause the setter to override the
            # method with the observed attribute value.
            raise AttributeError(
                f'Cannot set attribute "{name}" because this would override '
                f'the method "{getattr(self, name)}".'
            )
        object.__setattr__(self, name, value)

    @property
    def disabled(self):
        return self._disabled

    @disabled.setter
    def disabled(self, value):
        if value is True:
            self._disabled = True
        else:
            self._disabled = False
        self.comm.send({'disabled': self._disabled})

    @property
    def valid(self):
        return self._valid

    @valid.setter
    def valid(self, value):
        mapping = {
            True: 'valid',
            False: 'invalid',
            None: 'valid',
        }
        self._valid = mapping.get(value, '')
        self.comm.send({'className': f'pyrope {self._valid}'})

    @property
    def value(self):
        return self._value

    @value.setter
    def value(self, value):
        if self._value != value:
            self._value = value
            self.comm.send({'value': self._value}, {'sync': True})
            self.runner.set_widget_value(self.widget, self._value)

    def change_hover_text(self, text):
        self.comm.send({'title': str(text)})

    def display_score(self, show=True):
        if show:
            score_string = ''
            if (
                self.displayed_score is not None
                and self.displayed_max_score is not None
            ):
                score_string = '{}/{}'.format(
                    self.displayed_score, self.displayed_max_score
                )
            elif self.displayed_score is not None:
                score_string = f'{self.displayed_score}/?'
            elif self.displayed_max_score is not None:
                score_string = f'?/{self.displayed_max_score}'
            self.result_span.score = score_string
        else:
            self.result_span.score = ''

    def display_solution(self, show=True):
        if show and self.solution is not None:
            self.result_span.solution = self.solution
        else:
            self.result_span.solution = ''


class JupyterHtmlCheckbox(JupyterHtmlWidget):

    def __init__(self, widget, runner):
        JupyterHtmlWidget.__init__(self, widget, runner)
        self.checked = False

    def __str__(self):
        return (
            f'<input type="checkbox" data-pyrope-id="{self.ID}" '
            f'class="pyrope {self.valid}" '
            f'title="{self.description}" '
            f'{"checked " if self.checked else ""}'
            f'onclick="PyRope.send(\'{self.ID}\', this.checked)">'
        )

    @property
    def value(self):
        return self._value

    @value.setter
    def value(self, value):
        if self._value != value:
            self._value = value
            self.comm.send({'checked': self._value}, {'sync': True})
            self.runner.set_widget_value(self.widget, self._value)

    def display_solution(self, show=True):
        pass


class JupyterHtmlDropdown(JupyterHtmlWidget):

    def __init__(self, widget, runner):
        JupyterHtmlWidget.__init__(self, widget, runner)
        self.options = ()

    def __str__(self):
        html_options = ''.join([
            f'<option value="{option}">{escape_markdown(str(option))}</option>'
            for option in self.options
        ])
        html_options = (
                '<option value="" selected disabled hidden></option>' +
                html_options
        )
        return (
            f'<select data-pyrope-id="{self.ID}" '
            f'class="pyrope {self.valid}" '
            f'title="{self.description}" '
            f'onchange="PyRope.send(\'{self.ID}\', '
            f'this.options[this.selectedIndex].value)">'
            f'{html_options}'
            f'</select>'
        )


class _JupyterHtmlRadioButton(JupyterHtmlWidget):

    def __init__(self, group_ID, option):
        JupyterHtmlWidget.__init__(self, None, None)
        self.group_ID = group_ID
        self.option = option
        self.times_rendered = 0

    def __str__(self):
        return (
            f'<input type="radio" data-pyrope-id="{self.ID}" '
            f'class="pyrope {self.valid}" '
            f'name="{self.group_ID}_{self.times_rendered}" '
            f'title="{self.description}" '
            f'value="{self.option}" '
            f'onchange="PyRope.send(\'{self.group_ID}\', this.value); '
            f'PyRope.send(\'{self.ID}\', this.checked);">'
            f'<label class="pyrope">{escape_markdown(str(self.option))}'
            f'</label>'
        )

    @property
    def value(self):
        return self._value

    @value.setter
    def value(self, value):
        self.comm.send({'checked': value}, {'sync': True})


class JupyterHtmlRadioButtons(JupyterHtmlWidget):

    def __init__(self, widget, runner):
        self.radio_buttons = []
        JupyterHtmlWidget.__init__(self, widget, runner)
        self._options = ()
        self.times_rendered = 0
        self.vertical = False

    def __str__(self):
        self.times_rendered += 1
        for btn in self.radio_buttons:
            btn.times_rendered = self.times_rendered
        if self.vertical:
            table = ''.join([
                f'<tr><td>{btn}</td></tr>'
                for btn in self.radio_buttons
            ])
            return f'<table class="pyrope"><tbody>{table}</tbody></table>'
        else:
            return (
                '<span style="display: inline-block; width: 20px;"></span>'
                .join([str(btn) for btn in self.radio_buttons])
            )

    @property
    def disabled(self):
        if self.radio_buttons:
            return self.radio_buttons[0].disabled
        else:
            return self._disabled

    @disabled.setter
    def disabled(self, value):
        for btn in self.radio_buttons:
            btn.disabled = value

    @property
    def options(self):
        return self._options

    @options.setter
    def options(self, value):
        self._options = value
        self.radio_buttons = [
            _JupyterHtmlRadioButton(self.ID, option)
            for option in self._options
        ]
        for btn in self.radio_buttons:
            btn.description = self.description

    @property
    def valid(self):
        if self.radio_buttons:
            return self.radio_buttons[0].valid
        else:
            return self._valid

    @valid.setter
    def valid(self, value):
        for btn in self.radio_buttons:
            btn.valid = value

    def change_hover_text(self, text):
        for btn in self.radio_buttons:
            btn.change_hover_text(text)


class JupyterHtmlSlider(JupyterHtmlWidget):

    def __init__(self, widget, runner):
        JupyterHtmlWidget.__init__(self, widget, runner)
        self.label_ID = f'id_{uuid4().hex}'
        self.label_position = 'right'
        self.maximum = 100
        self.minimum = 0
        self.step = 1
        self.width = 25

    def __str__(self):
        label = (
            f'<label data-pyrope-id="{self.label_ID}" class="pyrope"></label>'
        )
        return (
            f'{label if self.label_position == "left" else ""}'
            f'<input type="range" data-pyrope-id="{self.ID}" '
            f'class="pyrope {self.valid}" '
            f'title="{self.description}" '
            f'min="{self.minimum}" max="{self.maximum}" step="{self.step}" '
            f'style="width: {self.width}%;" '
            f'oninput="PyRope.update_slider_label(\'{self.label_ID}\', '
            f'this.value)" '
            f'onmouseup="PyRope.send(\'{self.ID}\', this.value)" '
            f'ontouchend="PyRope.send(\'{self.ID}\', this.value)">'
            f'{label if self.label_position == "right" else ""}'
        )


class JupyterHtmlText(JupyterHtmlWidget):

    def __init__(self, widget, runner):
        JupyterHtmlWidget.__init__(self, widget, runner)
        self.placeholder = ''
        self.width = 20

    def __str__(self):
        return (
            f'<input type="text" data-pyrope-id="{self.ID}" '
            f'class="pyrope {self.valid}" '
            f'title="{self.description}" '
            f'placeholder="{self.placeholder}" '
            f'style="width: {self.width}ch;" '
            f'oninput="PyRope.send(\'{self.ID}\', this.value)">'
        )


class JupyterHtmlTextarea(JupyterHtmlText):

    def __init__(self, widget, runner):
        JupyterHtmlText.__init__(self, widget, runner)
        self.height = 4

    def __str__(self):
        return (
            f'<textarea data-pyrope-id="{self.ID}" '
            f'class="pyrope {self.valid}" '
            f'title="{self.description}" '
            f'placeholder="{self.placeholder}" '
            f'style="width: {self.width}ch;" '
            f'rows="{self.height}" '
            f'oninput="PyRope.send(\'{self.ID}\', this.value)">'
            '</textarea>'
        )


class JupyterHtmlWidgetFactory(WidgetFactory):

    mapping = {
        Checkbox: JupyterHtmlCheckbox,
        Dropdown: JupyterHtmlDropdown,
        RadioButtons: JupyterHtmlRadioButtons,
        Slider: JupyterHtmlSlider,
        Text: JupyterHtmlText,
        Textarea: JupyterHtmlTextarea,
    }

pyrope/frontends/jupyter_frontend.py
PyRope-E-Assessment/pyrope

from .magics import PyRopeMagics

__all__ = ['PyRopeMagics']

pyrope/ipython_extension/__init__.py
PyRope-E-Assessment/pyrope

import shlex
import types
import unittest

from IPython.core.magic import (
    line_magic, Magics, magics_class, needs_local_scope
)

from pyrope import examples, ExercisePool, ExerciseRunner
from pyrope.core import CLIParser
from pyrope.frontends import ConsoleFrontend, JupyterFrontend


@magics_class
class PyRopeMagics(Magics):

    parser = CLIParser(prog='%pyrope')

    @line_magic('pyrope')
    @needs_local_scope
    def cli(self, line, local_ns):
        args = self.parser.parse_args(shlex.split(line))
        pool = ExercisePool()
        if args.patterns:
            for pattern in args.patterns:
                pool.add_exercises_from_file(pattern)
        else:
            notebook = types.ModuleType('notebook')
            notebook.__dict__.update(**local_ns)
            pool.add_exercises_from_module(notebook)
            if len(pool.data) == 0:
                pool.add_exercises_from_module(examples)

        if args.subcommand == 'run':
            for exercise in pool:
                runner = ExerciseRunner(exercise)
                if args.frontend == 'console':
                    frontend = ConsoleFrontend(debug=args.debug)
                else:
                    frontend = JupyterFrontend(debug=args.debug)
                runner.set_frontend(frontend)
                frontend.set_runner(runner)
                runner.run()

        if args.subcommand == 'test':
            test_cases = [
                test_case
                for exercise in pool
                for test_case in exercise.test_cases()
            ]
            suite = unittest.TestSuite(test_cases)
            runner = unittest.TextTestRunner()
            runner.run(suite)

pyrope/ipython_extension/magics.py
PyRope-E-Assessment/pyrope

import io

import matplotlib.tri
import matplotlib.pyplot as pp
import numpy as np


def logo(figsize=(2, 2), R=1/np.sqrt(2), seed=42):

    np.random.seed(seed)

    # ratio of the circumradii of an octahedron and the quadrilateral
    # obtained by prolonging every second edge
    q = np.sqrt(2-np.sqrt(2))

    # polar vertex coordinates
    r = np.array([0, R*q, R, R/q, 1, 1], ndmin=2).T
    phi = np.linspace(0, 2*np.pi, num=9)
    dphi = np.pi/8 * np.array(3*[0, 1], ndmin=2).T

    # cartesian vertex coordinates
    x = (r * np.cos(phi-dphi)).flatten()[7:]
    y = (r * np.sin(phi-dphi)).flatten()[7:]

    # Delaunay triangulation
    D = matplotlib.tri.Triangulation(x, y)

    # triangle centers
    Cx, Cy = np.mean([x[D.triangles],
                      y[D.triangles]], axis=-1)

    # polar coordinates of triangle centers
    phi = np.arctan2(Cy, Cx)
    r = np.sqrt(Cx**2+Cy**2) + phi/2**7

    # order triangles
    triangles = D.triangles[np.lexsort((phi, r))]

    # reorder triangles
    for i in (16, 32, 48):
        triangles[i:i+8], triangles[i+8:i+16] = (
            triangles[i+0:i+16:2].copy(),
            triangles[i+1:i+16:2].copy()
        )

    # trinagulation with reordered triangles
    T = matplotlib.tri.Triangulation(x, y, triangles)

    # triangle colours
    TC = np.random.random(size=(8, 8))

    # inner octogon
    TC[0] = 1/3

    # equal colours for tringles forming a quadrilateral
    TC[[3, 6, 7]] = TC[[2, 4, 5]]

    TC = TC.flatten()

    # sparkling triangles
    S = matplotlib.tri.Triangulation(x, y, triangles[8:16])

    # sparkle colours
    SC = np.random.rand(8)

    # plot figure
    pp.figure(figsize=figsize)
    pp.axis('equal')
    pp.xlim(-1, +1)
    pp.ylim(-1, +1)
    pp.axis('off')
    pp.tripcolor(T, facecolors=TC, vmin=+0.0, vmax=3.0, cmap='hot')
    pp.tripcolor(S, facecolors=SC, vmin=-1.0, vmax=1.0, cmap='bone')

    # convert figure to image
    with io.BytesIO() as iobuffer:
        pp.savefig(iobuffer, dpi=300, bbox_inches='tight')
        iobuffer.seek(0)
        image = pp.imread(iobuffer)

    return image

pyrope/logo.py
PyRope-E-Assessment/pyrope

import ast
from copy import deepcopy
from fractions import Fraction
from functools import cached_property
import sympy
import tokenize
from uuid import uuid4

import numpy as np

from pyrope import config, widgets
from pyrope.core import ValidationError
from pyrope.dtypes import (
     BoolType, ComplexType, DictType, EquationType, ExpressionType, IntType,
     ListType, MatrixType, OneOfType, RationalType, RealType, SetType,
     StringType, TupleType, TypeChecked, VectorType
)
from pyrope.formatters import TemplateFormatter


class Node:

    dtype = None
    value = TypeChecked()

    a_solution = TypeChecked()
    the_solution = TypeChecked()
    solution = TypeChecked()

    def __init__(self, template, **ifields):
        self.ID = uuid4()
        self.parent = None
        ifields = {
            name: ifield.clone() for name, ifield in ifields.items()
        }

        for name, ifield in ifields.items():
            if not isinstance(ifield, Node):
                raise TypeError(
                    f'Input field {name} must be a Node subclass instance.'
                )
        names = tuple(
            name
            for _, name, _ in TemplateFormatter.parse(template or '')
            if name is not None
        )
        for name in ifields:
            if name in names:
                ifields[name].parent = self
            else:
                raise KeyError(f"Missing input field '{name}' in template.")

        if template is not None:
            self.template = template
        # order dict entries according to input field order in template
        self.ifields = {
            name: ifields[name] for name in names if name in ifields
        }
        self.ofields = names - ifields.keys()
        self._displayed_score = None

    def __str__(self):
        return TemplateFormatter.format(self.template, **self.ifields)

    def __repr__(self):
        cls = self.__class__
        return f'<{cls.__module__}.{cls.__name__} ID="{self.ID}">'

    @cached_property
    def name(self):
        if self.parent is None:
            return ''
        keys = list(self.parent.ifields.keys())
        values = list(self.parent.ifields.values())
        return keys[values.index(self)]

    @property
    def auto_max_score(self):
        return sum([
            ifield.auto_max_score
            for ifield in self.ifields.values()
        ])

    @property
    def displayed_max_score(self):
        return None

    @displayed_max_score.setter
    def displayed_max_score(self, value):
        # TODO: configure the max score distribution
        *all_but_last, last = self.ifields.keys()
        for name in all_but_last:
            self.ifields[name].displayed_max_score = None
        self.ifields[last].displayed_max_score = value

    @property
    def auto_score(self):
        return sum([
            ifield.auto_score
            for ifield in self.ifields.values()
        ])

    @property
    def displayed_score(self):
        return None

    @displayed_score.setter
    def displayed_score(self, value):
        # TODO: configure the score distribution
        *all_but_last, last = self.ifields.keys()
        for name in all_but_last:
            self.ifields[name].displayed_score = None
        self.ifields[last].displayed_score = value

    def assemble(self, **ifields):
        if len(self.ifields) != 1:
            raise NotImplementedError(
                f'No way how to assemble value of class '
                f'{self.__class__.__name__} from subfield values.'
            )
        return list(ifields.values())[0]

    def disassemble(self, value):
        if len(self.ifields) != 1:
            raise NotImplementedError(
                f'No way how to disassemble value '
                f'of class {self.__class__.__name__} into subfield values.'
            )
        return {list(self.ifields.keys())[0]: value}

    def cast(self, value):
        if self.dtype is None:
            return value
        return self.dtype.cast(value)

    def check_type(self, value):
        if self.dtype is None:
            return
        try:
            self.dtype.check_type(value)
        except ValidationError as e:
            e.ifield = self
            raise e

    def normalize(self, value):
        if self.dtype is None:
            return value
        return self.dtype.normalize(value)

    def compare(self, LHS, RHS):
        if self.dtype is None:
            return LHS == RHS
        return self.dtype.compare(LHS, RHS)

    def clone(self):
        clone = deepcopy(self)
        self.reset_IDs()
        return clone

    def reset_IDs(self):
        self.ID = uuid4()
        for ifield in self.ifields.values():
            ifield.reset_IDs()

    @cached_property
    def widgets(self):
        if self.ifields == {} and self.parent is not None:
            return (self,)
        widgets = tuple(self.ifields[name].widgets for name in self.ifields)
        return sum(widgets, ())

    @cached_property
    def info(self):
        ifield = self
        while ifield.parent is not None \
                and ifield.parent.parent is not None \
                and len(ifield.parent.ifields) == 1:
            ifield = ifield.parent
        return ifield.dtype.info


class Problem(Node):

    dtype = DictType()

    def __init__(self, template, **ifields):
        Node.__init__(self, template, **ifields)

    def assemble(self, **ifields):
        return ifields

    def disassemble(self, ifields):
        return ifields

    @property
    def answers(self):
        ifields = {}
        for name in self.ifields:
            try:
                ifields[name] = self.ifields[name].value
            except ValidationError:
                ifields[name] = None
        return ifields

    def compare(self, LHS, RHS):
        return sum(
            ifield.compare(LHS[name], RHS[name])
            for name, ifield in self.ifields.items()
        ) / len(self.ifields)


class Parser(Node):

    # c.f. https://docs.python.org/3/library/ast.html#ast.literal_eval
    dtypes = (str, int, float, complex, tuple, list, dict, set, bool)

    def __init__(self, ifield, dtype):
        Node.__init__(self, '<<_>>', _=ifield)
        if dtype.dtype not in self.dtypes:
            raise TypeError(f'Unable to parse data type {dtype.dtype}.')
        self.dtype = dtype

    # c.f. https://bugs.python.org/issue39159
    @staticmethod
    def safe_eval(s):
        if len(s) > config.maximum_input_length:
            raise ValidationError(
                f'Input size {len(s)} '
                f'exceeds limit {config.maximum_input_length}.'
            )
        try:
            expr = ast.literal_eval(s)
        except (MemoryError, SyntaxError, TypeError, ValueError) as e:
            raise ValidationError(e)
        return expr

    def assemble(self, _):
        if not isinstance(_, str) or self.dtype.dtype == str:
            return _
        if _ == '':
            return None
        value = self.safe_eval(_)
        if value is None:
            return _
        return value

    def disassemble(self, value):
        return {'_': str(value)}


class Bool(Node):

    def __init__(self, widget=None):
        if widget is None:
            widget = widgets.Checkbox()
        self.dtype = BoolType()
        Node.__init__(self, '<<_>>', _=widget)

    def assemble(self, _):
        if _ == '':
            return None
        return _


class Complex(Node):

    def __init__(self, elementwise=True, i_on_the='right', widget=None):
        if widget is None:
            widget = widgets.Text()
        self.dtype = ComplexType()
        if elementwise is True:
            _ = ElementwiseComplex(i_on_the=i_on_the, widget=widget)
        else:
            _ = Parser(widget, dtype=self.dtype)
        Node.__init__(self, '<<_>>', _=_)


class ElementwiseComplex(Node):

    def __init__(self, i_on_the='right', widget=None):
        if widget is None:
            widget_a = widgets.Text()
            widget_b = widgets.Text()
        else:
            widget_a = widget
            widget_b = widget.new_instance()
        self.dtype = ComplexType()
        if i_on_the == 'right':
            template = '<<a>> + <<b>> i'
        elif i_on_the == 'left':
            template = '<<a>> + i <<b>>'
        else:
            raise ValueError("Parameter 'i_on_the' must be 'left' or 'right'.")
        Node.__init__(
            self, template, a=Real(widget=widget_a), b=Real(widget=widget_b)
        )

    def assemble(self, **ifields):
        return ifields['a'] + ifields['b'] * 1j

    def disassemble(self, z):
        return {'a': z.real, 'b': z.imag}


class Dict(Node):

    def __init__(self, count=None, widget=None):
        if widget is None:
            widget = widgets.Text()
        self.dtype = DictType(count=count)
        Node.__init__(self, '<<_>>', _=Parser(widget, dtype=self.dtype))


class Expression(Node):

    def __init__(self, symbols=None, widget=None, **kwargs):
        if widget is None:
            widget = widgets.Text()
        self.dtype = ExpressionType(symbols=symbols)
        Node.__init__(self, '<<_>>', _=widget)
        if 'transformations' not in kwargs:
            kwargs['transformations'] = tuple(
                getattr(sympy.parsing.sympy_parser, transformation)
                for transformation in config.transformations
            )
        self.kwargs = kwargs

    def assemble(self, _):
        if _ == '':
            return None
        try:
            expr = sympy.parse_expr(_, **self.kwargs)
        except (SyntaxError, TypeError, tokenize.TokenError) as e:
            raise ValidationError(e)
        return expr

    def disassemble(self, value):
        return {'_': str(value)}


class Equation(Expression):

    def __init__(self, symbols=None, widget=None, **kwargs):
        Expression.__init__(self, symbols=symbols, widget=widget, **kwargs)
        self.dtype = EquationType(symbols=symbols)

    def assemble(self, _):
        if _ == '':
            return None
        if '=' not in _:
            raise ValidationError('An equation needs an equal sign.')
        if _.count('=') != 1:
            raise ValidationError('Equation contains multiple equal signs.')
        LHS, RHS = _.split('=')
        return Expression.assemble(self, f'Eq({LHS}, {RHS}, evaluate=False)')

    def disassemble(self, value):
        return {'_': f'{value.lhs}={value.rhs}'}


class Int(Node):

    def __init__(self, minimum=None, maximum=None, widget=None):
        self.dtype = IntType(minimum, maximum)
        if widget is None:
            if minimum is not None and maximum is not None:
                widget = widgets.Slider(minimum, maximum)
            else:
                widget = widgets.Text()
        Node.__init__(self, '<<_>>', _=Parser(widget, dtype=self.dtype))


class Matrix(Node):

    def __init__(self, widget=None, **kwargs):
        self.dtype = MatrixType(**kwargs)
        if widget is None:
            widget = widgets.Text()
        Node.__init__(self, '<<_>>', _=widget)

    def assemble(self, _):
        if _ == '':
            return None
        value = Parser.safe_eval(_)
        try:
            value = np.array(value)
        except ValueError as e:
            raise ValidationError(e)
        return value

    def disassemble(self, value):
        return {'_': str(value.tolist())}


class _Vector(Matrix):

    def __init__(self, widget=None, **kwargs):
        self.dtype = VectorType(**kwargs)
        if widget is None:
            widget = widgets.Text()
        Node.__init__(self, '<<_>>', _=widget)


class ColumnVector(_Vector):

    def __init__(self, **kwargs):
        _Vector.__init__(self, orientation='column', **kwargs)


class RowVector(_Vector):

    def __init__(self, **kwargs):
        _Vector.__init__(self, orientation='row', **kwargs)


Vector = ColumnVector


class Natural(Int):

    def __init__(self, with_zero=True, widget=None):
        if not isinstance(with_zero, bool):
            raise ValueError("'with_zero' must be boolean.")
        minimum = 0
        if not with_zero:
            minimum = 1
        Int.__init__(self, minimum=minimum, widget=widget)


class OneOf(Node):

    def __init__(self, *args, widget=None):
        self.dtype = OneOfType(options=args)
        if widget is None:
            if len(args) <= 5:
                widget = widgets.RadioButtons(*args)
            else:
                widget = widgets.Dropdown(*args)
        Node.__init__(self, '<<_>>', _=Parser(widget, dtype=self.dtype))


class Rational(Node):

    def __init__(self, elementwise=True, widget=None):
        if widget is None:
            widget = widgets.Text()
        self.elementwise = elementwise
        self.dtype = RationalType()
        if self.elementwise is True:
            _ = ElementwiseRational(widget=widget)
        else:
            _ = widget
        Node.__init__(self, '<<_>>', _=_)

    def assemble(self, _):
        if _ == '':
            return None
        try:
            fraction = Fraction(_)
        except (ValueError, TypeError, ZeroDivisionError) as e:
            raise ValidationError(e)
        return fraction

    def disassemble(self, value):
        if self.elementwise:
            return Node.disassemble(self, value)
        return {'_': str(value)}


class ElementwiseRational(Node):

    def __init__(self, widget=None):
        if widget is None:
            widget_a = widgets.Text()
            widget_b = widgets.Text()
        else:
            widget_a = widget
            widget_b = widget.new_instance()
        self.dtype = RationalType()
        Node.__init__(
            self, '<<a>> / <<b>>', a=Int(widget=widget_a),
            b=Int(widget=widget_b)
        )

    def assemble(self, **ifields):
        return Fraction(ifields['a'], ifields['b'])

    def disassemble(self, q):
        return {'a': q.numerator, 'b': q.denominator}


class Real(Node):

    def __init__(self, widget=None):
        if widget is None:
            widget = widgets.Text()
        self.dtype = RealType()
        Node.__init__(self, '<<_>>', _=Parser(widget, dtype=self.dtype))


class Set(Node):

    def __init__(self, count=None, compare='equality', widget=None):
        if widget is None:
            widget = widgets.Text()
        self.dtype = SetType(count=count, compare=compare)
        Node.__init__(self, '<<_>>', _=Parser(widget, dtype=self.dtype))


class String(Node):

    def __init__(self, strip=False, widget=None):
        if widget is None:
            widget = widgets.Text()
        self.dtype = StringType(strip)
        Node.__init__(self, '<<_>>', _=widget)


class Tuple(Node):

    dtype = TupleType

    def __init__(self, count=None, widget=None):
        if widget is None:
            widget = widgets.Text()
        self.dtype = self.dtype(count=count)
        Node.__init__(self, '<<_>>', _=Parser(widget, dtype=self.dtype))


class List(Tuple):

    dtype = ListType

pyrope/nodes.py
PyRope-E-Assessment/pyrope

import itertools
import unittest

import matplotlib.pyplot as plt

from pyrope import config, core, nodes
from pyrope.formatters import TemplateFormatter


class TestExercise(unittest.TestCase):

    def __init__(self, exercises, method_name):
        unittest.TestCase.__init__(self, method_name)
        try:
            iter(exercises)
        except TypeError:
            exercises = (exercises,)
        self.exercises = exercises

    @staticmethod
    def with_all_exercises(test):
        def wrapped_test(self):
            for exercise in self.exercises:
                with self.subTest(exercise=exercise.__class__):
                    test(self, exercise)
        return wrapped_test

    @with_all_exercises
    def test_parameters_method(self, exercise):
        '''
        If implemented, the 'parameters' method of an exercise must return a
        dictionary. Its keys must be strings, as they name the input fields.
        '''
        parameters = exercise.parameters()
        self.assertIsInstance(
            parameters, dict,
            "The 'parameters' method must return a dictionary."
        )
        for key in parameters.keys():
            self.assertIsInstance(
                key, str,
                f"The dictionary returned by the 'parameters' method "
                f"must be keyed with strings (got {type(key)})."
            )

    @with_all_exercises
    def test_solution_method_name(self, exercise):
        '''
        An exercise must not implement a method called 'solution', to avoid
        ambiguity: Solutions are provided via the 'the_solution' method if
        they are unique or via 'a_solution' if not.
        '''
        self.assertFalse(
            hasattr(exercise, 'solution'),
            "Do not implement a 'solution' method. Use 'the_solution' "
            "instead it the solution is unique or 'a_solution' if not."
        )

    @with_all_exercises
    def test_scores_method_name(self, exercise):
        '''
        An exercise must not implement a method called 'scores' to explicitly
        point out that the method to implement custom scores is called 'score'.
        '''
        self.assertFalse(
            hasattr(exercise, 'scores'),
            "Do not implement a 'scores' method. Use 'score' to implement "
            "custom scores."
        )


class TestParametrizedExercise(unittest.TestCase):

    def __init__(self, pexercises, method_name):
        unittest.TestCase.__init__(self, method_name)
        try:
            iter(pexercises)
        except TypeError:
            pexercises = (pexercises,)
        self.pexercises = pexercises

    @classmethod
    def tearDownClass(cls):
        # Prevent matplotlib from rendering plots after testing.
        plt.close()

    @staticmethod
    def with_all_pexercises(test):
        def wrapped_test(self):
            for pexercise in self.pexercises:
                with self.subTest(exercise=pexercise.exercise.__class__):
                    test(self, pexercise)
        return wrapped_test

    @with_all_pexercises
    def test_preamble_method(self, pexercise):
        '''
        The preamble must be a string. Its output fields must be in the
        exercise's parameters.
        '''
        preamble = pexercise.exercise.preamble
        if callable(preamble):
            preamble = pexercise.apply(preamble, pexercise.parameters)
        self.assertIsInstance(
            preamble, str,
            f"The 'preamble' method must return a string, not an instance of "
            f"{preamble.__class__}."
        )
        ofields = {
            ofield for _, ofield, _ in TemplateFormatter.parse(preamble)
            if ofield is not None
        }
        for ofield in ofields:
            self.assertIn(
                ofield, pexercise.parameters,
                f"There is no parameter for output field '{ofield}' in the "
                f"preamble string."
            )

    @with_all_pexercises
    def test_problem_method(self, pexercise):
        '''
        A problem must be an instance of class 'Problem'.
        '''
        problem = pexercise.apply(
            pexercise.exercise.problem, pexercise.parameters
        )
        self.assertIsInstance(
                problem, nodes.Problem,
                f"The 'problem' method must return an instance of "
                f"{nodes.Problem}, not of {problem.__class__}."
            )

    @with_all_pexercises
    def test_feedback_method(self, pexercise):
        '''
        A feedback must be a string. Its output fields must be in the
        exercise's parameters or in the answers.
        '''
        kwargs = pexercise.parameters | pexercise.dummy_input
        feedback = pexercise.apply(
            pexercise.exercise.feedback, kwargs
        )
        self.assertIsInstance(
            feedback, str,
            f"The 'feedback' method must return a string, not an instance "
            f"of {feedback.__class__}."
        )
        ofields = {
            ofield for _, ofield, _ in TemplateFormatter.parse(feedback)
            if ofield is not None
        }
        for ofield in ofields:
            self.assertIn(
                ofield, kwargs,
                f"There is no parameter or answer for output field '{ofield}' "
                f"in the feedback string."
            )

    @with_all_pexercises
    def test_maximal_total_score_is_positive(self, pexercise):
        '''
        The maximal total score should be positive.
        '''
        self.assertGreater(
            pexercise.max_total_score, 0.0,
            'The maximal total score is not positive.'
        )

    @with_all_pexercises
    def test_maximal_scores_are_positive(self, pexercise):
        '''
        The maximal input field scores should be positive.
        '''
        max_scores = pexercise.max_scores
        if None in max_scores.values():
            return
        for ifield, score in max_scores.items():
            self.assertGreater(
                score, 0.0,
                f'The maximal score for input field {ifield} is not positive.'
            )

    @with_all_pexercises
    def test_no_score_for_empty_inputs(self, pexercise):
        '''
        If all input fields are empty, the total score should be zero.
        '''
        if pexercise.ifields == {}:
            return
        pexercise.answers = {
            name: None
            for name in pexercise.ifields
        }
        self.assertEqual(
            pexercise.total_score, 0.0,
            'The total score for empty input fields is not zero.'
        )

    @with_all_pexercises
    def test_maximal_scores_for_sample_solution(self, pexercise):
        '''
        A sample solution should get maximal input field scores.
        '''
        answers = pexercise.solution
        for value in answers.values():
            if value is None:
                return
        pexercise.answers = answers
        max_scores = pexercise.max_scores
        scores = pexercise.scores
        for ifield in pexercise.ifields:
            if scores[ifield]:
                self.assertEqual(
                    scores[ifield], max_scores[ifield],
                    f'The sample solution for input field {ifield} does not '
                    f'get maximal score.'
                )

    @with_all_pexercises
    def test_maximal_total_score_for_sample_solution(self, pexercise):
        '''
        A sample solution should get maximal total score.
        '''
        answers = pexercise.solution
        for value in answers.values():
            if value is None:
                return
        pexercise.answers = answers
        self.assertEqual(
            pexercise.total_score, pexercise.max_total_score,
            'The sample solution does not get maximal total score.'
        )

    @staticmethod
    def with_all_pexercises_and_all_inputs(test):
        def wrapped_test(self):
            k = config.maximum_test_repetitions
            for pexercise in self.pexercises:
                exercise_class = pexercise.exercise.__class__
                input_generator = pexercise.input_generator()
                for input_data in itertools.islice(input_generator, 0, k):
                    pexercise.answers = input_data
                    with self.subTest(exercise=exercise_class,
                                      input=input_data):
                        test(self, pexercise)
        return wrapped_test

    @with_all_pexercises_and_all_inputs
    def test_total_score_is_nonnegative(self, pexercise):
        '''
        The total score should not be negative.
        '''
        self.assertGreaterEqual(
            pexercise.total_score, 0.0,
            'The maximal total score is negative.'
        )

    @with_all_pexercises_and_all_inputs
    def test_total_score_is_less_equal_maximal_total_score(self, pexercise):
        '''
        The total score should be less or equal the maximal total score.
        '''
        self.assertLessEqual(
            pexercise.total_score, pexercise.max_total_score,
            'The total score is greater than the maximal total score.'
        )

    @with_all_pexercises_and_all_inputs
    def test_scores_are_nonnegative(self, pexercise):
        '''
        The input field scores should not be negative.
        '''
        # Note that either all or none of the input fields are scored.
        if None in pexercise.scores.values():
            return
        for ifield, score in pexercise.scores.items():
            self.assertGreaterEqual(
                score, 0.0,
                f'The score for input field {ifield} is negative.'
            )

    @with_all_pexercises_and_all_inputs
    def test_scores_are_less_equal_maximal_scores(self, pexercise):
        '''
        For each input field the score should be less or equal the maximal
        score.
        '''
        # Note that either all or none of the input fields are scored.
        if None in pexercise.scores.values():
            return
        for ifield in pexercise.ifields:
            self.assertLessEqual(
                pexercise.scores[ifield], pexercise.max_scores[ifield],
                f'The score for input field {ifield} is greater than the '
                f'maximal score.'
            )

    @with_all_pexercises_and_all_inputs
    def test_score_sum_is_not_greater_total_score(self, pexercise):
        '''
        The total score is equal to the sum of all input field scores if the
        input fields are scored individually.
        '''
        # TODO: This should be a framework test and not a test for users.
        # Note that either all or none of the input fields are scored.
        score_output = pexercise.apply(
            pexercise.exercise.score,
            pexercise.parameters | pexercise.dummy_input
        )
        scores = pexercise.scores.values()
        if isinstance(score_output, dict) or len(pexercise.ifields) == 1:
            self.assertEqual(
                sum(scores), pexercise.total_score,
                'The sum of the input field scores is not equal to the '
                'total score.'
            )

    @with_all_pexercises_and_all_inputs
    def test_score_method_return_values(self, pexercise):
        '''
        The score method of an exercise can return the scores for the answers
        in several formats:
            - A scalar value which is interpreted as the total score. Valid
              data types are bool, int, float, numpy.bool_, numpy.int_,
              numpy.float_. Booleans are converted to 0 or 1.
            - A tuple where the first value is interpreted as the total score
              and the second one as the maximal total score. Its elements have
              to match a type from above.
            - A dictionary with input field names as keys and scalar values or
              tuples as values. The input field names have to be strings. In
              case of a tuple the first value is interpreted as the score and
              the second one as the maximal score of the input field specified
              in the key.
        '''
        exercise = pexercise.exercise

        output = pexercise.apply(
            exercise.score,
            pexercise.parameters | pexercise.dummy_input
        )
        if isinstance(output, dict) or len(pexercise.ifields) == 1:
            for answer in pexercise.answers.values():
                if answer is None:
                    return

        try:
            scores = pexercise.apply(
                exercise.score,
                pexercise.parameters | pexercise.answers
            )
        except BaseException as e:
            if None in pexercise.answers.values():
                e.add_note(
                    f'It seems empty input fields in exercise '
                    f'{pexercise.exercise} are not properly treated.'
                )
            raise e

        if scores is None:
            return
        if isinstance(scores, core.score_types):
            return
        if isinstance(scores, tuple):
            self.assertEqual(
                len(scores), 2,
                f'The tuple returned by the score method of exercise '
                f'{exercise} must consist of exactly two elements (got '
                f'{len(scores)}).'
            )
            for score in scores:
                self.assertIsInstance(
                    score, core.score_types,
                    f'The two elements in the tuple returned by the score '
                    f'method of exercise {exercise} must be integers or '
                    f'floats (got {type(score)}).'
                )
            self.assertLessEqual(
                scores[0], scores[1],
                f"The score ({scores[0]}) for exercise {exercise} "
                f"exceeds the maximal score ({scores[1]})."
            )
            return
        self.assertIsInstance(
            scores, dict,
            f'If implemented, the scores method of exercise {exercise} must '
            f'return an integer, a float or a dictionary (got '
            f'{type(scores)}).'
        )
        for name, score in scores.items():
            self.assertIsInstance(
                name, str,
                f'The dictionary returned by the score method of exercise '
                f'{exercise} must be keyed with strings (got {type(name)}.'
            )
            self.assertIn(
                name, pexercise.ifields.keys(),
                f"The score method of exercise {exercise} scores a "
                f"non-existent input field '{name}'."
            )
            if isinstance(score, core.score_types):
                continue
            self.assertIsInstance(
                score, tuple,
                f"The score for input field '{name}' in exercise must be an "
                f"integer or a float (got {type(scores)})."
            )
            self.assertEqual(
                len(score), 2,
                f"The score tuple for input field '{name}' in exercise "
                f"{exercise} must consist of exactly two elements (got "
                f"{len(score)})."
            )
            for component in score:
                self.assertIsInstance(
                    component, core.score_types,
                    f"The two elements in the score tuple for input field "
                    f"'{name}' in exercise {exercise} must be integers or "
                    f"floats (got {type(component)})."
                )
            self.assertLessEqual(
                score[0], score[1],
                f"The score ({score[0]}) for input field '{name}' in "
                f"exercise {exercise} exceeds the maximal score ({score[1]})."
            )

pyrope/tests.py
PyRope-E-Assessment/pyrope

import inspect
import numbers

from pyrope import nodes
from pyrope.config import process_score
from pyrope.core import IllPosedError, ValidationError


class NotifyingAttribute:

    def __init__(self, dtype=str):
        self.dtype = dtype

    def __set_name__(self, owner, name):
        self.name = name
        self._name = '_' + name

    def __get__(self, obj, objtype=None):
        return getattr(obj, self._name)

    def __set__(self, obj, value):
        if not isinstance(value, self.dtype):
            raise TypeError(f'{self.name} has to be of type {self.dtype}.')
        setattr(obj, self._name, value)
        self.notify(obj)

    def notify(self, obj):
        value = getattr(obj, self._name)
        obj.notify(obj.__class__, 'attribute', {self.name: value})


class Widget(nodes.Node):

    description = NotifyingAttribute()

    def __init__(self, description=''):
        nodes.Node.__init__(self, None)
        self.observers = []
        self.description = description
        self._value = None
        self._valid = None
        self._the_solution = None
        self._a_solution = None
        self._solution = None
        self._show_solution = False
        self._show_score = False
        self._displayed_score = None
        self._show_max_score = False
        self._displayed_max_score = None

    @property
    def template(self):
        return f'<<#{self.ID}>>'

    def register_observer(self, observer):
        self.observers.append(observer)

    def notify(self, owner, name, value):
        for observer in self.observers:
            observer(self, owner, name, value)

    def observe_attributes(self):
        for _, obj in inspect.getmembers(self.__class__):
            if isinstance(obj, NotifyingAttribute):
                obj.notify(self)

    def new_instance(self):
        kwargs = {}
        for name, obj in inspect.getmembers(self.__class__):
            if isinstance(obj, NotifyingAttribute):
                kwargs[name] = getattr(self, name)
        return self.__class__(**kwargs)

    def validate(self):
        ifield = self
        while ifield.parent is not None and len(ifield.parent.ifields) == 1:
            ifield = ifield.parent
        try:
            # trigger type checking via get mechanism
            ifield.value
        except ValidationError as e:
            e.ifield = ifield
            raise e

    @property
    def value(self):
        return self._value

    @value.setter
    def value(self, value):
        if self._value != value:
            self._value = value
            self.notify(self.__class__, 'attribute', {'value': value})
            try:
                self.validate()
            except ValidationError as e:
                self.valid = False
                self.notify(e.ifield.__class__, 'error', e)
            else:
                self.valid = None if value is None else True

    @property
    def valid(self):
        return self._valid

    @valid.setter
    def valid(self, value):
        self._valid = value
        self.notify(self.__class__, 'attribute', {'valid': value})

    @property
    def the_solution(self):
        return self._the_solution

    @the_solution.setter
    def the_solution(self, value):
        self._the_solution = value
        if value is None:
            return
        if self.solution is not None:
            ifield = self
            while ifield.parent is not None:
                if ifield.parent.parent is None:
                    break
                ifield = ifield.parent
            raise IllPosedError(
                f'Contradicting solutions for input field {ifield.name}.'
            )
        self.solution = value

    @property
    def a_solution(self):
        return self._a_solution

    @a_solution.setter
    def a_solution(self, value):
        self._a_solution = value
        if value is None:
            return
        if self.solution is not None:
            ifield = self
            while ifield.parent is not None:
                if ifield.parent.parent is None:
                    break
                ifield = ifield.parent
            raise IllPosedError(
                f'Contradicting solutions for input field {ifield.name}.'
            )
        self.solution = value

    @property
    def solution(self):
        return self._solution

    @solution.setter
    def solution(self, value):
        self._solution = value
        if self.show_solution is True:
            self.notify(self.__class__, 'attribute', {'solution': value})

    @property
    def show_solution(self):
        return self._show_solution

    @show_solution.setter
    def show_solution(self, value):
        self._show_solution = value
        self.notify(self.__class__, 'attribute', {'show_solution': value})
        if value is True:
            self.notify(
                self.__class__, 'attribute', {'solution': self.solution}
            )

    @property
    def auto_max_score(self):
        ifield = self
        while ifield.parent is not None and len(ifield.parent.ifields) == 1:
            ifield = ifield.parent
        solution = ifield.solution

        # If no maximal score is given, a (not necessarily unique) sample
        # solution must be provided.
        if solution is None:
            while ifield.parent is not None:
                if ifield.parent.parent is None:
                    break
                ifield = ifield.parent
            raise IllPosedError(
                f'Automatic setting of maximal score for {ifield.name} '
                f'needs a sample solution.'
            )

        return float(ifield.compare(solution, solution))

    @property
    def displayed_max_score(self):
        return (
            None if self._displayed_max_score is None
            else process_score(self._displayed_max_score)
        )

    @displayed_max_score.setter
    def displayed_max_score(self, value):
        self._displayed_max_score = value
        if self.show_max_score is True:
            self.notify(
                self.__class__, 'attribute',
                {'displayed_max_score': self.displayed_max_score}
            )

    @property
    def show_max_score(self):
        return self._show_max_score

    @show_max_score.setter
    def show_max_score(self, value):
        self._show_max_score = value
        self.notify(self.__class__, 'attribute', {'show_max_score': value})
        if value is True:
            self.notify(
                self.__class__, 'attribute',
                {'displayed_max_score': self.displayed_max_score}
            )

    @property
    def auto_score(self):
        ifield = self
        while ifield.parent is not None and len(ifield.parent.ifields) == 1:
            ifield = ifield.parent
        the_solution = ifield.the_solution

        # If no score is given, a unique sample solution must be provided.
        if the_solution is None:
            while ifield.parent is not None:
                if ifield.parent.parent is None:
                    break
                ifield = ifield.parent
            raise IllPosedError(
                f'Automatic scoring for {ifield.name} '
                f'needs a unique sample solution.'
            )

        # Empty or invalid input fields are scored with 0 points.
        try:
            value = ifield.value
        except ValidationError:
            return 0.0
        if value is None:
            return 0.0

        # If no score, but a unique sample solution is given, the input field
        # is scored by comparing it to this sample solution.
        return float(ifield.compare(value, the_solution))

    @property
    def displayed_score(self):
        return (
            None if self._displayed_score is None
            else process_score(self._displayed_score)
        )

    @displayed_score.setter
    def displayed_score(self, value):
        self._displayed_score = value
        if self.show_score is True:
            self.notify(
                self.__class__, 'attribute',
                {'displayed_score': self.displayed_score}
            )

    @property
    def show_score(self):
        return self._show_score

    @show_score.setter
    def show_score(self, value):
        self._show_score = value
        self.notify(self.__class__, 'attribute', {'show_score': value})
        if value is True:
            self.notify(
                self.__class__, 'attribute',
                {'displayed_score': self.displayed_score}
            )


class Checkbox(Widget):

    checked = NotifyingAttribute(dtype=bool)

    def __init__(self, checked=False, description=''):
        Widget.__init__(self, description=description)
        self._value = checked
        self.checked = checked


class Dropdown(Widget):

    options = NotifyingAttribute(dtype=tuple)

    def __init__(self, *args, description=''):
        Widget.__init__(self, description=description)
        self.options = args


class RadioButtons(Widget):

    options = NotifyingAttribute(dtype=tuple)
    vertical = NotifyingAttribute(dtype=bool)

    def __init__(self, *args, description='', vertical=True):
        Widget.__init__(self, description=description)
        self.options = args
        self.vertical = vertical


class Slider(Widget):

    label_position = NotifyingAttribute()
    maximum = NotifyingAttribute(dtype=numbers.Real)
    minimum = NotifyingAttribute(dtype=numbers.Real)
    step = NotifyingAttribute(dtype=numbers.Real)
    width = NotifyingAttribute(dtype=int)

    def __init__(
            self, minimum, maximum, description='', label_position='right',
            step=1, width=25
    ):
        Widget.__init__(self, description=description)
        if label_position not in ('left', 'right', 'neither'):
            raise ValueError(
                "'label_position' has to be either left, right or neither."
            )
        self.label_position = label_position
        self.maximum = maximum
        self.minimum = minimum
        self.step = step
        self.width = width


class Text(Widget):

    placeholder = NotifyingAttribute()
    width = NotifyingAttribute(dtype=int)

    def __init__(self, description='', placeholder='', width=20):
        Widget.__init__(self, description=description)
        self.placeholder = placeholder
        self.width = width


class Textarea(Text):

    height = NotifyingAttribute(dtype=int)

    def __init__(self, description='', height=4, placeholder='', width=50):
        Text.__init__(
            self, description=description, placeholder=placeholder, width=width
        )
        self.height = height


class WidgetFactory:

    mapping = {}

    def __call__(self, abstract_widget, *args):
        if self.mapping == {}:
            return abstract_widget
        widget = self.mapping[type(abstract_widget)]
        return widget(abstract_widget, *args)

pyrope/widgets.py
PyRope-E-Assessment/pyrope