Commit 9d2af8636c907decb9f2b8726e0c64b5fdb3c8b7

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

Allow generator to send args to correct script.

fix yaml.load() to yaml.safe_load() as per deprecation warning.
fix flake8 errors
BUGS.md
... ... @@ -8,7 +8,6 @@ e.g. retornar None quando nao ha alteracoes relativamente à última vez.
8 8 ou usar push (websockets?)
9 9 - pymips: nao pode executar syscalls do spim.
10 10 - perguntas checkbox [right,wrong] com pelo menos uma opção correcta.
11   -- questions.py textarea has a abspath which does not make sense! why is it there? not working for perguntations, but seems to work for aprendizations
12 11 - eventos unfocus?
13 12 - servidor nao esta a lidar com eventos scroll/resize. ignorar?
14 13 - Test.reset_answers() unused.
... ... @@ -62,6 +61,8 @@ ou usar push (websockets?)
62 61  
63 62 # FIXED
64 63  
  64 +- questions.py textarea has a abspath which does not make sense! why is it there? not working for perguntations, but seems to work for aprendizations
  65 +- textarea foi modificado em aprendizations para receber cmd line args. corrigir aqui tb.
65 66 - usar npm para instalar javascript
66 67 - se aluno entrar com l12345 rebenta. numa funcao get_... ver imagem no ipad
67 68 - no test3 está contar 1.0 valores numa pergunta do tipo info? acontece para type: info, e não para type: information
... ...
README.md
... ... @@ -149,7 +149,7 @@ Instead, tcp traffic can be forwarded from port 443 to 8443 where the server is
149 149 listening.
150 150 The details depend on the operating system/firewall.
151 151  
152   -### debian:
  152 +### debian
153 153  
154 154 FIXME: Untested
155 155  
... ... @@ -226,34 +226,34 @@ directory.
226 226  
227 227 ## Troubleshooting
228 228  
229   -* The server tries to run `python3` so this command must be accessible from
  229 +- The server tries to run `python3` so this command must be accessible from
230 230 user accounts. Currently, the minimum supported python version is 3.6.
231 231  
232   -* If you are getting any `UnicodeEncodeError` type of errors that's because the
  232 +- If you are getting any `UnicodeEncodeError` type of errors that's because the
233 233 terminal is not supporting UTF-8.
234 234 This error may occur when a unicode character is printed to the screen by the
235 235 server or, when running question generator or correction scripts, a message is
236 236 piped between the server and the scripts that includes unicode characters.
237 237 Try running `locale` on the terminal and see if there are any error messages.
238 238 Solutions:
239   - - debian: `sudo dpkg-reconfigure locales` and select your UTF-8 locales.
240   - - FreeBSD: edit `~/.login_conf` to use UTF-8, for example:
  239 + - debian: `sudo dpkg-reconfigure locales` and select your UTF-8 locales.
  240 + - FreeBSD: edit `~/.login_conf` to use UTF-8, for example:
241 241  
242   - ```
243   - me:\
244   - :charset=UTF-8:\
245   - :lang=en_US.UTF-8:
246   - ```
  242 + ```text
  243 + me:\
  244 + :charset=UTF-8:\
  245 + :lang=en_US.UTF-8:
  246 + ```
247 247  
248 248 ---
249 249  
250   -## Contribute ###
  250 +## Contribute
251 251  
252   -* Writing questions in yaml format
253   -* Testing and reporting bugs
254   -* Code review
255   -* New features and ideas
  252 +- Writing questions in yaml format
  253 +- Testing and reporting bugs
  254 +- Code review
  255 +- New features and ideas
256 256  
257   -### Contacts ###
  257 +### Contacts
258 258  
259   -* Miguel Barão mjsb@uevora.pt
  259 +- Miguel Barão mjsb@uevora.pt
... ...
perguntations/app.py
... ... @@ -32,10 +32,12 @@ async def check_password(try_pw, password):
32 32 hashed = await loop.run_in_executor(None, bcrypt.hashpw, try_pw, password)
33 33 return password == hashed
34 34  
  35 +
35 36 async def hash_password(pw):
36 37 pw = pw.encode('utf-8')
37 38 loop = asyncio.get_running_loop()
38   - return await loop.run_in_executor(None, bcrypt.hashpw, pw, bcrypt.gensalt())
  39 + r = await loop.run_in_executor(None, bcrypt.hashpw, pw, bcrypt.gensalt())
  40 + return r
