Commit 5f3daeeb3ac4e298da47a9e570544b0786861a36

Authored by Miguel Barão
1 parent 2a25e542
Exists in master and in 1 other branch dev

add lots of type annotations

BUGS.md
1 1  
2 2 # BUGS
3 3  
  4 +- marking all options right in a radio question breaks!
  5 +- impedir que quando students.db não é encontrado, crie um ficheiro vazio.
  6 +- opcao --prefix devia afectar a base de dados?
4 7 - duplo clicks no botao "responder" dessincroniza as questões, ver debounce em https://stackoverflow.com/questions/20281546/how-to-prevent-calling-of-en-event-handler-twice-on-fast-clicks
5 8 - quando termina topico devia apagar as perguntas todas (se falhar a gerar novo topico, aparecem perguntas do antigo)
6 9 - apos clicar no botao responder, inactivar o input (importante quando o tempo de correcção é grande)
... ...
aprendizations/learnapp.py
... ... @@ -6,7 +6,7 @@ from contextlib import contextmanager # `with` statement in db sessions
6 6 import asyncio
7 7 from datetime import datetime
8 8 from random import random
9   -from typing import Dict
  9 +from typing import Any, Dict, Iterable, List, Optional, Tuple
10 10  
11 11 # third party libraries
12 12 import bcrypt
... ... @@ -16,7 +16,7 @@ import networkx as nx
16 16 # this project
17 17 from .models import Student, Answer, Topic, StudentTopic
18 18 from .student import StudentState
19   -from .questions import QFactory
  19 +from .questions import Question, QFactory, QDict
20 20 from .tools import load_yaml
21 21  
22 22 # setup logger for this module
... ... @@ -56,16 +56,21 @@ class LearnApp(object):
56 56 # ------------------------------------------------------------------------
57 57 # init
58 58 # ------------------------------------------------------------------------
59   - def __init__(self, config_files, prefix, db, check=False):
  59 + def __init__(self,
  60 + config_files: List[str],
  61 + prefix: str, # path to topics
  62 + db: str, # database filename
  63 + check: bool = False) -> None:
  64 +
60 65 self.db_setup(db) # setup database and check students
61   - self.online = dict() # online students
  66 + self.online: Dict[str, Dict] = dict() # online students
62 67  
63 68 self.deps = nx.DiGraph(prefix=prefix)
64 69 for c in config_files:
65 70 self.populate_graph(c)
66 71  
67 72 # factory is a dict with question generators for all topics
68   - self.factory = self.make_factory() # {'qref': QFactory()}
  73 + self.factory: Dict[str, QFactory] = self.make_factory()
69 74  
70 75 # if graph has topics that are not in the database, add them
71 76 self.db_add_missing_topics(self.deps.nodes())
... ... @@ -74,10 +79,10 @@ class LearnApp(object):
74 79 self.sanity_check_questions()
75 80  
76 81 # ------------------------------------------------------------------------
77   - def sanity_check_questions(self):
  82 + def sanity_check_questions(self) -> None:
78 83 logger.info('Starting sanity checks (may take a while...)')
79 84  
80   - errors = 0
  85 + errors: int = 0
81 86 for qref in self.factory:
82 87 logger.debug(f'checking {qref}...')
83 88 try:
... ... @@ -114,7 +119,8 @@ class LearnApp(object):
114 119 # ------------------------------------------------------------------------
115 120 # login
116 121 # ------------------------------------------------------------------------
117   - async def login(self, uid, pw):
  122 + async def login(self, uid: str, pw: str) -> bool:
  123 +
118 124 with self.db_session() as s:
119 125 found = s.query(Student.name, Student.password) \
120 126 .filter_by(id=uid) \
... ... @@ -168,14 +174,14 @@ class LearnApp(object):
168 174 # ------------------------------------------------------------------------
169 175 # logout
170 176 # ------------------------------------------------------------------------
171   - def logout(self, uid):
  177 + def logout(self, uid: str) -> None:
172 178 del self.online[uid]
173 179 logger.info(f'User "{uid}" logged out')
174 180  
175 181 # ------------------------------------------------------------------------
176 182 # change_password. returns True if password is successfully changed.
177 183 # ------------------------------------------------------------------------
178   - async def change_password(self, uid, pw):
  184 + async def change_password(self, uid: str, pw: str) -> bool:
179 185 if not pw:
180 186 return False
181 187  
... ... @@ -191,12 +197,14 @@ class LearnApp(object):
191 197 return True
192 198  
193 199 # ------------------------------------------------------------------------
194   - # checks answer (updating student state) and returns grade.
  200 + # checks answer (updating student state) and returns grade. FIXME type of answer
195 201 # ------------------------------------------------------------------------
196   - async def check_answer(self, uid, answer):
  202 + async def check_answer(self, uid: str, answer) -> Tuple[Question, str]:
197 203 knowledge = self.online[uid]['state']
198 204 topic = knowledge.get_current_topic()
  205 +
199 206 q, action = await knowledge.check_answer(answer) # may move questions
  207 +
200 208 logger.info(f'User "{uid}" got {q["grade"]:.2} in "{q["ref"]}"')
201 209  
202 210 # always save grade of answered question
... ... @@ -213,8 +221,8 @@ class LearnApp(object):
213 221 if knowledge.topic_has_finished():
214 222 # finished topic, save into database
215 223 logger.info(f'User "{uid}" finished "{topic}"')
216   - level = knowledge.get_topic_level(topic)
217   - date = str(knowledge.get_topic_date(topic))
  224 + level: float = knowledge.get_topic_level(topic)
  225 + date: str = str(knowledge.get_topic_date(topic))
218 226  
219 227 with self.db_session() as s:
220 228 a = s.query(StudentTopic) \
... ... @@ -242,19 +250,19 @@ class LearnApp(object):
242 250 # ------------------------------------------------------------------------
243 251 # Start new topic
244 252 # ------------------------------------------------------------------------
245   - async def start_topic(self, uid, topic):
  253 + async def start_topic(self, uid: str, topic: str) -> None:
246 254 student = self.online[uid]['state']
247 255 try:
248 256 await student.start_topic(topic)
249 257 except Exception as e:
250   - logger.warning(f'User "{uid}" couldn\'t start "{topic}": {e}')
  258 + logger.warning(f'User "{uid}" could not start "{topic}": {e}')
251 259 else:
252 260 logger.info(f'User "{uid}" started topic "{topic}"')
253 261  
254 262 # ------------------------------------------------------------------------
255 263 # Fill db table 'Topic' with topics from the graph if not already there.
256 264 # ------------------------------------------------------------------------
257   - def db_add_missing_topics(self, topics):
  265 + def db_add_missing_topics(self, topics: List[str]) -> None:
258 266 with self.db_session() as s:
259 267 new_topics = [Topic(id=t) for t in topics
260 268 if (t,) not in s.query(Topic.id)]
... ... @@ -267,15 +275,16 @@ class LearnApp(object):
267 275 # ------------------------------------------------------------------------
268 276 # setup and check database contents
269 277 # ------------------------------------------------------------------------
270   - def db_setup(self, db):
  278 + def db_setup(self, db: str) -> None:
  279 +
271 280 logger.info(f'Checking database "{db}":')
272 281 engine = sa.create_engine(f'sqlite:///{db}', echo=False)
273 282 self.Session = sa.orm.sessionmaker(bind=engine)
274 283 try:
275 284 with self.db_session() as s:
276   - n = s.query(Student).count()
277   - m = s.query(Topic).count()
278   - q = s.query(Answer).count()
  285 + n: int = s.query(Student).count()
  286 + m: int = s.query(Topic).count()
  287 + q: int = s.query(Answer).count()
279 288 except Exception:
280 289 logger.error(f'Database "{db}" not usable!')
281 290 raise DatabaseUnusableError()
... ... @@ -293,21 +302,22 @@ class LearnApp(object):
293 302 #
294 303 # Edges are obtained from the deps defined in the YAML file for each topic.
295 304 # ------------------------------------------------------------------------
296   - def populate_graph(self, conffile: str):
  305 + def populate_graph(self, conffile: str) -> None:
  306 +
297 307 logger.info(f'Populating graph from: {conffile}...')
298   - config = load_yaml(conffile) # course configuration
  308 + config: Dict[str, Any] = load_yaml(conffile) # course configuration
