├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── envsettings ├── __init__.py ├── base.py ├── cache.py ├── database.py └── email.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py └── test_base.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | __pycache__ 3 | /MANIFEST 4 | /.tox 5 | /.coverage 6 | /.coverage.* 7 | /htmlcov 8 | /docs/_build 9 | /dist 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.3" 5 | - "3.4" 6 | - "pypy" 7 | install: travis_retry pip install pytest==2.6.4 $DJANGO_VERSION 8 | env: 9 | global: 10 | - PYTHONWARNINGS=all 11 | matrix: 12 | - DJANGO_VERSION='Django>=1.5,<1.6' 13 | - DJANGO_VERSION='Django>=1.6,<1.7' 14 | - DJANGO_VERSION='Django>=1.7,<1.8' 15 | matrix: 16 | include: 17 | - python: 2.7 18 | env: DJANGO_VERSION='Django>=1.4,<1.5' 19 | - python: "pypy" 20 | env: DJANGO_VERSION='Django>=1.4,<1.5' 21 | fast_finish: true 22 | script: python -m pytest 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 David Evans 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE 3 | include tox.ini 4 | recursive-include envsettings * 5 | recursive-include docs * 6 | recursive-exclude * __pycache__ 7 | recursive-exclude * *.py[co] 8 | prune docs/_build 9 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Django EnvSettings 2 | ================== 3 | 4 | .. image:: https://img.shields.io/travis/evansd/django-envsettings.svg 5 | :target: https://travis-ci.org/evansd/django-envsettings 6 | :alt: Build Status 7 | 8 | .. image:: https://img.shields.io/pypi/v/django-envsettings.svg 9 | :target: https://pypi.python.org/pypi/django-envsettings 10 | :alt: Latest PyPI version 11 | 12 | **One-stop shop for configuring 12-factor Django apps** 13 | 14 | * Simple API for getting settings from environment variables. 15 | * Supports wide variety of email, cache and database backends. 16 | * Easily customisable and extensible. 17 | * One line auto-config for many Heroku add-ons. 18 | 19 | 20 | Basic Settings 21 | -------------- 22 | 23 | In your Django project's ``settings.py``: 24 | 25 | .. code-block:: python 26 | 27 | import envsettings 28 | 29 | SECRET_KEY = envsettings.get('DJANGO_SECRET_KEY', 'development_key_not_a_secret') 30 | 31 | # Accepts the strings "True" and "False" 32 | DEBUG = envsettings.get_bool('DJANGO_DEBUG', default=True) 33 | 34 | FILE_UPLOAD_MAX_MEMORY_SIZE = envsettings.get_int('MAX_UPLOAD_SIZE', default=2621440) 35 | 36 | 37 | Email Settings 38 | -------------- 39 | 40 | Because of the way Django's email settings work, this requires a bit of a hack with 41 | ``locals()``: 42 | 43 | .. code-block:: python 44 | 45 | import envsettings 46 | 47 | locals().update( 48 | envsettings.email.get('MAIL_URL', default='file:///dev/stdout')) 49 | 50 | 51 | This sets ``EMAIL_BACKEND`` and whatever other values are needed to 52 | configure the selected backend. 53 | 54 | Example URLs 55 | ++++++++++++ 56 | 57 | Standard SMTP backend: 58 | 59 | .. code-block:: bash 60 | 61 | # SMTP without TLS 62 | smtp://username:password@host.example.com:25 63 | # SMTP with TLS 64 | smtps://username:password@host.example.com:587 65 | 66 | 67 | Special Django backends for use in development: 68 | 69 | .. code-block:: bash 70 | 71 | # Console backend 72 | file:///dev/stdout 73 | 74 | # Dummy packend 75 | file:///dev/null 76 | 77 | # File-based backend 78 | file:///path/to/output/dir 79 | 80 | 81 | Proprietary backends (each requires the appropriate package installed): 82 | 83 | .. code-block:: bash 84 | 85 | # Requires `django-mailgun` 86 | mailgun://api:api_key@my-sending-domain.com 87 | 88 | # Requires `sendgrid-django` 89 | sendgrid://username:password@sendgrid.com 90 | 91 | # Requires `djrill` 92 | mandrill://:api_key@mandrillapp.com 93 | mandrill://subaccount_name:api_key@mandrillapp.com 94 | 95 | # Requires `django-ses-backend` 96 | ses://access_key_id:access_key@us-east-1 97 | ses://access_key_id:access_key@email.eu-west-1.amazonaws.com 98 | 99 | # Requires `django-postmark` 100 | postmark://api:api_key@postmarkapp.com 101 | 102 | 103 | Heroku Auto-Config 104 | ++++++++++++++++++ 105 | 106 | Pass ``auto_config=True`` like so: 107 | 108 | .. code-block:: python 109 | 110 | locals().update( 111 | envsettings.email.get(default='file:///dev/stdout', auto_config=True)) 112 | 113 | This will automatically detect and configure any of the following Heroku email add-ons: 114 | *Mailgun*, *Sendgrid*, *Mandrill*, *Postmark*. 115 | 116 | So, for instance, you can configure your app to send email via Mailgun simply by running: 117 | 118 | .. code-block:: bash 119 | 120 | heroku addons:add mailgun:starter 121 | 122 | By default it will use each provider's SMTP endpoint, however if it detects that 123 | the appropriate backend is installed (see list above) it will configure Django to 124 | use the HTTP endpoint which will be faster. 125 | 126 | 127 | Cache Settings 128 | -------------- 129 | 130 | .. code-block:: python 131 | 132 | import envsettings 133 | 134 | CACHES = {'default': envsettings.cache.get('CACHE_URL', 'locmem://')} 135 | 136 | 137 | Example URLs 138 | ++++++++++++ 139 | 140 | Django backends for use in development: 141 | 142 | .. code-block:: bash 143 | 144 | # Local memory 145 | locmem:// 146 | # Local memory with prefix 147 | locmem://some-prefix 148 | 149 | # File based 150 | file:///path/to/cache/directory 151 | 152 | # Dummy cache 153 | file:///dev/null 154 | 155 | 156 | Redis (requires ``django-redis`` package): 157 | 158 | .. code-block:: bash 159 | 160 | # Basic Redis configuration 161 | redis://example.com:6379 162 | # With password 163 | redis://:secret@example.com:6379 164 | # Specifying database number 165 | redis://example.com:6379/3 166 | # Using UNIX socket 167 | redis:///path/to/socket 168 | # Using UNIX socket with password and database number 169 | redis://:secret@/path/to/socket:3 170 | 171 | 172 | To use Memcached you need one of the following packages installed: 173 | ``django_pylibmc``, ``django_bmemcached``, ``pylibmc``, ``mecached`` 174 | 175 | Only ``django_pylibmc`` and ``django_bmemcachd`` support authentication and the memcached 176 | binary protocol, so if you want to use either of these featues you'll need one of those 177 | packages. 178 | 179 | .. code-block:: bash 180 | 181 | # Basic Memcached configuration 182 | memcached://example.com:11211 183 | # Multiple servers 184 | memcached://example.com:11211,another.com:11211,onemore.com:11211 185 | # With authentication 186 | memcached://username:password@example.com 187 | # Using the binary protocol 188 | memcached-binary://example.com:11211 189 | 190 | 191 | Heroku Auto-Config 192 | ++++++++++++++++++ 193 | 194 | Pass ``auto_config=True`` like so: 195 | 196 | .. code-block:: python 197 | 198 | CACHES = {'default': envsettings.cache.get(default='locmen://', auto_config=True)} 199 | 200 | This will automatically detect and configure any of the following Heroku cache add-ons: 201 | *Memcachier*, *MemcachedCloud*, *RedisToGo*, *RedisCloud*, *OpenRedis*, *RedisGreen*. 202 | 203 | 204 | Customising & Extending 205 | ----------------------- 206 | 207 | Django EnvSettings is designed to be easily extensible by subclassing one of the existing 208 | settings providers: ``CacheSettings``, ``EmailSettings``, or ``DatabaseSettings``. 209 | 210 | 211 | Changing default configuration 212 | ++++++++++++++++++++++++++++++ 213 | 214 | Obviously you can modify the configuration dictionary after it's returned from ``envsettings``. 215 | However you can also set default values for each backend, while letting the environment determine 216 | which backend to use. For example: 217 | 218 | .. code-block:: python 219 | 220 | envsettings.database.CONFIG['postgres']['OPTIONS'] = { 221 | 'isolation_level': psycopg2.extensions.ISOLATION_LEVEL_SERIALIZABLE} 222 | 223 | 224 | Supporting new backends 225 | +++++++++++++++++++++++ 226 | 227 | To add a new backend, subclass the appropriate settings class. 228 | You will then need to add a key to the ``CONFIG`` dictionary which maps 229 | the URL scheme you want to use for your backend to the default config 230 | for that backend. You will also need to add a method named 231 | ``handle__url`` which will be passed the output from ``urlparse`` and the 232 | default config. The method should use the values from the parsed URL to update the 233 | config appropriately. 234 | 235 | For example: 236 | 237 | 238 | .. code-block:: python 239 | 240 | import envsettings 241 | 242 | class CacheSettings(envsettings.CacheSettings): 243 | 244 | CONFIG = dict(envsettings.CacheSettings.CONFIG, **{ 245 | 'my-proto': {'BACKEND': 'my_cache_backend.MyCacheBackend'} 246 | }) 247 | 248 | def handle_my_proto_url(self, parsed_url, config): 249 | config['HOST'] = parsed_url.hostname or 'localhost' 250 | config['PORT'] = parsed_url.port or 9000 251 | config['USERNAME'] = parsed_url.username 252 | config['PASSWORD'] = parsed_url.password 253 | return config 254 | 255 | cachesettings = CacheSettings() 256 | 257 | CACHES = {'default': cachesettings.get('CACHE_URL')} 258 | 259 | 260 | Supporting new auto configuration options 261 | +++++++++++++++++++++++++++++++++++++++++ 262 | 263 | To add a new auto-configuration provider, subclass the appropriate settings class and add a method 264 | named ``auto_config_``. This will be passed a dictionary of environment 265 | variables and should return either an appropriate configuration URL, or None. 266 | 267 | The auto config methods are tried in lexical order, so if you want to force a method 268 | to be tried first you could call it ``auto_config_00_my_provider``, or something like 269 | that. 270 | 271 | Here's an example: 272 | 273 | .. code-block:: python 274 | 275 | import envsettings 276 | 277 | class CacheSettings(envsettings.CacheSettings): 278 | 279 | def auto_config_my_redis(self, env): 280 | try: 281 | host = env['MY_REDIS_HOST'] 282 | password = env['MY_REDIS_PASSWORD'] 283 | except KeyError: 284 | return None 285 | else: 286 | return 'redis://:{password}@{host}'.format( 287 | host=host, password=password) 288 | 289 | cachesettings = CacheSettings() 290 | 291 | CACHES = {'default': cachesettings.get('CACHE_URL', auto_config=True)} 292 | 293 | 294 | Compatibility 295 | ------------- 296 | 297 | Tested on Python **2.7**, **3.3**, **3.4** and **PyPy**, 298 | with Django versions **1.4** --- **1.7** 299 | 300 | 301 | Issues & Contributing 302 | --------------------- 303 | 304 | Raise an issue on the `GitHub project `_ or 305 | feel free to nudge `@_EvansD `_ on Twitter. 306 | 307 | 308 | License 309 | ------- 310 | 311 | MIT Licensed 312 | -------------------------------------------------------------------------------- /envsettings/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import EnvSettings 2 | from .cache import CacheSettings 3 | from .database import DatabaseSettings 4 | from .email import EmailSettings 5 | 6 | 7 | envsettings = EnvSettings() 8 | 9 | get = envsettings.get 10 | get_int = envsettings.get_int 11 | get_bool = envsettings.get_bool 12 | 13 | cache = CacheSettings() 14 | database = DatabaseSettings() 15 | email = EmailSettings() 16 | -------------------------------------------------------------------------------- /envsettings/base.py: -------------------------------------------------------------------------------- 1 | import copy 2 | try: 3 | from importlib.util import find_spec 4 | except ImportError: 5 | from imp import find_module 6 | find_spec = False 7 | import os 8 | import re 9 | try: 10 | import urllib.parse as urlparse 11 | except ImportError: 12 | import urlparse 13 | 14 | try: 15 | from django.utils.encoding import smart_text 16 | except ImportError: 17 | from django.utils.encoding import smart_unicode as smart_text 18 | 19 | 20 | if find_spec: 21 | def is_importable(module_name): 22 | """ 23 | Test if a package (just the top-level) is importable, without 24 | actually importing it 25 | """ 26 | package = module_name.split('.')[0] 27 | return bool(find_spec(package)) 28 | else: 29 | # For Python < 3.4 30 | def is_importable(module_name): 31 | package = module_name.split('.')[0] 32 | try: 33 | f = find_module(package)[0] 34 | if f: 35 | f.close() 36 | return True 37 | except ImportError: 38 | return False 39 | 40 | 41 | class EnvSettings(object): 42 | 43 | def __init__(self, env=os.environ): 44 | self.env = env 45 | 46 | def get(self, key, default=None): 47 | return smart_text(self.env.get(key, default)) 48 | 49 | def get_bool(self, key, default=None): 50 | return self.parse_bool(self.env.get(key, default)) 51 | 52 | def get_int(self, key, default=None): 53 | return int(self.env.get(key, default)) 54 | 55 | @staticmethod 56 | def parse_bool(value): 57 | # Accept bools as well as strings so we can pass them 58 | # as default values 59 | if value == 'True' or value == True: 60 | return True 61 | elif value == 'False' or value == False: 62 | return False 63 | else: 64 | raise ValueError( 65 | "invalid boolean {!r} (must be 'True' or " 66 | "'False')".format(value)) 67 | 68 | 69 | class URLSettingsBase(EnvSettings): 70 | """ 71 | Base class which from which all other URL-based configuration 72 | classes inherit 73 | """ 74 | 75 | CONFIG = {} 76 | 77 | def __init__(self, *args, **kwargs): 78 | super(URLSettingsBase, self).__init__(*args, **kwargs) 79 | # Each instance gets its own copy of the config so it 80 | # can be safely mutated 81 | self.CONFIG = copy.deepcopy(self.CONFIG) 82 | 83 | def get(self, key=None, default=None, auto_config=False): 84 | value = self.env.get(key) if key else None 85 | if value is None and auto_config: 86 | value = self.get_auto_config() 87 | if value is None: 88 | value = default 89 | return self.parse(value) 90 | 91 | def parse(self, url): 92 | """ 93 | Return a configuration dict from a URL 94 | """ 95 | parsed_url = urlparse.urlparse(url) 96 | try: 97 | default_config = self.CONFIG[parsed_url.scheme] 98 | except KeyError: 99 | raise ValueError( 100 | 'unrecognised URL scheme for {}: {}'.format( 101 | self.__class__.__name__, url)) 102 | handler = self.get_handler_for_scheme(parsed_url.scheme) 103 | config = copy.deepcopy(default_config) 104 | return handler(parsed_url, config) 105 | 106 | def get_handler_for_scheme(self, scheme): 107 | method_name = 'handle_{}_url'.format(re.sub('[\+\.\-]', '_', scheme)) 108 | return getattr(self, method_name, self.handle_url) 109 | 110 | def handle_url(self, parsed_url, config): 111 | # Default implementation does nothing 112 | return config 113 | 114 | def get_auto_config(self): 115 | """ 116 | Walk over all available auto_config methods, passing them the current 117 | environment and seeing if they return a configuration URL 118 | """ 119 | methods = [m for m in dir(self) if m.startswith('auto_config_')] 120 | for method_name in sorted(methods): 121 | auto_config_method = getattr(self, method_name) 122 | url = auto_config_method(self.env) 123 | if url: 124 | return url 125 | -------------------------------------------------------------------------------- /envsettings/cache.py: -------------------------------------------------------------------------------- 1 | from .base import URLSettingsBase, is_importable 2 | 3 | 4 | class CacheSettings(URLSettingsBase): 5 | 6 | REDIS_CONFIG = {'BACKEND': 'django_redis.cache.RedisCache', 'OPTIONS': {}} 7 | 8 | CONFIG = { 9 | 'locmem': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}, 10 | 'file': {'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache'}, 11 | # Memcached backends are auto-selected based on what packages are installed 12 | 'memcached': {'BACKEND': None}, 13 | 'memcached-binary': {'BACKEND': None, 'BINARY': True}, 14 | 'redis': REDIS_CONFIG, 15 | 'rediss': REDIS_CONFIG 16 | } 17 | 18 | def handle_file_url(self, parsed_url, config): 19 | if parsed_url.path == '/dev/null': 20 | config['BACKEND'] = 'django.core.cache.backends.dummy.DummyCache' 21 | else: 22 | config['LOCATION'] = parsed_url.path 23 | return config 24 | 25 | def handle_locmem_url(self, parsed_url, config): 26 | config['LOCATION'] = '{0}{1}'.format( 27 | parsed_url.hostname or '', parsed_url.path or '') 28 | return config 29 | 30 | def handle_redis_url(self, parsed_url, config): 31 | if not parsed_url.hostname: 32 | parsed_url = parsed_url._replace(scheme='unix') 33 | config['LOCATION'] = parsed_url.geturl() 34 | return config 35 | 36 | def handle_rediss_url(self, parsed_url, config): 37 | return self.handle_redis_url(parsed_url, config) 38 | 39 | def handle_memcached_url(self, parsed_url, config): 40 | if parsed_url.hostname: 41 | netloc = parsed_url.netloc.split('@')[-1] 42 | if ',' in netloc: 43 | location = netloc.split(',') 44 | else: 45 | location = '{}:{}'.format( 46 | parsed_url.hostname, 47 | parsed_url.port or 11211) 48 | else: 49 | location = 'unix:{}'.format(parsed_url.path) 50 | config['LOCATION'] = location 51 | if parsed_url.username: 52 | config['USERNAME'] = parsed_url.username 53 | if parsed_url.password: 54 | config['PASSWORD'] = parsed_url.password 55 | # Only auto-select backend if one hasn't been explicitly configured 56 | if not config['BACKEND']: 57 | self.set_memcached_backend(config) 58 | return config 59 | 60 | def handle_memcached_binary_url(self, parsed_url, config): 61 | return self.handle_memcached_url(parsed_url, config) 62 | 63 | def set_memcached_backend(self, config): 64 | """ 65 | Select the most suitable Memcached backend based on the config and 66 | on what's installed 67 | """ 68 | # This is the preferred backend as it is the fastest and most fully 69 | # featured, so we use this by default 70 | config['BACKEND'] = 'django_pylibmc.memcached.PyLibMCCache' 71 | if is_importable(config['BACKEND']): 72 | return 73 | # Otherwise, binary connections can use this pure Python implementation 74 | if config.get('BINARY') and is_importable('django_bmemcached'): 75 | config['BACKEND'] = 'django_bmemcached.memcached.BMemcached' 76 | return 77 | # For text-based connections without any authentication we can fall 78 | # back to Django's core backends if the supporting libraries are 79 | # installed 80 | if not any([config.get(key) for key in ('BINARY', 'USERNAME', 'PASSWORD')]): 81 | if is_importable('pylibmc'): 82 | config['BACKEND'] = \ 83 | 'django.core.cache.backends.memcached.PyLibMCCache' 84 | elif is_importable('memcached'): 85 | config['BACKEND'] = \ 86 | 'django.core.cache.backends.memcached.MemcachedCache' 87 | 88 | def auto_config_memcachier(self, env, prefix='MEMCACHIER'): 89 | try: 90 | servers, username, password = [ 91 | env[prefix + key] for key in [ 92 | '_SERVERS', '_USERNAME', '_PASSWORD']] 93 | except KeyError: 94 | return 95 | return 'memcached-binary://{username}:{password}@{servers}/'.format( 96 | servers=servers, username=username, password=password) 97 | 98 | def auto_config_memcachedcloud(self, env): 99 | return self.auto_config_memcachier(env, prefix='MEMCACHEDCLOUD') 100 | 101 | def auto_config_redis_url(self, env): 102 | return env.get('REDIS_URL') 103 | 104 | def auto_config_redistogo(self, env): 105 | return env.get('REDISTOGO_URL') 106 | 107 | def auto_config_rediscloud(self, env): 108 | return env.get('REDISCLOUD_URL') 109 | 110 | def auto_config_openredis(self, env): 111 | return env.get('OPENREDIS_URL') 112 | 113 | def auto_config_redisgreen(self, env): 114 | return env.get('REDISGREEN_URL') 115 | -------------------------------------------------------------------------------- /envsettings/database.py: -------------------------------------------------------------------------------- 1 | from .base import URLSettingsBase, urlparse 2 | 3 | 4 | class DatabaseSettings(URLSettingsBase): 5 | 6 | CONFIG = { 7 | 'postgres': {'ENGINE': 'django.db.backends.postgresql_psycopg2'}, 8 | 'postgresql': {'ENGINE': 'django.db.backends.postgresql_psycopg2'}, 9 | 'postgis': {'ENGINE': 'django.contrib.gis.db.backends.postgis'}, 10 | 'mysql': {'ENGINE': 'django.db.backends.mysql'}, 11 | 'mysql2': {'ENGINE': 'django.db.backends.mysql'}, 12 | 'sqlite': {'ENGINE': 'django.db.backends.sqlite3'}, 13 | } 14 | 15 | def handle_url(self, parsed_url, config): 16 | config.update({ 17 | 'NAME': parsed_url.path[1:], 18 | 'USER': parsed_url.username or '', 19 | 'PASSWORD': parsed_url.password or '', 20 | 'HOST': parsed_url.hostname or '', 21 | 'PORT': parsed_url.port or ''}) 22 | # Allow query params to override values 23 | for key, value in urlparse.parse_qsl(parsed_url.query): 24 | if key.upper() in config: 25 | config[key.upper()] = value 26 | return config 27 | -------------------------------------------------------------------------------- /envsettings/email.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import email.utils 4 | 5 | from .base import URLSettingsBase, is_importable 6 | 7 | 8 | class EmailSettings(URLSettingsBase): 9 | 10 | CONFIG = { 11 | 'smtp': {'EMAIL_BACKEND': 'django.core.mail.backends.smtp.EmailBackend', 12 | 'EMAIL_USE_TLS': False}, 13 | 'smtps': {'EMAIL_BACKEND': 'django.core.mail.backends.smtp.EmailBackend', 14 | 'EMAIL_USE_TLS': True}, 15 | 'file': {'EMAIL_BACKEND': 'django.core.mail.backends.filebased.EmailBackend'}, 16 | 'mailgun': {'EMAIL_BACKEND': 'django_mailgun.MailgunBackend'}, 17 | 'sendgrid': {'EMAIL_BACKEND': 'sgbackend.SendGridBackend'}, 18 | 'mandrill': {'EMAIL_BACKEND': 'djrill.mail.backends.djrill.DjrillBackend'}, 19 | 'ses': {'EMAIL_BACKEND': 'django_ses_backend.SESBackend'}, 20 | 'postmark': {'EMAIL_BACKEND': 'postmark.django_backend.EmailBackend'}, 21 | } 22 | 23 | @staticmethod 24 | def parse_address_list(address_string): 25 | """ 26 | Takes an email address list string and returns a list of (name, address) pairs 27 | """ 28 | return email.utils.getaddresses([address_string]) 29 | 30 | def get_address_list(self, key, default=None): 31 | return self.parse_address_list(self.env.get(key, default)) 32 | 33 | def handle_smtp_url(self, parsed_url, config): 34 | if config.get('EMAIL_USE_TLS'): 35 | default_port = 587 36 | elif config.get('EMAIL_USE_SSL'): 37 | default_port = 465 38 | else: 39 | default_port = 25 40 | config.update({ 41 | 'EMAIL_HOST': parsed_url.hostname or 'localhost', 42 | 'EMAIL_PORT': parsed_url.port or default_port, 43 | 'EMAIL_HOST_USER': parsed_url.username or '', 44 | 'EMAIL_HOST_PASSWORD': parsed_url.password or ''}) 45 | return config 46 | 47 | def handle_smtps_url(self, parsed_url, config): 48 | return self.handle_smtp_url(parsed_url, config) 49 | 50 | def handle_file_url(self, parsed_url, config): 51 | if parsed_url.path == '/dev/stdout': 52 | config['EMAIL_BACKEND'] = 'django.core.mail.backends.console.EmailBackend' 53 | elif parsed_url.path == '/dev/null': 54 | config['EMAIL_BACKEND'] = 'django.core.mail.backends.dummy.EmailBackend' 55 | else: 56 | config['EMAIL_FILE_PATH'] = parsed_url.path 57 | return config 58 | 59 | def handle_mailgun_url(self, parsed_url, config): 60 | config['MAILGUN_ACCESS_KEY'] = parsed_url.password 61 | config['MAILGUN_SERVER_NAME'] = parsed_url.hostname 62 | return config 63 | 64 | def auto_config_mailgun(self, environ): 65 | try: 66 | api_key, login, password, server, port = [ 67 | environ['MAILGUN_' + key] for key in ( 68 | 'API_KEY', 'SMTP_LOGIN', 'SMTP_PASSWORD', 69 | 'SMTP_SERVER', 'SMTP_PORT')] 70 | except KeyError: 71 | return 72 | if is_importable(self.CONFIG['mailgun']['EMAIL_BACKEND']): 73 | domain = login.split('@')[-1] 74 | return 'mailgun://api:{api_key}@{domain}'.format( 75 | api_key=api_key, domain=domain) 76 | else: 77 | return 'smtps://{login}:{password}@{server}:{port}'.format( 78 | login=login, password=password, server=server, port=port) 79 | 80 | def handle_sendgrid_url(self, parsed_url, config): 81 | config['SENDGRID_USER'] = parsed_url.username 82 | config['SENDGRID_PASSWORD'] = parsed_url.password 83 | return config 84 | 85 | def auto_config_sendgrid(self, environ): 86 | try: 87 | user, password = environ['SENDGRID_USERNAME'], environ['SENDGRID_PASSWORD'] 88 | except KeyError: 89 | return 90 | if is_importable(self.CONFIG['sendgrid']['EMAIL_BACKEND']): 91 | return 'sendgrid://{user}:{password}@sendgrid.com'.format( 92 | user=user, password=password) 93 | else: 94 | return 'smtps://{user}:{password}@smtp.sendgrid.net:587'.format( 95 | user=user, password=password) 96 | 97 | def handle_mandrill_url(self, parsed_url, config): 98 | config['MANDRILL_API_KEY'] = parsed_url.password 99 | if parsed_url.username: 100 | config['MANDRILL_SUBACCOUNT'] = parsed_url.username 101 | return config 102 | 103 | def auto_config_mandrill(self, environ): 104 | try: 105 | user, api_key = environ['MANDRILL_USERNAME'], environ['MANDRILL_APIKEY'] 106 | except KeyError: 107 | return 108 | if is_importable(self.CONFIG['mandrill']['EMAIL_BACKEND']): 109 | return 'mandrill://:{api_key}@mandrillapp.com'.format( 110 | api_key=api_key) 111 | else: 112 | return 'smtps://{user}:{api_key}@smtp.mandrillapp.com:587'.format( 113 | user=user, api_key=api_key) 114 | 115 | def handle_ses_url(self, parsed_url, config): 116 | if parsed_url.username: 117 | config['AWS_SES_ACCESS_KEY_ID'] = parsed_url.username 118 | if parsed_url.password: 119 | config['AWS_SES_SECRET_ACCESS_KEY'] = parsed_url.password 120 | if parsed_url.hostname: 121 | if '.' in parsed_url.hostname: 122 | config['AWS_SES_REGION_ENDPOINT'] = parsed_url.hostname 123 | else: 124 | config['AWS_SES_REGION_NAME'] = parsed_url.hostname 125 | return config 126 | 127 | def handle_postmark_url(self, parsed_url, config): 128 | config['POSTMARK_API_KEY'] = parsed_url.password 129 | return config 130 | 131 | def auto_config_postmark(self, environ): 132 | try: 133 | api_key, server = (environ['POSTMARK_API_KEY'], 134 | environ['POSTMARK_SMTP_SERVER']) 135 | except KeyError: 136 | return 137 | if is_importable(self.CONFIG['postmark']['EMAIL_BACKEND']): 138 | return 'postmark://user:{api_key}@postmarkapp.com'.format( 139 | api_key=api_key) 140 | else: 141 | return 'smtps://{api_key}:{api_key}@{server}:25'.format( 142 | api_key=api_key, server=server) 143 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import codecs 3 | from setuptools import setup, find_packages 4 | 5 | 6 | PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__)) 7 | 8 | def read(*path): 9 | full_path = os.path.join(PROJECT_ROOT, *path) 10 | with codecs.open(full_path, 'r', encoding='utf-8') as f: 11 | return f.read() 12 | 13 | setup( 14 | name='django-envsettings', 15 | version='1.1.0', 16 | author='David Evans', 17 | author_email='d@evans.io', 18 | url='http://github.com/evansd/django-envsettings', 19 | packages=find_packages(exclude=['tests*']), 20 | license='MIT', 21 | description="One-stop shop for configuring 12-factor Django apps", 22 | long_description=read('README.rst'), 23 | classifiers=[ 24 | 'Development Status :: 5 - Production/Stable', 25 | 'Framework :: Django', 26 | 'Intended Audience :: Developers', 27 | 'License :: OSI Approved :: MIT License', 28 | 'Operating System :: OS Independent', 29 | 'Programming Language :: Python :: 2.7', 30 | 'Programming Language :: Python :: 3.3', 31 | 'Programming Language :: Python :: 3.4', 32 | ], 33 | ) 34 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evansd/django-envsettings/541932af261d5369f211f836a238dc020ee316e8/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_base.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from envsettings.base import is_importable 4 | from envsettings import (EnvSettings, CacheSettings, DatabaseSettings, 5 | EmailSettings) 6 | 7 | 8 | def get_from(SettingsClass, value): 9 | settings = SettingsClass({'SOMEVAR': value}) 10 | return settings.get('SOMEVAR') 11 | 12 | 13 | class TestIsImportable: 14 | 15 | def test_existing_package_importable(self): 16 | assert is_importable('imaplib') 17 | 18 | def test_nonexistent_package_not_importable(self): 19 | assert not is_importable('aintnopackageevelkdsjfl') 20 | 21 | 22 | class TestEnvSettings: 23 | 24 | def test_get_bool(self): 25 | for var, default, result in [ 26 | ('True', None, True), 27 | ('False', None, False), 28 | (None, True, True), 29 | (None, False, False), 30 | ]: 31 | envsettings = EnvSettings({'MYVAR': var} if var is not None else {}) 32 | assert result == envsettings.get_bool('MYVAR', default=default) 33 | 34 | 35 | class TestCacheSettings: 36 | 37 | def test_redis_url(self): 38 | url = 'redis://:mypassword@/path/to/socket' 39 | config = { 40 | 'BACKEND': 'django_redis.cache.RedisCache', 41 | 'LOCATION': 'unix://:mypassword@/path/to/socket', 42 | 'OPTIONS': {}, 43 | } 44 | assert config == get_from(CacheSettings, url) 45 | 46 | def test_memcachier_auto_config(self): 47 | env = { 48 | 'MEMCACHIER_SERVERS': '127.0.0.1:9000,127.0.0.2:9001', 49 | 'MEMCACHIER_USERNAME': 'me', 50 | 'MEMCACHIER_PASSWORD': 'mypassword' 51 | } 52 | config = { 53 | 'BACKEND': 'django_pylibmc.memcached.PyLibMCCache', 54 | 'BINARY': True, 55 | 'LOCATION': ['127.0.0.1:9000', '127.0.0.2:9001'], 56 | 'USERNAME': 'me', 57 | 'PASSWORD': 'mypassword', 58 | } 59 | assert config == CacheSettings(env).get(auto_config=True) 60 | 61 | 62 | class TestDatabaseSettings: 63 | 64 | def test_postgres_url(self): 65 | url = 'postgres://me:mypassword@example.com:999/mydb' 66 | config = { 67 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 68 | 'NAME': 'mydb', 69 | 'HOST': 'example.com', 70 | 'PORT': 999, 71 | 'USER': 'me', 72 | 'PASSWORD': 'mypassword', 73 | } 74 | assert config == get_from(DatabaseSettings, url) 75 | 76 | 77 | class TestEmailSettings: 78 | 79 | def test_smtps_url(self): 80 | url = 'smtps://me:mypassword@example.com:999' 81 | config = { 82 | 'EMAIL_BACKEND': 'django.core.mail.backends.smtp.EmailBackend', 83 | 'EMAIL_HOST': 'example.com', 84 | 'EMAIL_PORT': 999, 85 | 'EMAIL_HOST_USER': 'me', 86 | 'EMAIL_HOST_PASSWORD': 'mypassword', 87 | 'EMAIL_USE_TLS': True 88 | } 89 | assert config == get_from(EmailSettings, url) 90 | 91 | def test_sendgrid_auto_config(self): 92 | env = { 93 | 'SENDGRID_USERNAME': 'me', 94 | 'SENDGRID_PASSWORD': 'mypassword' 95 | } 96 | config = { 97 | 'EMAIL_BACKEND': 'django.core.mail.backends.smtp.EmailBackend', 98 | 'EMAIL_HOST': 'smtp.sendgrid.net', 99 | 'EMAIL_PORT': 587, 100 | 'EMAIL_HOST_USER': 'me', 101 | 'EMAIL_HOST_PASSWORD': 'mypassword', 102 | 'EMAIL_USE_TLS': True 103 | } 104 | assert config == EmailSettings(env).get(auto_config=True) 105 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py27-django14, 4 | py27-django15, 5 | py27-django16, 6 | py27-django17, 7 | py33-django15, 8 | py33-django16, 9 | py33-django17, 10 | py34-django15, 11 | py34-django16, 12 | py34-django17, 13 | pypy-django15, 14 | pypy-django16, 15 | pypy-django17, 16 | 17 | [testenv] 18 | commands = 19 | coverage run --branch --include=envsettings/* -m py.test 20 | coverage report 21 | setenv = 22 | PYTHONWARNINGS = all 23 | COVERAGE_FILE = .coverage.{envname} 24 | deps = 25 | pytest==2.6.4 26 | coverage==3.7.1 27 | 28 | [testenv:py27-django14] 29 | basepython=python2.7 30 | deps = 31 | {[testenv]deps} 32 | Django>=1.4,<1.5 33 | 34 | [testenv:py27-django15] 35 | basepython=python2.7 36 | deps = 37 | {[testenv]deps} 38 | Django>=1.5,<1.6 39 | 40 | [testenv:py27-django16] 41 | basepython=python2.7 42 | deps = 43 | {[testenv]deps} 44 | Django>=1.6,<1.7 45 | 46 | [testenv:py27-django17] 47 | basepython=python2.7 48 | deps = 49 | {[testenv]deps} 50 | Django>=1.7,<1.8 51 | 52 | [testenv:py33-django15] 53 | basepython=python3.3 54 | deps = 55 | {[testenv]deps} 56 | Django>=1.5,<1.6 57 | 58 | [testenv:py33-django16] 59 | basepython=python3.3 60 | deps = 61 | {[testenv]deps} 62 | Django>=1.6,<1.7 63 | 64 | [testenv:py33-django17] 65 | basepython=python3.3 66 | deps = 67 | {[testenv]deps} 68 | Django>=1.7,<1.8 69 | 70 | [testenv:py34-django15] 71 | basepython=python3.4 72 | deps = 73 | {[testenv]deps} 74 | Django>=1.5,<1.6 75 | 76 | [testenv:py34-django16] 77 | basepython=python3.4 78 | deps = 79 | {[testenv]deps} 80 | Django>=1.6,<1.7 81 | 82 | [testenv:py34-django17] 83 | basepython=python3.4 84 | deps = 85 | {[testenv]deps} 86 | Django>=1.7,<1.8 87 | 88 | [testenv:pypy-django15] 89 | basepython=pypy 90 | deps = 91 | {[testenv]deps} 92 | Django>=1.5,<1.6 93 | 94 | [testenv:pypy-django16] 95 | basepython=pypy 96 | deps = 97 | {[testenv]deps} 98 | Django>=1.6,<1.7 99 | 100 | [testenv:pypy-django17] 101 | basepython=pypy 102 | deps = 103 | {[testenv]deps} 104 | Django>=1.7,<1.8 105 | --------------------------------------------------------------------------------