Tornado utils. Settings

There is options module in tornado similar to what I call settings, it allows to parse options passed via command line or settings file. The options module doesn't cover a few features I use widely: load settings from environ variables and initialize class variables with settings values (options must be available on class initialization, i.e. import). Things I don't like from the options: I usually must execute parse_command_line or parse_config_file before using options, there are two options (tornado.options and tornado.options.options) that confuses a bit, I want options to be immutable (there must be a special method to change them in runtime). I had tried different approaches to overcome the issues and came up with small patch of tornado options and few conventions.

Usage example

All settings located in the defines.py file, similar to what we have in options:

# ...
Setting(name='MY_SETTING', content_type=int, default=100, description="My setting.")

There are two ways to change the default setting value:

To retrieve setting value just get attribute with the same name:

from project.settings import settings


class MyClass:

    MY_CONST = settings.MY_SETTING

The implementation

Project structure:

- project/
  - app.py
  - project/
    - common/
    - settings/
       - __init__.py
       - defines.py
       - models.py
       - tests.py
    - __init__.py

project/app.py:

from project.settings import settings

from tornado.ioloop import IOLoop
from tornado.web import Application as BaseApplication


class Application(BaseApplication):

    def __init__(self, **kwargs):
        kwargs['debug'] = settings.DEBUG
        super(Application, self).__init__(handlers=[], **kwargs)


if __name__ == '__main__':

    application = Application()
    application.listen(port=settings.PORT)

    IOLoop.instance().start()

project/project/settings/__init__.py:

from .models import *
from .tests import *

project/project/defines.py:

from collections import namedtuple


__all__ = ('DEFINES',)


Setting = namedtuple(typename='Setting', field_names=('name', 'content_type', 'default', 'description'))


DEFINES = (
    Setting(name='DEBUG', content_type=bool, default=True, description="Enable debug mode."),
    Setting(name='PORT', content_type=int, default=8000, description="Port, default: 8000."),
)

project/project/models.py:

import os

from tornado import options

from .defines import DEFINES


__all__ = ('settings',)


class Settings:

    ENV_PREFIX = 'PROJECT'

    _setting_parsers = {}

    def __init__(self, defines):
        for setting_define in defines:
            if setting_define.name.upper() != setting_define.name:
                raise ValueError("Setting name must be uppercase.")
            if setting_define.content_type not in self._setting_parsers:
                raise ValueError("Unknown setting content type.")
            if setting_define.name in self.__dict__:
                raise ValueError("Invalid setting name.")

            default = os.getenv(
                '{prefix}_{attr}'.format(
                    prefix=self.ENV_PREFIX,
                    attr=setting_define.name),
                setting_define.default
            )

            parser = self._setting_parsers[setting_define.content_type]

            options.define(
                setting_define.name,
                type=parser.OPTIONS_TYPE,
                default=parser.decode(value=default),
                help=setting_define.description
            )

        options.parse_command_line()

    def __setattr__(self, key, value):
        if key in options.options:
            raise RuntimeError("Settings are immutable.")
        super(Settings, self).__setattr__(key=key, value=value)

    def __getattr__(self, item):
        return options.options[item]

    @classmethod
    def register_content_type(cls, content_type_name, content_type_cls):
        if content_type_name in cls._setting_parsers:
            raise ValueError("Duplicate setting content type.")
        cls._setting_parsers[content_type_name] = content_type_cls

    def update_setting(self, name, value):
        setattr(options.options, name, value)


class SettingTypeMeta(type):

    def __init__(cls, name, bases, attrs):
        super(SettingTypeMeta, cls).__init__(name, bases, attrs)
        if getattr(cls, 'CONTENT_TYPE_NAMES', None):
            for content_type_name in cls.CONTENT_TYPE_NAMES:
                Settings.register_content_type(content_type_name=content_type_name, content_type_cls=cls)


class SettingType(metaclass=SettingTypeMeta):

    CONTENT_TYPE_NAMES = None
    OPTIONS_TYPE = None


class IntegerSettingType(SettingType):

    CONTENT_TYPE_NAMES = (int, 'int', 'integer')
    OPTIONS_TYPE = int

    @classmethod
    def decode(cls, value):
        if isinstance(value, int):
            return value
        return int(value)


class StringSettingType(SettingType):

    CONTENT_TYPE_NAMES = (str, 'str', 'string')
    OPTIONS_TYPE = str

    @classmethod
    def decode(cls, value):
        if isinstance(value, str):
            return value
        return str(value)


class BooleanSettingType(SettingType):

    CONTENT_TYPE_NAMES = (bool, 'bool', 'boolean')
    OPTIONS_TYPE = bool

    @classmethod
    def decode(cls, value):
        if isinstance(value, bool):
            return value
        if str(value).lower() in ('t', 'true', 'y', 'yes'):
            return True
        return False


settings = Settings(defines=DEFINES)

project/project/settings/tests.py:

import os
import sys

from unittest import TestCase

from .defines import Setting
from .models import Settings


__all__ = ('TornadoSettingsTestCase',)


class TornadoSettingsTestCase(TestCase):

    SETTING_NAME = 'SETTINGS_TEST'
    ENV_SETTING_NAME = '{prefix}_{name}'.format(prefix=Settings.ENV_PREFIX, name=SETTING_NAME)

    def setUp(self):
        self.assertNotIn(self.ENV_SETTING_NAME, os.environ)
        self._sys_argv = tuple(sys.argv)

    def test_settings(self):
        DEFAULT_VALUE = 10
        ENVIRON_VALUE = 20
        COMMAND_LINE_VALUE = 30

        defines = (
            Setting(name=self.SETTING_NAME, content_type=int, default=DEFAULT_VALUE, description="Test."),
        )
        settings = Settings(defines=defines)
        self.assertEqual(getattr(settings, self.SETTING_NAME), DEFAULT_VALUE)

        os.environ[self.ENV_SETTING_NAME] = str(ENVIRON_VALUE)
        settings = Settings(defines=defines)
        self.assertEqual(getattr(settings, self.SETTING_NAME), ENVIRON_VALUE)

        sys.argv.insert(1, '--{name}={value}'.format(name=self.SETTING_NAME, value=COMMAND_LINE_VALUE))
        settings = Settings(defines=defines)
        self.assertEqual(getattr(settings, self.SETTING_NAME), COMMAND_LINE_VALUE)

    def test_edit_setting(self):
        DEFAULT_VALUE = 10
        NEW_VALUE = 20

        defines = (
            Setting(name=self.SETTING_NAME, content_type=int, default=DEFAULT_VALUE, description="Test."),
        )
        settings = Settings(defines=defines)
        self.assertEqual(getattr(settings, self.SETTING_NAME), DEFAULT_VALUE)

        self.assertRaises(RuntimeError, setattr, settings, self.SETTING_NAME, NEW_VALUE)

        self.assertEqual(getattr(settings, self.SETTING_NAME), DEFAULT_VALUE)
        settings.update_setting(name=self.SETTING_NAME, value=NEW_VALUE)
        self.assertEqual(getattr(settings, self.SETTING_NAME), NEW_VALUE)

    def tearDown(self):
        if self.ENV_SETTING_NAME in os.environ:
            del os.environ[self.ENV_SETTING_NAME]
        sys.argv = list(self._sys_argv)
Licensed under CC BY-SA 3.0