Commit a566a5d6395366df475d15df76969fcc14f0c0ba

Authored by Miguel Barão
1 parent 255d1724
Exists in master and in 1 other branch dev

refactoring. split serve.py into serve.py and main.py

commandline options: --version and --port
BUGS.md
1 1  
2 2 # BUGS
3 3  
  4 +- quando scale_max não é 20, as cores das barras continuam a reflectir a escala 0,20
  5 +- servidor ntpd para configurar a data/hora dos portateis dell
  6 +- link na pagina com a nota para voltar ao principio.
  7 +- CRITICAL se answer for `i<n` a revisão de provas mostra apenas i (interpreta `<` como tag?)
  8 +- sock.bind(sockaddr) OSError: [Errno 48] Address already in use
4 9 - na pagina grade.html as barras estao normalizadas para os limites scale_min e max do teste actual e nao do realizado.
5 10 - codigo `hello world` nao esta a preservar o whitespace. O renderer de markdown gera a tag <code> que não preserva whitespace. Necessario adicionar <pre>.
6 11 - teste nao esta a mostrar imagens de vez em quando.
... ... @@ -16,6 +21,7 @@ ou usar push (websockets?)
16 21 - Test.reset_answers() unused.
17 22 - mudar ref do test para test_id (ref já é usado nas perguntas)
18 23 - incluir test_id na tabela questions (futuro semestre, pode quebrar compatibilidade).
  24 +- na pagina admin, mostrar com cor vermelha as horas de entrada dos alunos que ja tenha excedido o tempo
19 25  
20 26 # TODO
21 27  
... ...
README.md
... ... @@ -42,7 +42,7 @@ Download and install:
42 42 git clone https://git.xdi.uevora.pt/perguntations.git
43 43 cd perguntations
44 44 npm install
45   -pip3 install . # this must come last
  45 +pip3 install .
