Source

DailyPromptBot / minibot / __init__.py

Full commit
# -*- coding: utf-8 -*-
#-------------------------------------------------------------------------------
# The Daily Prompt Mini-Bot - A Shut Up and Write Project
# Author: Marc-Alexandre Chan <laogeodritt at arenthil.net>
#-------------------------------------------------------------------------------
#
# Copyright (c) 2012 Marc-Alexandre Chan. Licensed under the GNU GPL version 3
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
#-------------------------------------------------------------------------------

""" Minibot main driver and event scheduler. """

from minibot.config import Config
from minibot.db import Database
from minibot.eventscheduler import EventScheduler
from minibot.events import CheckMessageEvent, CheckPostQueueEvent,\
    CheckSuggestionQueueEvent, SuggestionThreadQueueMaintainer
from minibot.util import log_exc_info
from minibot.errors import InvalidUserPass, InvalidRedditError, HTTPError,\
    SQLAlchemyError, DBAPIError, ConfigFileError, LogFileError

from praw import Reddit

import time

import signal

import logging
from logging.handlers import RotatingFileHandler

import os
from os.path import realpath, isdir, dirname


class DailyPromptMinibot(object):
    """ The Daily Prompt Minibot. Collects writing prompts sent to it via Reddit
    private messages, queues them, and posts them daily on the Daily Prompt
    subreddit.

    This class automatically loads the configuration, sets up the main
    background events in the scheduler, and runs the scheduler. To run the bot,
    instantiate this class and call the ``run`` method().

    Attributes (need not be modified, documented for informational purposes):

    stdin_path
    stdout_path
    stderr_path
        *Default: os.devnull*

        The path to which to redirect ``stdin``. ``stdout`` and ``stderr`` when
        run via DaemonRunner. These can be ignored in most cases, as the
        standard I/O streams aren't used by the Minibot.

    pidfile_path
        *Default: 'minibot.pid' (script directory)*

        The absolute filepath for the daemon's pidfile. Only applicable if the
        minibot is run as a daemon using the ``daemon`` package.

    pidfile_timeout
        *Default: 300 (5 minutes)*

        Used as the default acquisition timeout value supplied to the runner's
        PID lock file. Only applicable if the minibot is run as a daemon using
        the ``daemon`` package.

    """

    # for DaemonRunner
    stdin_path  = os.devnull
    stdout_path = os.devnull
    stderr_path = os.devnull

    THREAD_MAINTAINER_INTERVAL_FACTOR = 5

    def __init__(self, config_path='minibot.ini'):
        # config
        try:
            self.config = Config(config_path)
        except (IOError, OSError) as e:
            raise ConfigFileError("Cannot open config file '{}': {}".format(
                config_path, str(e)))

        # logging
        try:
            self._init_logging()
        except (IOError, OSError) as e:
            raise LogFileError("Cannot open log file '{}': {}".format(
                self.config.log.file, str(e)))

        # state attributes
        self._initialised = False
        self._running = False

        # for DaemonRunner
        self.pidfile_path = realpath(self.config.minibot.pidfile_path)
        self.pidfile_timeout = self.config.minibot.pidfile_timeout

        # extra processing
        if not self.pidfile_path.endswith('.pid'):
            self.pidfile_path += '.pid'

        for sig in ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT', 'SIGABRT']:
            try: # Windows doesn't like all of those signals
                signal.signal(getattr(signal, sig), self.sigterm_handler)
            except (ValueError, AttributeError) as e:
                self.logger.warn("Cannot attach handler to signal %s: %s",
                    sig, str(e))

    def init_resources(self):
        """ Initialises resource components (e.g. database and network classes).
        This is provided as a separate method in case any action must be taken
        between instantiation of DailyPromptMinibot and its components,
        e.g. when using DaemonRunner.
        """
        if self._initialised:
            return

        self.logger.info("Initialising Daily Prompt Minibot resources...")

        # database
        try:
            self._init_db()
        except (SQLAlchemyError, DBAPIError) as e:
            basic, src, trace = log_exc_info()
            logger = self.logger.getChild('sqlalchemy')
            if classname(e) == 'DBAPIError':
                logger = self.logger.getChild('dialects')
            logger.error(*basic)
            logger.debug(*src)
            logger.debug(*trace)
            logger.critical(
                'Error initialising database object. Terminating application.')
            raise

        # reddit
        try:
            self._init_reddit()
        except InvalidUserPass:
            self.logger.critical("Error initialising Reddit object: cannot "
                "log in. Terminating application.")
            raise
        except InvalidRedditError:
            self.logger.critical("Error initialising Reddit object: target "
                "subreddit is invalid. Terminating application.")
            raise
        except HTTPError:
            self.logger.critical("Error initialising Reddit object: %s",
                str(HTTPError))
            raise

        self._init_scheduler()

        self.logger.info("Daily Prompt Minibot resources initialised.")
        self._initialised = True

    def _init_logging(self):
        """ Initialises logging. The application-level logger is stored in
        the attribute ``logger``. """
        config = self.config # shorthand
        log_path = realpath(config.log.file)

        logging._srcfile = None
        if config.log.level > logging.DEBUG:
            logging.raiseExceptions = 0
        else:
            logging.raiseExceptions = 1
        self.logger = logging.getLogger('dailyprompt.minibot')

        # check directory
        if not isdir(dirname(log_path)):
            os.makedirs(dirname(log_path))

        formatter = logging.Formatter(
            '%(asctime)s %(process)d:%(thread)d) '
            '[%(name)s:%(levelname)s] %(message)s',
            '%Y-%m-%d %H:%M:%S')
        handler = RotatingFileHandler(
                    log_path, maxBytes=1048576, backupCount=9)
        handler.setFormatter(formatter)
        self.logger.addHandler(handler)
        self.logger.setLevel(config.log.level)

    def _init_db(self):
        """ Initialise the SQLAlchemy ORM functionality, stored in the ``db``
        attribute, and create all tables. The configuration must be loaded. """
        config = self.config # shorthand
        self.db = Database(
            config.sqlite.file, table_prefix=config.sqlite.tableprefix,
            logfile=config.log.db_file, loglevel=config.log.db_level)
        self.db.create_tables()
        self.logger.debug("Initialised Database resource.")

    def _init_reddit(self):
        """ Initialise the Reddit API object. The configuration must be loaded.
        """
        config = self.config
        self.reddit = Reddit(user_agent=config.minibot.user_agent)
        self.reddit.login(config.reddit.user, config.reddit.password)
        try:
            self.reddit.get_subreddit(config.reddit.target).content_id
        except ValueError as e:
            # alas, PRAW doesn't give us a very specific error
            if 'No JSON object' in e.args[0]:
                raise InvalidRedditError("{} is not a valid reddit".format(
                    config.reddit.target))
            else:
                raise
        self.logger.debug("Initialised Reddit resource.")

    def _init_scheduler(self):
        """ Initialise the event scheduler. """
        self.scheduler = EventScheduler(
            self.config, self.reddit, self.db, self.logger)
        self.logger.debug("Initialised Scheduler resource.")

    def run(self):
        """ Run the minibot. """
        self.init_resources() # init components if not done yet
        self._populate_queue()
        self._running = True
        try:
            self.scheduler.run()
        except:
            self._running = False
            raise
        self._running = False

    def _populate_queue(self):
        """ Populates event scheduler with default/always-running events. """
        now = time.time()

        ev_msg = CheckMessageEvent()
        ev_msg.start_time = now + 5
        ev_msg.interval = self.config.minibot.msg_rate
        ev_msg.duration = -1
        self.scheduler.queue_event(ev_msg)

        ev_post = CheckPostQueueEvent()
        ev_post.start_time = now + 8
        ev_post.interval = self.config.minibot.msg_rate
        ev_post.duration = -1
        self.scheduler.queue_event(ev_post)

        ev_sugg = CheckSuggestionQueueEvent()
        ev_sugg.start_time = now + 13
        ev_sugg.interval = self.config.minibot.msg_rate
        ev_sugg.duration = -1
        self.scheduler.queue_event(ev_sugg)

        ev_sugg_maint = SuggestionThreadQueueMaintainer()
        ev_sugg_maint.start_time = now + 21
        ev_sugg_maint.interval = self.THREAD_MAINTAINER_INTERVAL_FACTOR *\
                                 self.config.minibot.queue_rate
        ev_sugg.duration = -1
        self.scheduler.queue_event(ev_sugg_maint)

    def sigterm_handler(self, signum, frame):
        """ Handles signals. """
        self.logger.warn("Received signal %d", signum)
        self.scheduler.request_exit()