39 41  
40 42  
41 43 # ============================================================================
... ... @@ -109,7 +111,9 @@ class App(object):
109 111  
110 112 # get name+password from db
111 113 with self.db_session() as s:
112   - name, password = s.query(Student.name, Student.password).filter_by(id=uid).one()
  114 + name, password = s.query(Student.name, Student.password)\
  115 + .filter_by(id=uid)\
  116 + .one()
113 117  
114 118 # first login updates the password
115 119 if password == '': # update password on first login
... ... @@ -140,7 +144,8 @@ class App(object):
140 144 if uid in self.online:
141 145 logger.info(f'Student {uid}: started generating new test.')
142 146 student_id = self.online[uid]['student'] # {number, name}
143   - self.online[uid]['test'] = await self.testfactory.generate(student_id)
  147 + test = await self.testfactory.generate(student_id)
  148 + self.online[uid]['test'] = test
144 149 logger.debug(f'Student {uid}: finished generating test.')
145 150 return self.online[uid]['test']
146 151 else:
... ... @@ -156,7 +161,8 @@ class App(object):
156 161 grade = await t.correct()
157 162  
158 163 # save test in JSON format
159   - fname = ' -- '.join((t['student']['number'], t['ref'], str(t['finish_time']))) + '.json'
  164 + fields = (t['student']['number'], t['ref'], str(t['finish_time']))
  165 + fname = ' -- '.join(fields) + '.json'
160 166 fpath = path.join(t['answers_dir'], fname)
161 167 t.save_json(fpath)
162 168  
... ... @@ -189,9 +195,8 @@ class App(object):
189 195 t.giveup()
190 196  
191 197 # save JSON with the test
192   - # fname = ' -- '.join((t['student']['number'], t['ref'], str(t['finish_time']))) + '.json'
193   - # fpath = path.abspath(path.join(t['answers_dir'], fname))
194   - fname = ' -- '.join((t['student']['number'], t['ref'], str(t['finish_time']))) + '.json'
  198 + fields = (t['student']['number'], t['ref'], str(t['finish_time']))
  199 + fname = ' -- '.join(fields) + '.json'
195 200 fpath = path.join(t['answers_dir'], fname)
196 201 t.save_json(fpath)
197 202  
... ... @@ -225,22 +230,28 @@ class App(object):
225 230  
226 231 def get_student_grades_from_all_tests(self, uid):
227 232 with self.db_session() as s:
228   - return s.query(Test.title, Test.grade, Test.finishtime).filter_by(student_id=uid).order_by(Test.finishtime)
  233 + return s.query(Test.title, Test.grade, Test.finishtime)\
  234 + .filter_by(student_id=uid)\
  235 + .order_by(Test.finishtime)
229 236  
230 237 def get_json_filename_of_test(self, test_id):
231 238 with self.db_session() as s:
232   - return s.query(Test.filename).filter_by(id=test_id).scalar()
233   -
234   - # def get_online_students(self): # [('uid', 'name', 'starttime')]
235   - # return [(k, v['student']['name'], str(v.get('test', {}).get('start_time', '---'))) for k, v in self.online.items() if k != '0']
  239 + return s.query(Test.filename)\
  240 + .filter_by(id=test_id)\
  241 + .scalar()
236 242  
237 243 def get_all_students(self):
238 244 with self.db_session() as s:
239   - return s.query(Student.id, Student.name, Student.password).filter(Student.id != '0').order_by(Student.id)
  245 + return s.query(Student.id, Student.name, Student.password)\
  246 + .filter(Student.id != '0')\
  247 + .order_by(Student.id)
240 248  
241 249 def get_student_grades_from_test(self, uid, testid):
242 250 with self.db_session() as s:
243   - return s.query(Test.grade, Test.finishtime, Test.id).filter_by(student_id=uid).filter_by(ref=testid).all()
  251 + return s.query(Test.grade, Test.finishtime, Test.id)\
  252 + .filter_by(student_id=uid)\
  253 + .filter_by(ref=testid)\
  254 + .all()
