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 @@ | @@ -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 |