diff --git a/demo/demo.yaml b/demo/demo.yaml index 4a29017..3b19431 100644 --- a/demo/demo.yaml +++ b/demo/demo.yaml @@ -65,7 +65,7 @@ questions: - tut-success - tut-warning - tut-alert - + - tut-generator # test: # - ref1 diff --git a/demo/questions/generators/generate-question.py b/demo/questions/generators/generate-question.py index a494ad3..36b8082 100755 --- a/demo/questions/generators/generate-question.py +++ b/demo/questions/generators/generate-question.py @@ -1,29 +1,72 @@ #!/usr/bin/env python3 +''' +Example of a question generator. +Arguments are read from stdin. +''' + from random import randint import sys -arg = sys.stdin.read() # read arguments +a, b = (int(n) for n in sys.argv[1:]) -a, b = (int(n) for n in arg.split(',')) +x = randint(a, b) +y = randint(a, b) +r = x + y -q = f'''--- -type: checkbox +print(f"""--- +type: text +title: Geradores de perguntas text: | - Indique quais das seguintes adições resultam em overflow quando se considera - a adição de números com sinal (complemento para 2) em registos de 8 bits. + Existe a possibilidade da pergunta ser gerada por um programa externo. + Este programa deve escrever no `stdout` uma pergunta em formato `yaml` como + os anteriores. Pode também receber argumentos para parametrizar a geração da + pergunta. Aqui está um exemplo de uma pergunta gerada por um script python: - Os números foram gerados aleatoriamente no intervalo de {a} a {b}. -options: -''' + ```python + #!/usr/bin/env python3 + + from random import randint + import sys + + a, b = (int(n) for n in sys.argv[1:]) # argumentos da linha de comando -correct = [] -for i in range(5): - x = randint(a, b) - y = randint(a, b) - q += f' - "`{x} + {y}`"\n' - correct.append(1 if x + y > 127 else -1) + x = randint(a, b) + y = randint(a, b) + r = x + y -q += 'correct: ' + str(correct) + print(f'''--- + type: text + title: Contas de somar + text: | + bla bla bla + correct: '{{r}}' + solution: | + A solução é {{r}}.''') + ``` -print(q) + Este script deve ter permissões para poder ser executado no terminal. Dá + jeito usar o comando `gen-somar.py 1 100 | yamllint -` para validar o `yaml` + gerado. + + Para indicar que uma pergunta é gerada externamente, esta é declarada com + + ```yaml + - type: generator + ref: gen-somar + script: gen-somar.py + # opcional + args: [1, 100] + ``` + + Os argumentos `args` são opcionais e são passados para o programa como + argumentos da linha de comando. + + --- + + Calcule o resultado de ${x} + {y}$. + + Os números foram gerados aleatoriamente no intervalo de {a} a {b}. +correct: '{r}' +solution: | + A solução é {r}.""") diff --git a/demo/questions/questions-tutorial.yaml b/demo/questions/questions-tutorial.yaml index 6d6428b..44ff1bf 100644 --- a/demo/questions/questions-tutorial.yaml +++ b/demo/questions/questions-tutorial.yaml @@ -22,6 +22,7 @@ # opcional duration: 60 # duração da prova em minutos (default: inf) + autosubmit: true # submissão automática (default: false) show_points: true # mostra cotação das perguntas (default: true) scale_points: true # recalcula cotações para [scale_min, scale_max] scale_max: 20 # limite superior da escala (default: 20) @@ -159,11 +160,10 @@ shuffle: false ``` - Por defeito, as respostas erradas descontam, tendo uma cotação de -1/(n-1) - do valor da pergunta, onde n é o número de opções apresentadas ao aluno - (a ideia é o valor esperado ser zero quando as respostas são aleatórias e - uniformemente distribuídas). - Para não descontar acrescenta-se: + Por defeito, as respostas erradas descontam, tendo uma cotação de + $-1/(n-1)$ do valor da pergunta, onde $n$ é o número de opções apresentadas + ao aluno (a ideia é o valor esperado ser zero quando as respostas são + aleatórias e uniformemente distribuídas). Para não descontar acrescenta-se: ```yaml discount: false @@ -561,3 +561,7 @@ Indices start at 0. # ---------------------------------------------------------------------------- +- type: generator + ref: tut-generator + script: generators/generate-question.py + args: [1, 100] diff --git a/perguntations/app.py b/perguntations/app.py index 548062d..2601430 100644 --- a/perguntations/app.py +++ b/perguntations/app.py @@ -1,13 +1,17 @@ +''' +Main application module +''' + # python standard libraries -from os import path -import logging -from contextlib import contextmanager # `with` statement in db sessions import asyncio +from contextlib import contextmanager # `with` statement in db sessions +import json +import logging +from os import path # user installed packages import bcrypt -import json from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker @@ -21,43 +25,50 @@ logger = logging.getLogger(__name__) # ============================================================================ class AppException(Exception): - pass + '''Exception raised in this module''' # ============================================================================ # helper functions # ============================================================================ async def check_password(try_pw, password): + '''check password in executor''' try_pw = try_pw.encode('utf-8') loop = asyncio.get_running_loop() hashed = await loop.run_in_executor(None, bcrypt.hashpw, try_pw, password) return password == hashed -async def hash_password(pw): - pw = pw.encode('utf-8') +async def hash_password(password): + '''hash password in executor''' loop = asyncio.get_running_loop() - r = await loop.run_in_executor(None, bcrypt.hashpw, pw, bcrypt.gensalt()) - return r + return await loop.run_in_executor(None, bcrypt.hashpw, + password.encode('utf-8'), + bcrypt.gensalt()) # ============================================================================ -# Application -# state: -# self.Session -# self.online - {uid: -# {'student':{...}, 'test': {...}}, -# ... -# } -# self.allowd - {'123', '124', ...} -# self.testfactory - TestFactory # ============================================================================ -class App(object): +class App(): + ''' + This is the main application + state: + self.Session + self.online - {uid: + {'student':{...}, 'test': {...}}, + ... + } + self.allowd - {'123', '124', ...} + self.testfactory - TestFactory + ''' + # ------------------------------------------------------------------------ - # helper to manage db sessions using the `with` statement, for example - # with self.db_session() as s: s.query(...) @contextmanager def db_session(self): + ''' + helper to manage db sessions using the `with` statement, for example: + with self.db_session() as s: s.query(...) + ''' session = self.Session() try: yield session @@ -73,15 +84,15 @@ class App(object): self.online = dict() # {uid: {'student':{...}, 'test': {...}}, ...} self.allowed = set([]) # '0' is hardcoded to allowed elsewhere - logger.info(f'Loading test configuration "{conf["testfile"]}".') + logger.info('Loading test configuration "%s".', conf["testfile"]) testconf = load_yaml(conf['testfile']) testconf.update(conf) # command line options override configuration # start test factory try: self.testfactory = TestFactory(testconf) - except TestFactoryException as e: - logger.critical(e) + except TestFactoryException as exc: + logger.critical(exc) raise AppException('Failed to create test factory!') else: logger.info('No errors found. Test factory ready.') @@ -92,12 +103,12 @@ class App(object): engine = create_engine(database, echo=False) self.Session = sessionmaker(bind=engine) try: - with self.db_session() as s: - n = s.query(Student).filter(Student.id != '0').count() + with self.db_session() as sess: + num = sess.query(Student).filter(Student.id != '0').count() except Exception: raise AppException(f'Database unusable {dbfile}.') else: - logger.info(f'Database "{dbfile}" has {n} students.') + logger.info('Database "%s" has %s students.', dbfile, num) # command line option --allow-all if conf['allow_all']: @@ -117,15 +128,16 @@ class App(object): # ------------------------------------------------------------------------ async def login(self, uid, try_pw): + '''login authentication''' if uid not in self.allowed and uid != '0': # not allowed - logger.warning(f'Student {uid}: not allowed to login.') + logger.warning('Student %s: not allowed to login.', uid) return False # get name+password from db - with self.db_session() as s: - name, password = s.query(Student.name, Student.password)\ - .filter_by(id=uid)\ - .one() + with self.db_session() as sess: + name, password = sess.query(Student.name, Student.password)\ + .filter_by(id=uid)\ + .one() # first login updates the password if password == '': # update password on first login @@ -137,104 +149,112 @@ class App(object): if pw_ok: # success self.allowed.discard(uid) # remove from set of allowed students if uid in self.online: - logger.warning(f'Student {uid}: already logged in.') + logger.warning('Student %s: already logged in.', uid) else: # make student online self.online[uid] = {'student': {'name': name, 'number': uid}} - logger.info(f'Student {uid}: logged in.') + logger.info('Student %s: logged in.', uid) return True - else: # wrong password - logger.info(f'Student {uid}: wrong password.') - return False + # wrong password + logger.info('Student %s: wrong password.', uid) + return False # ------------------------------------------------------------------------ def logout(self, uid): + '''student logout''' self.online.pop(uid, None) # remove from dict if exists - logger.info(f'Student {uid}: logged out.') + logger.info('Student %s: logged out.', uid) # ------------------------------------------------------------------------ async def generate_test(self, uid): + '''generate a test for a given student''' if uid in self.online: - logger.info(f'Student {uid}: generating new test.') + logger.info('Student %s: generating new test.', uid) student_id = self.online[uid]['student'] # {number, name} test = await self.testfactory.generate(student_id) self.online[uid]['test'] = test - logger.info(f'Student {uid}: test is ready.') + logger.info('Student %s: test is ready.', uid) return self.online[uid]['test'] - else: - # this implies an error in the code. should never be here! - logger.critical(f'Student {uid}: offline, can\'t generate test') + + # this implies an error in the code. should never be here! + logger.critical('Student %s: offline, can\'t generate test', uid) # ------------------------------------------------------------------------ - # ans is a dictionary {question_index: answer, ...} - # for example: {0:'hello', 1:[1,2]} async def correct_test(self, uid, ans): - t = self.online[uid]['test'] + ''' + Corrects test + + ans is a dictionary {question_index: answer, ...} + for example: {0:'hello', 1:[1,2]} + ''' + test = self.online[uid]['test'] # --- submit answers and correct test - t.update_answers(ans) - logger.info(f'Student {uid}: {len(ans)} answers submitted.') + test.update_answers(ans) + logger.info('Student %s: %d answers submitted.', uid, len(ans)) - grade = await t.correct() - logger.info(f'Student {uid}: grade = {grade:5.3} points.') + grade = await test.correct() + logger.info('Student %s: grade = %g points.', uid, grade) # --- save test in JSON format - fields = (uid, t['ref'], str(t['finish_time'])) - fname = ' -- '.join(fields) + '.json' - fpath = path.join(t['answers_dir'], fname) - with open(path.expanduser(fpath), 'w') as f: + fields = (uid, test['ref'], str(test['finish_time'])) + fname = '--'.join(fields) + '.json' + fpath = path.join(test['answers_dir'], fname) + with open(path.expanduser(fpath), 'w') as file: # default=str required for datetime objects - json.dump(t, f, indent=2, default=str) - logger.info(f'Student {uid}: saved JSON.') + json.dump(test, file, indent=2, default=str) + logger.info('Student %s: saved JSON.', uid) # --- insert test and questions into database - with self.db_session() as s: - s.add(Test( - ref=t['ref'], - title=t['title'], - grade=t['grade'], - starttime=str(t['start_time']), - finishtime=str(t['finish_time']), + with self.db_session() as sess: + sess.add(Test( + ref=test['ref'], + title=test['title'], + grade=test['grade'], + starttime=str(test['start_time']), + finishtime=str(test['finish_time']), filename=fpath, student_id=uid, - state=t['state'], + state=test['state'], comment='')) - s.add_all([Question( + sess.add_all([Question( ref=q['ref'], grade=q['grade'], - starttime=str(t['start_time']), - finishtime=str(t['finish_time']), + starttime=str(test['start_time']), + finishtime=str(test['finish_time']), student_id=uid, - test_id=t['ref']) for q in t['questions'] if 'grade' in q]) + test_id=test['ref']) + for q in test['questions'] if 'grade' in q]) - logger.info(f'Student {uid}: database updated.') + logger.info('Student %s: database updated.', uid) return grade # ------------------------------------------------------------------------ def giveup_test(self, uid): - t = self.online[uid]['test'] - t.giveup() + '''giveup test - not used??''' + test = self.online[uid]['test'] + test.giveup() # save JSON with the test - fields = (t['student']['number'], t['ref'], str(t['finish_time'])) - fname = ' -- '.join(fields) + '.json' - fpath = path.join(t['answers_dir'], fname) - t.save_json(fpath) + fields = (test['student']['number'], test['ref'], + str(test['finish_time'])) + fname = '--'.join(fields) + '.json' + fpath = path.join(test['answers_dir'], fname) + test.save_json(fpath) # insert test into database - with self.db_session() as s: - s.add(Test( - ref=t['ref'], - title=t['title'], - grade=t['grade'], - starttime=str(t['start_time']), - finishtime=str(t['finish_time']), - filename=fpath, - student_id=t['student']['number'], - state=t['state'], - comment='')) - - logger.info(f'Student {uid}: gave up.') - return t + with self.db_session() as sess: + sess.add(Test(ref=test['ref'], + title=test['title'], + grade=test['grade'], + starttime=str(test['start_time']), + finishtime=str(test['finish_time']), + filename=fpath, + student_id=test['student']['number'], + state=test['state'], + comment='')) + + logger.info('Student %s: gave up.', uid) + return test # ------------------------------------------------------------------------ @@ -243,52 +263,58 @@ class App(object): # return self.online[uid]['student']['name'] def get_student_test(self, uid, default=None): + '''get test from online student''' return self.online[uid].get('test', default) # def get_questions_dir(self): # return self.testfactory['questions_dir'] def get_student_grades_from_all_tests(self, uid): - with self.db_session() as s: - return s.query(Test.title, Test.grade, Test.finishtime)\ - .filter_by(student_id=uid)\ - .order_by(Test.finishtime) + '''get grades of student from all tests''' + with self.db_session() as sess: + return sess.query(Test.title, Test.grade, Test.finishtime)\ + .filter_by(student_id=uid)\ + .order_by(Test.finishtime) def get_json_filename_of_test(self, test_id): - with self.db_session() as s: - return s.query(Test.filename)\ - .filter_by(id=test_id)\ - .scalar() + '''get JSON filename from database given the test_id''' + with self.db_session() as sess: + return sess.query(Test.filename)\ + .filter_by(id=test_id)\ + .scalar() def get_all_students(self): - with self.db_session() as s: - return s.query(Student.id, Student.name, Student.password)\ - .filter(Student.id != '0')\ - .order_by(Student.id) + '''get all students from database''' + with self.db_session() as sess: + return sess.query(Student.id, Student.name, Student.password)\ + .filter(Student.id != '0')\ + .order_by(Student.id) def get_student_grades_from_test(self, uid, testid): - with self.db_session() as s: - return s.query(Test.grade, Test.finishtime, Test.id)\ - .filter_by(student_id=uid)\ - .filter_by(ref=testid)\ - .all() + '''get grades of student for a given testid''' + with self.db_session() as sess: + return sess.query(Test.grade, Test.finishtime, Test.id)\ + .filter_by(student_id=uid)\ + .filter_by(ref=testid)\ + .all() def get_students_state(self): + '''get list of states of every student''' return [{ - 'uid': uid, - 'name': name, - 'allowed': uid in self.allowed, - 'online': uid in self.online, - 'start_time': self.online.get(uid, {}).get('test', {}) - .get('start_time', ''), - 'password_defined': pw != '', - 'grades': self.get_student_grades_from_test( - uid, - self.testfactory['ref'] - ), - - # 'focus': self.online.get(uid, {}).get('student', {}).get('focus', True), # FIXME - } for uid, name, pw in self.get_all_students()] + 'uid': uid, + 'name': name, + 'allowed': uid in self.allowed, + 'online': uid in self.online, + 'start_time': self.online.get(uid, {}).get('test', {}) + .get('start_time', ''), + 'password_defined': pw != '', + 'grades': self.get_student_grades_from_test( + uid, + self.testfactory['ref'] + ), + + # 'focus': self.online.get(uid, {}).get('student', {}).get('focus', True), # FIXME + } for uid, name, pw in self.get_all_students()] # def get_allowed_students(self): # # set of 'uid' allowed to login @@ -303,26 +329,30 @@ class App(object): # --- helpers (change state) def allow_student(self, uid): + '''allow a single student to login''' self.allowed.add(uid) - logger.info(f'Student {uid}: allowed to login.') + logger.info('Student %s: allowed to login.', uid) def deny_student(self, uid): + '''deny a single student to login''' self.allowed.discard(uid) - logger.info(f'Student {uid}: denied to login') + logger.info('Student %s: denied to login', uid) - async def update_student_password(self, uid, pw=''): - if pw: - pw = await hash_password(pw) - with self.db_session() as s: - student = s.query(Student).filter_by(id=uid).one() - student.password = pw - logger.info(f'Student {uid}: password updated.') + async def update_student_password(self, uid, password=''): + '''change password on the database''' + if password: + password = await hash_password(password) + with self.db_session() as sess: + student = sess.query(Student).filter_by(id=uid).one() + student.password = password + logger.info('Student %s: password updated.', uid) def insert_new_student(self, uid, name): + '''insert new student into the database''' try: - with self.db_session() as s: - s.add(Student(id=uid, name=name, password='')) + with self.db_session() as sess: + sess.add(Student(id=uid, name=name, password='')) except Exception: - logger.error(f'Insert failed: student {uid} already exists.') + logger.error('Insert failed: student %s already exists.', uid) else: - logger.info(f'New student inserted: {uid}, {name}') + logger.info('New student inserted: %s, %s', uid, name) diff --git a/perguntations/initdb.py b/perguntations/initdb.py index 20e97f4..5cb9ed5 100644 --- a/perguntations/initdb.py +++ b/perguntations/initdb.py @@ -1,5 +1,9 @@ #!/usr/bin/env python3 +''' +Commandline utilizty to initialize and update student database +''' + # base import csv import argparse @@ -16,8 +20,8 @@ from perguntations.models import Base, Student # =========================================================================== -# Parse command line options def parse_commandline_arguments(): + '''Parse command line options''' parser = argparse.ArgumentParser( formatter_class=argparse.ArgumentDefaultsHelpFormatter, description='Insert new users into a database. Users can be imported ' @@ -65,9 +69,11 @@ def parse_commandline_arguments(): # =========================================================================== -# SIIUE names have alien strings like "(TE)" and are sometimes capitalized -# We remove them so that students dont keep asking what it means def get_students_from_csv(filename): + ''' + SIIUE names have alien strings like "(TE)" and are sometimes capitalized + We remove them so that students dont keep asking what it means + ''' csv_settings = { 'delimiter': ';', 'quotechar': '"', @@ -75,8 +81,8 @@ def get_students_from_csv(filename): } try: - with open(filename, encoding='iso-8859-1') as f: - csvreader = csv.DictReader(f, **csv_settings) + with open(filename, encoding='iso-8859-1') as file: + csvreader = csv.DictReader(file, **csv_settings) students = [{ 'uid': s['N.º'], 'name': capwords(re.sub(r'\(.*\)', '', s['Nome']).strip()) @@ -92,20 +98,22 @@ def get_students_from_csv(filename): # =========================================================================== -# replace password by hash for a single student -def hashpw(student, pw=None): +def hashpw(student, password=None): + '''replace password by hash for a single student''' print('.', end='', flush=True) - if pw is None: + if password is None: student['pw'] = '' else: - student['pw'] = bcrypt.hashpw(pw.encode('utf-8'), bcrypt.gensalt()) + student['pw'] = bcrypt.hashpw(password.encode('utf-8'), + bcrypt.gensalt()) # =========================================================================== def insert_students_into_db(session, students): + '''insert list of students into the database''' try: session.add_all([Student(id=s['uid'], name=s['name'], password=s['pw']) - for s in students]) + for s in students]) session.commit() except sa.exc.IntegrityError: @@ -115,33 +123,33 @@ def insert_students_into_db(session, students): # ============================================================================ def show_students_in_database(session, verbose=False): - try: - users = session.query(Student).all() - except Exception: - raise + '''get students from database''' + users = session.query(Student).all() + + total_users = len(users) + print('Registered users:') + if total_users == 0: + print(' -- none --') else: - n = len(users) - print(f'Registered users:') - if n == 0: - print(' -- none --') + users.sort(key=lambda u: f'{u.id:>12}') # sort by number + if verbose: + for user in users: + print(f'{user.id:>12} {user.name}') else: - users.sort(key=lambda u: f'{u.id:>12}') # sort by number - if verbose: - for u in users: - print(f'{u.id:>12} {u.name}') - else: - print(f'{users[0].id:>12} {users[0].name}') - if n > 1: - print(f'{users[1].id:>12} {users[1].name}') - if n > 3: - print(' | |') - if n > 2: - print(f'{users[-1].id:>12} {users[-1].name}') - print(f'Total: {n}.') + print(f'{users[0].id:>12} {users[0].name}') + if total_users > 1: + print(f'{users[1].id:>12} {users[1].name}') + if total_users > 3: + print(' | |') + if total_users > 2: + print(f'{users[-1].id:>12} {users[-1].name}') + print(f'Total: {total_users}.') # ============================================================================ def main(): + '''insert, update, show students from database''' + args = parse_commandline_arguments() # --- make list of students to insert/update @@ -162,27 +170,27 @@ def main(): # --- password hashing if students: - print(f'Generating password hashes', end='') + print('Generating password hashes', end='') with ThreadPoolExecutor() as executor: # hashing in parallel executor.map(lambda s: hashpw(s, args.pw), students) print() # --- database stuff - print(f'Using database: ', args.db) + print(f'Using database: {args.db}') engine = sa.create_engine(f'sqlite:///{args.db}', echo=False) Base.metadata.create_all(engine) # Criates schema if needed - Session = sa.orm.sessionmaker(bind=engine) - session = Session() + SessionMaker = sa.orm.sessionmaker(bind=engine) + session = SessionMaker() if students: print(f'Inserting {len(students)}') insert_students_into_db(session, students) - for s in args.update: - print(f'Updating password of: {s}') - u = session.query(Student).get(s) - pw = (args.pw or s).encode('utf-8') - u.password = bcrypt.hashpw(pw, bcrypt.gensalt()) + for student_id in args.update: + print(f'Updating password of: {student_id}') + student = session.query(Student).get(student_id) + password = (args.pw or student_id).encode('utf-8') + student.password = bcrypt.hashpw(password, bcrypt.gensalt()) session.commit() show_students_in_database(session, args.verbose) diff --git a/perguntations/main.py b/perguntations/main.py index 35dfc80..2c19d49 100644 --- a/perguntations/main.py +++ b/perguntations/main.py @@ -1,5 +1,10 @@ #!/usr/bin/env python3 +''' +Main file that starts the application and the web server +''' + + # python standard library import argparse import logging @@ -10,7 +15,7 @@ import sys # from typing import Any, Dict # this project -from .app import App +from .app import App, AppException from .serve import run_webserver from .tools import load_yaml from . import APP_NAME, APP_VERSION @@ -125,7 +130,7 @@ def main(): # testapp = App(config) try: testapp = App(config) - except Exception: + except AppException: logging.critical('Failed to start application.') sys.exit(-1) diff --git a/perguntations/models.py b/perguntations/models.py index 58e96f1..c02275a 100644 --- a/perguntations/models.py +++ b/perguntations/models.py @@ -1,3 +1,7 @@ +''' +Database tables +''' + from sqlalchemy import Column, ForeignKey, Integer, Float, String from sqlalchemy.ext.declarative import declarative_base @@ -11,6 +15,7 @@ Base = declarative_base() # ---------------------------------------------------------------------------- class Student(Base): + '''Student table''' __tablename__ = 'students' id = Column(String, primary_key=True) name = Column(String) @@ -29,6 +34,7 @@ class Student(Base): # ---------------------------------------------------------------------------- class Test(Base): + '''Test table''' __tablename__ = 'tests' id = Column(Integer, primary_key=True) # auto_increment ref = Column(String) @@ -61,6 +67,7 @@ class Test(Base): # --------------------------------------------------------------------------- class Question(Base): + '''Question table''' __tablename__ = 'questions' id = Column(Integer, primary_key=True) # auto_increment ref = Column(String) diff --git a/perguntations/parser_markdown.py b/perguntations/parser_markdown.py index 2a1d276..93c80e0 100644 --- a/perguntations/parser_markdown.py +++ b/perguntations/parser_markdown.py @@ -1,3 +1,9 @@ +''' +Parse markdown and generate HTML +Includes support for LaTeX formulas +''' + + # python standard library import logging import re @@ -33,18 +39,19 @@ class MathBlockLexer(mistune.BlockLexer): rules = MathBlockGrammar() super().__init__(rules, **kwargs) - def parse_block_math(self, m): - """Parse a $$math$$ block""" + def parse_block_math(self, math): + '''Parse a $$math$$ block''' self.tokens.append({ 'type': 'block_math', - 'text': m.group(1) + 'text': math.group(1) }) - def parse_latex_environment(self, m): + def parse_latex_environment(self, math): + '''Parse latex environment in formula''' self.tokens.append({ 'type': 'latex_environment', - 'name': m.group(1), - 'text': m.group(2) + 'name': math.group(1), + 'text': math.group(2) }) @@ -62,11 +69,11 @@ class MathInlineLexer(mistune.InlineLexer): rules = MathInlineGrammar() super().__init__(renderer, rules, **kwargs) - def output_math(self, m): - return self.renderer.inline_math(m.group(1)) + def output_math(self, math): + return self.renderer.inline_math(math.group(1)) - def output_block_math(self, m): - return self.renderer.block_math(m.group(1)) + def output_block_math(self, math): + return self.renderer.block_math(math.group(1)) class MarkdownWithMath(mistune.Markdown): @@ -104,6 +111,7 @@ class HighlightRenderer(mistune.Renderer): + header + '' + body + '' def image(self, src, title, alt): + '''render image''' alt = mistune.escape(alt, quote=True) if title is not None: if title: # not empty string, show as caption @@ -124,22 +132,26 @@ class HighlightRenderer(mistune.Renderer): ''' - else: # title indefined, show as inline image - return f''' - {alt} - ''' + # title indefined, show as inline image + return f''' + {alt} + ''' # Pass math through unaltered - mathjax does the rendering in the browser def block_math(self, text): + '''bypass block math''' return fr'$$ {text} $$' def latex_environment(self, name, text): + '''bypass latex environment''' return fr'\begin{{{name}}} {text} \end{{{name}}}' def inline_math(self, text): + '''bypass inline math''' return fr'$$$ {text} $$$' def md_to_html(qref='.'): + '''markdown to html interface''' return MarkdownWithMath(HighlightRenderer(qref=qref)) diff --git a/perguntations/serve.py b/perguntations/serve.py index ce692d2..799c683 100644 --- a/perguntations/serve.py +++ b/perguntations/serve.py @@ -12,12 +12,10 @@ import sys import base64 import uuid import logging.config -# import argparse import mimetypes import signal import functools import json -# import ssl # user installed libraries import tornado.ioloop @@ -30,9 +28,10 @@ from perguntations.parser_markdown import md_to_html # ---------------------------------------------------------------------------- -# Web Application. Routes to handler classes. -# ---------------------------------------------------------------------------- class WebApplication(tornado.web.Application): + ''' + Web Application. Routes to handler classes. + ''' def __init__(self, testapp, debug=False): handlers = [ (r'/login', LoginHandler), @@ -73,9 +72,11 @@ def admin_only(func): # ---------------------------------------------------------------------------- -# Base handler. Other handlers will inherit this one. -# ---------------------------------------------------------------------------- class BaseHandler(tornado.web.RequestHandler): + ''' + Base handler. Other handlers will inherit this one. + ''' + @property def testapp(self): ''' @@ -152,8 +153,10 @@ class BaseHandler(tornado.web.RequestHandler): # AdminSocketHandler.send_updates(chat) # send to clients -# --- ADMIN ------------------------------------------------------------------ +# ---------------------------------------------------------------------------- class AdminHandler(BaseHandler): + '''Handle /admin''' + # SUPPORTED_METHODS = ['GET', 'POST'] @tornado.web.authenticated @@ -209,19 +212,15 @@ class AdminHandler(BaseHandler): # ---------------------------------------------------------------------------- -# /login -# ---------------------------------------------------------------------------- class LoginHandler(BaseHandler): + '''Handle /login''' + def get(self): - ''' - Render login page. - ''' + '''Render login page.''' self.render('login.html', error='') async def post(self): - ''' - Authenticates student (prefix 'l' are removed) and login. - ''' + '''Authenticates student (prefix 'l' are removed) and login.''' uid = self.get_body_argument('uid').lstrip('l') password = self.get_body_argument('pw') @@ -235,14 +234,12 @@ class LoginHandler(BaseHandler): # ---------------------------------------------------------------------------- -# /logout -# ---------------------------------------------------------------------------- class LogoutHandler(BaseHandler): + '''Handle /logout''' + @tornado.web.authenticated def get(self): - ''' - Logs out a user. - ''' + '''Logs out a user.''' self.clear_cookie('user') self.redirect('/') @@ -251,9 +248,10 @@ class LogoutHandler(BaseHandler): # ---------------------------------------------------------------------------- -# handles root / to redirect students to /test and admininistrator to /admin -# ---------------------------------------------------------------------------- class RootHandler(BaseHandler): + ''' + Handles / to redirect students and admin to /test and /admin, resp. + ''' @tornado.web.authenticated def get(self): diff --git a/perguntations/test.py b/perguntations/test.py index 4a1d9ce..3efba48 100644 --- a/perguntations/test.py +++ b/perguntations/test.py @@ -267,23 +267,16 @@ class TestFactory(dict): if nerr > 0: logger.error('%s errors found!', nerr) + inherit = {'ref', 'title', 'database', 'answers_dir', + 'questions_dir', 'files', + 'duration', 'autosubmit', + 'scale_min', 'scale_max', 'show_points', + 'show_ref', 'debug', } + # NOT INCLUDED: scale_points, testfile, allow_all, review + return Test({ - 'ref': self['ref'], - 'title': self['title'], # title of the test - 'student': student, # student id - 'questions': test, # list of Question instances - 'answers_dir': self['answers_dir'], - 'duration': self['duration'], - 'autosubmit': self['autosubmit'], - 'scale_min': self['scale_min'], - 'scale_max': self['scale_max'], - 'show_points': self['show_points'], - 'show_ref': self['show_ref'], - 'debug': self['debug'], # required by template test.html - 'database': self['database'], - 'questions_dir': self['questions_dir'], - 'files': self['files'], - }) + **{'student': student, 'questions': test}, + **{k:self[k] for k in inherit}}) # ------------------------------------------------------------------------ def __repr__(self): @@ -323,14 +316,12 @@ class Test(dict): # ------------------------------------------------------------------------ async def correct(self): '''Corrects all the answers of the test and computes the final grade''' - self['finish_time'] = datetime.now() self['state'] = 'FINISHED' grade = 0.0 for question in self['questions']: await question.correct_async() grade += question['grade'] * question['points'] - # logger.debug(f'Correcting {q["ref"]:>30}: {q["grade"]*100:4.0f}%') logger.debug('Correcting %30s: %3g%%', question["ref"], question["grade"]*100) diff --git a/perguntations/tools.py b/perguntations/tools.py index 7cd8928..f7e114c 100644 --- a/perguntations/tools.py +++ b/perguntations/tools.py @@ -1,3 +1,9 @@ +''' +This module contains helper functions to: +- load yaml files and report errors +- run external programs (sync and async) +''' + # python standard library import asyncio @@ -15,105 +21,119 @@ logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- -# load data from yaml file -# --------------------------------------------------------------------------- def load_yaml(filename: str, default: Any = None) -> Any: + '''load data from yaml file''' + filename = path.expanduser(filename) try: - f = open(filename, 'r', encoding='utf-8') - except Exception as e: - logger.error(e) + file = open(filename, 'r', encoding='utf-8') + except Exception as exc: + logger.error(exc) if default is not None: return default - else: - raise + raise - with f: + with file: try: - return yaml.safe_load(f) - except yaml.YAMLError as e: - logger.error(str(e).replace('\n', ' ')) + return yaml.safe_load(file) + except yaml.YAMLError as exc: + logger.error(str(exc).replace('\n', ' ')) if default is not None: return default - else: - raise + raise # --------------------------------------------------------------------------- -# Runs a script and returns its stdout parsed as yaml, or None on error. -# The script is run in another process but this function blocks waiting -# for its termination. -# --------------------------------------------------------------------------- def run_script(script: str, - args: List[str] = [], + args: List[str], stdin: str = '', - timeout: int = 2) -> Any: - + timeout: int = 3) -> Any: + ''' + Runs a script and returns its stdout parsed as yaml, or None on error. + The script is run in another process but this function blocks waiting + for its termination. + ''' + logger.info('run_script "%s"', script) + + output = None script = path.expanduser(script) + cmd = [script] + [str(a) for a in args] + + # --- run process try: - cmd = [script] + [str(a) for a in args] - p = subprocess.run(cmd, - input=stdin, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - universal_newlines=True, - timeout=timeout, - ) - except FileNotFoundError: - logger.error(f'Can not execute script "{script}": not found.') - except PermissionError: - logger.error(f'Can not execute script "{script}": wrong permissions.') + proc = subprocess.run(cmd, + input=stdin, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True, + timeout=timeout, + check=False, + ) except OSError: - logger.error(f'Can not execute script "{script}": unknown reason.') + logger.error('Can not execute script "%s".', script) + return output except subprocess.TimeoutExpired: - logger.error(f'Timeout {timeout}s exceeded while running "{script}".') + logger.error('Timeout %ds exceeded running "%s".', timeout, script) + return output except Exception: - logger.error(f'An Exception ocurred running {script}.') - else: - if p.returncode != 0: - logger.error(f'Return code {p.returncode} running "{script}".') - else: - try: - output = yaml.safe_load(p.stdout) - except Exception: - logger.error(f'Error parsing yaml output of "{script}"') - else: - return output + logger.error('An Exception ocurred running "%s".', script) + return output + + # --- check return code + if proc.returncode != 0: + logger.error('Return code %d running "%s".', proc.returncode, script) + return output + + # --- parse yaml + try: + output = yaml.safe_load(proc.stdout) + except yaml.YAMLError: + logger.error('Error parsing yaml output of "%s".', script) + + return output -# ---------------------------------------------------------------------------- -# Same as above, but asynchronous # ---------------------------------------------------------------------------- async def run_script_async(script: str, - args: List[str] = [], + args: List[str], stdin: str = '', - timeout: int = 2) -> Any: + timeout: int = 3) -> Any: + '''Same as above, but asynchronous''' script = path.expanduser(script) args = [str(a) for a in args] + output = None - p = await asyncio.create_subprocess_exec( - script, *args, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.DEVNULL, - ) - + # --- start process try: - stdout, stderr = await asyncio.wait_for( - p.communicate(input=stdin.encode('utf-8')), - timeout=timeout + proc = await asyncio.create_subprocess_exec( + script, *args, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, ) + except OSError: + logger.error('Can not execute script "%s".', script) + return output + + # --- send input and wait for termination + try: + stdout, _ = await asyncio.wait_for( + proc.communicate(input=stdin.encode('utf-8')), + timeout=timeout) except asyncio.TimeoutError: - logger.warning(f'Timeout {timeout}s running script "{script}".') - return + logger.warning('Timeout %ds running script "%s".', timeout, script) + return output - if p.returncode != 0: - logger.error(f'Return code {p.returncode} running "{script}".') - else: - try: - output = yaml.safe_load(stdout.decode('utf-8', 'ignore')) - except Exception: - logger.error(f'Error parsing yaml output of "{script}"') - else: - return output + # --- check return code + if proc.returncode != 0: + logger.error('Return code %d running "%s".', proc.returncode, script) + return output + + # --- parse yaml + try: + output = yaml.safe_load(stdout.decode('utf-8', 'ignore')) + except yaml.YAMLError: + logger.error('Error parsing yaml output of "%s"', script) + + return output -- libgit2 0.21.2