__author__ = 'Robbert Harms'
__date__ = '2020-04-16'
__maintainer__ = 'Robbert Harms'
__email__ = 'robbert@xkls.nl'
__licence__ = 'GPL v3'
import collections
import os
from ruamel import yaml
from ybe.lib.errors import YbeLoadingError, YbeMultipleLoadingErrors
from ybe.lib.ybe_contents import YbeExam, YbeInfo, MultipleChoice, OpenQuestion, QuestionMetaData, \
GeneralQuestionMetaData, LifecycleQuestionMetaData, ClassificationQuestionMetaData, OpenQuestionOptions, \
Text, AnalyticsQuestionMetaData, MultipleChoiceAnswer, TextMarkdown, TextHTML, MultipleResponse, \
MultipleResponseAnswer, DirectoryContext, TextOnlyQuestion
[docs]def read_ybe_file(fname):
"""Load the data from the provided .ybe file and return an :class:`ybe.lib.ybe_contents.YbeExam` object.
Args:
fname (str): the filename of the .ybe file to load
Returns:
ybe.lib.ybe_contents.YbeExam: the contents of the .ybe file.
Raises:
ybe.lib.errors.YbeLoadingError: if the file could not be loaded due to syntax errors
"""
with open(fname, "r") as f:
ybe_exam = read_ybe_string(f.read())
ybe_exam.resource_context = DirectoryContext(os.path.dirname(os.path.abspath(fname)))
return ybe_exam
[docs]def read_ybe_string(ybe_str):
"""Load the data from the provided Ybe formatted string and return an :class:`ybe.lib.ybe_contents.YbeExam` object.
Args:
ybe_str (str): an .ybe formatted string to load
Returns:
ybe.lib.ybe_contents.YbeExam: the contents of the .ybe file.
Raises:
ybe.lib.errors.YbeLoadingError: if the file could not be loaded due to syntax errors
"""
items = yaml.safe_load(ybe_str)
if not len(items):
return YbeExam()
if 'ybe_version' not in items:
raise YbeLoadingError('Missing "ybe_version" specifier.')
return YbeExam(questions=_load_questions(items.get('questions', [])), info=YbeInfo(**items.get('info', {})))
def _load_questions(node):
"""Load a list of questions.
Args:
node (List[dict]): list of question information, the key of each dict should be the question type,
the value should be the content.
Returns:
List[Question]: list of question objects, parsed from the provided information
"""
results = []
exceptions = []
for ind, question in enumerate(node):
try:
results.append(_load_question(question))
except YbeLoadingError as ex:
ex.question_ind = ind
ex.question_id = list(question.values())[0].get('id')
exceptions.append(ex)
continue
question_ids = [question.id for question in results]
if len(question_ids) != len(set(question_ids)):
duplicates = [item for item, count in collections.Counter(question_ids).items() if count > 1]
exceptions.append(YbeLoadingError(f'There were multiple questions with the same id "{duplicates}"'))
if len(exceptions):
str(YbeMultipleLoadingErrors(exceptions))
raise YbeMultipleLoadingErrors(exceptions)
return results
def _load_question(node):
"""Load the information of a single question.
This infers the question type from the keyword ``type`` and loads the appropriate class.
Args:
node (dict): list of question information, the key should be the question type, the value should be the content.
Returns:
ybe.lib.ybe_contents.Question: the loaded question object, parsed from the provided information
Raises:
ybe.lib.errors.YbeLoadingError: if the information could not be loaded due to syntax errors
"""
question_types = {
'multiple_choice': _load_multiple_choice,
'multiple_response': _load_multiple_response,
'open': _load_open_question,
'text_only': _load_text_only_question
}
question_type, question_content = list(node.items())[0]
question_loader = question_types.get(question_type, None)
if question_loader is None:
raise YbeLoadingError('The requested question type {} was not recognized.'.format(question_type))
return question_loader(question_content)
def _load_question_basics(node, points_must_be_set=True):
"""Load the basic information of an Ybe question.
Args:
node (dict): the question information
points_must_be_set (boolean): if points must be set as a field for this question.
Returns:
dict: basic information for a Ybe question.
"""
exceptions = []
try:
text = _load_text_from_node(node)
except YbeLoadingError as ex:
exceptions.append(ex)
try:
meta_data = _load_question_meta_data(node.get('meta_data', {}))
except YbeLoadingError as ex:
exceptions.append(ex)
try:
points = _load_points(node.get('points'), must_be_set=points_must_be_set)
except YbeLoadingError as ex:
exceptions.append(ex)
if len(exceptions):
raise YbeMultipleLoadingErrors(exceptions)
return {'id': node.get('id'), 'text': text, 'meta_data': meta_data, 'points': points}
def _load_multiple_choice(node):
"""Load the information of a multiple choice question.
Args:
node (dict): the question information
Returns:
ybe.lib.ybe_contents.MultipleChoice: the loaded question object, parsed from the provided information
"""
exceptions = []
try:
basic_info = _load_question_basics(node)
except YbeLoadingError as ex:
exceptions.append(ex)
try:
answers = _load_multiple_choice_answers(node.get('answers', []))
except YbeLoadingError as ex:
exceptions.append(ex)
if len(exceptions):
raise YbeMultipleLoadingErrors(exceptions)
return MultipleChoice(answers=answers, **basic_info)
def _load_multiple_response(node):
"""Load the information of a multiple response question.
Args:
node (dict): the question information
Returns:
ybe.lib.ybe_contents.MultipleResponse: the loaded question object, parsed from the provided information
"""
exceptions = []
try:
basic_info = _load_question_basics(node)
except YbeLoadingError as ex:
exceptions.append(ex)
try:
answers = _load_multiple_response_answers(node.get('answers', []))
except YbeLoadingError as ex:
exceptions.append(ex)
if len(exceptions):
raise YbeMultipleLoadingErrors(exceptions)
return MultipleResponse(answers=answers, **basic_info)
def _load_open_question(node):
"""Load the information of an open question.
Args:
node (dict): the question information
Returns:
ybe.lib.ybe_contents.OpenQuestion: the loaded question object, parsed from the provided information
"""
exceptions = []
try:
basic_info = _load_question_basics(node)
except YbeLoadingError as ex:
exceptions.append(ex)
try:
options = OpenQuestionOptions(**node.get('options', {}))
except YbeLoadingError as ex:
exceptions.append(ex)
if len(exceptions):
raise YbeMultipleLoadingErrors(exceptions)
return OpenQuestion(options=options, **basic_info)
def _load_text_only_question(node):
"""Load the information of text only question.
Args:
node (dict): the question information
Returns:
ybe.lib.ybe_contents.TextOnlyQuestion: the loaded question object, parsed from the provided information
"""
exceptions = []
try:
basic_info = _load_question_basics(node, points_must_be_set=False)
except YbeLoadingError as ex:
exceptions.append(ex)
if len(exceptions):
raise YbeMultipleLoadingErrors(exceptions)
return TextOnlyQuestion(**basic_info)
def _load_multiple_choice_answers(node):
"""Load all the answers of a multiple choice question.
Args:
node (List[dict]): the list of answer items
Returns:
List[ybe.lib.ybe_contents.MultipleChoiceAnswer]: the multiple choice answers
"""
exceptions = []
answers = []
for ind, item in enumerate(node):
content = item['answer']
answers.append(MultipleChoiceAnswer(
text=_load_text_from_node(content),
correct=content.get('correct', False)
))
if not (s := sum(answer.correct for answer in answers)) == 1:
exceptions.append(YbeLoadingError(f'A multiple choice question must have exactly '
f'1 answer marked as correct, {s} marked.'))
if len(exceptions):
raise YbeMultipleLoadingErrors(exceptions)
return answers
def _load_multiple_response_answers(node):
"""Load all the answers of a multiple response question.
Args:
node (List[dict]): the list of answer items
Returns:
List[ybe.lib.ybe_contents.MultipleResponseAnswer]: the multiple reponse answers
"""
exceptions = []
answers = []
for ind, item in enumerate(node):
content = item['answer']
answers.append(MultipleResponseAnswer(
text=_load_text_from_node(content),
correct=content.get('correct', False)
))
if not (s := sum(answer.correct for answer in answers)) > 0:
exceptions.append(YbeLoadingError(f'A multiple response question must have at least '
f'1 answer marked as correct, {s} marked.'))
if len(exceptions):
raise YbeMultipleLoadingErrors(exceptions)
return answers
def _load_points(value, must_be_set=False):
"""Load the points from the provided value.
Args:
value (object): the content of a ``points`` node.
must_be_set: if True, the value must be set, if False, None is allowed
Returns:
int or float: the point value
Raises:
YbeLoadingError: if the value was not a float or int
"""
if value is None:
if must_be_set:
raise YbeLoadingError(f'No points set, while points is a required field.')
return None
try:
points = float(value)
except ValueError:
raise YbeLoadingError(f'Points should be a float, "{value}" given.')
if points.is_integer():
return int(points)
return points
def _load_text_from_node(node):
"""Load the text of a question.
Args:
node (dict): the information of the question to get the right text object for
Returns:
ybe.lib.ybe_contents.TextNode: the correct implementation of the question text
"""
text_modes = {
'text': Text,
'text_markdown': TextMarkdown,
'text_html': TextHTML
}
found_text_blocks = []
found_text_modes = []
for key, cls in text_modes.items():
if key in node:
found_text_blocks.append(cls(text=node[key]))
found_text_modes.append(key)
if len(found_text_blocks) == 0:
raise YbeLoadingError('No text block defined in question.')
elif len(found_text_blocks) > 1:
raise YbeLoadingError(f'Multiple text blocks found {found_text_modes} in question.')
return found_text_blocks[0]
def _load_question_meta_data(node):
"""Load the meta data of a question.
Args:
node (dict): the information of the meta data
Returns:
ybe.lib.ybe_contents.QuestionMetaData: the meta data as an object.
"""
keywords = node.get('general', {}).get('keywords')
if not (isinstance(keywords, list) or keywords is None):
raise YbeLoadingError(f'The value for ``meta_data.general.keywords`` should be a list, '
f'"{keywords}" was given.')
return QuestionMetaData(
general=GeneralQuestionMetaData(**node.get('general', {})),
lifecycle=LifecycleQuestionMetaData(**node.get('lifecycle', {})),
classification=_load_meta_data_classification(node.get('classification', {})),
analytics=_load_meta_data_analytics(node.get('analytics', []))
)
def _load_meta_data_analytics(node):
"""Load the analytics information of a question.
Args:
node (list): list of statistics per exam
Returns:
ybe.lib.ybe_contents.AnalyticsQuestionMetaData: the question analytics
"""
return AnalyticsQuestionMetaData(node)
def _load_meta_data_classification(node):
"""Load the classification meta data of a question.
Args:
node (dict): the content of the classification meta data node
Returns:
ybe.lib.ybe_contents.ClassificationQuestionMetaData: the question classification meta data
"""
if not len(node):
return ClassificationQuestionMetaData()
exceptions = []
related_concepts = node.get('related_concepts')
if not (isinstance(related_concepts, list) or related_concepts is None):
exceptions.append(YbeLoadingError(f'The value for ``meta_data.classification.related_concepts`` '
f'should be a list, "{related_concepts}" given.'))
skill_level = node.get('skill_level')
skill_levels = ClassificationQuestionMetaData.available_skill_levels
if skill_level not in skill_levels and skill_level is not None:
exceptions.append(YbeLoadingError(f'The value for ``meta_data.classification.skill_level`` should be one of '
f'"{skill_levels}", while "{skill_level}" was given.'))
chapter = node.get('chapter')
if not isinstance(chapter, int) and chapter is not None:
exceptions.append(YbeLoadingError(f'The value for ``meta_data.classification.chapter`` should be an integer, '
f'"{chapter}" was given.'))
difficulty = node.get('difficulty')
if (not isinstance(difficulty, int) or difficulty not in range(0, 11)) and difficulty is not None:
exceptions.append(YbeLoadingError(f'The value for ``meta_data.classification.difficulty`` should be an '
f'integer between [1-10], "{difficulty}" was given.'))
if len(exceptions):
raise YbeMultipleLoadingErrors(exceptions)
return ClassificationQuestionMetaData(
skill_level=skill_level,
related_concepts=related_concepts,
module=node.get('module'),
chapter=chapter,
difficulty=difficulty
)