299 309  
300 310 # default attributes that apply to the topics
301   - default_file = config.get('file', 'questions.yaml')
302   - default_shuffle_questions = config.get('shuffle_questions', True)
303   - default_choose = config.get('choose', 9999)
304   - default_forgetting_factor = config.get('forgetting_factor', 1.0)
305   - default_maxtries = config.get('max_tries', 3)
306   - default_append_wrong = config.get('append_wrong', True)
307   - default_min_level = config.get('min_level', 0.01) # to unlock topic
  311 + default_file: str = config.get('file', 'questions.yaml')
  312 + default_shuffle_questions: bool = config.get('shuffle_questions', True)
  313 + default_choose: int = config.get('choose', 9999)
  314 + default_forgetting_factor: float = config.get('forgetting_factor', 1.0)
  315 + default_maxtries: int = config.get('max_tries', 3)
  316 + default_append_wrong: bool = config.get('append_wrong', True)
  317 + default_min_level: float = config.get('min_level', 0.01) # to unlock
308 318  
309 319 # iterate over topics and populate graph
310   - topics = config.get('topics', {})
  320 + topics: Dict[str, Dict] = config.get('topics', {})
311 321 g = self.deps # dependency graph
312 322  
313 323 g.add_nodes_from(topics.keys())
... ... @@ -345,15 +355,18 @@ class LearnApp(object):
345 355 # Buils dictionary of question factories
346 356 # ------------------------------------------------------------------------
347 357 def make_factory(self) -> Dict[str, QFactory]:
  358 +
348 359 logger.info('Building questions factory:')
349   - factory = {} # {'qref': QFactory()}
  360 + factory: Dict[str, QFactory] = {}
350 361 g = self.deps
351 362 for tref in g.nodes():
352 363 t = g.node[tref]
353 364  
354 365 # load questions as list of dicts
355   - topicpath = path.join(g.graph['prefix'], tref)
356   - questions = load_yaml(path.join(topicpath, t['file']), default=[])
  366 + topicpath: str = path.join(g.graph['prefix'], tref)
  367 + fullpath: str = path.join(topicpath, t['file'])
  368 +
  369 + questions: List[QDict] = load_yaml(fullpath, default=[])
357 370  
358 371 # update refs to include topic as prefix.
359 372 # refs are required to be unique only within the file.
... ... @@ -389,16 +402,16 @@ class LearnApp(object):
389 402 return self.online[uid].get('name', '')
390 403  
391 404 # ------------------------------------------------------------------------
392   - def get_student_state(self, uid: str):
  405 + def get_student_state(self, uid: str) -> List[Dict[str, Any]]:
393 406 return self.online[uid]['state'].get_knowledge_state()
394 407  
395 408 # ------------------------------------------------------------------------
396   - def get_student_progress(self, uid: str):
  409 + def get_student_progress(self, uid: str) -> float:
397 410 return self.online[uid]['state'].get_topic_progress()
398 411  
399 412 # ------------------------------------------------------------------------
400   - def get_current_question(self, uid: str):
401   - return self.online[uid]['state'].get_current_question() # dict
  413 + def get_current_question(self, uid: str) -> Optional[Question]:
  414 + return self.online[uid]['state'].get_current_question()
402 415  
403 416 # ------------------------------------------------------------------------
404 417 def get_current_question_id(self, uid: str) -> str:
... ... @@ -410,27 +423,29 @@ class LearnApp(object):
410 423  
411 424 # ------------------------------------------------------------------------
412 425 def get_student_topic(self, uid: str) -> str:
413   - return self.online[uid]['state'].get_current_topic() # str
  426 + return self.online[uid]['state'].get_current_topic()
414 427  
415 428 # ------------------------------------------------------------------------
416 429 def get_title(self) -> str:
417   - return self.deps.graph.get('title', '') # FIXME
  430 + return self.deps.graph.get('title', '')