244 255  
245 256 def get_students_state(self):
246 257 return [{
... ... @@ -248,7 +259,8 @@ class App(object):
248 259 'name': name,
249 260 'allowed': uid in self.allowed,
250 261 'online': uid in self.online,
251   - 'start_time': self.online.get(uid, {}).get('test', {}).get('start_time', ''),
  262 + 'start_time': self.online.get(uid, {}).get('test', {})
  263 + .get('start_time', ''),
252 264 'password_defined': pw != '',
253 265 'grades': self.get_student_grades_from_test(uid, self.testfactory['ref']),
254 266 # 'focus': self.online.get(uid, {}).get('student', {}).get('focus', True), # FIXME
... ...
perguntations/factory.py
... ... @@ -32,9 +32,10 @@ import logging
32 32  
33 33 # this project
34 34 from perguntations.tools import load_yaml, run_script
35   -from perguntations.questions import (QuestionRadio, QuestionCheckbox, QuestionText,
36   - QuestionTextRegex, QuestionNumericInterval,
37   - QuestionTextArea, QuestionInformation)
  35 +from perguntations.questions import (QuestionRadio, QuestionCheckbox,
  36 + QuestionText, QuestionTextRegex,
  37 + QuestionNumericInterval, QuestionTextArea,
  38 + QuestionInformation)
38 39  
39 40 # setup logger for this module
40 41 logger = logging.getLogger(__name__)
... ... @@ -73,16 +74,18 @@ class QuestionFactory(dict):
73 74 # After this, each question will have at least 'ref' and 'type' keys.
74 75 # ------------------------------------------------------------------------
75 76 def add_question(self, question):
  77 + q = question
  78 +
76 79 # if missing defaults to ref='/path/file.yaml:3'
77   - question.setdefault('ref', f'{question["filename"]}:{question["index"]}')
  80 + q.setdefault('ref', f'{q["filename"]}:{q["index"]}')
78 81  
79   - if question['ref'] in self:
80   - logger.error(f'Duplicate reference "{question["ref"]}".')
  82 + if q['ref'] in self:
  83 + logger.error(f'Duplicate reference "{q["ref"]}".')
81 84  
82   - question.setdefault('type', 'information')
  85 + q.setdefault('type', 'information')
83 86  
84   - self[question['ref']] = question
85   - logger.debug(f'Added question "{question["ref"]}" to the pool.')
  87 + self[q['ref']] = q
  88 + logger.debug(f'Added question "{q["ref"]}" to the pool.')
86 89  
87 90 # ------------------------------------------------------------------------
88 91 # load single YAML questions file
... ... @@ -140,10 +143,11 @@ class QuestionFactory(dict):
140 143 # which will print a valid question in yaml format to stdout. This
141 144 # output is then converted to a dictionary and `q` becomes that dict.
142 145 if q['type'] == 'generator':
143   - logger.debug(f'Running script to generate question "{ref}".')
144   - q.setdefault('arg', '') # optional arguments will be sent to stdin
145   - script = path.normpath(path.join(q['path'], q['script']))
146   - out = run_script(script=script, stdin=q['arg'])
  146 + logger.debug(f'Generating "{ref}" from {q["script"]}')
  147 + q.setdefault('args', []) # optional arguments
  148 + q.setdefault('stdin', '')
  149 + script = path.join(q['path'], q['script'])
  150 + out = run_script(script=script, args=q['args'], stdin=q['stdin'])
147 151 try:
148 152 q.update(out)
149 153 except Exception:
... ... @@ -152,6 +156,7 @@ class QuestionFactory(dict):
152 156 'title': 'Erro interno',
153 157 'text': 'Ocorreu um erro a gerar esta pergunta.'
154 158 })
  159 +
155 160 # The generator was replaced by a question but not yet instantiated
156 161  
157 162 # Finally we create an instance of Question()
... ...
perguntations/initdb.py
... ... @@ -181,7 +181,7 @@ def main():
181 181 for s in args.update:
182 182 print(f'Updating password of: {s}')
183 183 u = session.query(Student).get(s)
184   - pw =(args.pw or s).encode('utf-8')
  184 + pw = (args.pw or s).encode('utf-8')
185 185 u.password = bcrypt.hashpw(pw, bcrypt.gensalt())
186 186 session.commit()
187 187  
... ...
perguntations/questions.py
... ... @@ -57,7 +57,7 @@ class Question(dict):
57 57  
58 58 def set_defaults(self, d):
59 59 'Add k:v pairs from default dict d for nonexistent keys'
60   - for k,v in d.items():
  60 + for k, v in d.items():
