Commit 4f960e53bb56d5a8c499c0fa8fb0ed54decff53c

Authored by Miguel Barão
1 parent adb8522e
Exists in master and in 1 other branch dev

split test.py into test.py and testfactory.py

perguntations/app.py
... ... @@ -20,7 +20,7 @@ from sqlalchemy.orm import sessionmaker
20 20 # this project
21 21 from perguntations.models import Student, Test, Question
22 22 from perguntations.tools import load_yaml
23   -from perguntations.test import TestFactory, TestFactoryException
  23 +from perguntations.testfactory import TestFactory, TestFactoryException
24 24  
25 25 logger = logging.getLogger(__name__)
26 26  
... ... @@ -182,22 +182,16 @@ class App():
182 182 testconf.update(conf)
183 183  
184 184 # start test factory
185   - logger.info('Making test factory...')
  185 + logger.info('Running test factory...')
186 186 try:
187 187 self.testfactory = TestFactory(testconf)
188 188 except TestFactoryException as exc:
189 189 logger.critical(exc)
190 190 raise AppException('Failed to create test factory!') from exc
191 191  
192   - logger.info('Test factory ready. No errors found.')
193   -
194 192 # ------------------------------------------------------------------------
195 193 def _pregenerate_tests(self, num):
196 194 event_loop = asyncio.get_event_loop()
197   - # for _ in range(num):
198   - # test = event_loop.run_until_complete(self.testfactory.generate())
199   - # self.pregenerated_tests.append(test)
200   -
201 195 self.pregenerated_tests += [
202 196 event_loop.run_until_complete(self.testfactory.generate())
203 197 for _ in range(num)]
... ...
perguntations/main.py
... ... @@ -99,13 +99,12 @@ def get_logger_config(debug=False):
99 99 },
100 100 },
101 101 }
102   - default_config['loggers'].update({
103   - f'{APP_NAME}.{module}': {
104   - 'handlers': ['default'],
105   - 'level': level,
106   - 'propagate': False,
107   - } for module in ['app', 'models', 'factory', 'questions',
108   - 'test', 'tools']})
  102 +
  103 + modules = ['app', 'models', 'questions', 'test', 'testfactory', 'tools']
  104 + logger = {'handlers': ['default'], 'level': level, 'propagate': False}
  105 +
  106 + default_config['loggers'].update({f'{APP_NAME}.{module}': logger
  107 + for module in modules})
