diff --git a/django12factor/__init__.py b/django12factor/__init__.py index 6459d86..7486950 100644 --- a/django12factor/__init__.py +++ b/django12factor/__init__.py @@ -14,8 +14,11 @@ import six import sys +from .environment_variable_loader import EnvironmentVariableLoader + logger = logging.getLogger(__name__) + _FALSE_STRINGS = [ "no", "false", @@ -71,7 +74,7 @@ def factorise(custom_settings=None): } settings['DATABASES'] = { - 'default': dj_database_url.config(default='sqlite://:memory:') + 'default': _database() } for (key, value) in six.iteritems(os.environ): @@ -123,13 +126,35 @@ def factorise(custom_settings=None): settings.update(dj_email_url.config(default='dummy://')) - settings['ALLOWED_HOSTS'] = os.getenv('ALLOWED_HOSTS', '').split(',') + settings['ALLOWED_HOSTS'] = _allowed_hosts() # For keys to different apis, etc. if custom_settings is None: custom_settings = [] for cs in custom_settings: - settings[cs] = os.getenv(cs) + if not isinstance(cs, EnvironmentVariableLoader): + cs = EnvironmentVariableLoader(cs) + settings[cs.name] = cs.load_value() return settings + + +def _allowed_hosts(): + e = EnvironmentVariableLoader( + "ALLOWED_HOSTS", + parser=lambda x: x.split(","), + default=[] + ) + + return e.load_value() + + +def _database(): + e = EnvironmentVariableLoader( + "DATABASE_URL", + parser=dj_database_url.parse, + default=dj_database_url.parse('sqlite://:memory:') + ) + + return e.load_value() diff --git a/django12factor/environment_variable_loader.py b/django12factor/environment_variable_loader.py new file mode 100644 index 0000000..296cca8 --- /dev/null +++ b/django12factor/environment_variable_loader.py @@ -0,0 +1,54 @@ +""" +Utility class for transforming environment variables into Django settings. +""" + +import os + + +def identity(x): + """ + Because Python's stdlib doesn't have an identity function. + """ + + return x + + +class EnvironmentVariableLoader(object): + """ + Load an environment variable, for a flexible value of load. + + Provide an (optional) parser function (e.g. `int`), and an (optional) + default. + """ + + def __init__(self, name, default=None, parser=identity): + """ + Build a loader for an environment variable. + + * `name`: (required) the environment key to use + * `default`: a value to use if the environment variable is not set + * `loader`: a function that takes a string environment value and + performs type conversion / parsing / etc. (e.g. `int` or + `lambda x: x.split(',')`) + + Note there is no checking to ensure the type of `default` is the same + as the return type of `loader`, but if this isn't the case, you're + probably going to have a sad time. + """ + + self.name = name + self.default = default + self.parser = parser + + def load_value(self): + """ + Load and process an environment variable. + """ + + if self.name in os.environ: + return self.parser(os.environ[self.name]) + else: + return self.default + + +EVL = EnvironmentVariableLoader diff --git a/docs/index.rst b/docs/index.rst index 74c7afb..3301d0f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -136,6 +136,34 @@ Treats ``os.environ("ALLOWED_HOSTS")`` as a comma-separated list. Uses ``os.environ("SECRET_KEY")`` - required if ``DEBUG==False``. +Custom Settings +--------------- + +You can make ``django12factor`` load arbitrary settings from environment +variables with the ``custom_setting`` ``kwarg``. This takes an iterable of +``django12factor.EnvironmentVariableLoader`` instances (also importable as +``django12factor.EVL`` for brevity), or strings. + +``EnvironmentVariableLoader`` instances are created with a ``name`` - the name +of the environment variable to load, an optional ``default`` for the case when +the environment variable is not set, and an optional ``parser`` - a callable +that should take the string value of an environment variable and convert it as +desired for your settings. + +For example: + +.. code-block: python:: + + from django12factor import EVL + + custom_settings = ( + EVL("API_HOST", default="localhost"), + EVL("API_PORT", default=8080, parser=int), + ) + +For brevity and compatibility, any ``custom_setting`` that is a string will be +treated as the name of an ``EnvironmentVariableLoader``. + Indices and tables ================== diff --git a/tests/env.py b/tests/env.py index f110a2d..330062b 100644 --- a/tests/env.py +++ b/tests/env.py @@ -21,4 +21,9 @@ def __enter__(self): def __exit__(self, type, value, traceback): os.environ = self.oldenviron + +def debugenv(**kwargs): + return env(DEBUG="true", **kwargs) + + env = Env diff --git a/tests/test_d12f.py b/tests/test_d12f.py index e02d217..4fa2726 100644 --- a/tests/test_d12f.py +++ b/tests/test_d12f.py @@ -3,15 +3,14 @@ import django12factor import unittest -from .env import env +from .env import ( + debugenv, + env, +) d12f = django12factor.factorise -def debugenv(**kwargs): - return env(DEBUG="true", **kwargs) - - class TestD12F(unittest.TestCase): def test_object_no_secret_key_prod(self): @@ -66,6 +65,29 @@ def test_missing_custom_keys(self): self.assertEquals(present, settings['PRESENT']) self.assertIsNone(settings['MISSING']) + def test_allowed_hosts(self): + with debugenv(ALLOWED_HOSTS="a"): + self.assertEquals(d12f()['ALLOWED_HOSTS'], ["a"]) + + with debugenv(ALLOWED_HOSTS="a,b"): + self.assertEquals(d12f()['ALLOWED_HOSTS'], ["a", "b"]) + + with debugenv(): + self.assertEquals(d12f()["ALLOWED_HOSTS"], []) + + def test_djdb(self): + """ + Assert basic behaviour about DATABASE_URL parsing and defaults + """ + def defaultdb(): + return d12f()['DATABASES']['default'] + + with debugenv(): + self.assertEquals(defaultdb()['NAME'], ":memory:") + + with debugenv(DATABASE_URL="postgres://USER:PASSWORD@HOST:9/NAME"): + self.assertEquals(defaultdb()['NAME'], "NAME") + def test_multiple_db_support(self): """ Explicit test that multiple DATABASE_URLs are supported. diff --git a/tests/test_evl.py b/tests/test_evl.py new file mode 100644 index 0000000..45b202c --- /dev/null +++ b/tests/test_evl.py @@ -0,0 +1,53 @@ +import django12factor +import unittest + +from .env import ( + debugenv, +) +from django12factor.environment_variable_loader import EVL + +d12f = django12factor.factorise + + +class TestEVL(unittest.TestCase): + + def test_equivalence(self): + with debugenv(FOO='x'): + custom_string = d12f(custom_settings=['FOO']) + + with debugenv(FOO='x'): + custom_evl = d12f(custom_settings=[EVL("FOO")]) + + self.assertEqual(custom_string, custom_evl) + + def test_default_is_none(self): + with debugenv(): + key = "FOO" + d = d12f(custom_settings=[EVL(key)]) + self.assertIsNone( + d[key], + "d12f tried to load the (unset) environment variable '%s' " + "with no default; expected None but got %s instead" % + (key, str(d[key])) + ) + + def test_custom_default(self): + with debugenv(): + key = "FOO" + default = "llama" + d = d12f(custom_settings=[EVL(key, default=default)]) + self.assertEquals( + default, d[key], + "d12f tried to load the (unset) environment variable '%s' " + "with a default of %s, but got %s instead" % + (key, str(default), str(d[key])) + ) + + def test_loading_int(self): + key = "FOO" + value = 1 + with debugenv(**{key: str(value)}): + d = d12f(custom_settings=[EVL(key, parser=int)]) + i = d[key] + self.assertEquals(i, value) + self.assertIsInstance(i, int)