61 61 self.setdefault(k, v)
62 62  
63 63  
... ... @@ -74,7 +74,7 @@ class QuestionRadio(Question):
74 74 choose (int) # only used if shuffle=True
75 75 '''
76 76  
77   - #------------------------------------------------------------------------
  77 + # ------------------------------------------------------------------------
78 78 def __init__(self, q):
79 79 super().__init__(q)
80 80  
... ... @@ -91,7 +91,12 @@ class QuestionRadio(Question):
91 91 # always convert to list, e.g. correct: 2 --> correct: [0,0,1,0,0]
92 92 # correctness levels from 0.0 to 1.0 (no discount here!)
93 93 if isinstance(self['correct'], int):
94   - self['correct'] = [1.0 if x==self['correct'] else 0.0 for x in range(n)]
  94 + self['correct'] = [1.0 if x == self['correct'] else 0.0
  95 + for x in range(n)]
  96 +
  97 + if len(self['correct']) != n:
  98 + logger.error(f'Options and correct mismatch in '
  99 + f'"{self["ref"]}", file "{self["filename"]}".')
95 100  
96 101 if self['shuffle']:
97 102 # separate right from wrong options
... ... @@ -102,8 +107,8 @@ class QuestionRadio(Question):
102 107  
103 108 # choose 1 correct option
104 109 r = random.choice(right)
105   - options = [ self['options'][r] ]
106   - correct = [ 1.0 ]
  110 + options = [self['options'][r]]
  111 + correct = [1.0]
107 112  
108 113 # choose remaining wrong options
109 114 random.shuffle(wrong)
... ... @@ -113,10 +118,10 @@ class QuestionRadio(Question):
113 118  
114 119 # final shuffle of the options
115 120 perm = random.sample(range(self['choose']), self['choose'])
116   - self['options'] = [ str(options[i]) for i in perm ]
117   - self['correct'] = [ float(correct[i]) for i in perm ]
  121 + self['options'] = [str(options[i]) for i in perm]
  122 + self['correct'] = [float(correct[i]) for i in perm]
118 123  
119   - #------------------------------------------------------------------------
  124 + # ------------------------------------------------------------------------
120 125 # can return negative values for wrong answers
121 126 def correct(self):
122 127 super().correct()
... ... @@ -145,7 +150,7 @@ class QuestionCheckbox(Question):
145 150 answer (None or an actual answer)
146 151 '''
147 152  
148   - #------------------------------------------------------------------------
  153 + # ------------------------------------------------------------------------
149 154 def __init__(self, q):
150 155 super().__init__(q)
151 156  
... ... @@ -154,14 +159,15 @@ class QuestionCheckbox(Question):
154 159 # set defaults if missing
155 160 self.set_defaults({
156 161 'text': '',
157   - 'correct': [1.0] * n, # Using 0.0 breaks the (right, wrong) options
  162 + 'correct': [1.0] * n, # Using 0.0 breaks the (right, wrong) opts
158 163 'shuffle': True,
159 164 'discount': True,
160   - 'choose': n, # number of options
  165 + 'choose': n, # number of options
161 166 })
162 167  
163 168 if len(self['correct']) != n:
164   - logger.error(f'Options and correct size mismatch in "{self["ref"]}", file "{self["filename"]}".')
  169 + logger.error(f'Options and correct size mismatch in '
  170 + f'"{self["ref"]}", file "{self["filename"]}".')
165 171  
166 172 # if an option is a list of (right, wrong), pick one
167 173 # FIXME it's possible that all options are chosen wrong
... ... @@ -169,9 +175,9 @@ class QuestionCheckbox(Question):
169 175 correct = []
170 176 for o, c in zip(self['options'], self['correct']):
171 177 if isinstance(o, list):
172   - r = random.randint(0,1)
  178 + r = random.randint(0, 1)
173 179 o = o[r]
174   - c = c if r==0 else -c
  180 + c = c if r == 0 else -c
175 181 options.append(str(o))
176 182 correct.append(float(c))
177 183  
... ... @@ -182,7 +188,7 @@ class QuestionCheckbox(Question):
182 188 self['options'] = [options[i] for i in perm]
183 189 self['correct'] = [correct[i] for i in perm]
184 190  
185   - #------------------------------------------------------------------------
  191 + # ------------------------------------------------------------------------
