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_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_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_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_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 )
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 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_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.'))
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 """ yaml = YAML(typ='safe') yaml.register_class(TextHTML) yaml.register_class(TextMarkdown) data = yaml.load(ybe_str) if not len(data): return YbeExam() if 'ybe_version' not in data: raise YbeLoadingError('Missing "ybe_version" specifier.') def _convert_text(input_data): if isinstance(input_data, TextData): return input_data return TextPlain(input_data) def _convert_answer_options(input_data): result = [] for item in input_data: if 'answer' in item: result.append( dacite.from_dict( AnswerOption, item['answer'], config=dacite.Config( type_hooks={TextData: _convert_text}))) return result def _convert_analytics(input_data): analytic_types = { 'exam': QuestionUsedInExam, } result = [] for usage in input_data: usage_type, usage_value = list(usage.items())[0] result.append( dacite.from_dict(analytic_types[usage_type], usage_value, config=dacite.Config( type_hooks={TextData: _convert_text}))) return result def _convert_questions(input_data): question_types = { 'multiple_choice': MultipleChoice, 'multiple_response': MultipleResponse, 'open': OpenQuestion, 'text_only': TextOnly } result = [] for question in input_data: q_type, q_value = list(question.items())[0] result.append( dacite.from_dict( question_types[q_type], q_value, config=dacite.Config( type_hooks={ TextData: _convert_text, List[AnswerOption]: _convert_answer_options, List[AnalyticsQuestionUsage]: _convert_analytics }))) return result return dacite.from_dict( YbeExam, data, config=dacite.Config(type_hooks={ List[Question]: _convert_questions, TextData: _convert_text }))