109 108  
110 109 return load_yaml(config_file, default=default_config)
111 110  
... ...
perguntations/test.py
1 1 '''
2   -TestFactory - generates tests for students
3 2 Test - instances of this class are individual tests
4 3 '''
5 4  
6   -
7 5 # python standard library
8   -from os import path
9   -import random
10 6 from datetime import datetime
11 7 import logging
12   -import re
13   -from typing import Any, Dict
14   -
15   -# this project
16   -from perguntations.questions import QFactory, QuestionException
17   -from perguntations.tools import load_yaml
18 8  
19 9 # Logger configuration
20 10 logger = logging.getLogger(__name__)
21 11  
22 12  
23 13 # ============================================================================
24   -class TestFactoryException(Exception):
25   - '''exception raised in this module'''
26   -
27   -
28   -# ============================================================================
29   -class TestFactory(dict):
30   - '''
31   - Each instance of TestFactory() is a test generator.
32   - For example, if we want to serve two different tests, then we need two
33   - instances of TestFactory(), one for each test.
34   - '''
35   -
36   - # ------------------------------------------------------------------------
37   - def __init__(self, conf: Dict[str, Any]) -> None:
38   - '''
39   - Loads configuration from yaml file, then overrides some configurations
40   - using the conf argument.
41   - Base questions are added to a pool of questions factories.
42   - '''
43   -
44   - # --- set test defaults and then use given configuration
45   - super().__init__({ # defaults
46   - 'title': '',
47   - 'show_points': True,
48   - 'scale': None, # or [0, 20]
49   - 'duration': 0, # 0=infinite
50   - 'autosubmit': False,
51   - 'debug': False,
52   - 'show_ref': False,
53   - })
54   - self.update(conf)
55   -
56   - # --- perform sanity checks and normalize the test questions
57   - self.sanity_checks()
58   - logger.info('Sanity checks PASSED.')
59   -
60   - # --- find refs of all questions used in the test
61   - qrefs = {r for qq in self['questions'] for r in qq['ref']}
62   - logger.info('Declared %d questions (each test uses %d).',
63   - len(qrefs), len(self["questions"]))
64   -
65   - # --- for review, we are done. no factories needed
66   - if self['review']:
67   - logger.info('Review mode. No questions loaded. No factories.')
68   - return
69   -
70   - # --- load and build question factories
71   - self.question_factory = {}
72   -
73   - counter = 1
74   - for file in self["files"]:
75   - fullpath = path.normpath(path.join(self["questions_dir"], file))
76   - (dirname, filename) = path.split(fullpath)
77   -
78   - logger.info('Loading "%s"...', fullpath)
79   - questions = load_yaml(fullpath) # , default=[])
80   -
81   - for i, question in enumerate(questions):
82   - # make sure every question in the file is a dictionary
83   - if not isinstance(question, dict):
84   - msg = f'Question {i} in {file} is not a dictionary'
85   - raise TestFactoryException(msg)
86   -
87   - # check if ref is missing, then set to '/path/file.yaml:3'
88   - if 'ref' not in question:
89   - question['ref'] = f'{file}:{i:04}'
90   - logger.warning('Missing ref set to "%s"', question["ref"])
91   -
92   - # check for duplicate refs
93   - if question['ref'] in self.question_factory:
94   - other = self.question_factory[question['ref']]
95   - otherfile = path.join(other.question['path'],
96   - other.question['filename'])
97   - msg = (f'Duplicate reference "{question["ref"]}" in files '
98   - f'"{otherfile}" and "{fullpath}".')
99   - raise TestFactoryException(msg)
100   -
101   - # make factory only for the questions used in the test
102   - if question['ref'] in qrefs:
103   - question.setdefault('type', 'information')
104   - question.update({
105   - 'filename': filename,
106   - 'path': dirname,
107   - 'index': i # position in the file, 0 based
108   - })
109   -
110   - self.question_factory[question['ref']] = QFactory(question)
111   -
112   - # check if all the questions can be correctly generated
113   - try:
114   - self.question_factory[question['ref']].generate()
115   - except Exception as exc:
116   - msg = f'Failed to generate "{question["ref"]}"'
117   - raise TestFactoryException(msg) from exc
118   - else:
119   - logger.info('%4d. "%s" Ok.', counter, question["ref"])
120   - counter += 1
121   -
122   - qmissing = qrefs.difference(set(self.question_factory.keys()))
123   - if qmissing:
124   - raise TestFactoryException(f'Could not find questions {qmissing}.')
125   -
126   - # ------------------------------------------------------------------------
127   - def check_test_ref(self) -> None:
128   - '''Test must have a `ref`'''
129   - if 'ref' not in self:
130   - raise TestFactoryException('Missing "ref" in configuration!')
131   - if not re.match(r'^[a-zA-Z0-9_-]+$', self['ref']):
132   - raise TestFactoryException('Test "ref" can only contain the '
133   - 'characters a-zA-Z0-9_-')
134   -
135   - def check_missing_database(self) -> None:
136   - '''Test must have a database'''
137   - if 'database' not in self:
138   - raise TestFactoryException('Missing "database" in configuration')
139   - if not path.isfile(path.expanduser(self['database'])):
140   - msg = f'Database "{self["database"]}" not found!'
141   - raise TestFactoryException(msg)
142   -
143   - def check_missing_answers_directory(self) -> None:
144   - '''Test must have a answers directory'''
145   - if 'answers_dir' not in self:
146   - msg = 'Missing "answers_dir" in configuration'
147   - raise TestFactoryException(msg)
148   -
149   - def check_answers_directory_writable(self) -> None:
150   - '''Answers directory must be writable'''
151   - testfile = path.join(path.expanduser(self['answers_dir']), 'REMOVE-ME')
152   - try:
153   - with open(testfile, 'w') as file:
154   - file.write('You can safely remove this file.')
155   - except OSError as exc:
156   - msg = f'Cannot write answers to directory "{self["answers_dir"]}"'
157   - raise TestFactoryException(msg) from exc
158   -
159   - def check_questions_directory(self) -> None:
160   - '''Check if questions directory is missing or not accessible.'''
161   - if 'questions_dir' not in self:
162   - logger.warning('Missing "questions_dir". Using "%s"',
163   - path.abspath(path.curdir))
164   - self['questions_dir'] = path.curdir
165   - elif not path.isdir(path.expanduser(self['questions_dir'])):
166   - raise TestFactoryException(f'Can\'t find questions directory '
167   - f'"{self["questions_dir"]}"')
168   -
169   - def check_import_files(self) -> None:
170   - '''Check if there are files to import (with questions)'''
171   - if 'files' not in self:
172   - msg = ('Missing "files" in configuration with the list of '
173   - 'question files to import!')
174   - raise TestFactoryException(msg)
175   -
176   - if isinstance(self['files'], str):
177   - self['files'] = [self['files']]
178   -
179   - def check_question_list(self) -> None:
180   - '''normalize question list'''
181   - if 'questions' not in self:
182   - raise TestFactoryException('Missing "questions" in configuration')
183   -
184   - for i, question in enumerate(self['questions']):
185   - # normalize question to a dict and ref to a list of references
186   - if isinstance(question, str): # e.g., - some_ref
187   - question = {'ref': [question]} # becomes - ref: [some_ref]
188   - elif isinstance(question, dict) and isinstance(question['ref'], str):
189   - question['ref'] = [question['ref']]
190   - elif isinstance(question, list):
191   - question = {'ref': [str(a) for a in question]}
192   -
193   - self['questions'][i] = question
194   -
195   - def check_missing_title(self) -> None:
196   - '''Warns if title is missing'''
197   - if not self['title']:
198   - logger.warning('Title is undefined!')
199   -
200   - def check_grade_scaling(self) -> None:
201   - '''Just informs the scale limits'''
202   - if 'scale_points' in self:
203   - msg = ('*** DEPRECATION WARNING: *** scale_points, scale_min, '
204   - 'scale_max were replaced by "scale: [min, max]".')
205   - logger.warning(msg)
206   - self['scale'] = [self['scale_min'], self['scale_max']]
207   -
208   -
209   - # ------------------------------------------------------------------------
210   - def sanity_checks(self) -> None:
211   - '''
212   - Checks for valid keys and sets default values.
213   - Also checks if some files and directories exist
214   - '''
215   - self.check_test_ref()
216   - self.check_missing_database()
217   - self.check_missing_answers_directory()
218   - self.check_answers_directory_writable()
219   - self.check_questions_directory()
220   - self.check_import_files()
221   - self.check_question_list()
222   - self.check_missing_title()
223   - self.check_grade_scaling()
224   -
225   - # ------------------------------------------------------------------------
226   - async def generate(self):
227   - '''
228   - Given a dictionary with a student dict {'name':'john', 'number': 123}
229   - returns instance of Test() for that particular student
230   - '''
231   -
232   - # make list of questions
233   - questions = []
234   - qnum = 1 # track question number
235   - nerr = 0 # count errors during questions generation
236   -
237   - for qlist in self['questions']:
238   - # choose list of question variants
239   - choose = qlist.get('choose', 1)
240   - qrefs = random.sample(qlist['ref'], k=choose)
241   -
242   - for qref in qrefs:
243   - # generate instance of question
244   - try:
245   - question = await self.question_factory[qref].gen_async()
246   - except QuestionException:
247   - logger.error('Can\'t generate question "%s". Skipping.', qref)
248   - nerr += 1
249   - continue
250   -
251   - # some defaults
252   - if question['type'] in ('information', 'success', 'warning',
253   - 'alert'):
254   - question['points'] = qlist.get('points', 0.0)
255   - else:
256   - question['points'] = qlist.get('points', 1.0)
257   - question['number'] = qnum # counter for non informative panels
258   - qnum += 1
259   -
260   - questions.append(question)
261   -
262   - # setup scale
263   - total_points = sum(q['points'] for q in questions)
264   -
265   - if total_points > 0:
266   - # normalize question points to scale
267   - if self['scale'] is not None:
268   - scale_min, scale_max = self['scale']
269   - for question in questions:
270   - question['points'] *= (scale_max - scale_min) / total_points
271   - else:
272   - self['scale'] = [0, total_points]
273   - else:
274   - logger.warning('Total points is **ZERO**.')
275   - if self['scale'] is None:
276   - self['scale'] = [0, 20] # default
277   -
278   - if nerr > 0:
279   - logger.error('%s errors found!', nerr)
280   -
281   - # copy these from the test configuratoin to each test instance
282   - inherit = {'ref', 'title', 'database', 'answers_dir',
283   - 'questions_dir', 'files',
284   - 'duration', 'autosubmit',
285   - 'scale', 'show_points',
286   - 'show_ref', 'debug', }
287   - # NOT INCLUDED: testfile, allow_all, review
288   -
289   - return Test({'questions': questions, **{k:self[k] for k in inherit}})
290   -
291   - # ------------------------------------------------------------------------
292   - def __repr__(self):
293   - testsettings = '\n'.join(f' {k:14s}: {v}' for k, v in self.items())
294   - return '{\n' + testsettings + '\n}'
295   -
296   -
297   -# ============================================================================
298 14 class Test(dict):
299 15 '''
300 16 Each instance Test() is a concrete test of a single student.
... ... @@ -335,7 +51,6 @@ class Test(dict):
335 51 '''
336 52 for ref, ans in answers_dict.items():
337 53 self['questions'][ref].set_answer(ans)
338   - # self['questions'][ref]['answer'] = ans
339 54  
340 55 # ------------------------------------------------------------------------
341 56 async def correct(self) -> float:
... ...
perguntations/testfactory.py 0 → 100644
... ... @@ -0,0 +1,295 @@
  1 +'''
  2 +TestFactory - generates tests for students
  3 +'''
  4 +
  5 +# python standard library
  6 +from os import path
  7 +import random
  8 +import logging
  9 +import re
  10 +from typing import Any, Dict
  11 +
  12 +# this project
  13 +from perguntations.questions import QFactory, QuestionException
  14 +from perguntations.test import Test
  15 +from perguntations.tools import load_yaml
  16 +
  17 +# Logger configuration
  18 +logger = logging.getLogger(__name__)
  19 +
  20 +
  21 +# ============================================================================
  22 +class TestFactoryException(Exception):
  23 + '''exception raised in this module'''
  24 +
  25 +
  26 +# ============================================================================
  27 +class TestFactory(dict):
  28 + '''
  29 + Each instance of TestFactory() is a test generator.
  30 + For example, if we want to serve two different tests, then we need two
  31 + instances of TestFactory(), one for each test.
  32 + '''
  33 +
  34 + # ------------------------------------------------------------------------
  35 + def __init__(self, conf: Dict[str, Any]) -> None:
  36 + '''
  37 + Loads configuration from yaml file, then overrides some configurations
  38 + using the conf argument.
  39 + Base questions are added to a pool of questions factories.
  40 + '''
  41 +
  42 + # --- set test defaults and then use given configuration
  43 + super().__init__({ # defaults
  44 + 'title': '',
  45 + 'show_points': True,
  46 + 'scale': None, # or [0, 20]
  47 + 'duration': 0, # 0=infinite
  48 + 'autosubmit': False,
  49 + 'debug': False,
  50 + 'show_ref': False,
  51 + })
  52 + self.update(conf)
  53 +
  54 + # --- for review, we are done. no factories needed
  55 + if self['review']:
  56 + logger.info('Review mode. No questions loaded. No factories.')
  57 + return
  58 +
  59 + # --- perform sanity checks and normalize the test questions
  60 + self.sanity_checks()
  61 + logger.info('Sanity checks PASSED.')
  62 +
  63 + # --- find refs of all questions used in the test
  64 + qrefs = {r for qq in self['questions'] for r in qq['ref']}
  65 + logger.info('Declared %d questions (each test uses %d).',
  66 + len(qrefs), len(self["questions"]))
  67 +
  68 + # --- load and build question factories
  69 + self.question_factory = {}
  70 +
  71 + counter = 1
  72 + for file in self["files"]:
  73 + fullpath = path.normpath(path.join(self["questions_dir"], file))
  74 + (dirname, filename) = path.split(fullpath)
  75 +
  76 + logger.info('Loading "%s"...', fullpath)
  77 + questions = load_yaml(fullpath) # , default=[])
  78 +
  79 + for i, question in enumerate(questions):
  80 + # make sure every question in the file is a dictionary
  81 + if not isinstance(question, dict):
  82 + msg = f'Question {i} in {file} is not a dictionary'
  83 + raise TestFactoryException(msg)
  84 +
  85 + # check if ref is missing, then set to '/path/file.yaml:3'
  86 + if 'ref' not in question:
  87 + question['ref'] = f'{file}:{i:04}'
  88 + logger.warning('Missing ref set to "%s"', question["ref"])
  89 +
  90 + # check for duplicate refs
  91 + if question['ref'] in self.question_factory:
  92 + other = self.question_factory[question['ref']]
  93 + otherfile = path.join(other.question['path'],
  94 + other.question['filename'])
  95 + msg = (f'Duplicate reference "{question["ref"]}" in files '
  96 + f'"{otherfile}" and "{fullpath}".')
  97 + raise TestFactoryException(msg)
  98 +
  99 + # make factory only for the questions used in the test
  100 + if question['ref'] in qrefs:
  101 + question.setdefault('type', 'information')
  102 + question.update({
  103 + 'filename': filename,
  104 + 'path': dirname,
  105 + 'index': i # position in the file, 0 based
  106 + })
  107 +
  108 + self.question_factory[question['ref']] = QFactory(question)
  109 +
  110 + # check if all the questions can be correctly generated
  111 + try:
  112 + self.question_factory[question['ref']].generate()
  113 + except Exception as exc:
  114 + msg = f'Failed to generate "{question["ref"]}"'
  115 + raise TestFactoryException(msg) from exc
  116 + else:
  117 + logger.info('%4d. "%s" Ok.', counter, question["ref"])
  118 + counter += 1
  119 +
  120 + qmissing = qrefs.difference(set(self.question_factory.keys()))
  121 + if qmissing:
  122 + raise TestFactoryException(f'Could not find questions {qmissing}.')
  123 +
  124 + logger.info('Test factory ready. No errors found.')
  125 +
  126 +
  127 + # ------------------------------------------------------------------------
  128 + def check_test_ref(self) -> None:
  129 + '''Test must have a `ref`'''
  130 + if 'ref' not in self:
  131 + raise TestFactoryException('Missing "ref" in configuration!')
  132 + if not re.match(r'^[a-zA-Z0-9_-]+$', self['ref']):
  133 + raise TestFactoryException('Test "ref" can only contain the '
  134 + 'characters a-zA-Z0-9_-')
  135 +
  136 + def check_missing_database(self) -> None:
  137 + '''Test must have a database'''
  138 + if 'database' not in self:
  139 + raise TestFactoryException('Missing "database" in configuration')
  140 + if not path.isfile(path.expanduser(self['database'])):
  141 + msg = f'Database "{self["database"]}" not found!'
  142 + raise TestFactoryException(msg)
  143 +
  144 + def check_missing_answers_directory(self) -> None:
  145 + '''Test must have a answers directory'''
  146 + if 'answers_dir' not in self:
  147 + msg = 'Missing "answers_dir" in configuration'
  148 + raise TestFactoryException(msg)
  149 +
  150 + def check_answers_directory_writable(self) -> None:
  151 + '''Answers directory must be writable'''
  152 + testfile = path.join(path.expanduser(self['answers_dir']), 'REMOVE-ME')
  153 + try:
  154 + with open(testfile, 'w') as file:
  155 + file.write('You can safely remove this file.')
  156 + except OSError as exc:
  157 + msg = f'Cannot write answers to directory "{self["answers_dir"]}"'
  158 + raise TestFactoryException(msg) from exc
  159 +
  160 + def check_questions_directory(self) -> None:
  161 + '''Check if questions directory is missing or not accessible.'''
  162 + if 'questions_dir' not in self:
  163 + logger.warning('Missing "questions_dir". Using "%s"',
  164 + path.abspath(path.curdir))
  165 + self['questions_dir'] = path.curdir
  166 + elif not path.isdir(path.expanduser(self['questions_dir'])):
  167 + raise TestFactoryException(f'Can\'t find questions directory '
  168 + f'"{self["questions_dir"]}"')
  169 +
  170 + def check_import_files(self) -> None:
  171 + '''Check if there are files to import (with questions)'''
  172 + if 'files' not in self:
  173 + msg = ('Missing "files" in configuration with the list of '
  174 + 'question files to import!')
  175 + raise TestFactoryException(msg)
  176 +
  177 + if isinstance(self['files'], str):
  178 + self['files'] = [self['files']]
  179 +
  180 + def check_question_list(self) -> None:
  181 + '''normalize question list'''
  182 + if 'questions' not in self:
  183 + raise TestFactoryException('Missing "questions" in configuration')
  184 +
  185 + for i, question in enumerate(self['questions']):
  186 + # normalize question to a dict and ref to a list of references
  187 + if isinstance(question, str): # e.g., - some_ref
  188 + question = {'ref': [question]} # becomes - ref: [some_ref]
  189 + elif isinstance(question, dict) and isinstance(question['ref'], str):
  190 + question['ref'] = [question['ref']]
  191 + elif isinstance(question, list):
  192 + question = {'ref': [str(a) for a in question]}
  193 +
  194 + self['questions'][i] = question
  195 +
  196 + def check_missing_title(self) -> None:
  197 + '''Warns if title is missing'''
  198 + if not self['title']:
  199 + logger.warning('Title is undefined!')
  200 +
  201 + def check_grade_scaling(self) -> None:
  202 + '''Just informs the scale limits'''
  203 + if 'scale_points' in self:
  204 + msg = ('*** DEPRECATION WARNING: *** scale_points, scale_min, '
  205 + 'scale_max were replaced by "scale: [min, max]".')
  206 + logger.warning(msg)
  207 + self['scale'] = [self['scale_min'], self['scale_max']]
  208 +
  209 +
  210 + # ------------------------------------------------------------------------
  211 + def sanity_checks(self) -> None:
  212 + '''
  213 + Checks for valid keys and sets default values.
  214 + Also checks if some files and directories exist
  215 + '''
  216 + self.check_test_ref()
  217 + self.check_missing_database()
  218 + self.check_missing_answers_directory()
  219 + self.check_answers_directory_writable()
  220 + self.check_questions_directory()
  221 + self.check_import_files()
  222 + self.check_question_list()
  223 + self.check_missing_title()
  224 + self.check_grade_scaling()
  225 +
  226 + # ------------------------------------------------------------------------
  227 + async def generate(self):
  228 + '''
  229 + Given a dictionary with a student dict {'name':'john', 'number': 123}
  230 + returns instance of Test() for that particular student
  231 + '''
  232 +
  233 + # make list of questions
  234 + questions = []
  235 + qnum = 1 # track question number
  236 + nerr = 0 # count errors during questions generation
  237 +
  238 + for qlist in self['questions']:
  239 + # choose list of question variants
  240 + choose = qlist.get('choose', 1)
  241 + qrefs = random.sample(qlist['ref'], k=choose)
  242 +
  243 + for qref in qrefs:
  244 + # generate instance of question
  245 + try:
  246 + question = await self.question_factory[qref].gen_async()
  247 + except QuestionException:
  248 + logger.error('Can\'t generate question "%s". Skipping.', qref)
  249 + nerr += 1
  250 + continue
  251 +
  252 + # some defaults
  253 + if question['type'] in ('information', 'success', 'warning',
  254 + 'alert'):
  255 + question['points'] = qlist.get('points', 0.0)
  256 + else:
  257 + question['points'] = qlist.get('points', 1.0)
  258 + question['number'] = qnum # counter for non informative panels
  259 + qnum += 1
  260 +
  261 + questions.append(question)
  262 +
  263 + # setup scale
  264 + total_points = sum(q['points'] for q in questions)
  265 +
  266 + if total_points > 0:
  267 + # normalize question points to scale
  268 + if self['scale'] is not None:
  269 + scale_min, scale_max = self['scale']
  270 + for question in questions:
  271 + question['points'] *= (scale_max - scale_min) / total_points
  272 + else:
  273 + self['scale'] = [0, total_points]
  274 + else:
  275 + logger.warning('Total points is **ZERO**.')
  276 + if self['scale'] is None:
  277 + self['scale'] = [0, 20] # default
  278 +
  279 + if nerr > 0:
  280 + logger.error('%s errors found!', nerr)
  281 +
  282 + # copy these from the test configuratoin to each test instance
  283 + inherit = {'ref', 'title', 'database', 'answers_dir',
  284 + 'questions_dir', 'files',
  285 + 'duration', 'autosubmit',
  286 + 'scale', 'show_points',
  287 + 'show_ref', 'debug', }
  288 + # NOT INCLUDED: testfile, allow_all, review
  289 +
  290 + return Test({'questions': questions, **{k:self[k] for k in inherit}})
  291 +
  292 + # ------------------------------------------------------------------------
  293 + def __repr__(self):
  294 + testsettings = '\n'.join(f' {k:14s}: {v}' for k, v in self.items())
  295 + return '{\n' + testsettings + '\n}'
... ...