186 192 # can return negative values for wrong answers
187 193 def correct(self):
188 194 super().correct()
... ... @@ -207,7 +213,7 @@ class QuestionCheckbox(Question):
207 213 return self['grade']
208 214  
209 215  
210   -# ===========================================================================
  216 +# ============================================================================
211 217 class QuestionText(Question):
212 218 '''An instance of QuestionText will always have the keys:
213 219 type (str)
... ... @@ -216,7 +222,7 @@ class QuestionText(Question):
216 222 answer (None or an actual answer)
217 223 '''
218 224  
219   - #------------------------------------------------------------------------
  225 + # ------------------------------------------------------------------------
220 226 def __init__(self, q):
221 227 super().__init__(q)
222 228  
... ... @@ -232,7 +238,7 @@ class QuestionText(Question):
232 238 # make sure all elements of the list are strings
233 239 self['correct'] = [str(a) for a in self['correct']]
234 240  
235   - #------------------------------------------------------------------------
  241 + # ------------------------------------------------------------------------
236 242 # can return negative values for wrong answers
237 243 def correct(self):
238 244 super().correct()
... ... @@ -252,7 +258,7 @@ class QuestionTextRegex(Question):
252 258 answer (None or an actual answer)
253 259 '''
254 260  
255   - #------------------------------------------------------------------------
  261 + # ------------------------------------------------------------------------
256 262 def __init__(self, q):
257 263 super().__init__(q)
258 264  
... ... @@ -261,15 +267,17 @@ class QuestionTextRegex(Question):
261 267 'correct': '$.^', # will always return false
262 268 })
263 269  
264   - #------------------------------------------------------------------------
  270 + # ------------------------------------------------------------------------
265 271 # can return negative values for wrong answers
266 272 def correct(self):
267 273 super().correct()
268 274 if self['answer'] is not None:
269 275 try:
270   - self['grade'] = 1.0 if re.match(self['correct'], self['answer']) else 0.0
  276 + self['grade'] = 1.0 if re.match(self['correct'],
  277 + self['answer']) else 0.0
271 278 except TypeError:
272   - logger.error('While matching regex {self["correct"]} with answer {self["answer"]}.')
  279 + logger.error('While matching regex {self["correct"]} with '
  280 + 'answer {self["answer"]}.')
273 281  
274 282 return self['grade']
275 283  
... ... @@ -284,7 +292,7 @@ class QuestionNumericInterval(Question):
284 292 An answer is correct if it's in the closed interval.
285 293 '''
286 294  
287   - #------------------------------------------------------------------------
  295 + # ------------------------------------------------------------------------
288 296 def __init__(self, q):
289 297 super().__init__(q)
290 298  
... ... @@ -293,7 +301,7 @@ class QuestionNumericInterval(Question):
293 301 'correct': [1.0, -1.0], # will always return false
294 302 })
295 303  
296   - #------------------------------------------------------------------------
  304 + # ------------------------------------------------------------------------
297 305 # can return negative values for wrong answers
298 306 def correct(self):
299 307 super().correct()
... ... @@ -321,7 +329,7 @@ class QuestionTextArea(Question):
321 329 lines (int)
322 330 '''
323 331  
324   - #------------------------------------------------------------------------
  332 + # ------------------------------------------------------------------------
325 333 def __init__(self, q):
326 334 super().__init__(q)
327 335  
... ... @@ -332,33 +340,34 @@ class QuestionTextArea(Question):
332 340 'correct': '' # trying to execute this will fail => grade 0.0
333 341 })
334 342  
335   - # self['correct'] = path.join(self['path'], self['correct'])
336   - self['correct'] = path.abspath(path.normpath(path.join(self['path'], self['correct'])))
  343 + self['correct'] = path.join(self['path'], self['correct'])
337 344  
338   - #------------------------------------------------------------------------
  345 + # ------------------------------------------------------------------------
339 346 # can return negative values for wrong answers
340 347 def correct(self):
341 348 super().correct()
342 349  
343 350 if self['answer'] is not None:
344   - # correct answer
345 351 out = run_script( # and parse yaml ouput
346 352 script=self['correct'],
  353 + args=self['args'],
347 354 stdin=self['answer'],
348 355 timeout=self['timeout']
349 356 )
350 357  
351   - if type(out) in (int, float):
352   - self['grade'] = float(out)
353   -
354   - elif isinstance(out, dict):
  358 + if isinstance(out, dict):
355 359 self['comments'] = out.get('comments', '')
356 360 try:
357 361 self['grade'] = float(out['grade'])
358 362 except ValueError:
359   - logger.error(f'Correction script of "{self["ref"]}" returned nonfloat.')
  363 + logger.error(f'Grade value error in "{self["correct"]}".')
360 364 except KeyError:
361   - logger.error('Correction script of "{self["ref"]}" returned no "grade".')
  365 + logger.error(f'No grade in "{self["correct"]}".')
  366 + else:
  367 + try:
  368 + self['grade'] = float(out)
  369 + except (TypeError, ValueError):
  370 + logger.error(f'Grade value error in "{self["correct"]}".')
362 371  
363 372 return self['grade']
364 373  
... ... @@ -370,14 +379,14 @@ class QuestionInformation(Question):
370 379 text (str)
371 380 points (0.0)
372 381 '''
373   - #------------------------------------------------------------------------
  382 + # ------------------------------------------------------------------------
