Commit 8e6019534a61621920e1352ff4cc18d642a2527a
1 parent
26c0d61d
Exists in
master
and in
1 other branch
fix mostly flake8 errors
Showing
10 changed files
with
180 additions
and
150 deletions
Show diff stats
aprendizations/factory.py
| ... | ... | @@ -33,8 +33,9 @@ import logging |
| 33 | 33 | # project |
| 34 | 34 | from aprendizations.tools import run_script |
| 35 | 35 | from aprendizations.questions import (QuestionInformation, QuestionRadio, |
| 36 | - QuestionCheckbox, QuestionText, QuestionTextRegex, QuestionTextArea, | |
| 37 | - QuestionNumericInterval) | |
| 36 | + QuestionCheckbox, QuestionText, | |
| 37 | + QuestionTextRegex, QuestionTextArea, | |
| 38 | + QuestionNumericInterval) | |
| 38 | 39 | |
| 39 | 40 | # setup logger for this module |
| 40 | 41 | logger = logging.getLogger(__name__) |
| ... | ... | @@ -47,17 +48,17 @@ class QFactory(object): |
| 47 | 48 | # Depending on the type of question, a different question class will be |
| 48 | 49 | # instantiated. All these classes derive from the base class `Question`. |
| 49 | 50 | _types = { |
| 50 | - 'radio' : QuestionRadio, | |
| 51 | - 'checkbox' : QuestionCheckbox, | |
| 52 | - 'text' : QuestionText, | |
| 53 | - 'text-regex': QuestionTextRegex, | |
| 51 | + 'radio': QuestionRadio, | |
| 52 | + 'checkbox': QuestionCheckbox, | |
| 53 | + 'text': QuestionText, | |
| 54 | + 'text-regex': QuestionTextRegex, | |
| 54 | 55 | 'numeric-interval': QuestionNumericInterval, |
| 55 | - 'textarea' : QuestionTextArea, | |
| 56 | + 'textarea': QuestionTextArea, | |
| 56 | 57 | # -- informative panels -- |
| 57 | - 'information': QuestionInformation, | |
| 58 | - 'warning' : QuestionInformation, | |
| 59 | - 'alert' : QuestionInformation, | |
| 60 | - 'success' : QuestionInformation, | |
| 58 | + 'information': QuestionInformation, | |
| 59 | + 'warning': QuestionInformation, | |
| 60 | + 'alert': QuestionInformation, | |
| 61 | + 'success': QuestionInformation, | |
| 61 | 62 | } |
| 62 | 63 | |
| 63 | 64 | def __init__(self, question_dict={}): |
| ... | ... | @@ -82,17 +83,16 @@ class QFactory(object): |
| 82 | 83 | # output is then yaml parsed into a dictionary `q`. |
| 83 | 84 | if q['type'] == 'generator': |
| 84 | 85 | logger.debug(f' \\_ Running "{q["script"]}".') |
| 85 | - q.setdefault('arg', '') # optional arguments will be sent to stdin | |
| 86 | + q.setdefault('arg', '') # optional arguments will be sent to stdin | |
| 86 | 87 | script = path.join(q['path'], q['script']) |
| 87 | 88 | out = run_script(script=script, stdin=q['arg']) |
| 88 | 89 | q.update(out) |
| 89 | 90 | |
| 90 | 91 | # Finally we create an instance of Question() |
| 91 | 92 | try: |
| 92 | - qinstance = self._types[q['type']](q) # instance with correct class | |
| 93 | + qinstance = self._types[q['type']](q) # instance matching class | |
| 93 | 94 | except KeyError as e: |
| 94 | - logger.error(f'Unknown type "{q["type"]}" in question "{q["ref"]}"') | |
| 95 | + logger.error(f'Unknown type "{q["type"]}" in "{q["ref"]}"') | |
| 95 | 96 | raise e |
| 96 | 97 | else: |
| 97 | 98 | return qinstance |
| 98 | - | ... | ... |
aprendizations/initdb.py
| ... | ... | @@ -26,40 +26,40 @@ def parse_commandline_arguments(): |
| 26 | 26 | ' is created.') |
| 27 | 27 | |
| 28 | 28 | argparser.add_argument('csvfile', |
| 29 | - nargs='*', | |
| 30 | - type=str, | |
| 31 | - default='', | |
| 32 | - help='CSV file to import (SIIUE)') | |
| 29 | + nargs='*', | |
| 30 | + type=str, | |
| 31 | + default='', | |
| 32 | + help='CSV file to import (SIIUE)') | |
| 33 | 33 | |
| 34 | 34 | argparser.add_argument('--db', |
| 35 | - default='students.db', | |
| 36 | - type=str, | |
| 37 | - help='database file') | |
| 35 | + default='students.db', | |
| 36 | + type=str, | |
| 37 | + help='database file') | |
| 38 | 38 | |
| 39 | 39 | argparser.add_argument('-A', '--admin', |
| 40 | - action='store_true', | |
| 41 | - help='insert the admin user') | |
| 40 | + action='store_true', | |
| 41 | + help='insert the admin user') | |
| 42 | 42 | |
| 43 | 43 | argparser.add_argument('-a', '--add', |
| 44 | - nargs=2, | |
| 45 | - action='append', | |
| 46 | - metavar=('uid', 'name'), | |
| 47 | - help='add new user') | |
| 44 | + nargs=2, | |
| 45 | + action='append', | |
| 46 | + metavar=('uid', 'name'), | |
| 47 | + help='add new user') | |
| 48 | 48 | |
| 49 | 49 | argparser.add_argument('-u', '--update', |
| 50 | - nargs='+', | |
| 51 | - metavar='uid', | |
| 52 | - default=[], | |
| 53 | - help='users to update') | |
| 50 | + nargs='+', | |
| 51 | + metavar='uid', | |
| 52 | + default=[], | |
| 53 | + help='users to update') | |
| 54 | 54 | |
| 55 | 55 | argparser.add_argument('--pw', |
| 56 | - default=None, | |
| 57 | - type=str, | |
| 58 | - help='set password for new and updated users') | |
| 56 | + default=None, | |
| 57 | + type=str, | |
| 58 | + help='set password for new and updated users') | |
| 59 | 59 | |
| 60 | 60 | argparser.add_argument('-V', '--verbose', |
| 61 | - action='store_true', | |
| 62 | - help='show all students in database') | |
| 61 | + action='store_true', | |
| 62 | + help='show all students in database') | |
| 63 | 63 | |
| 64 | 64 | return argparser.parse_args() |
| 65 | 65 | |
| ... | ... | @@ -104,7 +104,7 @@ def insert_students_into_db(session, students): |
| 104 | 104 | try: |
| 105 | 105 | # --- start db session --- |
| 106 | 106 | session.add_all([Student(id=s['uid'], name=s['name'], password=s['pw']) |
| 107 | - for s in students]) | |
| 107 | + for s in students]) | |
| 108 | 108 | |
| 109 | 109 | session.commit() |
| 110 | 110 | |
| ... | ... | @@ -117,7 +117,7 @@ def insert_students_into_db(session, students): |
| 117 | 117 | def show_students_in_database(session, verbose=False): |
| 118 | 118 | try: |
| 119 | 119 | users = session.query(Student).order_by(Student.id).all() |
| 120 | - except: | |
| 120 | + except Exception: | |
| 121 | 121 | raise |
| 122 | 122 | else: |
| 123 | 123 | n = len(users) |
| ... | ... | @@ -191,5 +191,5 @@ def main(): |
| 191 | 191 | |
| 192 | 192 | |
| 193 | 193 | # =========================================================================== |
| 194 | -if __name__=='__main__': | |
| 194 | +if __name__ == '__main__': | |
| 195 | 195 | main() | ... | ... |
aprendizations/knowledge.py
| 1 | 1 | |
| 2 | 2 | # python standard library |
| 3 | 3 | import random |
| 4 | -from datetime import datetime | |
| 4 | +from datetime import datetime | |
| 5 | 5 | import logging |
| 6 | 6 | |
| 7 | 7 | # libraries |
| ... | ... | @@ -13,6 +13,7 @@ import networkx as nx |
| 13 | 13 | # setup logger for this module |
| 14 | 14 | logger = logging.getLogger(__name__) |
| 15 | 15 | |
| 16 | + | |
| 16 | 17 | # ---------------------------------------------------------------------------- |
| 17 | 18 | # kowledge state of each student....?? |
| 18 | 19 | # Contains: |
| ... | ... | @@ -25,16 +26,15 @@ class StudentKnowledge(object): |
| 25 | 26 | # methods that update state |
| 26 | 27 | # ======================================================================= |
| 27 | 28 | def __init__(self, deps, factory, state={}): |
| 28 | - self.deps = deps # dependency graph shared among students | |
| 29 | + self.deps = deps # shared dependency graph | |
| 29 | 30 | self.factory = factory # question factory |
| 30 | - self.state = state # {'topic': {'level':0.5, 'date': datetime}, ...} | |
| 31 | + self.state = state # {'topic': {'level': 0.5, 'date': datetime}, ...} | |
| 31 | 32 | |
| 32 | 33 | self.update_topic_levels() # applies forgetting factor |
| 33 | - self.topic_sequence = self.recommend_topic_sequence() # ['a', 'b', ...] | |
| 34 | - self.unlock_topics() # whose dependencies have been completed | |
| 34 | + self.topic_sequence = self.recommend_topic_sequence() # ['ref1', ...] | |
| 35 | + self.unlock_topics() # whose dependencies have been completed | |
| 35 | 36 | self.current_topic = None |
| 36 | 37 | |
| 37 | - | |
| 38 | 38 | # ------------------------------------------------------------------------ |
| 39 | 39 | # Updates the proficiency levels of the topics, with forgetting factor |
| 40 | 40 | # FIXME no dependencies are considered yet... |
| ... | ... | @@ -45,7 +45,6 @@ class StudentKnowledge(object): |
| 45 | 45 | dt = now - s['date'] |
| 46 | 46 | s['level'] *= 0.95 ** dt.days # forgetting factor 0.95 FIXME |
| 47 | 47 | |
| 48 | - | |
| 49 | 48 | # ------------------------------------------------------------------------ |
| 50 | 49 | # Unlock topics whose dependencies are satisfied (> min_level) |
| 51 | 50 | # ------------------------------------------------------------------------ |
| ... | ... | @@ -57,15 +56,15 @@ class StudentKnowledge(object): |
| 57 | 56 | for topic in self.topic_sequence: |
| 58 | 57 | if topic not in self.state: # if locked |
| 59 | 58 | pred = self.deps.predecessors(topic) |
| 60 | - if all(d in self.state and self.state[d]['level'] > min_level for d in pred): | |
| 61 | - # all dependencies are done | |
| 59 | + if all(d in self.state and self.state[d]['level'] > min_level | |
| 60 | + for d in pred): # all dependencies are done | |
| 61 | + | |
| 62 | 62 | self.state[topic] = { |
| 63 | 63 | 'level': 0.0, # unlocked |
| 64 | 64 | 'date': datetime.now() |
| 65 | 65 | } |
| 66 | 66 | logger.debug(f'Unlocked "{topic}".') |
| 67 | 67 | |
| 68 | - | |
| 69 | 68 | # ------------------------------------------------------------------------ |
| 70 | 69 | # Start a new topic. |
| 71 | 70 | # questions: list of generated questions to do in the topic |
| ... | ... | @@ -74,6 +73,8 @@ class StudentKnowledge(object): |
| 74 | 73 | # FIXME async mas nao tem awaits... |
| 75 | 74 | async def start_topic(self, topic): |
| 76 | 75 | logger.debug(f'StudentKnowledge.start_topic({topic})') |
| 76 | + if self.current_topic == topic: | |
| 77 | + return False | |
| 77 | 78 | |
| 78 | 79 | # do not allow locked topics |
| 79 | 80 | if self.is_locked(topic): |
| ... | ... | @@ -94,8 +95,8 @@ class StudentKnowledge(object): |
| 94 | 95 | logger.debug(f'Questions: {", ".join(questions)}') |
| 95 | 96 | |
| 96 | 97 | # generate instances of questions |
| 97 | - gen = lambda qref: self.factory[qref].generate() | |
| 98 | - self.questions = [gen(qref) for qref in questions] | |
| 98 | + # gen = lambda qref: self.factory[qref].generate() | |
| 99 | + self.questions = [self.factory[qref].generate() for qref in questions] | |
| 99 | 100 | # self.questions = [gen(qref) for qref in questions] |
| 100 | 101 | logger.debug(f'Total: {len(self.questions)} questions') |
| 101 | 102 | |
| ... | ... | @@ -103,7 +104,6 @@ class StudentKnowledge(object): |
| 103 | 104 | self.next_question() |
| 104 | 105 | return True |
| 105 | 106 | |
| 106 | - | |
| 107 | 107 | # ------------------------------------------------------------------------ |
| 108 | 108 | # The topic has finished and there are no more questions. |
| 109 | 109 | # The topic level is updated in state and unlocks are performed. |
| ... | ... | @@ -114,11 +114,11 @@ class StudentKnowledge(object): |
| 114 | 114 | |
| 115 | 115 | self.state[self.current_topic] = { |
| 116 | 116 | 'date': datetime.now(), |
| 117 | - 'level': self.correct_answers / (self.correct_answers + self.wrong_answers) | |
| 117 | + 'level': self.correct_answers / (self.correct_answers + | |
| 118 | + self.wrong_answers) | |
| 118 | 119 | } |
| 119 | 120 | self.unlock_topics() |
| 120 | 121 | |
| 121 | - | |
| 122 | 122 | # ------------------------------------------------------------------------ |
| 123 | 123 | # corrects current question with provided answer. |
| 124 | 124 | # implements the logic: |
| ... | ... | @@ -143,21 +143,19 @@ class StudentKnowledge(object): |
| 143 | 143 | else: |
| 144 | 144 | self.wrong_answers += 1 |
| 145 | 145 | self.current_question['tries'] -= 1 |
| 146 | - logger.debug(f'Wrong answers = {self.wrong_answers}; Tries = {self.current_question["tries"]}') | |
| 147 | 146 | |
| 148 | 147 | if self.current_question['tries'] > 0: |
| 149 | 148 | action = 'try_again' |
| 150 | 149 | else: |
| 151 | 150 | action = 'wrong' |
| 152 | 151 | if self.current_question['append_wrong']: |
| 153 | - logger.debug("Appending new instance of this question to the end") | |
| 152 | + logger.debug("Append new instance of question at the end") | |
| 154 | 153 | self.questions.append(self.factory[q['ref']].generate()) |
| 155 | 154 | self.next_question() |
| 156 | 155 | |
| 157 | 156 | # returns corrected question (not new one) which might include comments |
| 158 | 157 | return q, action |
| 159 | 158 | |
| 160 | - | |
| 161 | 159 | # ------------------------------------------------------------------------ |
| 162 | 160 | # Move to next question |
| 163 | 161 | # ------------------------------------------------------------------------ |
| ... | ... | @@ -170,7 +168,8 @@ class StudentKnowledge(object): |
| 170 | 168 | else: |
| 171 | 169 | self.current_question['start_time'] = datetime.now() |
| 172 | 170 | default_maxtries = self.deps.nodes[self.current_topic]['max_tries'] |
| 173 | - self.current_question['tries'] = self.current_question.get('max_tries', default_maxtries) | |
| 171 | + maxtries = self.current_question.get('max_tries', default_maxtries) | |
| 172 | + self.current_question['tries'] = maxtries | |
| 174 | 173 | logger.debug(f'Next question is "{self.current_question["ref"]}"') |
| 175 | 174 | |
| 176 | 175 | return self.current_question # question or None |
| ... | ... | @@ -185,9 +184,21 @@ class StudentKnowledge(object): |
| 185 | 184 | # ------------------------------------------------------------------------ |
| 186 | 185 | # compute recommended sequence of topics ['a', 'b', ...] |
| 187 | 186 | # ------------------------------------------------------------------------ |
| 188 | - def recommend_topic_sequence(self): | |
| 187 | + def recommend_topic_sequence(self, target=None): | |
| 189 | 188 | return list(nx.topological_sort(self.deps)) |
| 190 | 189 | |
| 190 | + # if target is None: | |
| 191 | + # target = list(nx.topological_sort(self.deps))[-1] | |
| 192 | + | |
| 193 | + # weights = {} | |
| 194 | + # tseq = [target] | |
| 195 | + | |
| 196 | + # def topic_weights(d, g, n): | |
| 197 | + # pred = g.predecessors(n) | |
| 198 | + # for p in pred: | |
| 199 | + # topic_weights(d, g, p) | |
| 200 | + # d[n] = 1 + sum(d[m]) | |
| 201 | + | |
| 191 | 202 | # ------------------------------------------------------------------------ |
| 192 | 203 | def get_current_question(self): |
| 193 | 204 | return self.current_question |
| ... | ... | @@ -212,11 +223,12 @@ class StudentKnowledge(object): |
| 212 | 223 | 'type': self.deps.nodes[ref]['type'], |
| 213 | 224 | 'name': self.deps.nodes[ref]['name'], |
| 214 | 225 | 'level': self.state[ref]['level'] if ref in self.state else None |
| 215 | - } for ref in self.topic_sequence ] | |
| 226 | + } for ref in self.topic_sequence] | |
| 216 | 227 | |
| 217 | 228 | # ------------------------------------------------------------------------ |
| 218 | 229 | def get_topic_progress(self): |
| 219 | - return self.correct_answers / (1 + self.correct_answers + len(self.questions)) | |
| 230 | + return self.correct_answers / (1 + self.correct_answers + | |
| 231 | + len(self.questions)) | |
| 220 | 232 | |
| 221 | 233 | # ------------------------------------------------------------------------ |
| 222 | 234 | def get_topic_level(self, topic): |
| ... | ... | @@ -231,4 +243,3 @@ class StudentKnowledge(object): |
| 231 | 243 | # ------------------------------------------------------------------------ |
| 232 | 244 | # def get_recommended_topic(self): # FIXME untested |
| 233 | 245 | # return min(self.state.items(), key=lambda x: x[1]['level'])[0] |
| 234 | - | ... | ... |
aprendizations/learnapp.py
| ... | ... | @@ -28,7 +28,8 @@ logger = logging.getLogger(__name__) |
| 28 | 28 | async def _bcrypt_hash(a, b): |
| 29 | 29 | # loop = asyncio.get_running_loop() # FIXME python 3.7 only |
| 30 | 30 | loop = asyncio.get_event_loop() |
| 31 | - return await loop.run_in_executor(None, bcrypt.hashpw, a.encode('utf-8'), b) | |
| 31 | + return await loop.run_in_executor(None, bcrypt.hashpw, | |
| 32 | + a.encode('utf-8'), b) | |
| 32 | 33 | |
| 33 | 34 | |
| 34 | 35 | async def check_password(try_pw, pw): |
| ... | ... | @@ -77,7 +78,9 @@ class LearnApp(object): |
| 77 | 78 | async def login(self, uid, try_pw): |
| 78 | 79 | with self.db_session() as s: |
| 79 | 80 | try: |
| 80 | - name, password = s.query(Student.name, Student.password).filter_by(id=uid).one() | |
| 81 | + name, password = s.query(Student.name, Student.password) \ | |
| 82 | + .filter_by(id=uid) \ | |
| 83 | + .one() | |
| 81 | 84 | except Exception: |
| 82 | 85 | logger.info(f'User "{uid}" does not exist') |
| 83 | 86 | return False |
| ... | ... | @@ -103,7 +106,8 @@ class LearnApp(object): |
| 103 | 106 | self.online[uid] = { |
| 104 | 107 | 'number': uid, |
| 105 | 108 | 'name': name, |
| 106 | - 'state': StudentKnowledge(deps=self.deps, factory=self.factory, state=state), | |
| 109 | + 'state': StudentKnowledge(deps=self.deps, factory=self.factory, | |
| 110 | + state=state), | |
| 107 | 111 | 'counter': counter + 1, # counts simultaneous logins |
| 108 | 112 | } |
| 109 | 113 | |
| ... | ... | @@ -162,14 +166,17 @@ class LearnApp(object): |
| 162 | 166 | date = str(knowledge.get_topic_date(topic)) |
| 163 | 167 | |
| 164 | 168 | with self.db_session() as s: |
| 165 | - a = s.query(StudentTopic).filter_by(student_id=uid, topic_id=topic).one_or_none() | |
| 169 | + a = s.query(StudentTopic) \ | |
| 170 | + .filter_by(student_id=uid, topic_id=topic) \ | |
| 171 | + .one_or_none() | |
| 166 | 172 | if a is None: |
| 167 | 173 | # insert new studenttopic into database |
| 168 | 174 | logger.debug('Database insert new studenttopic') |
| 169 | 175 | t = s.query(Topic).get(topic) |
| 170 | 176 | u = s.query(Student).get(uid) |
| 171 | 177 | # association object |
| 172 | - a = StudentTopic(level=level, date=date, topic=t, student=u) | |
| 178 | + a = StudentTopic(level=level, date=date, topic=t, | |
| 179 | + student=u) | |
| 173 | 180 | u.topics.append(a) |
| 174 | 181 | else: |
| 175 | 182 | # update studenttopic in database |
| ... | ... | @@ -204,7 +211,7 @@ class LearnApp(object): |
| 204 | 211 | missing_topics = [Topic(id=t) for t in topics if t not in dbtopics] |
| 205 | 212 | if missing_topics: |
| 206 | 213 | s.add_all(missing_topics) |
| 207 | - logger.info(f'Added {len(missing_topics)} new topics to the database') | |
| 214 | + logger.info(f'Added {len(missing_topics)} new topics') | |
| 208 | 215 | |
| 209 | 216 | # ------------------------------------------------------------------------ |
| 210 | 217 | # setup and check database |
| ... | ... | @@ -262,7 +269,8 @@ class LearnApp(object): |
| 262 | 269 | t['file'] = attr.get('file', default_file) # questions.yaml |
| 263 | 270 | t['shuffle'] = attr.get('shuffle', default_shuffle) |
| 264 | 271 | t['max_tries'] = attr.get('max_tries', default_maxtries) |
| 265 | - t['forgetting_factor'] = attr.get('forgetting_factor', default_forgetting_factor) | |
| 272 | + t['forgetting_factor'] = attr.get('forgetting_factor', | |
| 273 | + default_forgetting_factor) | |
| 266 | 274 | t['choose'] = attr.get('choose', default_choose) |
| 267 | 275 | t['append_wrong'] = attr.get('append_wrong', default_append_wrong) |
| 268 | 276 | t['questions'] = attr.get('questions', []) |
| ... | ... | @@ -347,10 +355,6 @@ class LearnApp(object): |
| 347 | 355 | def get_topic_name(self, ref): |
| 348 | 356 | return self.deps.node[ref]['name'] |
| 349 | 357 | |
| 350 | - # # ------------------------------------------------------------------------ | |
| 351 | - # def get_topic_type(self, ref): | |
| 352 | - # return self.deps.node[ref]['type'] | |
| 353 | - | |
| 354 | 358 | # ------------------------------------------------------------------------ |
| 355 | 359 | def get_current_public_dir(self, uid): |
| 356 | 360 | topic = self.online[uid]['state'].get_current_topic() | ... | ... |
aprendizations/models.py
| 1 | -# FIXME see https://stackoverflow.com/questions/38248415/many-to-many-with-association-object-and-all-relationships-defined-crashes-on-de | |
| 2 | -# And fix the association StudentTopic etc | |
| 3 | 1 | |
| 4 | - | |
| 5 | - | |
| 6 | -from sqlalchemy import Table, Column, ForeignKey, Integer, Float, String, DateTime | |
| 2 | +from sqlalchemy import Column, ForeignKey, Integer, Float, String | |
| 7 | 3 | from sqlalchemy.ext.declarative import declarative_base |
| 8 | 4 | from sqlalchemy.orm import relationship |
| 9 | 5 | |
| ... | ... | @@ -12,13 +8,14 @@ from sqlalchemy.orm import relationship |
| 12 | 8 | # Declare ORM |
| 13 | 9 | Base = declarative_base() |
| 14 | 10 | |
| 11 | + | |
| 15 | 12 | # --------------------------------------------------------------------------- |
| 16 | 13 | class StudentTopic(Base): |
| 17 | 14 | __tablename__ = 'studenttopic' |
| 18 | 15 | student_id = Column(String, ForeignKey('students.id'), primary_key=True) |
| 19 | 16 | topic_id = Column(String, ForeignKey('topics.id'), primary_key=True) |
| 20 | 17 | level = Column(Float) |
| 21 | - date = Column(String) | |
| 18 | + date = Column(String) | |
| 22 | 19 | |
| 23 | 20 | # --- |
| 24 | 21 | student = relationship('Student', back_populates='topics') |
| ... | ... | @@ -31,6 +28,7 @@ class StudentTopic(Base): |
| 31 | 28 | level: "{self.level}" |
| 32 | 29 | date: "{self.date}"''' |
| 33 | 30 | |
| 31 | + | |
| 34 | 32 | # --------------------------------------------------------------------------- |
| 35 | 33 | # Registered students |
| 36 | 34 | # --------------------------------------------------------------------------- |
| ... | ... | @@ -50,12 +48,13 @@ class Student(Base): |
| 50 | 48 | name: "{self.name}" |
| 51 | 49 | password: "{self.password}"''' |
| 52 | 50 | |
| 51 | + | |
| 53 | 52 | # --------------------------------------------------------------------------- |
| 54 | 53 | # Table with every answer given |
| 55 | 54 | # --------------------------------------------------------------------------- |
| 56 | 55 | class Answer(Base): |
| 57 | 56 | __tablename__ = 'answers' |
| 58 | - id = Column(Integer, primary_key=True) # auto_increment | |
| 57 | + id = Column(Integer, primary_key=True) # auto_increment | |
| 59 | 58 | ref = Column(String) |
| 60 | 59 | grade = Column(Float) |
| 61 | 60 | starttime = Column(String) |
| ... | ... | @@ -77,6 +76,7 @@ class Answer(Base): |
| 77 | 76 | student_id: "{self.student_id}" |
| 78 | 77 | topic_id: "{self.topic_id}"''' |
| 79 | 78 | |
| 79 | + | |
| 80 | 80 | # --------------------------------------------------------------------------- |
| 81 | 81 | # Table with student state |
| 82 | 82 | # --------------------------------------------------------------------------- | ... | ... |
aprendizations/questions.py
| ... | ... | @@ -43,12 +43,12 @@ class Question(dict): |
| 43 | 43 | 'files': {}, |
| 44 | 44 | }) |
| 45 | 45 | |
| 46 | - def correct(self): | |
| 46 | + def correct(self) -> float: | |
| 47 | 47 | self['comments'] = '' |
| 48 | 48 | self['grade'] = 0.0 |
| 49 | 49 | return 0.0 |
| 50 | 50 | |
| 51 | - async def correct_async(self): | |
| 51 | + async def correct_async(self) -> float: | |
| 52 | 52 | # loop = asyncio.get_running_loop() # FIXME python 3.7 only |
| 53 | 53 | loop = asyncio.get_event_loop() |
| 54 | 54 | grade = await loop.run_in_executor(None, self.correct) |
| ... | ... | @@ -56,7 +56,7 @@ class Question(dict): |
| 56 | 56 | |
| 57 | 57 | def set_defaults(self, d): |
| 58 | 58 | 'Add k:v pairs from default dict d for nonexistent keys' |
| 59 | - for k,v in d.items(): | |
| 59 | + for k, v in d.items(): | |
| 60 | 60 | self.setdefault(k, v) |
| 61 | 61 | |
| 62 | 62 | |
| ... | ... | @@ -73,7 +73,7 @@ class QuestionRadio(Question): |
| 73 | 73 | choose (int) # only used if shuffle=True |
| 74 | 74 | ''' |
| 75 | 75 | |
| 76 | - #------------------------------------------------------------------------ | |
| 76 | + # ------------------------------------------------------------------------ | |
| 77 | 77 | def __init__(self, q): |
| 78 | 78 | super().__init__(q) |
| 79 | 79 | |
| ... | ... | @@ -90,7 +90,8 @@ class QuestionRadio(Question): |
| 90 | 90 | # always convert to list, e.g. correct: 2 --> correct: [0,0,1,0,0] |
| 91 | 91 | # correctness levels from 0.0 to 1.0 (no discount here!) |
| 92 | 92 | if isinstance(self['correct'], int): |
| 93 | - self['correct'] = [1.0 if x==self['correct'] else 0.0 for x in range(n)] | |
| 93 | + self['correct'] = [1.0 if x == self['correct'] else 0.0 | |
| 94 | + for x in range(n)] | |
| 94 | 95 | |
| 95 | 96 | if self['shuffle']: |
| 96 | 97 | # separate right from wrong options |
| ... | ... | @@ -101,8 +102,8 @@ class QuestionRadio(Question): |
| 101 | 102 | |
| 102 | 103 | # choose 1 correct option |
| 103 | 104 | r = random.choice(right) |
| 104 | - options = [ self['options'][r] ] | |
| 105 | - correct = [ 1.0 ] | |
| 105 | + options = [self['options'][r]] | |
| 106 | + correct = [1.0] | |
| 106 | 107 | |
| 107 | 108 | # choose remaining wrong options |
| 108 | 109 | random.shuffle(wrong) |
| ... | ... | @@ -112,10 +113,10 @@ class QuestionRadio(Question): |
| 112 | 113 | |
| 113 | 114 | # final shuffle of the options |
| 114 | 115 | perm = random.sample(range(self['choose']), self['choose']) |
| 115 | - self['options'] = [ str(options[i]) for i in perm ] | |
| 116 | - self['correct'] = [ float(correct[i]) for i in perm ] | |
| 116 | + self['options'] = [str(options[i]) for i in perm] | |
| 117 | + self['correct'] = [float(correct[i]) for i in perm] | |
| 117 | 118 | |
| 118 | - #------------------------------------------------------------------------ | |
| 119 | + # ------------------------------------------------------------------------ | |
| 119 | 120 | # can return negative values for wrong answers |
| 120 | 121 | def correct(self): |
| 121 | 122 | super().correct() |
| ... | ... | @@ -144,7 +145,7 @@ class QuestionCheckbox(Question): |
| 144 | 145 | answer (None or an actual answer) |
| 145 | 146 | ''' |
| 146 | 147 | |
| 147 | - #------------------------------------------------------------------------ | |
| 148 | + # ------------------------------------------------------------------------ | |
| 148 | 149 | def __init__(self, q): |
| 149 | 150 | super().__init__(q) |
| 150 | 151 | |
| ... | ... | @@ -153,24 +154,25 @@ class QuestionCheckbox(Question): |
| 153 | 154 | # set defaults if missing |
| 154 | 155 | self.set_defaults({ |
| 155 | 156 | 'text': '', |
| 156 | - 'correct': [1.0] * n, # Using 0.0 breaks the (right, wrong) options | |
| 157 | + 'correct': [1.0] * n, # Using 0.0 breaks (right, wrong) options | |
| 157 | 158 | 'shuffle': True, |
| 158 | 159 | 'discount': True, |
| 159 | 160 | 'choose': n, # number of options |
| 160 | 161 | }) |
| 161 | 162 | |
| 162 | 163 | if len(self['correct']) != n: |
| 163 | - logger.error(f'Options and correct size mismatch in "{self["ref"]}", file "{self["filename"]}".') | |
| 164 | + logger.error(f'Options and correct size mismatch in ' | |
| 165 | + f'"{self["ref"]}", file "{self["filename"]}".') | |
| 164 | 166 | |
| 165 | 167 | # if an option is a list of (right, wrong), pick one |
| 166 | 168 | # FIXME it's possible that all options are chosen wrong |
| 167 | 169 | options = [] |
| 168 | 170 | correct = [] |
| 169 | - for o,c in zip(self['options'], self['correct']): | |
| 171 | + for o, c in zip(self['options'], self['correct']): | |
| 170 | 172 | if isinstance(o, list): |
| 171 | - r = random.randint(0,1) | |
| 173 | + r = random.randint(0, 1) | |
| 172 | 174 | o = o[r] |
| 173 | - c = c if r==0 else -c | |
| 175 | + c = c if r == 0 else -c | |
| 174 | 176 | options.append(str(o)) |
| 175 | 177 | correct.append(float(c)) |
| 176 | 178 | |
| ... | ... | @@ -181,7 +183,7 @@ class QuestionCheckbox(Question): |
| 181 | 183 | self['options'] = [options[i] for i in perm] |
| 182 | 184 | self['correct'] = [correct[i] for i in perm] |
| 183 | 185 | |
| 184 | - #------------------------------------------------------------------------ | |
| 186 | + # ------------------------------------------------------------------------ | |
| 185 | 187 | # can return negative values for wrong answers |
| 186 | 188 | def correct(self): |
| 187 | 189 | super().correct() |
| ... | ... | @@ -215,7 +217,7 @@ class QuestionText(Question): |
| 215 | 217 | answer (None or an actual answer) |
| 216 | 218 | ''' |
| 217 | 219 | |
| 218 | - #------------------------------------------------------------------------ | |
| 220 | + # ------------------------------------------------------------------------ | |
| 219 | 221 | def __init__(self, q): |
| 220 | 222 | super().__init__(q) |
| 221 | 223 | |
| ... | ... | @@ -231,7 +233,7 @@ class QuestionText(Question): |
| 231 | 233 | # make sure all elements of the list are strings |
| 232 | 234 | self['correct'] = [str(a) for a in self['correct']] |
| 233 | 235 | |
| 234 | - #------------------------------------------------------------------------ | |
| 236 | + # ------------------------------------------------------------------------ | |
| 235 | 237 | # can return negative values for wrong answers |
| 236 | 238 | def correct(self): |
| 237 | 239 | super().correct() |
| ... | ... | @@ -251,7 +253,7 @@ class QuestionTextRegex(Question): |
| 251 | 253 | answer (None or an actual answer) |
| 252 | 254 | ''' |
| 253 | 255 | |
| 254 | - #------------------------------------------------------------------------ | |
| 256 | + # ------------------------------------------------------------------------ | |
| 255 | 257 | def __init__(self, q): |
| 256 | 258 | super().__init__(q) |
| 257 | 259 | |
| ... | ... | @@ -260,15 +262,17 @@ class QuestionTextRegex(Question): |
| 260 | 262 | 'correct': '$.^', # will always return false |
| 261 | 263 | }) |
| 262 | 264 | |
| 263 | - #------------------------------------------------------------------------ | |
| 265 | + # ------------------------------------------------------------------------ | |
| 264 | 266 | # can return negative values for wrong answers |
| 265 | 267 | def correct(self): |
| 266 | 268 | super().correct() |
| 267 | 269 | if self['answer'] is not None: |
| 268 | 270 | try: |
| 269 | - self['grade'] = 1.0 if re.match(self['correct'], self['answer']) else 0.0 | |
| 271 | + ok = re.match(self['correct'], self['answer']) | |
| 270 | 272 | except TypeError: |
| 271 | - logger.error('While matching regex {self["correct"]} with answer {self["answer"]}.') | |
| 273 | + logger.error(f'While matching regex {self["correct"]} with ' | |
| 274 | + f'answer {self["answer"]}.') | |
| 275 | + self['grade'] = 1.0 if ok else 0.0 | |
| 272 | 276 | |
| 273 | 277 | return self['grade'] |
| 274 | 278 | |
| ... | ... | @@ -283,7 +287,7 @@ class QuestionNumericInterval(Question): |
| 283 | 287 | An answer is correct if it's in the closed interval. |
| 284 | 288 | ''' |
| 285 | 289 | |
| 286 | - #------------------------------------------------------------------------ | |
| 290 | + # ------------------------------------------------------------------------ | |
| 287 | 291 | def __init__(self, q): |
| 288 | 292 | super().__init__(q) |
| 289 | 293 | |
| ... | ... | @@ -292,7 +296,7 @@ class QuestionNumericInterval(Question): |
| 292 | 296 | 'correct': [1.0, -1.0], # will always return false |
| 293 | 297 | }) |
| 294 | 298 | |
| 295 | - #------------------------------------------------------------------------ | |
| 299 | + # ------------------------------------------------------------------------ | |
| 296 | 300 | # can return negative values for wrong answers |
| 297 | 301 | def correct(self): |
| 298 | 302 | super().correct() |
| ... | ... | @@ -302,7 +306,8 @@ class QuestionNumericInterval(Question): |
| 302 | 306 | try: # replace , by . and convert to float |
| 303 | 307 | answer = float(self['answer'].replace(',', '.', 1)) |
| 304 | 308 | except ValueError: |
| 305 | - self['comments'] = 'A resposta tem de ser numérica, por exemplo 12.345.' | |
| 309 | + self['comments'] = ('A resposta tem de ser numérica, ' | |
| 310 | + 'por exemplo 12.345.') | |
| 306 | 311 | self['grade'] = 0.0 |
| 307 | 312 | else: |
| 308 | 313 | self['grade'] = 1.0 if lower <= answer <= upper else 0.0 |
| ... | ... | @@ -320,7 +325,7 @@ class QuestionTextArea(Question): |
| 320 | 325 | lines (int) |
| 321 | 326 | ''' |
| 322 | 327 | |
| 323 | - #------------------------------------------------------------------------ | |
| 328 | + # ------------------------------------------------------------------------ | |
| 324 | 329 | def __init__(self, q): |
| 325 | 330 | super().__init__(q) |
| 326 | 331 | |
| ... | ... | @@ -331,10 +336,9 @@ class QuestionTextArea(Question): |
| 331 | 336 | 'correct': '' # trying to execute this will fail => grade 0.0 |
| 332 | 337 | }) |
| 333 | 338 | |
| 334 | - # self['correct'] = path.join(self['path'], self['correct']) | |
| 335 | - self['correct'] = path.abspath(path.normpath(path.join(self['path'], self['correct']))) | |
| 339 | + self['correct'] = path.join(self['path'], self['correct']) # FIXME | |
| 336 | 340 | |
| 337 | - #------------------------------------------------------------------------ | |
| 341 | + # ------------------------------------------------------------------------ | |
| 338 | 342 | # can return negative values for wrong answers |
| 339 | 343 | def correct(self): |
| 340 | 344 | super().correct() |
| ... | ... | @@ -347,31 +351,30 @@ class QuestionTextArea(Question): |
| 347 | 351 | timeout=self['timeout'] |
| 348 | 352 | ) |
| 349 | 353 | |
| 350 | - if type(out) in (int, float): | |
| 351 | - self['grade'] = float(out) | |
| 352 | - | |
| 353 | - elif isinstance(out, dict): | |
| 354 | + if isinstance(out, dict): | |
| 354 | 355 | self['comments'] = out.get('comments', '') |
| 355 | 356 | try: |
| 356 | 357 | self['grade'] = float(out['grade']) |
| 357 | 358 | except ValueError: |
| 358 | - logger.error(f'Correction script of "{self["ref"]}" returned nonfloat.') | |
| 359 | + logger.error(f'Output error in "{self["correct"]}".') | |
| 359 | 360 | except KeyError: |
| 360 | - logger.error('Correction script of "{self["ref"]}" returned no "grade".') | |
| 361 | + logger.error(f'No grade in "{self["correct"]}".') | |
| 362 | + else: | |
| 363 | + self['grade'] = float(out) | |
| 361 | 364 | |
| 362 | 365 | return self['grade'] |
| 363 | 366 | |
| 364 | 367 | |
| 365 | 368 | # =========================================================================== |
| 366 | 369 | class QuestionInformation(Question): |
| 367 | - #------------------------------------------------------------------------ | |
| 370 | + # ------------------------------------------------------------------------ | |
| 368 | 371 | def __init__(self, q): |
| 369 | 372 | super().__init__(q) |
| 370 | 373 | self.set_defaults({ |
| 371 | 374 | 'text': '', |
| 372 | 375 | }) |
| 373 | 376 | |
| 374 | - #------------------------------------------------------------------------ | |
| 377 | + # ------------------------------------------------------------------------ | |
| 375 | 378 | # can return negative values for wrong answers |
| 376 | 379 | def correct(self): |
| 377 | 380 | super().correct() | ... | ... |
aprendizations/redirect.py
aprendizations/serve.py
| ... | ... | @@ -381,7 +381,8 @@ def parse_cmdline_arguments(): |
| 381 | 381 | |
| 382 | 382 | return argparser.parse_args() |
| 383 | 383 | |
| 384 | -# ------------------------------------------------------------------------- | |
| 384 | + | |
| 385 | +# ---------------------------------------------------------------------------- | |
| 385 | 386 | def get_logger_config(debug=False): |
| 386 | 387 | if debug: |
| 387 | 388 | filename = 'logger-debug.yaml' |
| ... | ... | @@ -397,7 +398,8 @@ def get_logger_config(debug=False): |
| 397 | 398 | 'version': 1, |
| 398 | 399 | 'formatters': { |
| 399 | 400 | 'standard': { |
| 400 | - 'format': '%(asctime)s %(name)-24s %(levelname)-8s %(message)s', | |
| 401 | + 'format': '%(asctime)s %(name)-24s %(levelname)-8s ' | |
| 402 | + '%(message)s', | |
| 401 | 403 | 'datefmt': '%H:%M:%S', |
| 402 | 404 | }, |
| 403 | 405 | }, | ... | ... |
aprendizations/tools.py
| ... | ... | @@ -23,11 +23,13 @@ logger = logging.getLogger(__name__) |
| 23 | 23 | # ------------------------------------------------------------------------- |
| 24 | 24 | class MathBlockGrammar(mistune.BlockGrammar): |
| 25 | 25 | block_math = re.compile(r'^\$\$(.*?)\$\$', re.DOTALL) |
| 26 | - latex_environment = re.compile(r'^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}', re.DOTALL) | |
| 26 | + latex_environment = re.compile(r'^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}', | |
| 27 | + re.DOTALL) | |
| 27 | 28 | |
| 28 | 29 | |
| 29 | 30 | class MathBlockLexer(mistune.BlockLexer): |
| 30 | - default_rules = ['block_math', 'latex_environment'] + mistune.BlockLexer.default_rules | |
| 31 | + default_rules = ['block_math', 'latex_environment'] \ | |
| 32 | + + mistune.BlockLexer.default_rules | |
| 31 | 33 | |
| 32 | 34 | def __init__(self, rules=None, **kwargs): |
| 33 | 35 | if rules is None: |
| ... | ... | @@ -82,27 +84,29 @@ class MarkdownWithMath(mistune.Markdown): |
| 82 | 84 | return self.renderer.block_math(self.token['text']) |
| 83 | 85 | |
| 84 | 86 | def output_latex_environment(self): |
| 85 | - return self.renderer.latex_environment(self.token['name'], self.token['text']) | |
| 86 | - | |
| 87 | + return self.renderer.latex_environment(self.token['name'], | |
| 88 | + self.token['text']) | |
| 87 | 89 | |
| 88 | 90 | |
| 89 | 91 | class HighlightRenderer(mistune.Renderer): |
| 90 | 92 | def block_code(self, code, lang='text'): |
| 91 | 93 | try: |
| 92 | 94 | lexer = get_lexer_by_name(lang, stripall=False) |
| 93 | - except: | |
| 95 | + except Exception: | |
| 94 | 96 | lexer = get_lexer_by_name('text', stripall=False) |
| 95 | 97 | |
| 96 | 98 | formatter = HtmlFormatter() |
| 97 | 99 | return highlight(code, lexer, formatter) |
| 98 | 100 | |
| 99 | 101 | def table(self, header, body): |
| 100 | - return '<table class="table table-sm"><thead class="thead-light">' + header + '</thead><tbody>' + body + "</tbody></table>" | |
| 102 | + return '<table class="table table-sm"><thead class="thead-light">' \ | |
| 103 | + + header + '</thead><tbody>' + body + "</tbody></table>" | |
| 101 | 104 | |
| 102 | 105 | def image(self, src, title, alt): |
| 103 | 106 | alt = mistune.escape(alt, quote=True) |
| 104 | 107 | title = mistune.escape(title or '', quote=True) |
| 105 | - return f'<img src="/file/{src}" class="img-fluid mx-auto d-block" alt="{alt}" title="{title}">' | |
| 108 | + return f'<img src="/file/{src}" class="img-fluid mx-auto d-block" ' \ | |
| 109 | + f'alt="{alt}" title="{title}">' | |
| 106 | 110 | |
| 107 | 111 | # Pass math through unaltered - mathjax does the rendering in the browser |
| 108 | 112 | def block_math(self, text): |
| ... | ... | @@ -115,7 +119,9 @@ class HighlightRenderer(mistune.Renderer): |
| 115 | 119 | return fr'$$$ {text} $$$' |
| 116 | 120 | |
| 117 | 121 | |
| 118 | -markdown = MarkdownWithMath(HighlightRenderer(escape=True)) # hard_wrap=True to insert <br> on newline | |
| 122 | +# hard_wrap=True to insert <br> on newline | |
| 123 | +markdown = MarkdownWithMath(HighlightRenderer(escape=True)) | |
| 124 | + | |
| 119 | 125 | |
| 120 | 126 | def md_to_html(text, strip_p_tag=False, q=None): |
| 121 | 127 | md = markdown(text) |
| ... | ... | @@ -124,6 +130,7 @@ def md_to_html(text, strip_p_tag=False, q=None): |
| 124 | 130 | else: |
| 125 | 131 | return md |
| 126 | 132 | |
| 133 | + | |
| 127 | 134 | # --------------------------------------------------------------------------- |
| 128 | 135 | # load data from yaml file |
| 129 | 136 | # --------------------------------------------------------------------------- |
| ... | ... | @@ -143,10 +150,12 @@ def load_yaml(filename, default=None): |
| 143 | 150 | default = yaml.load(f) |
| 144 | 151 | except yaml.YAMLError as e: |
| 145 | 152 | mark = e.problem_mark |
| 146 | - logger.error(f'In YAML file "{filename}" near line {mark.line}, column {mark.column+1}') | |
| 153 | + logger.error(f'In file "{filename}" near line {mark.line}, ' | |
| 154 | + f'column {mark.column+1}') | |
| 147 | 155 | finally: |
| 148 | 156 | return default |
| 149 | 157 | |
| 158 | + | |
| 150 | 159 | # --------------------------------------------------------------------------- |
| 151 | 160 | # Runs a script and returns its stdout parsed as yaml, or None on error. |
| 152 | 161 | # The script is run in another process but this function blocks waiting |
| ... | ... | @@ -156,27 +165,27 @@ def run_script(script, stdin='', timeout=5): |
| 156 | 165 | script = path.expanduser(script) |
| 157 | 166 | try: |
| 158 | 167 | p = subprocess.run([script], |
| 159 | - input=stdin, | |
| 160 | - stdout=subprocess.PIPE, | |
| 161 | - stderr=subprocess.STDOUT, | |
| 162 | - universal_newlines=True, | |
| 163 | - timeout=timeout, | |
| 164 | - ) | |
| 168 | + input=stdin, | |
| 169 | + stdout=subprocess.PIPE, | |
| 170 | + stderr=subprocess.STDOUT, | |
| 171 | + universal_newlines=True, | |
| 172 | + timeout=timeout, | |
| 173 | + ) | |
| 165 | 174 | except FileNotFoundError: |
| 166 | - logger.error(f'Could not execute script "{script}": not found.') | |
| 175 | + logger.error(f'Can not execute script "{script}": not found.') | |
| 167 | 176 | except PermissionError: |
| 168 | - logger.error(f'Could not execute script "{script}": wrong permissions.') | |
| 177 | + logger.error(f'Can not execute script "{script}": wrong permissions.') | |
| 169 | 178 | except OSError: |
| 170 | - logger.error(f'Could not execute script "{script}": unknown reason.') | |
| 179 | + logger.error(f'Can not execute script "{script}": unknown reason.') | |
| 171 | 180 | except subprocess.TimeoutExpired: |
| 172 | - logger.error(f'Timeout exceeded ({timeout}s) while running "{script}".') | |
| 181 | + logger.error(f'Timeout {timeout}s exceeded while running "{script}".') | |
| 173 | 182 | else: |
| 174 | 183 | if p.returncode != 0: |
| 175 | - logger.error(f'Script "{script}" returned error code {p.returncode}.') | |
| 184 | + logger.error(f'Return code {p.returncode} running "{script}".') | |
| 176 | 185 | else: |
| 177 | 186 | try: |
| 178 | 187 | output = yaml.load(p.stdout) |
| 179 | - except: | |
| 188 | + except Exception: | |
| 180 | 189 | logger.error(f'Error parsing yaml output of "{script}"') |
| 181 | 190 | else: |
| 182 | 191 | return output | ... | ... |
demo/solar_system/correct-first_3_planets.py
| ... | ... | @@ -9,19 +9,19 @@ s = sys.stdin.read() |
| 9 | 9 | answer = set(re.findall(r'[\w]+', s.lower())) |
| 10 | 10 | answer.difference_update({'e', 'a', 'planeta', 'planetas'}) # ignore these |
| 11 | 11 | |
| 12 | -# correct set of colors | |
| 13 | -planets = set(['mercúrio', 'vénus', 'terra']) | |
| 12 | +# correct set of planets | |
| 13 | +planets = {'mercúrio', 'vénus', 'terra'} | |
| 14 | 14 | |
| 15 | -correct = set.intersection(answer, planets) # os que acertei | |
| 16 | -wrong = set.difference(answer, planets) # os que errei | |
| 17 | -# allnames = set.union(answer, planets) | |
| 15 | +correct = set.intersection(answer, planets) # the ones I got right | |
| 16 | +wrong = set.difference(answer, planets) # the ones I got wrong | |
| 18 | 17 | |
| 19 | 18 | grade = (len(correct) - len(wrong)) / len(planets) |
| 20 | 19 | |
| 21 | -out = f'grade: {grade}' | |
| 20 | +out = f'''--- | |
| 21 | +grade: {grade}''' | |
| 22 | 22 | |
| 23 | 23 | if grade < 1.0: |
| 24 | - out += '\ncomments: A resposta correcta é `Mercúrio, Vénus e Terra`.' | |
| 24 | + out += '\ncomments: Vou dar uma ajuda, as iniciais são M, V e T...' | |
| 25 | 25 | |
| 26 | 26 | print(out) |
| 27 | -exit(0) | |
| 28 | 27 | \ No newline at end of file |
| 28 | +exit(0) | ... | ... |