Skip to content

Commit

Permalink
Merge pull request fedora-infra#450 from jeremycline/config-module
Browse files Browse the repository at this point in the history
Decouple the Anitya config from Flask
  • Loading branch information
jeremycline authored Mar 23, 2017
2 parents 2d9db0b + 835016a commit 5a28f82
Show file tree
Hide file tree
Showing 11 changed files with 436 additions and 84 deletions.
40 changes: 3 additions & 37 deletions anitya/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@
import logging
import logging.config
import logging.handlers
import os

import flask
from bunch import Bunch
from flask_openid import OpenID

from anitya.config import config as anitya_config
import anitya.lib
import anitya.mail_logging

Expand All @@ -28,46 +28,12 @@

# Create the application.
APP = flask.Flask(__name__)

APP.config.from_object('anitya.default_config')
if 'ANITYA_WEB_CONFIG' in os.environ: # pragma: no cover
APP.config.from_envvar('ANITYA_WEB_CONFIG')
APP.config.update(anitya_config)

# Set up OpenID
APP.oid = OpenID(APP)

# Set up the logging
logging_config = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'simple': {
'format': '[%(name)s %(levelname)s] %(message)s',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'simple',
'stream': 'ext://sys.stdout',
}
},
'loggers': {
'anitya': {
'level': APP.config['ANITYA_LOG_LEVEL'],
'propagate': False,
'handlers': ['console'],
},
},
# The root logger configuration; this is a catch-all configuration
# that applies to all log messages not handled by a different logger
'root': {
'level': APP.config['ANITYA_LOG_LEVEL'],
'handlers': ['console'],
},
}

logging.config.dictConfig(logging_config)
# Add a flask-dependent log handler for Anitya-related errors
if APP.config['EMAIL_ERRORS']:
# If email logging is configured, set up the anitya logger with an email
# handler for any ERROR-level logs.
Expand Down
132 changes: 132 additions & 0 deletions anitya/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
#
# Copyright © 2017 Red Hat, Inc.
#
# This copyrighted material is made available to anyone wishing to use,
# modify, copy, or redistribute it subject to the terms and conditions
# of the GNU General Public License v.2, or (at your option) any later
# version. This program is distributed in the hope that it will be
# useful, but WITHOUT ANY WARRANTY expressed or implied, including the
# implied warranties 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, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# Any Red Hat trademarks that are incorporated in the source
# code or documentation are not subject to the GNU General Public
# License and may only be used or replicated with the express permission
# of Red Hat, Inc.
"""This module is responsible for loading the application configuration."""

from datetime import timedelta
import logging
import os

import pytoml


_log = logging.getLogger(__name__)


#: A dictionary of application configuration defaults.
DEFAULTS = dict(
# Set the time after which the session expires
PERMANENT_SESSION_LIFETIME=timedelta(seconds=3600),
# Secret key used to generate the csrf token in the forms
SECRET_KEY='changeme please',
# URL to the database
DB_URL='sqlite:////var/tmp/anitya-dev.sqlite',
# List of admins based on their openid
ANITYA_WEB_ADMINS=[],
ANITYA_WEB_FEDORA_OPENID='https://id.fedoraproject.org',
ANITYA_WEB_ALLOW_FAS_OPENID=True,
ANITYA_WEB_ALLOW_GOOGLE_OPENID=True,
ANITYA_WEB_ALLOW_YAHOO_OPENID=True,
ANITYA_WEB_ALLOW_GENERIC_OPENID=True,
ADMIN_EMAIL='[email protected]',
ANITYA_LOG_CONFIG={
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'simple': {
'format': '[%(name)s %(levelname)s] %(message)s',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'simple',
'stream': 'ext://sys.stdout',
}
},
'loggers': {
'anitya': {
'level': 'INFO',
'propagate': False,
'handlers': ['console'],
},
},
# The root logger configuration; this is a catch-all configuration
# that applies to all log messages not handled by a different logger
'root': {
'level': 'INFO',
'handlers': ['console'],
},
},
# The SMTP server to send mail through
SMTP_SERVER='127.0.0.1',
# Whether or not to send emails to MAIL_ADMIN via SMTP_SERVER when HTTP 500
# errors occur.
EMAIL_ERRORS=False,
BLACKLISTED_USERS=[],
)

# Start with a basic logging configuration, which will be replaced by any user-
# specified logging configuration when the configuration is loaded.
logging.config.dictConfig(DEFAULTS['ANITYA_LOG_CONFIG'])


def load():
"""
Load application configuration from a file and merge it with the default
configuration.
If the ``ANITYA_WEB_CONFIG`` environment variable is set to a filesystem
path, the configuration will be loaded from that location. Otherwise, the
path defaults to ``/etc/anitya/anitya.toml``.
"""
config = DEFAULTS.copy()