46 46 ```
47 47  
48 48 The command `npm` installs the javascript libraries and then `pip3` installs the python webserver. This will also install any required dependencies.
... ...
demo/demo.yaml
... ... @@ -7,7 +7,7 @@ ref: tutorial
7 7  
8 8 # Database with student credentials and grades of all questions and tests done
9 9 # The database is an sqlite3 file generate with the command initdb
10   -database: ../demo/students.db
  10 +database: students.db
11 11  
12 12 # Directory where the tests including submitted answers and grades are stored.
13 13 # The submitted tests and their corrections can be reviewed later.
... ... @@ -64,3 +64,20 @@ questions:
64 64 - tut-success
65 65 - tut-warning
66 66 - tut-alert
  67 +
  68 +
  69 +# test:
  70 +# - ref1
  71 +# - block: a
  72 +# - block: [b, c]
  73 +# - ref2
  74 +
  75 +# blocks:
  76 +# a:
  77 +# - ref1
  78 +# - ref2
  79 +# - ref3
  80 +# b:
  81 +# - rr4
  82 +# - rr5
  83 +# - rr6
... ...
demo/questions/questions-tutorial.yaml
... ... @@ -180,8 +180,8 @@
180 180 A solução correcta é a **Opção 0** ou a **Opção 1**.
181 181  
182 182 # ----------------------------------------------------------------------------
183   -- ref: tut-checkbox
184   - type: checkbox
  183 +- type: checkbox
  184 + ref: tut-checkbox
185 185 title: Escolha múltipla, várias opções correctas
186 186 text: |
187 187 As perguntas de escolha múltipla permitem apresentar um conjunto de opções
... ... @@ -359,7 +359,7 @@
359 359 ou em vírgula flutuante, como em `0.23`, `1e-3`.
360 360 correct: [3.14, 3.15]
361 361 solution: |
362   - Sabems que $\pi\approx 3.14159265359$.
  362 + Sabemos que $\pi\approx 3.14159265359$.
363 363 Portanto, um exemplo de uma resposta correcta é `3.1416`.
364 364  
365 365 # ---------------------------------------------------------------------------
... ... @@ -556,6 +556,7 @@
556 556 This question is not included in the test and will not shown up.
557 557 It also lacks a "ref" and is automatically named
558 558 `questions/questions-tutorial.yaml:0012`.
  559 + A warning is shown on the console about this.
559 560 The number at the end is the index position of this question.
560 561 Indices start at 0.
561 562  
... ...
perguntations/app.py
... ... @@ -43,6 +43,14 @@ async def hash_password(pw):
43 43  
44 44 # ============================================================================
45 45 # Application
  46 +# state:
  47 +# self.Session
  48 +# self.online - {uid:
  49 +# {'student':{...}, 'test': {...}},
  50 +# ...
  51 +# }
  52 +# self.allowd - {'123', '124', ...}
  53 +# self.testfactory - TestFactory
46 54 # ============================================================================
47 55 class App(object):
48 56 # ------------------------------------------------------------------------
... ... @@ -61,12 +69,12 @@ class App(object):
61 69 session.close()
62 70  
63 71 # ------------------------------------------------------------------------
64   - def __init__(self, conf={}):
  72 + def __init__(self, conf):
65 73 self.online = dict() # {uid: {'student':{...}, 'test': {...}}, ...}
66 74 self.allowed = set([]) # '0' is hardcoded to allowed elsewhere
67 75  
68   - logger.info(f'Loading test configuration "{conf["filename"]}".')
69   - testconf = load_yaml(conf['filename'])
  76 + logger.info(f'Loading test configuration "{conf["testfile"]}".')
  77 + testconf = load_yaml(conf['testfile'])
70 78 testconf.update(conf) # command line options override configuration
71 79  
72 80 # start test factory
... ... @@ -89,13 +97,15 @@ class App(object):
89 97 except Exception:
90 98 raise AppException(f'Database unusable {dbfile}.')
91 99 else:
92   - logger.info(f'Database {dbfile} has {n} students.')
  100 + logger.info(f'Database "{dbfile}" has {n} students.')
93 101  
94 102 # command line option --allow-all
95 103 if conf['allow_all']:
96 104 logger.info('Allowing all students:')
97 105 for student in self.get_all_students():
98 106 self.allow_student(student[0])
  107 + else:
  108 + logger.info('Students not yet allowed to login.')
99 109  
100 110 # ------------------------------------------------------------------------
101 111 # FIXME unused???
... ...
perguntations/main.py 0 → 100644
... ... @@ -0,0 +1,144 @@
  1 +#!/usr/bin/env python3
  2 +
  3 +# python standard library
  4 +import argparse
  5 +import logging
  6 +import os
  7 +from os import environ, path
  8 +import ssl
  9 +import sys
  10 +# from typing import Any, Dict
  11 +
  12 +# this project
  13 +from .app import App
  14 +from .serve import run_webserver
  15 +from .tools import load_yaml
  16 +from . import APP_NAME, APP_VERSION
  17 +
  18 +
  19 +# ----------------------------------------------------------------------------
  20 +def parse_cmdline_arguments():
  21 + parser = argparse.ArgumentParser(
  22 + description='Server for online tests. Enrolled students and tests '
  23 + 'have to be previously configured. Please read the documentation '
  24 + 'included with this software before running the server.')
  25 + parser.add_argument('testfile',
  26 + type=str, nargs='?', # FIXME only one test supported
  27 + help='tests in YAML format')
  28 + parser.add_argument('--allow-all',
  29 + action='store_true',
  30 + help='Allow all students to login immediately')
  31 + parser.add_argument('--debug',
  32 + action='store_true',
  33 + help='Enable debug messages')
  34 + parser.add_argument('--show-ref',
  35 + action='store_true',
  36 + help='Show question references')
  37 + parser.add_argument('--review',
  38 + action='store_true',
  39 + help='Review mode: doesn\'t generate test')
  40 + parser.add_argument('--port',
  41 + type=int, default=8443,
  42 + help='port for the HTTPS server (default: 8443)')
  43 + parser.add_argument('-v', '--version', action='store_true',
  44 + help='Show version information and exit')
  45 + return parser.parse_args()
  46 +
  47 +
  48 +# ----------------------------------------------------------------------------
  49 +def get_logger_config(debug=False):
  50 + if debug:
  51 + filename = 'logger-debug.yaml'
  52 + level = 'DEBUG'
  53 + else:
  54 + filename = 'logger.yaml'
  55 + level = 'INFO'
  56 +
  57 + config_dir = environ.get('XDG_CONFIG_HOME', '~/.config/')
  58 + config_file = path.join(path.expanduser(config_dir), APP_NAME, filename)
  59 +
  60 + default_config = {
  61 + 'version': 1,
  62 + 'formatters': {
  63 + 'standard': {
  64 + 'format': '%(asctime)s %(levelname)-8s %(message)s',
  65 + 'datefmt': '%H:%M',
  66 + },
  67 + },
  68 + 'handlers': {
  69 + 'default': {
  70 + 'level': level,
  71 + 'class': 'logging.StreamHandler',
  72 + 'formatter': 'standard',
  73 + 'stream': 'ext://sys.stdout',
  74 + },
  75 + },
  76 + 'loggers': {
  77 + '': { # configuration for serve.py
  78 + 'handlers': ['default'],
  79 + 'level': level,
  80 + },
  81 + },
  82 + }
  83 + default_config['loggers'].update({
  84 + APP_NAME+'.'+module: {
  85 + 'handlers': ['default'],
  86 + 'level': level,
  87 + 'propagate': False,
  88 + } for module in ['app', 'models', 'factory', 'questions',
  89 + 'test', 'tools']})
  90 +
  91 + return load_yaml(config_file, default=default_config)
  92 +
  93 +
  94 +# ----------------------------------------------------------------------------
  95 +# Tornado web server
  96 +# ----------------------------------------------------------------------------
  97 +def main():
  98 + args = parse_cmdline_arguments()
  99 +
  100 + if args.version:
  101 + print(f'{APP_NAME} {APP_VERSION}\nPython {sys.version}')
  102 + sys.exit(0)
  103 +
  104 + # --- Setup logging ------------------------------------------------------
  105 + logging.config.dictConfig(get_logger_config(args.debug))
  106 + logging.info('====================== Start Logging ======================')
  107 +
  108 + # --- start application --------------------------------------------------
  109 + config = {
  110 + 'testfile': args.testfile,
  111 + 'debug': args.debug,
  112 + 'allow_all': args.allow_all,
  113 + 'show_ref': args.show_ref,
  114 + 'review': args.review,
  115 + }
  116 +
  117 + # testapp = App(config)
  118 + try:
  119 + testapp = App(config)
  120 + except Exception:
  121 + logging.critical('Failed to start application.')
  122 + sys.exit(-1)
  123 +
  124 + # --- get SSL certificates -----------------------------------------------
  125 + if 'XDG_DATA_HOME' in os.environ:
  126 + certs_dir = path.join(os.environ['XDG_DATA_HOME'], 'certs')
  127 + else:
  128 + certs_dir = path.expanduser('~/.local/share/certs')
  129 +
  130 + ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
  131 + try:
  132 + ssl_ctx.load_cert_chain(path.join(certs_dir, 'cert.pem'),
  133 + path.join(certs_dir, 'privkey.pem'))
  134 + except FileNotFoundError:
  135 + logging.critical(f'SSL certificates missing in {certs_dir}')
  136 + sys.exit(-1)
  137 +
  138 + # --- run webserver ----------------------------------------------------
  139 + run_webserver(app=testapp, ssl=ssl_ctx, port=args.port, debug=args.debug)
  140 +
  141 +
  142 +# ----------------------------------------------------------------------------
  143 +if __name__ == "__main__":
  144 + main()
... ...
perguntations/serve.py
1 1 #!/usr/bin/env python3
2 2  
3 3 # python standard library
4   -import os
5 4 from os import path
6 5 import sys
7 6 import base64
... ... @@ -21,9 +20,6 @@ import tornado.web
21 20 import tornado.httpserver
22 21  
23 22 # this project
24   -from perguntations.app import App, AppException
25   -from perguntations.tools import load_yaml
26   -from perguntations import APP_NAME
27 23 from perguntations.parser_markdown import md_to_html
28 24  
29 25  
... ... @@ -159,7 +155,7 @@ class AdminHandler(BaseHandler):
159 155 'data': {
160 156 'title': self.testapp.testfactory['title'],
161 157 'ref': self.testapp.testfactory['ref'],
162   - 'filename': self.testapp.testfactory['filename'],
  158 + 'filename': self.testapp.testfactory['testfile'],
163 159 'database': self.testapp.testfactory['database'],
164 160 'answers_dir': self.testapp.testfactory['answers_dir'],
165 161 }
... ... @@ -390,153 +386,44 @@ class ReviewHandler(BaseHandler):
390 386 templ=self._templates)
391 387  
392 388  
  389 +
393 390 # ----------------------------------------------------------------------------
394 391 def signal_handler(signal, frame):
395 392 r = input(' --> Stop webserver? (yes/no) ')
396   - if r in ('yes', 'YES'):
  393 + if r.lower() == 'yes':
397 394 tornado.ioloop.IOLoop.current().stop()
398 395 logging.critical('Webserver stopped.')
399 396 sys.exit(0)
400 397  
401   -
402 398 # ----------------------------------------------------------------------------
403   -def parse_cmdline_arguments():
404   - parser = argparse.ArgumentParser(
405   - description='Server for online tests. Enrolled students and tests '
406   - 'have to be previously configured. Please read the documentation '
407   - 'included with this software before running the server.')
408   - parser.add_argument('testfile',
409   - type=str, nargs='+', # FIXME only one test supported
410   - help='test configuration in YAML format')
411   - parser.add_argument('--allow-all',
412   - action='store_true',
413   - help='Allow all students to login immediately')
414   - parser.add_argument('--debug',
415   - action='store_true',
416   - help='Enable debug messages')
417   - parser.add_argument('--show-ref',
418   - action='store_true',
419   - help='Show question references')
420   - parser.add_argument('--review',
421   - action='store_true',
422   - help='Review mode: doesn\'t generate test')
423   - return parser.parse_args()
424   -
425   -
426   -# ----------------------------------------------------------------------------
427   -def get_logger_config(debug=False):
428   - if debug:
429   - filename = 'logger-debug.yaml'
430   - level = 'DEBUG'
431   - else:
432   - filename = 'logger.yaml'
433   - level = 'INFO'
434   -
435   - config_dir = os.environ.get('XDG_CONFIG_HOME', '~/.config/')
436   - config_file = path.join(path.expanduser(config_dir), APP_NAME, filename)
437   -
438   - default_config = {
439   - 'version': 1,
440   - 'formatters': {
441   - 'standard': {
442   - 'format': '%(asctime)s %(levelname)-8s %(message)s',
443   - 'datefmt': '%H:%M',
444   - },
445   - },
446   - 'handlers': {
447   - 'default': {
448   - 'level': level,
449   - 'class': 'logging.StreamHandler',
450   - 'formatter': 'standard',
451   - 'stream': 'ext://sys.stdout',
452   - },
453   - },
454   - 'loggers': {
455   - '': { # configuration for serve.py
456   - 'handlers': ['default'],
457   - 'level': level,
458   - },
459   - },
460   - }
461   - default_config['loggers'].update({
462   - APP_NAME+'.'+module: {
463   - 'handlers': ['default'],
464   - 'level': level,
465   - 'propagate': False,
466   - } for module in ['app', 'models', 'factory', 'questions',
467   - 'test', 'tools']})
468   -
469   - return load_yaml(config_file, default=default_config)
470   -
471   -
472   -# ----------------------------------------------------------------------------
473   -# Tornado web server
474   -# ----------------------------------------------------------------------------
475   -def main():
476   - args = parse_cmdline_arguments()
477   -
478   - # --- Setup logging
479   - logging.config.dictConfig(get_logger_config(args.debug))
480   - logging.info('====================== Start Logging ======================')
481   -
482   - # --- start application
483   - config = {
484   - 'filename': args.testfile[0] or '',
485   - 'debug': args.debug,
486   - 'allow_all': args.allow_all,
487   - 'show_ref': args.show_ref,
488   - 'review': args.review,
489   - }
490   -
  399 +def run_webserver(app, ssl, port, debug):
  400 + # --- create web application ---------------------------------------------
  401 + logging.info('Starting WebApplication (tornado)')
491 402 try:
492   - testapp = App(config)
493   - except Exception:
494   - logging.critical('Failed to start application.')
495   - sys.exit(-1)
496   -
497   - # --- create web application
498   - logging.info('Starting Web App (tornado)')
499   - try:
500   - webapp = WebApplication(testapp, debug=args.debug)
  403 + webapp = WebApplication(app, debug=debug)
501 404 except Exception:
502 405 logging.critical('Failed to start web application.')
503 406 raise
504 407  
505   - # --- get SSL certificates
506   - if 'XDG_DATA_HOME' in os.environ:
507   - certs_dir = path.join(os.environ['XDG_DATA_HOME'], 'certs')
508   - else:
509   - certs_dir = path.expanduser('~/.local/share/certs')
510   -
511   - ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
512 408 try:
513   - ssl_ctx.load_cert_chain(path.join(certs_dir, 'cert.pem'),
514   - path.join(certs_dir, 'privkey.pem'))
515   - except FileNotFoundError:
516   - logging.critical(f'SSL certificates missing in {certs_dir}')
517   - sys.exit(-1)
518   -
519   - # --- create webserver
520   - try:
521   - httpserver = tornado.httpserver.HTTPServer(webapp, ssl_options=ssl_ctx)
  409 + httpserver = tornado.httpserver.HTTPServer(webapp, ssl_options=ssl)
522 410 except ValueError:
523 411 logging.critical('Certificates cert.pem, privkey.pem not found')
524   - sys.exit(-1)
  412 + sys.exit(1)
525 413  
526   - httpserver.listen(8443)
  414 + try:
  415 + httpserver.listen(port)
  416 + except OSError:
  417 + logger.critical(f'Cannot bind port {port}. Already in use?')
  418 + sys.exit(1)
527 419  
528   - # --- run webserver
529   - logging.info('Webserver running... (Ctrl-C to stop)')
  420 + logging.info(f'Webserver listening on {port}... (Ctrl-C to stop)')
530 421 signal.signal(signal.SIGINT, signal_handler)
531 422  
  423 + # --- run webserver
532 424 try:
533 425 tornado.ioloop.IOLoop.current().start() # running...
534 426 except Exception:
535   - logging.critical('Webserver stopped.')
  427 + logging.critical('Webserver stopped!')
536 428 tornado.ioloop.IOLoop.current().stop()
537 429 raise
538   -
539   -
540   -# ----------------------------------------------------------------------------
541   -if __name__ == "__main__":
542   - main()
... ...
perguntations/test.py
... ... @@ -49,7 +49,8 @@ class TestFactory(dict):
49 49  
50 50 # --- find refs of all questions used in the test
51 51 qrefs = {r for qq in self['questions'] for r in qq['ref']}
52   - logger.info(f'Declared {len(qrefs)} questions (each test uses {len(self["questions"])}).')
  52 + logger.info(f'Declared {len(qrefs)} questions '
  53 + f'(each test uses {len(self["questions"])}).')
53 54  
54 55 # --- for review, we are done. no factories needed
55 56 if self['review']:
... ... @@ -70,7 +71,8 @@ class TestFactory(dict):
70 71 for i, q in enumerate(questions):
71 72 # make sure every question in the file is a dictionary
72 73 if not isinstance(q, dict):
73   - raise TestFactoryException(f'Question {i} in {file} is not a dictionary!')
  74 + msg = f'Question {i} in {file} is not a dictionary'
  75 + raise TestFactoryException(msg)
74 76  
75 77 # check if ref is missing, then set to '/path/file.yaml:3'
76 78 if 'ref' not in q:
... ... @@ -80,9 +82,11 @@ class TestFactory(dict):
80 82 # check for duplicate refs
81 83 if q['ref'] in self.question_factory:
82 84 other = self.question_factory[q['ref']]
83   - otherfile = path.join(other.question['path'], other.question['filename'])
84   - raise TestFactoryException(f'Duplicate reference "{q["ref"]}" in files '
85   - f'"{otherfile}" and "{fullpath}".')
  85 + otherfile = path.join(other.question['path'],
  86 + other.question['filename'])
  87 + msg = (f'Duplicate reference "{q["ref"]}" in files '
  88 + f'"{otherfile}" and "{fullpath}".')
  89 + raise TestFactoryException(msg)
86 90  
87 91 # make factory only for the questions used in the test
88 92 if q['ref'] in qrefs:
... ... @@ -98,8 +102,9 @@ class TestFactory(dict):
98 102 # check if all the questions can be correctly generated
99 103 try:
100 104 self.question_factory[q['ref']].generate()
101   - except Exception as e:
102   - raise TestFactoryException(f'Failed to generate "{q["ref"]}"')
  105 + except Exception:
  106 + msg = f'Failed to generate "{q["ref"]}"'
  107 + raise TestFactoryException(msg)
103 108 else:
104 109 logger.info(f'{n:4}. "{q["ref"]}" Ok.')
105 110 n += 1
... ... @@ -108,7 +113,6 @@ class TestFactory(dict):
108 113 if qmissing:
109 114 raise TestFactoryException(f'Could not find questions {qmissing}.')
110 115  
111   -
112 116 # ------------------------------------------------------------------------
113 117 # Checks for valid keys and sets default values.
114 118 # Also checks if some files and directories exist
... ... @@ -120,13 +124,15 @@ class TestFactory(dict):
120 124  
121 125 # --- check database
122 126 if 'database' not in self:
123   - raise TestFactoryException('Missing "database" in configuration!')
  127 + raise TestFactoryException('Missing "database" in configuration')
124 128 elif not path.isfile(path.expanduser(self['database'])):
125   - raise TestFactoryException(f'Database {self["database"]} not found!')
  129 + msg = f'Database "{self["database"]}" not found!'
  130 + raise TestFactoryException(msg)
126 131  
127 132 # --- check answers_dir
128 133 if 'answers_dir' not in self:
129   - raise TestFactoryException('Missing "answers_dir" in configuration!')
  134 + msg = 'Missing "answers_dir" in configuration'
  135 + raise TestFactoryException(msg)
130 136  
131 137 # --- check if answers_dir is a writable directory
132 138 testfile = path.join(path.expanduser(self['answers_dir']), 'REMOVE-ME')
... ... @@ -134,16 +140,19 @@ class TestFactory(dict):
134 140 with open(testfile, 'w') as f:
135 141 f.write('You can safely remove this file.')
136 142 except OSError:
137   - raise TestFactoryException(f'Cannot write answers to directory "{self["answers_dir"]}"!')
  143 + msg = f'Cannot write answers to directory "{self["answers_dir"]}"'
  144 + raise TestFactoryException(msg)
138 145  
139 146 # --- check title
140 147 if not self['title']:
141 148 logger.warning('Undefined title!')
142 149  
143 150 if self['scale_points']:
144   - logger.info(f'Grades will be scaled to [{self["scale_min"]}, {self["scale_max"]}]')
  151 + smin, smax = self["scale_min"], self["scale_max"]
  152 + logger.info(f'Grades will be scaled to [{smin}, {smax}]')
145 153 else:
146   - logger.info('Grades are just the sum of points defined for the questions, not being scaled.')
  154 + logger.info('Grades are just the sum of points defined for the '
  155 + 'questions, not being scaled.')
147 156  
148 157 # --- questions_dir
149 158 if 'questions_dir' not in self:
... ... @@ -156,7 +165,9 @@ class TestFactory(dict):
156 165  
157 166 # --- files
158 167 if 'files' not in self:
159   - raise TestFactoryException('Missing "files" in configuration with the list of question files to import!')
  168 + msg = ('Missing "files" in configuration with the list of '
  169 + 'question files to import!')
  170 + raise TestFactoryException(msg)
160 171 # FIXME allow no files and define the questions directly in the test
161 172  
162 173 if isinstance(self['files'], str):
... ... @@ -164,7 +175,7 @@ class TestFactory(dict):
164 175  
165 176 # --- questions
166 177 if 'questions' not in self:
167   - raise TestFactoryException(f'Missing "questions" in Configuration!')
  178 + raise TestFactoryException(f'Missing "questions" in Configuration')
168 179  
169 180 for i, q in enumerate(self['questions']):
170 181 # normalize question to a dict and ref to a list of references
... ...
setup.py
... ... @@ -15,15 +15,21 @@ setup(
15 15 description=APP_DESCRIPTION.split('\n')[0],
16 16 long_description=APP_DESCRIPTION,
17 17 long_description_content_type="text/markdown",
18   - url="https:USERNAME//bitbucket.org/USERNAME/perguntations.git",
  18 + url="https://git.xdi.uevora.pt/mjsb/perguntations.git",
19 19 packages=find_packages(),
20 20 include_package_data=True, # install files from MANIFEST.in
21 21 python_requires='>=3.7.*',
22 22 install_requires=[
23   - 'tornado', 'mistune', 'pyyaml', 'pygments', 'sqlalchemy', 'bcrypt'],
  23 + 'tornado>=6.0',
  24 + 'mistune',
  25 + 'pyyaml>=5.1',
  26 + 'pygments',
  27 + 'sqlalchemy',
  28 + 'bcrypt>=3.1'
  29 + ],
24 30 entry_points={
25 31 'console_scripts': [
26   - 'perguntations = perguntations.serve:main',
  32 + 'perguntations = perguntations.main:main',
27 33 'initdb = perguntations.initdb:main',
28 34 ]
29 35 },
... ...