Commit d9224bb6621ce6a511c6307b216c4e00827da418
1 parent
6cdd62d7
Exists in
master
and in
1 other branch
- missing questionfactory.py from last commit...
Showing
1 changed file
with
169 additions
and
0 deletions
Show diff stats
... | ... | @@ -0,0 +1,169 @@ |
1 | +# We start with an empty QuestionFactory() that will be populated with | |
2 | +# question generators that we can load from YAML files. | |
3 | +# To generate an instance of a question we use the method generate(ref) where | |
4 | +# the argument is the reference of the question we wish to produce. | |
5 | +# | |
6 | +# Example: | |
7 | +# | |
8 | +# # read everything from question files | |
9 | +# factory = QuestionFactory() | |
10 | +# factory.load_files(['file1.yaml', 'file1.yaml'], '/path/to') | |
11 | +# | |
12 | +# question = factory.generate('some_ref') | |
13 | +# | |
14 | +# # experiment answering one question and correct it | |
15 | +# question['answer'] = 42 # insert answer | |
16 | +# grade = question.correct() # correct answer | |
17 | + | |
18 | +# An instance of an actual question is an object that inherits from Question() | |
19 | +# | |
20 | +# Question - base class inherited by other classes | |
21 | +# QuestionRadio - single choice from a list of options | |
22 | +# QuestionCheckbox - multiple choice, equivalent to multiple true/false | |
23 | +# QuestionText - line of text compared to a list of acceptable answers | |
24 | +# QuestionTextRegex - line of text matched against a regular expression | |
25 | +# QuestionTextArea - corrected by an external program | |
26 | +# QuestionInformation - not a question, just a box with content | |
27 | + | |
28 | +# base | |
29 | +from os import path | |
30 | +from tools import load_yaml, run_script | |
31 | + | |
32 | +import logging | |
33 | + | |
34 | + | |
35 | + | |
36 | + | |
37 | +from questions import Question, QuestionRadio, QuestionCheckbox, QuestionText, QuestionTextRegex, QuestionNumericInterval, QuestionTextArea, QuestionInformation | |
38 | + | |
39 | + | |
40 | +# setup logger for this module | |
41 | +logger = logging.getLogger(__name__) | |
42 | + | |
43 | +# =========================================================================== | |
44 | +class QuestionFactoryException(Exception): | |
45 | + pass | |
46 | + | |
47 | + | |
48 | + | |
49 | +# =========================================================================== | |
50 | +# This class contains a pool of questions generators from which particular | |
51 | +# Question() instances are generated using QuestionsFactory.generate(ref). | |
52 | +# =========================================================================== | |
53 | +class QuestionFactory(dict): | |
54 | + _types = { | |
55 | + 'radio' : QuestionRadio, | |
56 | + 'checkbox' : QuestionCheckbox, | |
57 | + 'text' : QuestionText, | |
58 | + 'text-regex': QuestionTextRegex, | |
59 | + 'numeric-interval': QuestionNumericInterval, | |
60 | + 'textarea' : QuestionTextArea, | |
61 | + # -- informative panels -- | |
62 | + 'information': QuestionInformation, 'info': QuestionInformation, | |
63 | + 'warning' : QuestionInformation, 'warn': QuestionInformation, | |
64 | + 'alert' : QuestionInformation, | |
65 | + 'success' : QuestionInformation, | |
66 | + } | |
67 | + | |
68 | + | |
69 | + # ----------------------------------------------------------------------- | |
70 | + def __init__(self): | |
71 | + super().__init__() | |
72 | + | |
73 | + # ----------------------------------------------------------------------- | |
74 | + # Add single question provided in a dictionary. | |
75 | + # After this, each question will have at least 'ref' and 'type' keys. | |
76 | + # ----------------------------------------------------------------------- | |
77 | + def add_question(self, question): | |
78 | + # if missing defaults to ref='/path/file.yaml:3' | |
79 | + question.setdefault('ref', f'{question["filename"]}:{question["index"]}') | |
80 | + | |
81 | + if question['ref'] in self: | |
82 | + logger.error(f'Duplicate reference "{question["ref"]}" replaces the original.') | |
83 | + | |
84 | + question.setdefault('type', 'information') | |
85 | + | |
86 | + self[question['ref']] = question | |
87 | + logger.debug(f'Added question "{question["ref"]}" to the pool.') | |
88 | + | |
89 | + # ----------------------------------------------------------------------- | |
90 | + # load single YAML questions file | |
91 | + # ----------------------------------------------------------------------- | |
92 | + def load_file(self, pathfile, questions_dir=''): | |
93 | + # questions_dir is a base directory | |
94 | + # pathfile is a path of a file under the questions_dir | |
95 | + # For example, if | |
96 | + # pathfile = 'math/questions.yaml' | |
97 | + # questions_dir = '/home/john/questions' | |
98 | + # then the complete path is | |
99 | + # fullpath = '/home/john/questions/math/questions.yaml' | |
100 | + fullpath = path.normpath(path.join(questions_dir, pathfile)) | |
101 | + (dirname, filename) = path.split(fullpath) | |
102 | + | |
103 | + questions = load_yaml(fullpath, default=[]) | |
104 | + | |
105 | + for i, q in enumerate(questions): | |
106 | + try: | |
107 | + q.update({ | |
108 | + 'filename': filename, | |
109 | + 'path': dirname, | |
110 | + 'index': i # position in the file, 0 based | |
111 | + }) | |
112 | + except AttributeError: | |
113 | + logger.error(f'Question {pathfile}:{i} is not a dictionary. Skipped!') | |
114 | + else: | |
115 | + self.add_question(q) | |
116 | + | |
117 | + logger.info(f'Loaded {len(self)} questions from "{pathfile}".') | |
118 | + | |
119 | + # ----------------------------------------------------------------------- | |
120 | + # load multiple YAML question files | |
121 | + # ----------------------------------------------------------------------- | |
122 | + def load_files(self, files, questions_dir=''): | |
123 | + for filename in files: | |
124 | + self.load_file(filename, questions_dir) | |
125 | + | |
126 | + # ----------------------------------------------------------------------- | |
127 | + # Given a ref returns an instance of a descendent of Question(), | |
128 | + # i.e. a question object (radio, checkbox, ...). | |
129 | + # ----------------------------------------------------------------------- | |
130 | + def generate(self, ref): | |
131 | + | |
132 | + # Shallow copy so that script generated questions will not replace | |
133 | + # the original generators | |
134 | + try: | |
135 | + q = self[ref].copy() | |
136 | + except KeyError: #FIXME exception type? | |
137 | + logger.error(f'Can\'t find question "{ref}".') | |
138 | + raise QuestionFactoryException() | |
139 | + | |
140 | + # If question is of generator type, an external program will be run | |
141 | + # which will print a valid question in yaml format to stdout. This | |
142 | + # output is then converted to a dictionary and `q` becomes that dict. | |
143 | + if q['type'] == 'generator': | |
144 | + logger.debug('Running script to generate question "{0}".'.format(q['ref'])) | |
145 | + q.setdefault('arg', '') # optional arguments will be sent to stdin | |
146 | + script = path.normpath(path.join(q['path'], q['script'])) | |
147 | + out = run_script(script=script, stdin=q['arg']) | |
148 | + try: | |
149 | + q.update(out) | |
150 | + except: | |
151 | + q.update({ | |
152 | + 'type': 'alert', | |
153 | + 'title': 'Erro interno', | |
154 | + 'text': 'Ocorreu um erro a gerar esta pergunta.' | |
155 | + }) | |
156 | + # The generator was replaced by a question but not yet instantiated | |
157 | + | |
158 | + # Finally we create an instance of Question() | |
159 | + try: | |
160 | + qinstance = self._types[q['type']](q) # instance with correct class | |
161 | + except KeyError as e: | |
162 | + logger.error(f'Unknown type "{q["type"]}" in "{q["filename"]}:{q["ref"]}".') | |
163 | + raise e | |
164 | + except: | |
165 | + logger.error(f'Failed to create question "{q["ref"]}" from file "{q["filename"]}".') | |
166 | + raise | |
167 | + else: | |
168 | + logger.debug(f'Generated question "{ref}".') | |
169 | + return qinstance | ... | ... |