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