if 'ANITYA_WEB_CONFIG' in os.environ:
config_path = os.environ['ANITYA_WEB_CONFIG']
else:
config_path = '/etc/anitya/anitya.toml'

if os.path.exists(config_path):
_log.info('Loading Anitya configuration from {}'.format(config_path))
with open(config_path) as fd:
try:
file_config = pytoml.loads(fd.read())
for key in file_config:
config[key.upper()] = file_config[key]
except pytoml.core.TomlError as e:
_log.error('Failed to parse {}: {}'.format(config_path, str(e)))
else:
_log.info('The Anitya configuration file, {}, does not exist.'.format(config_path))

if not isinstance(config['PERMANENT_SESSION_LIFETIME'], timedelta):
config['PERMANENT_SESSION_LIFETIME'] = timedelta(
seconds=config['PERMANENT_SESSION_LIFETIME'])

if config['SECRET_KEY'] == DEFAULTS['SECRET_KEY']:
_log.warning('SECRET_KEY is not configured, falling back to the default. '
'This is NOT safe for production deployments!')
return config


#: The application configuration dictionary.
config = load()

# With the full configuration loaded, we can now configure logging properly.
logging.config.dictConfig(config['ANITYA_LOG_CONFIG'])
198 changes: 198 additions & 0 deletions anitya/tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
# -*- coding: utf-8 -*-
#
# Copyright © 2017 Red Hat, Inc.
#
# This copyrighted material is made available to anyone wishing to use,
# modify, copy, or redistribute it subject to the terms and conditions
# of the GNU General Public License v.2, or (at your option) any later
# version. This program is distributed in the hope that it will be
# useful, but WITHOUT ANY WARRANTY expressed or implied, including the
# implied warranties 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, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# Any Red Hat trademarks that are incorporated in the source
# code or documentation are not subject to the GNU General Public
# License and may only be used or replicated with the express permission
# of Red Hat, Inc.
"""Tests for the :mod:`anitya.config` module."""
from __future__ import unicode_literals

from datetime import timedelta
import unittest

import mock

from anitya import config as anitya_config

full_config = """
secret_key = "very_secret"
permanent_session_lifetime = 3600
db_url = "sqlite:////var/tmp/anitya-dev.sqlite"
anitya_web_admins = ["http://pingou.id.fedoraproject.org"]
anitya_web_fedora_openid = "https://id.fedoraproject.org"
anitya_web_allow_fas_openid = true
anitya_web_allow_google_openid = true
anitya_web_allow_yahoo_openid = true
anitya_web_allow_generic_openid = true
admin_email = "[email protected]"
smtp_server = "smtp.example.com"
email_errors = false
blacklisted_users = ["http://sometroublemaker.id.fedoraproject.org"]
[anitya_log_config]
version = 1
disable_existing_loggers = true
[anitya_log_config.formatters]
[anitya_log_config.formatters.simple]
format = "[%(name)s %(levelname)s] %(message)s"
[anitya_log_config.handlers]
[anitya_log_config.handlers.console]
class = "logging.StreamHandler"
formatter = "simple"
stream = "ext://sys.stdout"
[anitya_log_config.loggers]
[anitya_log_config.loggers.anitya]
level = "WARNING"
propagate = false
handlers = ["console"]
[anitya_log_config.root]
level = "ERROR"
handlers = ["console"]
"""

empty_config = '# secret_key = "muchsecretverysafe"'
partial_config = 'secret_key = "muchsecretverysafe"'


class LoadTests(unittest.TestCase):
"""Unit tests for the :func:`anitya.config.load` function."""

@mock.patch('anitya.config.open', mock.mock_open(read_data='Ni!'))
@mock.patch('anitya.config._log', autospec=True)
@mock.patch('anitya.config.os.path.exists', return_value=True)
def test_bad_config_file(self, mock_exists, mock_log):
config = anitya_config.load()
self.assertEqual(anitya_config.DEFAULTS, config)
mock_exists.assert_called_once_with('/etc/anitya/anitya.toml')
mock_log.info.assert_called_once_with(
'Loading Anitya configuration from /etc/anitya/anitya.toml')
error = 'Failed to parse /etc/anitya/anitya.toml: <string>(1, 1): msg'
self.assertEqual(error, mock_log.error.call_args_list[0][0][0])

@mock.patch('anitya.config.open', mock.mock_open(read_data=partial_config))
@mock.patch('anitya.config._log', autospec=True)
@mock.patch('anitya.config.os.path.exists', return_value=True)
def test_partial_config_file(self, mock_exists, mock_log):
config = anitya_config.load()
self.assertNotEqual('muchsecretverysafe', anitya_config.DEFAULTS['SECRET_KEY'])
self.assertEqual('muchsecretverysafe', config['SECRET_KEY'])
mock_exists.assert_called_once_with('/etc/anitya/anitya.toml')
mock_log.info.assert_called_once_with(
'Loading Anitya configuration from /etc/anitya/anitya.toml')
self.assertEqual(0, mock_log.warning.call_count)

