main.py 6.55 KB
#!/usr/bin/env python3

'''
Setup configurations and then runs the application.
'''


# python standard library
import argparse
import asyncio
import logging
import logging.config
from os import environ, path
import ssl
import sys
from typing import Any, Dict

# this project
from .learnapp import LearnApp, LearnException
from .serve import webserver
from .tools import load_yaml
from . import APP_NAME, APP_VERSION


# ----------------------------------------------------------------------------
def parse_cmdline_arguments():
    '''
    Parses command line arguments. Uses the argparse package.
    '''

    argparser = argparse.ArgumentParser(
        description='Webserver for interactive learning and practice. '
        'Please read the documentation included with this software before '
        'using it.'
        )

    argparser.add_argument(
        'courses', type=str, nargs='?', default='courses.yaml',
        help='configuration file in YAML format.'
        )

    argparser.add_argument(
        '-v', '--version', action='store_true',
        help='show version information and exit'
        )

    argparser.add_argument(
        '--prefix', type=str, default='.',
        help='path where the topic directories can be found (default: .)'
        )

    argparser.add_argument(
        '--port', type=int, default=8443,
        help='port for the HTTPS server (default: 8443)'
        )

    argparser.add_argument(
        '--db', type=str, default='students.db',
        help='SQLite3 database file (default: students.db)'
        )

    argparser.add_argument(
        '-c', '--check', action='store_true',
        help='sanity check questions (can take awhile)'
        )

    argparser.add_argument(
        '--debug', action='store_true',
        help='enable debug mode'
        )

    return argparser.parse_args()


# ----------------------------------------------------------------------------
def get_logger_config(debug: bool = False) -> Any:
    '''
    Loads logger configuration in yaml format from a file, otherwise sets up a
    default configuration.
    Returns the configuration.
    '''

    if debug:
        filename, level = 'logger-debug.yaml', 'DEBUG'
    else:
        filename, level = 'logger.yaml', 'INFO'

    config_dir = environ.get('XDG_CONFIG_HOME', '~/.config/')
    config_file = path.join(path.expanduser(config_dir), APP_NAME, filename)

    default_config: Dict[str, Any] = {
        'version': 1,
        'formatters': {
            'standard': {
                'format': '%(asctime)s | %(levelname)-8s | %(message)s',
                'datefmt': '%Y-%m-%d %H:%M:%S',
                },
            },
        'handlers': {
            'default': {
                'level': level,
                'class': 'logging.StreamHandler',
                'formatter': 'standard',
                'stream': 'ext://sys.stdout',
                },
            },
        'loggers': {
            '': {  # configuration for serve.py
                'handlers': ['default'],
                'level': level,
                },
            },
        }
    default_config['loggers'].update({
        APP_NAME+'.'+module: {
            'handlers': ['default'],
            'level': level,
            'propagate': False,
            } for module in ['learnapp', 'models', 'factory', 'tools', 'serve',
                             'questions', 'student', 'main']})

    return load_yaml(config_file, default=default_config)


# ----------------------------------------------------------------------------
def main():
    '''
    Start application and webserver
    '''

    # --- Commandline argument parsing
    arg = parse_cmdline_arguments()

    if arg.version:
        print(f'{APP_NAME} {APP_VERSION}\nPython {sys.version}')
        sys.exit(0)

    # --- Setup logging
    logger_config = get_logger_config(arg.debug)
    logging.config.dictConfig(logger_config)

    try:
        logging.config.dictConfig(logger_config)
    except (ValueError, TypeError, AttributeError, ImportError) as exc:
        print('An error ocurred while setting up the logging system: %s', exc)
        sys.exit(1)

    logging.info('====================== Start Logging ======================')

    # --- get SSL certificates
    if 'XDG_DATA_HOME' in environ:
        certs_dir = path.join(environ['XDG_DATA_HOME'], 'certs')
    else:
        certs_dir = path.expanduser('~/.local/share/certs')

    ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
    try:
        ssl_ctx.load_cert_chain(path.join(certs_dir, 'cert.pem'),
                                path.join(certs_dir, 'privkey.pem'))
    except FileNotFoundError:
        logging.critical('SSL certificates missing in %s', certs_dir)
        print('--------------------------------------------------------------',
              'Certificates should be issued by a certificate authority (CA),',
              'such as https://letsencrypt.org.                              ',
              'For testing purposes a selfsigned certificate can be generated',
              'locally by running:                                           ',
              '                                                              ',
              '  openssl req -x509 -newkey rsa:4096 -keyout privkey.pem \\   ',
              '          -out cert.pem -days 365 -nodes                      ',
              '                                                              ',
              'Copy the cert.pem and privkey.pem files to:                   ',
              '                                                              ',
              f'  {certs_dir:<62}',
              '                                                              ',
              'See README.md for more information                            ',
              '--------------------------------------------------------------',
              sep='\n')
        sys.exit(1)
    logging.info('SSL certificates loaded')

    # --- start application --------------------------------------------------
    try:
        app = LearnApp(courses=arg.courses,
                       prefix=arg.prefix,
                       dbase=arg.db,
                       check=arg.check)
    except LearnException:
        logging.critical('Failed to start application')
        sys.exit(1)
    logging.info('LearnApp started')

    # --- run webserver forever ----------------------------------------------
    asyncio.run(webserver(app=app, ssl=ssl_ctx, port=arg.port, debug=arg.debug))
    
    logging.critical('Webserver stopped.')


# ----------------------------------------------------------------------------
if __name__ == "__main__":
    main()