374 383 def __init__(self, q):
375 384 super().__init__(q)
376 385 self.set_defaults({
377 386 'text': '',
378 387 })
379 388  
380   - #------------------------------------------------------------------------
  389 + # ------------------------------------------------------------------------
381 390 # can return negative values for wrong answers
382 391 def correct(self):
383 392 super().correct()
... ...
perguntations/serve.py
... ... @@ -280,7 +280,8 @@ class ReviewHandler(BaseHandler):
280 280 else:
281 281 with f:
282 282 t = json.load(f)
283   - self.render('review.html', t=t, md=md_to_html, templ=self._templates)
  283 + self.render('review.html', t=t, md=md_to_html,
  284 + templ=self._templates)
284 285  
285 286  
286 287 # --- ADMIN ------------------------------------------------------------------
... ... @@ -406,7 +407,7 @@ def get_logger_config(debug=False):
406 407 'level': level,
407 408 'propagate': False,
408 409 } for module in ['app', 'models', 'factory', 'questions',
409   - 'test', 'tools', 'serve']})
  410 + 'test', 'tools']})
410 411  
411 412 return load_yaml(config_file, default=default_config)
412 413  
... ... @@ -465,8 +466,8 @@ def main():
465 466 except ValueError:
466 467 logging.critical('Certificates cert.pem, privkey.pem not found')
467 468 sys.exit(-1)
468   - else:
469   - httpserver.listen(8443)
  469 +
  470 + httpserver.listen(8443)
470 471  
471 472 # --- run webserver
472 473 logging.info('Webserver running... (Ctrl-C to stop)')
... ...
perguntations/tools.py
... ... @@ -145,7 +145,7 @@ def md_to_html(qref='.'):
145 145 def load_yaml(filename, default=None):
146 146 try:
147 147 with open(path.expanduser(filename), 'r', encoding='utf-8') as f:
148   - default = yaml.load(f)
  148 + default = yaml.safe_load(f)
149 149 except FileNotFoundError:
150 150 logger.error(f'File not found: "{filename}".')
151 151 except PermissionError:
... ... @@ -164,10 +164,11 @@ def load_yaml(filename, default=None):
164 164 # The script is run in another process but this function blocks waiting
165 165 # for its termination.
166 166 # ---------------------------------------------------------------------------
167   -def run_script(script, stdin='', timeout=5):
  167 +def run_script(script, args=[], stdin='', timeout=5):
168 168 script = path.expanduser(script)
169 169 try:
170   - p = subprocess.run([script],
  170 + cmd = [script] + [str(a) for a in args]
  171 + p = subprocess.run(cmd,
171 172 input=stdin,
172 173 stdout=subprocess.PIPE,
173 174 stderr=subprocess.STDOUT,
... ... @@ -187,7 +188,7 @@ def run_script(script, stdin='', timeout=5):
187 188 logger.error(f'Script "{script}" returned code {p.returncode}')
188 189 else:
189 190 try:
190   - output = yaml.load(p.stdout)
  191 + output = yaml.safe_load(p.stdout)
191 192 except Exception:
192 193 logger.error(f'Error parsing yaml output of "{script}"')
193 194 else:
... ...