@mock.patch('anitya.config.open', mock.mock_open(read_data=full_config))
@mock.patch('anitya.config._log', autospec=True)
@mock.patch('anitya.config.os.path.exists', return_value=True)
def test_full_config_file(self, mock_exists, mock_log):
expected_config = {
'SECRET_KEY': 'very_secret',
'PERMANENT_SESSION_LIFETIME': timedelta(seconds=3600),
'DB_URL': 'sqlite:////var/tmp/anitya-dev.sqlite',
'ANITYA_WEB_ADMINS': ['http://pingou.id.fedoraproject.org'],
'ANITYA_WEB_FEDORA_OPENID': 'https://id.fedoraproject.org',
'ANITYA_WEB_ALLOW_FAS_OPENID': True,
'ANITYA_WEB_ALLOW_GOOGLE_OPENID': True,
'ANITYA_WEB_ALLOW_YAHOO_OPENID': True,
'ANITYA_WEB_ALLOW_GENERIC_OPENID': True,
'ADMIN_EMAIL': '[email protected]',
'ANITYA_LOG_CONFIG': {
'version': 1,
'disable_existing_loggers': True,
'formatters': {
'simple': {
'format': '[%(name)s %(levelname)s] %(message)s',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'simple',
'stream': 'ext://sys.stdout',
}
},
'loggers': {
'anitya': {
'level': 'WARNING',
'propagate': False,
'handlers': ['console'],
},
},
'root': {
'level': 'ERROR',
'handlers': ['console'],
},
},
'SMTP_SERVER': 'smtp.example.com',
'EMAIL_ERRORS': False,
'BLACKLISTED_USERS': ['http://sometroublemaker.id.fedoraproject.org'],
}
config = anitya_config.load()
self.assertEqual(sorted(expected_config.keys()), sorted(config.keys()))
for key in expected_config:
self.assertEqual(expected_config[key], config[key])
mock_exists.assert_called_once_with('/etc/anitya/anitya.toml')
mock_log.info.assert_called_once_with(
'Loading Anitya configuration from /etc/anitya/anitya.toml')
self.assertEqual(0, mock_log.warning.call_count)

@mock.patch('anitya.config.open', mock.mock_open(read_data=partial_config))
@mock.patch.dict('anitya.config.os.environ', {'ANITYA_WEB_CONFIG': '/my/config'})
@mock.patch('anitya.config._log', autospec=True)
@mock.patch('anitya.config.os.path.exists', return_value=True)
def test_custom_config_file(self, mock_exists, mock_log):
config = anitya_config.load()
self.assertNotEqual('muchsecretverysafe', anitya_config.DEFAULTS['SECRET_KEY'])
self.assertEqual('muchsecretverysafe', config['SECRET_KEY'])
mock_exists.assert_called_once_with('/my/config')
mock_log.info.assert_called_once_with(
'Loading Anitya configuration from /my/config')
self.assertEqual(0, mock_log.warning.call_count)

@mock.patch('anitya.config.open', mock.mock_open(read_data=empty_config))
@mock.patch('anitya.config._log', autospec=True)
@mock.patch('anitya.config.os.path.exists', return_value=True)
def test_empty_config_file(self, mock_exists, mock_log):
"""Assert loading the config with an empty file that exists works."""
config = anitya_config.load()
self.assertEqual(anitya_config.DEFAULTS, config)
mock_exists.assert_called_once_with('/etc/anitya/anitya.toml')
mock_log.info.assert_called_once_with(
'Loading Anitya configuration from /etc/anitya/anitya.toml')
mock_log.warning.assert_called_once_with(
'SECRET_KEY is not configured, falling back to the default. '
'This is NOT safe for production deployments!')

@mock.patch('anitya.config._log', autospec=True)
@mock.patch('anitya.config.os.path.exists', return_value=False)
def test_missing_config_file(self, mock_exists, mock_log):
"""Assert loading the config with a missing file works."""
config = anitya_config.load()
self.assertEqual(anitya_config.DEFAULTS, config)
mock_exists.assert_called_once_with('/etc/anitya/anitya.toml')
mock_log.info.assert_called_once_with(
'The Anitya configuration file, /etc/anitya/anitya.toml, does not exist.')
mock_log.warning.assert_called_once_with(
'SECRET_KEY is not configured, falling back to the default. '
'This is NOT safe for production deployments!')


if __name__ == '__main__':
unittest.main(verbosity=2)
Loading

0 comments on commit 5a28f82

Please sign in to comment.