418 431  
419 432 # ------------------------------------------------------------------------
420 433 def get_topic_name(self, ref: str) -> str:
421 434 return self.deps.node[ref]['name']
422 435  
423 436 # ------------------------------------------------------------------------
424   - def get_current_public_dir(self, uid):
425   - topic = self.online[uid]['state'].get_current_topic()
426   - prefix = self.deps.graph['prefix']
  437 + def get_current_public_dir(self, uid: str) -> str:
  438 + topic: str = self.online[uid]['state'].get_current_topic()
  439 + prefix: str = self.deps.graph['prefix']
427 440 return path.join(prefix, topic, 'public')
428 441  
429 442 # ------------------------------------------------------------------------
430   - def get_rankings(self, uid):
  443 + def get_rankings(self, uid: str) -> Iterable[Tuple[str, str, float, float]]:
  444 +
431 445 logger.info(f'User "{uid}" get rankings')
432 446 with self.db_session() as s:
433 447 students = s.query(Student.id, Student.name).all()
  448 +
434 449 # topic progress
435 450 student_topics = s.query(StudentTopic.student_id,
436 451 StudentTopic.topic_id,
... ... @@ -448,10 +463,11 @@ class LearnApp(object):
448 463 all())
449 464  
450 465 # compute percentage of right answers
451   - perf = {uid: right.get(uid, 0.0)/total[uid] for uid in total}
  466 + perf: Dict[str, float] = {uid: right.get(uid, 0.0)/total[uid]
  467 + for uid in total}
452 468  
453 469 # compute topic progress
454   - prog = {s[0]: 0.0 for s in students}
  470 + prog: Dict[str, float] = {s[0]: 0.0 for s in students}
455 471 now = datetime.now()
456 472 for uid, topic, level, date in student_topics:
457 473 date = datetime.strptime(date, "%Y-%m-%d %H:%M:%S.%f")
... ...
aprendizations/main.py
... ... @@ -6,6 +6,7 @@ import logging
6 6 from os import environ, path
7 7 import ssl
8 8 import sys
  9 +from typing import Any, Dict
9 10  
10 11 # this project
11 12 from .learnapp import LearnApp, DatabaseUnusableError
... ... @@ -61,7 +62,7 @@ def parse_cmdline_arguments():
61 62  
62 63  
63 64 # ----------------------------------------------------------------------------
64   -def get_logger_config(debug=False):
  65 +def get_logger_config(debug: bool = False) -> Dict[str, Any]:
65 66 if debug:
66 67 filename, level = 'logger-debug.yaml', 'DEBUG'
67 68 else:
... ... @@ -70,7 +71,7 @@ def get_logger_config(debug=False):
70 71 config_dir = environ.get('XDG_CONFIG_HOME', '~/.config/')
71 72 config_file = path.join(path.expanduser(config_dir), APP_NAME, filename)
72 73  
73   - default_config = {
  74 + default_config: Dict[str, Any] = {
74 75 'version': 1,
75 76 'formatters': {
76 77 'standard': {
... ...
aprendizations/serve.py
... ... @@ -8,6 +8,7 @@ import mimetypes
8 8 from os import path
9 9 import signal
10 10 import sys
  11 +from typing import List, Optional, Tuple, Union
11 12 import uuid
12 13  
13 14 # third party libraries
... ... @@ -39,6 +40,7 @@ def admin_only(func):
39 40 # WebApplication - Tornado Web Server
40 41 # ============================================================================
41 42 class WebApplication(tornado.web.Application):
  43 +
42 44 def __init__(self, learnapp, debug=False):
43 45 handlers = [
44 46 (r'/login', LoginHandler),
... ... @@ -304,6 +306,7 @@ class QuestionHandler(BaseHandler):
304 306  
305 307 # --- answers are in a list. fix depending on question type
306 308 qtype = self.learn.get_student_question_type(user)
  309 + ans: Optional[Union[List, str]]
307 310 if qtype in ('success', 'information', 'info'):
308 311 ans = None
309 312 elif qtype == 'radio' and not answer:
... ... @@ -365,7 +368,7 @@ class QuestionHandler(BaseHandler):
365 368 # ----------------------------------------------------------------------------
366 369 # Signal handler to catch Ctrl-C and abort server
367 370 # ----------------------------------------------------------------------------
368   -def signal_handler(signal, frame):
  371 +def signal_handler(signal, frame) -> None:
369 372 r = input(' --> Stop webserver? (yes/no) ').lower()
370 373 if r == 'yes':
371 374 tornado.ioloop.IOLoop.current().stop()
... ... @@ -376,7 +379,11 @@ def signal_handler(signal, frame):
376 379  
377 380  
378 381 # ----------------------------------------------------------------------------
379   -def run_webserver(app, ssl, port=8443, debug=False):
  382 +def run_webserver(app,
  383 + ssl,
  384 + port: int = 8443,
  385 + debug: bool = False) -> None:
  386 +
380 387 # --- create web application
381 388 try:
382 389 webapp = WebApplication(app, debug=debug)
... ...
aprendizations/student.py
... ... @@ -3,7 +3,7 @@
3 3 import random
4 4 from datetime import datetime
5 5 import logging
6   -from typing import List, Optional
  6 +from typing import List, Optional, Tuple
7 7  
8 8 # third party libraries
9 9 import networkx as nx
... ... @@ -28,20 +28,21 @@ class StudentState(object):
28 28 # =======================================================================
29 29 # methods that update state
30 30 # =======================================================================
31   - def __init__(self, deps, factory, state={}):
  31 + def __init__(self, deps, factory, state={}) -> None:
32 32 self.deps = deps # shared dependency graph
33 33 self.factory = factory # question factory
34 34 self.state = state # {'topic': {'level': 0.5, 'date': datetime}, ...}
35 35  
36 36 self.update_topic_levels() # applies forgetting factor
37 37 self.unlock_topics() # whose dependencies have been completed
38   - self.topic_sequence = self.recommend_topic_sequence() # ['ref1', ...]
39   - self.current_topic = None
  38 +
  39 + self.topic_sequence: List[str] = self.recommend_topic_sequence()
  40 + self.current_topic: Optional[str] = None
40 41  
41 42 # ------------------------------------------------------------------------
42 43 # Updates the proficiency levels of the topics, with forgetting factor
43 44 # ------------------------------------------------------------------------
44   - def update_topic_levels(self):
  45 + def update_topic_levels(self) -> None:
45 46 now = datetime.now()
46 47 for tref, s in self.state.items():
47 48 dt = now - s['date']
... ... @@ -51,7 +52,7 @@ class StudentState(object):
51 52 # ------------------------------------------------------------------------
52 53 # Unlock topics whose dependencies are satisfied (> min_level)
53 54 # ------------------------------------------------------------------------
54   - def unlock_topics(self):
  55 + def unlock_topics(self) -> None:
55 56 for topic in self.deps.nodes():
56 57 if topic not in self.state: # if locked
57 58 pred = self.deps.predecessors(topic)
... ... @@ -72,8 +73,8 @@ class StudentState(object):
72 73 # questions: list of generated questions to do in the topic
73 74 # current_question: the current question to be presented
74 75 # ------------------------------------------------------------------------
75   - async def start_topic(self, topic: str):
76   - logger.debug(f'starting "{topic}"')
  76 + async def start_topic(self, topic: str) -> None:
  77 + logger.debug(f'start topic "{topic}"')
77 78  
78 79 # avoid regenerating questions in the middle of the current topic
79 80 if self.current_topic == topic:
... ... @@ -112,7 +113,7 @@ class StudentState(object):
112 113 # The topic level is updated in state and unlocks are performed.
113 114 # The current topic is unchanged.
114 115 # ------------------------------------------------------------------------
115   - def finish_topic(self):
  116 + def finish_topic(self) -> None:
116 117 logger.debug(f'finished {self.current_topic}')
117 118  
118 119 self.state[self.current_topic] = {
... ... @@ -129,8 +130,8 @@ class StudentState(object):
129 130 # - if answer ok, goes to next question
130 131 # - if wrong, counts number of tries. If exceeded, moves on.
131 132 # ------------------------------------------------------------------------
132   - async def check_answer(self, answer):
133   - q = self.current_question
  133 + async def check_answer(self, answer) -> Tuple[Question, str]:
  134 + q: Question = self.current_question
134 135 q['answer'] = answer
135 136 q['finish_time'] = datetime.now()
136 137 logger.debug(f'checking answer of {q["ref"]}...')
... ...