Commit f1ad38ab169168b4cd13d950150e9b82abc5fa30
1 parent
5822a2ec
Exists in
master
and in
1 other branch
- update to sqlalchemy-1.4
- fix mypy errors and warnings - remove unused code
Showing
7 changed files
with
146 additions
and
308 deletions
Show diff stats
mypy.ini
| 1 | [mypy] | 1 | [mypy] |
| 2 | -python_version = 3.7 | 2 | +python_version = 3.8 |
| 3 | # warn_return_any = True | 3 | # warn_return_any = True |
| 4 | # warn_unused_configs = True | 4 | # warn_unused_configs = True |
| 5 | 5 | ||
| 6 | -[mypy-sqlalchemy.*] | 6 | +[mypy-setuptools.*] |
| 7 | ignore_missing_imports = True | 7 | ignore_missing_imports = True |
| 8 | 8 | ||
| 9 | -[mypy-pygments.*] | 9 | +[mypy-sqlalchemy.*] |
| 10 | ignore_missing_imports = True | 10 | ignore_missing_imports = True |
| 11 | 11 | ||
| 12 | -[mypy-bcrypt.*] | 12 | +[mypy-pygments.*] |
| 13 | ignore_missing_imports = True | 13 | ignore_missing_imports = True |
| 14 | 14 | ||
| 15 | [mypy-mistune.*] | 15 | [mypy-mistune.*] |
perguntations/app.py
| @@ -5,7 +5,7 @@ Main application module | @@ -5,7 +5,7 @@ Main application module | ||
| 5 | 5 | ||
| 6 | # python standard libraries | 6 | # python standard libraries |
| 7 | import asyncio | 7 | import asyncio |
| 8 | -from contextlib import contextmanager # `with` statement in db sessions | 8 | +# from contextlib import contextmanager # `with` statement in db sessions |
| 9 | import csv | 9 | import csv |
| 10 | import io | 10 | import io |
| 11 | import json | 11 | import json |
| @@ -14,8 +14,10 @@ from os import path | @@ -14,8 +14,10 @@ from os import path | ||
| 14 | 14 | ||
| 15 | # installed packages | 15 | # installed packages |
| 16 | import bcrypt | 16 | import bcrypt |
| 17 | -from sqlalchemy import create_engine, exc | ||
| 18 | -from sqlalchemy.orm import sessionmaker | 17 | +from sqlalchemy import create_engine, select, func |
| 18 | +from sqlalchemy.orm import Session | ||
| 19 | +from sqlalchemy.exc import NoResultFound | ||
| 20 | +# from sqlalchemy.orm import sessionmaker | ||
| 19 | 21 | ||
| 20 | # this project | 22 | # this project |
| 21 | from perguntations.models import Student, Test, Question | 23 | from perguntations.models import Student, Test, Question |
| @@ -24,6 +26,7 @@ from perguntations.testfactory import TestFactory, TestFactoryException | @@ -24,6 +26,7 @@ from perguntations.testfactory import TestFactory, TestFactoryException | ||
| 24 | import perguntations.test | 26 | import perguntations.test |
| 25 | from perguntations.questions import question_from | 27 | from perguntations.questions import question_from |
| 26 | 28 | ||
| 29 | +# setup logger for this module | ||
| 27 | logger = logging.getLogger(__name__) | 30 | logger = logging.getLogger(__name__) |
| 28 | 31 | ||
| 29 | 32 | ||
| @@ -35,20 +38,20 @@ class AppException(Exception): | @@ -35,20 +38,20 @@ class AppException(Exception): | ||
| 35 | # ============================================================================ | 38 | # ============================================================================ |
| 36 | # helper functions | 39 | # helper functions |
| 37 | # ============================================================================ | 40 | # ============================================================================ |
| 38 | -async def check_password(try_pw, hashed_pw): | ||
| 39 | - '''check password in executor''' | ||
| 40 | - try_pw = try_pw.encode('utf-8') | ||
| 41 | - loop = asyncio.get_running_loop() | ||
| 42 | - hashed = await loop.run_in_executor(None, bcrypt.hashpw, try_pw, hashed_pw) | ||
| 43 | - return hashed_pw == hashed | 41 | +# async def check_password(try_pw, hashed_pw): |
| 42 | +# '''check password in executor''' | ||
| 43 | +# try_pw = try_pw.encode('utf-8') | ||
| 44 | +# loop = asyncio.get_running_loop() | ||
| 45 | +# hashed = await loop.run_in_executor(None, bcrypt.hashpw, try_pw, hashed_pw) | ||
| 46 | +# return hashed_pw == hashed | ||
| 44 | 47 | ||
| 45 | 48 | ||
| 46 | -async def hash_password(password): | ||
| 47 | - '''hash password in executor''' | ||
| 48 | - loop = asyncio.get_running_loop() | ||
| 49 | - return await loop.run_in_executor(None, bcrypt.hashpw, | ||
| 50 | - password.encode('utf-8'), | ||
| 51 | - bcrypt.gensalt()) | 49 | +# async def hash_password(password): |
| 50 | +# '''hash password in executor''' | ||
| 51 | +# loop = asyncio.get_running_loop() | ||
| 52 | +# return await loop.run_in_executor(None, bcrypt.hashpw, | ||
| 53 | +# password.encode('utf-8'), | ||
| 54 | +# bcrypt.gensalt()) | ||
| 52 | 55 | ||
| 53 | 56 | ||
| 54 | # ============================================================================ | 57 | # ============================================================================ |
| @@ -67,23 +70,23 @@ class App(): | @@ -67,23 +70,23 @@ class App(): | ||
| 67 | self.testfactory - TestFactory | 70 | self.testfactory - TestFactory |
| 68 | ''' | 71 | ''' |
| 69 | 72 | ||
| 70 | - # ------------------------------------------------------------------------ | ||
| 71 | - @contextmanager | ||
| 72 | - def _db_session(self): | ||
| 73 | - ''' | ||
| 74 | - helper to manage db sessions using the `with` statement, for example: | ||
| 75 | - with self._db_session() as s: s.query(...) | ||
| 76 | - ''' | ||
| 77 | - session = self.Session() | ||
| 78 | - try: | ||
| 79 | - yield session | ||
| 80 | - session.commit() | ||
| 81 | - except exc.SQLAlchemyError: | ||
| 82 | - logger.error('DB rollback!!!') | ||
| 83 | - session.rollback() | ||
| 84 | - raise | ||
| 85 | - finally: | ||
| 86 | - session.close() | 73 | + # # ------------------------------------------------------------------------ |
| 74 | + # @contextmanager | ||
| 75 | + # def _db_session(self): | ||
| 76 | + # ''' | ||
| 77 | + # helper to manage db sessions using the `with` statement, for example: | ||
| 78 | + # with self._db_session() as s: s.query(...) | ||
| 79 | + # ''' | ||
| 80 | + # session = self.Session() | ||
| 81 | + # try: | ||
| 82 | + # yield session | ||
| 83 | + # session.commit() | ||
| 84 | + # except exc.SQLAlchemyError: | ||
| 85 | + # logger.error('DB rollback!!!') | ||
| 86 | + # session.rollback() | ||
| 87 | + # raise | ||
| 88 | + # finally: | ||
| 89 | + # session.close() | ||
| 87 | 90 | ||
| 88 | # ------------------------------------------------------------------------ | 91 | # ------------------------------------------------------------------------ |
| 89 | def __init__(self, conf): | 92 | def __init__(self, conf): |
| @@ -94,18 +97,7 @@ class App(): | @@ -94,18 +97,7 @@ class App(): | ||
| 94 | self.pregenerated_tests = [] # list of tests to give to students | 97 | self.pregenerated_tests = [] # list of tests to give to students |
| 95 | 98 | ||
| 96 | self._make_test_factory(conf) | 99 | self._make_test_factory(conf) |
| 97 | - | ||
| 98 | - # connect to database and check registered students | ||
| 99 | - dbfile = self.testfactory['database'] | ||
| 100 | - database = f'sqlite:///{path.expanduser(dbfile)}' | ||
| 101 | - engine = create_engine(database, echo=False) | ||
| 102 | - self.Session = sessionmaker(bind=engine) | ||
| 103 | - try: | ||
| 104 | - with self._db_session() as sess: | ||
| 105 | - num = sess.query(Student).filter(Student.id != '0').count() | ||
| 106 | - except Exception as exc: | ||
| 107 | - raise AppException(f'Database unusable {dbfile}.') from exc | ||
| 108 | - logger.info('Database "%s" has %s students.', dbfile, num) | 100 | + self._db_setup() |
| 109 | 101 | ||
| 110 | # command line option --allow-all | 102 | # command line option --allow-all |
| 111 | if conf['allow_all']: | 103 | if conf['allow_all']: |
| @@ -126,6 +118,31 @@ class App(): | @@ -126,6 +118,31 @@ class App(): | ||
| 126 | if conf['correct']: | 118 | if conf['correct']: |
| 127 | self._correct_tests() | 119 | self._correct_tests() |
| 128 | 120 | ||
| 121 | + def _db_setup(self) -> None: | ||
| 122 | + logger.info('Setup database') | ||
| 123 | + | ||
| 124 | + # connect to database and check registered students | ||
| 125 | + dbfile = path.expanduser(self.testfactory['database']) | ||
| 126 | + if not path.exists(dbfile): | ||
| 127 | + raise AppException('Database does not exist.') | ||
| 128 | + self._engine = create_engine(f'sqlite:///{dbfile}', future=True) | ||
| 129 | + | ||
| 130 | + try: | ||
| 131 | + query = select(func.count(Student.id)).where(Student.id != '0') | ||
| 132 | + with Session(self._engine, future=True) as session: | ||
| 133 | + num = session.execute(query).scalar() | ||
| 134 | + except Exception as exc: | ||
| 135 | + raise AppException(f'Database unusable {dbfile}.') from exc | ||
| 136 | + | ||
| 137 | + # try: | ||
| 138 | + # with self._db_session() as sess: | ||
| 139 | + # num = sess.query(Student).filter(Student.id != '0').count() | ||
| 140 | + # except Exception as exc: | ||
| 141 | + # raise AppException(f'Database unusable {dbfile}.') from exc | ||
| 142 | + logger.info('Database "%s" has %s students.', dbfile, num) | ||
| 143 | + | ||
| 144 | + | ||
| 145 | + | ||
| 129 | # ------------------------------------------------------------------------ | 146 | # ------------------------------------------------------------------------ |
| 130 | def _correct_tests(self): | 147 | def _correct_tests(self): |
| 131 | with self._db_session() as sess: | 148 | with self._db_session() as sess: |
perguntations/initdb.py
| @@ -13,7 +13,9 @@ from concurrent.futures import ThreadPoolExecutor | @@ -13,7 +13,9 @@ from concurrent.futures import ThreadPoolExecutor | ||
| 13 | 13 | ||
| 14 | # installed packages | 14 | # installed packages |
| 15 | import bcrypt | 15 | import bcrypt |
| 16 | -import sqlalchemy as sa | 16 | +from sqlalchemy import create_engine, select |
| 17 | +from sqlalchemy.orm import Session | ||
| 18 | +from sqlalchemy.exc import IntegrityError | ||
| 17 | 19 | ||
| 18 | # this project | 20 | # this project |
| 19 | from perguntations.models import Base, Student | 21 | from perguntations.models import Base, Student |
| @@ -22,6 +24,7 @@ from perguntations.models import Base, Student | @@ -22,6 +24,7 @@ from perguntations.models import Base, Student | ||
| 22 | # ============================================================================ | 24 | # ============================================================================ |
| 23 | def parse_commandline_arguments(): | 25 | def parse_commandline_arguments(): |
| 24 | '''Parse command line options''' | 26 | '''Parse command line options''' |
| 27 | + | ||
| 25 | parser = argparse.ArgumentParser( | 28 | parser = argparse.ArgumentParser( |
| 26 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, | 29 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, |
| 27 | description='Insert new users into a database. Users can be imported ' | 30 | description='Insert new users into a database. Users can be imported ' |
| @@ -102,7 +105,7 @@ def get_students_from_csv(filename): | @@ -102,7 +105,7 @@ def get_students_from_csv(filename): | ||
| 102 | 105 | ||
| 103 | 106 | ||
| 104 | # ============================================================================ | 107 | # ============================================================================ |
| 105 | -def hashpw(student, password=None): | 108 | +def hashpw(student, password=None) -> None: |
| 106 | '''replace password by hash for a single student''' | 109 | '''replace password by hash for a single student''' |
| 107 | print('.', end='', flush=True) | 110 | print('.', end='', flush=True) |
| 108 | if password is None: | 111 | if password is None: |
| @@ -113,14 +116,14 @@ def hashpw(student, password=None): | @@ -113,14 +116,14 @@ def hashpw(student, password=None): | ||
| 113 | 116 | ||
| 114 | 117 | ||
| 115 | # ============================================================================ | 118 | # ============================================================================ |
| 116 | -def insert_students_into_db(session, students): | 119 | +def insert_students_into_db(session, students) -> None: |
| 117 | '''insert list of students into the database''' | 120 | '''insert list of students into the database''' |
| 118 | try: | 121 | try: |
| 119 | session.add_all([Student(id=s['uid'], name=s['name'], password=s['pw']) | 122 | session.add_all([Student(id=s['uid'], name=s['name'], password=s['pw']) |
| 120 | for s in students]) | 123 | for s in students]) |
| 121 | session.commit() | 124 | session.commit() |
| 122 | 125 | ||
| 123 | - except sa.exc.IntegrityError: | 126 | + except IntegrityError: |
| 124 | print('!!! Integrity error. Users already in database. Aborted !!!\n') | 127 | print('!!! Integrity error. Users already in database. Aborted !!!\n') |
| 125 | session.rollback() | 128 | session.rollback() |
| 126 | 129 | ||
| @@ -128,11 +131,12 @@ def insert_students_into_db(session, students): | @@ -128,11 +131,12 @@ def insert_students_into_db(session, students): | ||
| 128 | # ============================================================================ | 131 | # ============================================================================ |
| 129 | def show_students_in_database(session, verbose=False): | 132 | def show_students_in_database(session, verbose=False): |
| 130 | '''get students from database''' | 133 | '''get students from database''' |
| 131 | - users = session.query(Student).all() | 134 | + users = session.execute(select(Student)).scalars().all() |
| 135 | + # users = session.query(Student).all() | ||
| 136 | + total = len(users) | ||
| 132 | 137 | ||
| 133 | - total_users = len(users) | ||
| 134 | print('Registered users:') | 138 | print('Registered users:') |
| 135 | - if total_users == 0: | 139 | + if total == 0: |
| 136 | print(' -- none --') | 140 | print(' -- none --') |
| 137 | else: | 141 | else: |
| 138 | users.sort(key=lambda u: f'{u.id:>12}') # sort by number | 142 | users.sort(key=lambda u: f'{u.id:>12}') # sort by number |
| @@ -141,13 +145,13 @@ def show_students_in_database(session, verbose=False): | @@ -141,13 +145,13 @@ def show_students_in_database(session, verbose=False): | ||
| 141 | print(f'{user.id:>12} {user.name}') | 145 | print(f'{user.id:>12} {user.name}') |
| 142 | else: | 146 | else: |
| 143 | print(f'{users[0].id:>12} {users[0].name}') | 147 | print(f'{users[0].id:>12} {users[0].name}') |
| 144 | - if total_users > 1: | 148 | + if total > 1: |
| 145 | print(f'{users[1].id:>12} {users[1].name}') | 149 | print(f'{users[1].id:>12} {users[1].name}') |
| 146 | - if total_users > 3: | 150 | + if total > 3: |
| 147 | print(' | |') | 151 | print(' | |') |
| 148 | - if total_users > 2: | 152 | + if total > 2: |
| 149 | print(f'{users[-1].id:>12} {users[-1].name}') | 153 | print(f'{users[-1].id:>12} {users[-1].name}') |
| 150 | - print(f'Total: {total_users}.') | 154 | + print(f'Total: {total}.') |
| 151 | 155 | ||
| 152 | 156 | ||
| 153 | # ============================================================================ | 157 | # ============================================================================ |
| @@ -157,39 +161,41 @@ def main(): | @@ -157,39 +161,41 @@ def main(): | ||
| 157 | args = parse_commandline_arguments() | 161 | args = parse_commandline_arguments() |
| 158 | 162 | ||
| 159 | # --- database | 163 | # --- database |
| 160 | - print(f'Using database: {args.db}') | ||
| 161 | - engine = sa.create_engine(f'sqlite:///{args.db}', echo=False) | 164 | + print(f'Database: {args.db}') |
| 165 | + engine = create_engine(f'sqlite:///{args.db}', echo=False, future=True) | ||
| 162 | Base.metadata.create_all(engine) # Criates schema if needed | 166 | Base.metadata.create_all(engine) # Criates schema if needed |
| 163 | - SessionMaker = sa.orm.sessionmaker(bind=engine) | ||
| 164 | - session = SessionMaker() | 167 | + session = Session(engine, future=True) |
| 165 | 168 | ||
| 166 | # --- make list of students to insert | 169 | # --- make list of students to insert |
| 167 | - new_students = [] | 170 | + students = [] |
| 168 | 171 | ||
| 169 | if args.admin: | 172 | if args.admin: |
| 170 | print('Adding user: 0, Admin.') | 173 | print('Adding user: 0, Admin.') |
| 171 | - new_students.append({'uid': '0', 'name': 'Admin'}) | 174 | + students.append({'uid': '0', 'name': 'Admin'}) |
| 172 | 175 | ||
| 173 | for csvfile in args.csvfile: | 176 | for csvfile in args.csvfile: |
| 174 | print('Adding users from:', csvfile) | 177 | print('Adding users from:', csvfile) |
| 175 | - new_students.extend(get_students_from_csv(csvfile)) | 178 | + students.extend(get_students_from_csv(csvfile)) |
| 176 | 179 | ||
| 177 | if args.add: | 180 | if args.add: |
| 178 | for uid, name in args.add: | 181 | for uid, name in args.add: |
| 179 | print(f'Adding user: {uid}, {name}.') | 182 | print(f'Adding user: {uid}, {name}.') |
| 180 | - new_students.append({'uid': uid, 'name': name}) | 183 | + students.append({'uid': uid, 'name': name}) |
| 181 | 184 | ||
| 182 | # --- insert new students | 185 | # --- insert new students |
| 183 | - if new_students: | 186 | + if students: |
| 184 | print('Generating password hashes', end='') | 187 | print('Generating password hashes', end='') |
| 185 | - with ThreadPoolExecutor() as executor: # hashing in parallel | ||
| 186 | - executor.map(lambda s: hashpw(s, args.pw), new_students) | ||
| 187 | - print(f'\nInserting {len(new_students)}') | ||
| 188 | - insert_students_into_db(session, new_students) | 188 | + with ThreadPoolExecutor() as executor: # hashing |
| 189 | + executor.map(lambda s: hashpw(s, args.pw), students) | ||
| 190 | + print(f'\nInserting {len(students)}') | ||
| 191 | + insert_students_into_db(session, students) | ||
| 189 | 192 | ||
| 190 | # --- update all students | 193 | # --- update all students |
| 191 | if args.update_all: | 194 | if args.update_all: |
| 192 | - all_students = session.query(Student).filter(Student.id != '0').all() | 195 | + all_students = session.execute( |
| 196 | + select(Student).where(Student.id != '0') | ||
| 197 | + ).all() | ||
| 198 | + | ||
| 193 | print(f'Updating password of {len(all_students)} users', end='') | 199 | print(f'Updating password of {len(all_students)} users', end='') |
| 194 | for student in all_students: | 200 | for student in all_students: |
| 195 | password = (args.pw or student.id).encode('utf-8') | 201 | password = (args.pw or student.id).encode('utf-8') |
| @@ -202,7 +208,7 @@ def main(): | @@ -202,7 +208,7 @@ def main(): | ||
| 202 | else: | 208 | else: |
| 203 | for student_id in args.update: | 209 | for student_id in args.update: |
| 204 | print(f'Updating password of {student_id}') | 210 | print(f'Updating password of {student_id}') |
| 205 | - student = session.query(Student).get(student_id) | 211 | + student = session.execute(select(Student.id)) |
| 206 | password = (args.pw or student_id).encode('utf-8') | 212 | password = (args.pw or student_id).encode('utf-8') |
| 207 | student.password = bcrypt.hashpw(password, bcrypt.gensalt()) | 213 | student.password = bcrypt.hashpw(password, bcrypt.gensalt()) |
| 208 | session.commit() | 214 | session.commit() |
perguntations/models.py
| 1 | ''' | 1 | ''' |
| 2 | +perguntations/models.py | ||
| 2 | SQLAlchemy ORM | 3 | SQLAlchemy ORM |
| 3 | - | ||
| 4 | -The classes below correspond to database tables | ||
| 5 | ''' | 4 | ''' |
| 6 | 5 | ||
| 7 | 6 | ||
| 8 | from sqlalchemy import Column, ForeignKey, Integer, Float, String | 7 | from sqlalchemy import Column, ForeignKey, Integer, Float, String |
| 9 | -from sqlalchemy.ext.declarative import declarative_base | ||
| 10 | -from sqlalchemy.orm import relationship | 8 | +from sqlalchemy.orm import declarative_base, relationship |
| 11 | 9 | ||
| 12 | 10 | ||
| 13 | # ============================================================================ | 11 | # ============================================================================ |
| 14 | # Declare ORM | 12 | # Declare ORM |
| 15 | -Base = declarative_base() | 13 | +# FIXME Any is a workaround for mypy static type checking (see https://github.com/python/mypy/issues/6372) |
| 14 | +from typing import Any | ||
| 15 | +Base: Any = declarative_base() | ||
| 16 | +# Base = declarative_base() | ||
| 16 | 17 | ||
| 17 | 18 | ||
| 18 | # ---------------------------------------------------------------------------- | 19 | # ---------------------------------------------------------------------------- |
| @@ -26,11 +27,11 @@ class Student(Base): | @@ -26,11 +27,11 @@ class Student(Base): | ||
| 26 | # --- | 27 | # --- |
| 27 | tests = relationship('Test', back_populates='student') | 28 | tests = relationship('Test', back_populates='student') |
| 28 | 29 | ||
| 29 | - def __str__(self): | ||
| 30 | - return (f'Student:\n' | ||
| 31 | - f' id: "{self.id}"\n' | ||
| 32 | - f' name: "{self.name}"\n' | ||
| 33 | - f' password: "{self.password}"\n') | 30 | + def __repr__(self): |
| 31 | + return (f'Student(' | ||
| 32 | + f'id={self.id!r}, ' | ||
| 33 | + f'name={self.name!r}, ' | ||
| 34 | + f'password={self.password!r})') | ||
| 34 | 35 | ||
| 35 | 36 | ||
| 36 | # ---------------------------------------------------------------------------- | 37 | # ---------------------------------------------------------------------------- |
| @@ -52,18 +53,18 @@ class Test(Base): | @@ -52,18 +53,18 @@ class Test(Base): | ||
| 52 | student = relationship('Student', back_populates='tests') | 53 | student = relationship('Student', back_populates='tests') |
| 53 | questions = relationship('Question', back_populates='test') | 54 | questions = relationship('Question', back_populates='test') |
| 54 | 55 | ||
| 55 | - def __str__(self): | ||
| 56 | - return (f'Test:\n' | ||
| 57 | - f' id: {self.id}\n' | ||
| 58 | - f' ref: "{self.ref}"\n' | ||
| 59 | - f' title: "{self.title}"\n' | ||
| 60 | - f' grade: {self.grade}\n' | ||
| 61 | - f' state: "{self.state}"\n' | ||
| 62 | - f' comment: "{self.comment}"\n' | ||
| 63 | - f' starttime: "{self.starttime}"\n' | ||
| 64 | - f' finishtime: "{self.finishtime}"\n' | ||
| 65 | - f' filename: "{self.filename}"\n' | ||
| 66 | - f' student_id: "{self.student_id}"\n') | 56 | + def __repr__(self): |
| 57 | + return (f'Test(' | ||
| 58 | + f'id={self.id!r}, ' | ||
| 59 | + f'ref={self.ref!r}, ' | ||
| 60 | + f'title={self.title!r}, ' | ||
| 61 | + f'grade={self.grade!r}, ' | ||
| 62 | + f'state={self.state!r}, ' | ||
| 63 | + f'comment={self.comment!r}, ' | ||
| 64 | + f'starttime={self.starttime!r}, ' | ||
| 65 | + f'finishtime={self.finishtime!r}, ' | ||
| 66 | + f'filename={self.filename!r}, ' | ||
| 67 | + f'student_id={self.student_id!r})') | ||
| 67 | 68 | ||
| 68 | 69 | ||
| 69 | # --------------------------------------------------------------------------- | 70 | # --------------------------------------------------------------------------- |
| @@ -82,13 +83,13 @@ class Question(Base): | @@ -82,13 +83,13 @@ class Question(Base): | ||
| 82 | # --- | 83 | # --- |
| 83 | test = relationship('Test', back_populates='questions') | 84 | test = relationship('Test', back_populates='questions') |
| 84 | 85 | ||
| 85 | - def __str__(self): | ||
| 86 | - return (f'Question:\n' | ||
| 87 | - f' id: {self.id}\n' | ||
| 88 | - f' number: {self.number}\n' | ||
| 89 | - f' ref: "{self.ref}"\n' | ||
| 90 | - f' grade: {self.grade}\n' | ||
| 91 | - f' comment: "{self.comment}"\n' | ||
| 92 | - f' starttime: "{self.starttime}"\n' | ||
| 93 | - f' finishtime: "{self.finishtime}"\n' | ||
| 94 | - f' test_id: "{self.test_id}"\n') | 86 | + def __repr__(self): |
| 87 | + return (f'Question(' | ||
| 88 | + f'id={self.id!r}, ' | ||
| 89 | + f'number={self.number!r}, ' | ||
| 90 | + f'ref={self.ref!r}, ' | ||
| 91 | + f'grade={self.grade!r}, ' | ||
| 92 | + f'comment={self.comment!r}, ' | ||
| 93 | + f'starttime={self.starttime!r}, ' | ||
| 94 | + f'finishtime={self.finishtime!r}, ' | ||
| 95 | + f'test_id={self.test_id!r})') |
perguntations/questions.py
| 1 | ''' | 1 | ''' |
| 2 | -Classes the implement several types of questions. | 2 | +File: perguntations/questions.py |
| 3 | +Description: Classes the implement several types of questions. | ||
| 3 | ''' | 4 | ''' |
| 4 | 5 | ||
| 5 | 6 | ||
| @@ -13,24 +14,15 @@ import re | @@ -13,24 +14,15 @@ import re | ||
| 13 | from typing import Any, Dict, NewType | 14 | from typing import Any, Dict, NewType |
| 14 | import uuid | 15 | import uuid |
| 15 | 16 | ||
| 16 | - | ||
| 17 | -# from urllib.error import HTTPError | ||
| 18 | -# import json | ||
| 19 | -# import http.client | ||
| 20 | - | ||
| 21 | - | ||
| 22 | # this project | 17 | # this project |
| 23 | from perguntations.tools import run_script, run_script_async | 18 | from perguntations.tools import run_script, run_script_async |
| 24 | 19 | ||
| 25 | # setup logger for this module | 20 | # setup logger for this module |
| 26 | logger = logging.getLogger(__name__) | 21 | logger = logging.getLogger(__name__) |
| 27 | 22 | ||
| 28 | - | ||
| 29 | QDict = NewType('QDict', Dict[str, Any]) | 23 | QDict = NewType('QDict', Dict[str, Any]) |
| 30 | 24 | ||
| 31 | 25 | ||
| 32 | - | ||
| 33 | - | ||
| 34 | class QuestionException(Exception): | 26 | class QuestionException(Exception): |
| 35 | '''Exceptions raised in this module''' | 27 | '''Exceptions raised in this module''' |
| 36 | 28 | ||
| @@ -45,8 +37,6 @@ class Question(dict): | @@ -45,8 +37,6 @@ class Question(dict): | ||
| 45 | for each student. | 37 | for each student. |
| 46 | Instances can shuffle options or automatically generate questions. | 38 | Instances can shuffle options or automatically generate questions. |
| 47 | ''' | 39 | ''' |
| 48 | - # def __init__(self, q: QDict) -> None: | ||
| 49 | - # super().__init__(q) | ||
| 50 | 40 | ||
| 51 | def gen(self) -> None: | 41 | def gen(self) -> None: |
| 52 | ''' | 42 | ''' |
| @@ -96,9 +86,6 @@ class QuestionRadio(Question): | @@ -96,9 +86,6 @@ class QuestionRadio(Question): | ||
| 96 | ''' | 86 | ''' |
| 97 | 87 | ||
| 98 | # ------------------------------------------------------------------------ | 88 | # ------------------------------------------------------------------------ |
| 99 | - # def __init__(self, q: QDict) -> None: | ||
| 100 | - # super().__init__(q) | ||
| 101 | - | ||
| 102 | def gen(self) -> None: | 89 | def gen(self) -> None: |
| 103 | ''' | 90 | ''' |
| 104 | Sets defaults, performs checks and generates the actual question | 91 | Sets defaults, performs checks and generates the actual question |
| @@ -231,9 +218,6 @@ class QuestionCheckbox(Question): | @@ -231,9 +218,6 @@ class QuestionCheckbox(Question): | ||
| 231 | ''' | 218 | ''' |
| 232 | 219 | ||
| 233 | # ------------------------------------------------------------------------ | 220 | # ------------------------------------------------------------------------ |
| 234 | - # def __init__(self, q: QDict) -> None: | ||
| 235 | - # super().__init__(q) | ||
| 236 | - | ||
| 237 | def gen(self) -> None: | 221 | def gen(self) -> None: |
| 238 | super().gen() | 222 | super().gen() |
| 239 | 223 | ||
| @@ -288,19 +272,6 @@ class QuestionCheckbox(Question): | @@ -288,19 +272,6 @@ class QuestionCheckbox(Question): | ||
| 288 | f'Please fix "{self["ref"]}" in "{self["path"]}"') | 272 | f'Please fix "{self["ref"]}" in "{self["path"]}"') |
| 289 | logger.error(msg) | 273 | logger.error(msg) |
| 290 | raise QuestionException(msg) | 274 | raise QuestionException(msg) |
| 291 | - # msg0 = ('+--------------- BEHAVIOR CHANGE NOTICE ---------------+') | ||
| 292 | - # msg1 = ('| Correct values in checkbox questions must be in the |') | ||
| 293 | - # msg2 = ('| interval [0.0, 1.0]. I will convert to the new |') | ||
| 294 | - # msg3 = ('| behavior, for now, but you should fix it. |') | ||
| 295 | - # msg4 = ('+------------------------------------------------------+') | ||
| 296 | - # logger.warning(msg0) | ||
| 297 | - # logger.warning(msg1) | ||
| 298 | - # logger.warning(msg2) | ||
| 299 | - # logger.warning(msg3) | ||
| 300 | - # logger.warning(msg4) | ||
| 301 | - # logger.warning('please fix "%s"', self["ref"]) | ||
| 302 | - # # normalize to [0,1] | ||
| 303 | - # self['correct'] = [(x+1)/2 for x in self['correct']] | ||
| 304 | 275 | ||
| 305 | # if an option is a list of (right, wrong), pick one | 276 | # if an option is a list of (right, wrong), pick one |
| 306 | options = [] | 277 | options = [] |
| @@ -356,9 +327,6 @@ class QuestionText(Question): | @@ -356,9 +327,6 @@ class QuestionText(Question): | ||
| 356 | ''' | 327 | ''' |
| 357 | 328 | ||
| 358 | # ------------------------------------------------------------------------ | 329 | # ------------------------------------------------------------------------ |
| 359 | - # def __init__(self, q: QDict) -> None: | ||
| 360 | - # super().__init__(q) | ||
| 361 | - | ||
| 362 | def gen(self) -> None: | 330 | def gen(self) -> None: |
| 363 | super().gen() | 331 | super().gen() |
| 364 | self.set_defaults(QDict({ | 332 | self.set_defaults(QDict({ |
| @@ -389,6 +357,7 @@ class QuestionText(Question): | @@ -389,6 +357,7 @@ class QuestionText(Question): | ||
| 389 | def transform(self, ans): | 357 | def transform(self, ans): |
| 390 | '''apply optional filters to the answer''' | 358 | '''apply optional filters to the answer''' |
| 391 | 359 | ||
| 360 | + # apply transformations in sequence | ||
| 392 | for transform in self['transform']: | 361 | for transform in self['transform']: |
| 393 | if transform == 'remove_space': # removes all spaces | 362 | if transform == 'remove_space': # removes all spaces |
| 394 | ans = ans.replace(' ', '') | 363 | ans = ans.replace(' ', '') |
| @@ -410,7 +379,7 @@ class QuestionText(Question): | @@ -410,7 +379,7 @@ class QuestionText(Question): | ||
| 410 | super().correct() | 379 | super().correct() |
| 411 | 380 | ||
| 412 | if self['answer'] is not None: | 381 | if self['answer'] is not None: |
| 413 | - answer = self.transform(self['answer']) # apply transformations | 382 | + answer = self.transform(self['answer']) |
| 414 | self['grade'] = 1.0 if answer in self['correct'] else 0.0 | 383 | self['grade'] = 1.0 if answer in self['correct'] else 0.0 |
| 415 | 384 | ||
| 416 | 385 | ||
| @@ -427,9 +396,6 @@ class QuestionTextRegex(Question): | @@ -427,9 +396,6 @@ class QuestionTextRegex(Question): | ||
| 427 | ''' | 396 | ''' |
| 428 | 397 | ||
| 429 | # ------------------------------------------------------------------------ | 398 | # ------------------------------------------------------------------------ |
| 430 | - # def __init__(self, q: QDict) -> None: | ||
| 431 | - # super().__init__(q) | ||
| 432 | - | ||
| 433 | def gen(self) -> None: | 399 | def gen(self) -> None: |
| 434 | super().gen() | 400 | super().gen() |
| 435 | 401 | ||
| @@ -442,14 +408,6 @@ class QuestionTextRegex(Question): | @@ -442,14 +408,6 @@ class QuestionTextRegex(Question): | ||
| 442 | if not isinstance(self['correct'], list): | 408 | if not isinstance(self['correct'], list): |
| 443 | self['correct'] = [self['correct']] | 409 | self['correct'] = [self['correct']] |
| 444 | 410 | ||
| 445 | - # converts patterns to compiled versions | ||
| 446 | - # try: | ||
| 447 | - # self['correct'] = [re.compile(a) for a in self['correct']] | ||
| 448 | - # except Exception as exc: | ||
| 449 | - # msg = f'Failed to compile regex in "{self["ref"]}"' | ||
| 450 | - # logger.error(msg) | ||
| 451 | - # raise QuestionException(msg) from exc | ||
| 452 | - | ||
| 453 | # ------------------------------------------------------------------------ | 411 | # ------------------------------------------------------------------------ |
| 454 | def correct(self) -> None: | 412 | def correct(self) -> None: |
| 455 | super().correct() | 413 | super().correct() |
| @@ -464,15 +422,6 @@ class QuestionTextRegex(Question): | @@ -464,15 +422,6 @@ class QuestionTextRegex(Question): | ||
| 464 | regex, self['answer']) | 422 | regex, self['answer']) |
| 465 | self['grade'] = 0.0 | 423 | self['grade'] = 0.0 |
| 466 | 424 | ||
| 467 | - # try: | ||
| 468 | - # if regex.match(self['answer']): | ||
| 469 | - # self['grade'] = 1.0 | ||
| 470 | - # return | ||
| 471 | - # except TypeError: | ||
| 472 | - # logger.error('While matching regex %s with answer "%s".', | ||
| 473 | - # regex.pattern, self["answer"]) | ||
| 474 | - | ||
| 475 | - | ||
| 476 | # ============================================================================ | 425 | # ============================================================================ |
| 477 | class QuestionNumericInterval(Question): | 426 | class QuestionNumericInterval(Question): |
| 478 | '''An instance of QuestionTextNumeric will always have the keys: | 427 | '''An instance of QuestionTextNumeric will always have the keys: |
| @@ -484,9 +433,6 @@ class QuestionNumericInterval(Question): | @@ -484,9 +433,6 @@ class QuestionNumericInterval(Question): | ||
| 484 | ''' | 433 | ''' |
| 485 | 434 | ||
| 486 | # ------------------------------------------------------------------------ | 435 | # ------------------------------------------------------------------------ |
| 487 | - # def __init__(self, q: QDict) -> None: | ||
| 488 | - # super().__init__(q) | ||
| 489 | - | ||
| 490 | def gen(self) -> None: | 436 | def gen(self) -> None: |
| 491 | super().gen() | 437 | super().gen() |
| 492 | 438 | ||
| @@ -548,9 +494,6 @@ class QuestionTextArea(Question): | @@ -548,9 +494,6 @@ class QuestionTextArea(Question): | ||
| 548 | ''' | 494 | ''' |
| 549 | 495 | ||
| 550 | # ------------------------------------------------------------------------ | 496 | # ------------------------------------------------------------------------ |
| 551 | - # def __init__(self, q: QDict) -> None: | ||
| 552 | - # super().__init__(q) | ||
| 553 | - | ||
| 554 | def gen(self) -> None: | 497 | def gen(self) -> None: |
| 555 | super().gen() | 498 | super().gen() |
| 556 | 499 | ||
| @@ -625,129 +568,6 @@ class QuestionTextArea(Question): | @@ -625,129 +568,6 @@ class QuestionTextArea(Question): | ||
| 625 | 568 | ||
| 626 | 569 | ||
| 627 | # ============================================================================ | 570 | # ============================================================================ |
| 628 | -# class QuestionCode(Question): | ||
| 629 | -# ''' | ||
| 630 | -# Submits answer to a JOBE server to compile and run against the test cases. | ||
| 631 | -# ''' | ||
| 632 | - | ||
| 633 | -# _outcomes = { | ||
| 634 | -# 0: 'JOBE outcome: Successful run', | ||
| 635 | -# 11: 'JOBE outcome: Compile error', | ||
| 636 | -# 12: 'JOBE outcome: Runtime error', | ||
| 637 | -# 13: 'JOBE outcome: Time limit exceeded', | ||
| 638 | -# 15: 'JOBE outcome: Successful run', | ||
| 639 | -# 17: 'JOBE outcome: Memory limit exceeded', | ||
| 640 | -# 19: 'JOBE outcome: Illegal system call', | ||
| 641 | -# 20: 'JOBE outcome: Internal error, please report', | ||
| 642 | -# 21: 'JOBE outcome: Server overload', | ||
| 643 | -# } | ||
| 644 | - | ||
| 645 | -# # ------------------------------------------------------------------------ | ||
| 646 | -# def __init__(self, q: QDict) -> None: | ||
| 647 | -# super().__init__(q) | ||
| 648 | - | ||
| 649 | -# self.set_defaults(QDict({ | ||
| 650 | -# 'text': '', | ||
| 651 | -# 'timeout': 5, # seconds | ||
| 652 | -# 'server': '127.0.0.1', # JOBE server | ||
| 653 | -# 'language': 'c', | ||
| 654 | -# 'correct': [{'stdin': '', 'stdout': '', 'stderr': '', 'args': ''}], | ||
| 655 | -# })) | ||
| 656 | - | ||
| 657 | - # ------------------------------------------------------------------------ | ||
| 658 | - # def correct(self) -> None: | ||
| 659 | - # super().correct() | ||
| 660 | - | ||
| 661 | - # if self['answer'] is None: | ||
| 662 | - # return | ||
| 663 | - | ||
| 664 | - # # submit answer to JOBE server | ||
| 665 | - # resource = '/jobe/index.php/restapi/runs/' | ||
| 666 | - # headers = {"Content-type": "application/json; charset=utf-8", | ||
| 667 | - # "Accept": "application/json"} | ||
| 668 | - | ||
| 669 | - # for expected in self['correct']: | ||
| 670 | - # data_json = json.dumps({ | ||
| 671 | - # 'run_spec' : { | ||
| 672 | - # 'language_id': self['language'], | ||
| 673 | - # 'sourcecode': self['answer'], | ||
| 674 | - # 'input': expected.get('stdin', ''), | ||
| 675 | - # }, | ||
| 676 | - # }) | ||
| 677 | - | ||
| 678 | - # try: | ||
| 679 | - # connect = http.client.HTTPConnection(self['server']) | ||
| 680 | - # connect.request( | ||
| 681 | - # method='POST', | ||
| 682 | - # url=resource, | ||
| 683 | - # body=data_json, | ||
| 684 | - # headers=headers | ||
| 685 | - # ) | ||
| 686 | - # response = connect.getresponse() | ||
| 687 | - # logger.debug('JOBE response status %d', response.status) | ||
| 688 | - # if response.status != 204: | ||
| 689 | - # content = response.read().decode('utf8') | ||
| 690 | - # if content: | ||
| 691 | - # result = json.loads(content) | ||
| 692 | - # connect.close() | ||
| 693 | - | ||
| 694 | - # except (HTTPError, ValueError): | ||
| 695 | - # logger.error('HTTPError while connecting to JOBE server') | ||
| 696 | - | ||
| 697 | - # try: | ||
| 698 | - # outcome = result['outcome'] | ||
| 699 | - # except (NameError, TypeError, KeyError): | ||
| 700 | - # logger.error('Bad result returned from JOBE server: %s', result) | ||
| 701 | - # return | ||
| 702 | - # logger.debug(self._outcomes[outcome]) | ||
| 703 | - | ||
| 704 | - | ||
| 705 | - | ||
| 706 | - # if result['cmpinfo']: # compiler errors and warnings | ||
| 707 | - # self['comments'] = f'Erros de compilação:\n{result["cmpinfo"]}' | ||
| 708 | - # self['grade'] = 0.0 | ||
| 709 | - # return | ||
| 710 | - | ||
| 711 | - # if result['stdout'] != expected.get('stdout', ''): | ||
| 712 | - # self['comments'] = 'O output gerado é diferente do esperado.' # FIXME mostrar porque? | ||
| 713 | - # self['grade'] = 0.0 | ||
| 714 | - # return | ||
| 715 | - | ||
| 716 | - # self['comments'] = 'Ok!' | ||
| 717 | - # self['grade'] = 1.0 | ||
| 718 | - | ||
| 719 | - | ||
| 720 | - # # ------------------------------------------------------------------------ | ||
| 721 | - # async def correct_async(self) -> None: | ||
| 722 | - # self.correct() # FIXME there is no async correction!!! | ||
| 723 | - | ||
| 724 | - | ||
| 725 | - # out = run_script( | ||
| 726 | - # script=self['correct'], | ||
| 727 | - # args=self['args'], | ||
| 728 | - # stdin=self['answer'], | ||
| 729 | - # timeout=self['timeout'] | ||
| 730 | - # ) | ||
| 731 | - | ||
| 732 | - # if out is None: | ||
| 733 | - # logger.warning('No grade after running "%s".', self["correct"]) | ||
| 734 | - # self['comments'] = 'O programa de correcção abortou...' | ||
| 735 | - # self['grade'] = 0.0 | ||
| 736 | - # elif isinstance(out, dict): | ||
| 737 | - # self['comments'] = out.get('comments', '') | ||
| 738 | - # try: | ||
| 739 | - # self['grade'] = float(out['grade']) | ||
| 740 | - # except ValueError: | ||
| 741 | - # logger.error('Output error in "%s".', self["correct"]) | ||
| 742 | - # except KeyError: | ||
| 743 | - # logger.error('No grade in "%s".', self["correct"]) | ||
| 744 | - # else: | ||
| 745 | - # try: | ||
| 746 | - # self['grade'] = float(out) | ||
| 747 | - # except (TypeError, ValueError): | ||
| 748 | - # logger.error('Invalid grade in "%s".', self["correct"]) | ||
| 749 | - | ||
| 750 | -# ============================================================================ | ||
| 751 | class QuestionInformation(Question): | 571 | class QuestionInformation(Question): |
| 752 | ''' | 572 | ''' |
| 753 | Not really a question, just an information panel. | 573 | Not really a question, just an information panel. |
| @@ -755,9 +575,6 @@ class QuestionInformation(Question): | @@ -755,9 +575,6 @@ class QuestionInformation(Question): | ||
| 755 | ''' | 575 | ''' |
| 756 | 576 | ||
| 757 | # ------------------------------------------------------------------------ | 577 | # ------------------------------------------------------------------------ |
| 758 | - # def __init__(self, q: QDict) -> None: | ||
| 759 | - # super().__init__(q) | ||
| 760 | - | ||
| 761 | def gen(self) -> None: | 578 | def gen(self) -> None: |
| 762 | super().gen() | 579 | super().gen() |
| 763 | self.set_defaults(QDict({ | 580 | self.set_defaults(QDict({ |
| @@ -770,7 +587,6 @@ class QuestionInformation(Question): | @@ -770,7 +587,6 @@ class QuestionInformation(Question): | ||
| 770 | self['grade'] = 1.0 # always "correct" but points should be zero! | 587 | self['grade'] = 1.0 # always "correct" but points should be zero! |
| 771 | 588 | ||
| 772 | 589 | ||
| 773 | - | ||
| 774 | # ============================================================================ | 590 | # ============================================================================ |
| 775 | def question_from(qdict: QDict) -> Question: | 591 | def question_from(qdict: QDict) -> Question: |
| 776 | ''' | 592 | ''' |
| @@ -783,7 +599,6 @@ def question_from(qdict: QDict) -> Question: | @@ -783,7 +599,6 @@ def question_from(qdict: QDict) -> Question: | ||
| 783 | 'text-regex': QuestionTextRegex, | 599 | 'text-regex': QuestionTextRegex, |
| 784 | 'numeric-interval': QuestionNumericInterval, | 600 | 'numeric-interval': QuestionNumericInterval, |
| 785 | 'textarea': QuestionTextArea, | 601 | 'textarea': QuestionTextArea, |
| 786 | - # 'code': QuestionCode, | ||
| 787 | # -- informative panels -- | 602 | # -- informative panels -- |
| 788 | 'information': QuestionInformation, | 603 | 'information': QuestionInformation, |
| 789 | 'success': QuestionInformation, | 604 | 'success': QuestionInformation, |
| @@ -856,7 +671,7 @@ class QFactory(): | @@ -856,7 +671,7 @@ class QFactory(): | ||
| 856 | logger.debug('generating %s...', self.qdict["ref"]) | 671 | logger.debug('generating %s...', self.qdict["ref"]) |
| 857 | # Shallow copy so that script generated questions will not replace | 672 | # Shallow copy so that script generated questions will not replace |
| 858 | # the original generators | 673 | # the original generators |
| 859 | - qdict = self.qdict.copy() | 674 | + qdict = QDict(self.qdict.copy()) |
| 860 | qdict['qid'] = str(uuid.uuid4()) # unique for each question | 675 | qdict['qid'] = str(uuid.uuid4()) # unique for each question |
| 861 | 676 | ||
| 862 | # If question is of generator type, an external program will be run | 677 | # If question is of generator type, an external program will be run |
perguntations/testfactory.py
| @@ -10,7 +10,7 @@ import re | @@ -10,7 +10,7 @@ import re | ||
| 10 | from typing import Any, Dict | 10 | from typing import Any, Dict |
| 11 | 11 | ||
| 12 | # this project | 12 | # this project |
| 13 | -from perguntations.questions import QFactory, QuestionException | 13 | +from perguntations.questions import QFactory, QuestionException, QDict |
| 14 | from perguntations.test import Test | 14 | from perguntations.test import Test |
| 15 | from perguntations.tools import load_yaml | 15 | from perguntations.tools import load_yaml |
| 16 | 16 | ||
| @@ -99,14 +99,14 @@ class TestFactory(dict): | @@ -99,14 +99,14 @@ class TestFactory(dict): | ||
| 99 | if question['ref'] in qrefs: | 99 | if question['ref'] in qrefs: |
| 100 | question.update(zip(('path', 'filename', 'index'), | 100 | question.update(zip(('path', 'filename', 'index'), |
| 101 | path.split(fullpath) + (i,))) | 101 | path.split(fullpath) + (i,))) |
| 102 | - if question['type'] == 'code' and 'server' not in question: | ||
| 103 | - try: | ||
| 104 | - question['server'] = self['jobe_server'] | ||
| 105 | - except KeyError as exc: | ||
| 106 | - msg = f'Missing JOBE server in "{question["ref"]}"' | ||
| 107 | - raise TestFactoryException(msg) from exc | ||
| 108 | - | ||
| 109 | - self['question_factory'][question['ref']] = QFactory(question) | 102 | + # if question['type'] == 'code' and 'server' not in question: |
| 103 | + # try: | ||
| 104 | + # question['server'] = self['jobe_server'] | ||
| 105 | + # except KeyError as exc: | ||
| 106 | + # msg = f'Missing JOBE server in "{question["ref"]}"' | ||
| 107 | + # raise TestFactoryException(msg) from exc | ||
| 108 | + | ||
| 109 | + self['question_factory'][question['ref']] = QFactory(QDict(question)) | ||
| 110 | 110 | ||
| 111 | qmissing = qrefs.difference(set(self['question_factory'].keys())) | 111 | qmissing = qrefs.difference(set(self['question_factory'].keys())) |
| 112 | if qmissing: | 112 | if qmissing: |
perguntations/tools.py