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