pax_global_header00006660000000000000000000000064126241471350014517gustar00rootroot0000000000000052 comment=04322a90035c2bddb36b18b2e58dc0aaecc56e64 envparse-04322a90035c2bddb36b18b2e58dc0aaecc56e64/000077500000000000000000000000001262414713500204515ustar00rootroot00000000000000envparse-04322a90035c2bddb36b18b2e58dc0aaecc56e64/.gitignore000066400000000000000000000001211262414713500224330ustar00rootroot00000000000000*.py[cod] build dist *.egg-info .tox .cache .eggs # coverage .coverage htmlcov envparse-04322a90035c2bddb36b18b2e58dc0aaecc56e64/.travis.yml000066400000000000000000000004051262414713500225610ustar00rootroot00000000000000language: python python: - "3.5" env: matrix: - TOXENV=py27 - TOXENV=py32 - TOXENV=py33 - TOXENV=py34 - TOXENV=py35 - TOXENV=pypy - TOXENV=pypy3 - TOXENV=flake8 install: - travis_retry pip install tox script: - make test envparse-04322a90035c2bddb36b18b2e58dc0aaecc56e64/CHANGELOG.rst000066400000000000000000000006241262414713500224740ustar00rootroot00000000000000CHANGELOG ========= v0.2.0 ------ - Major rewrite, based on django-environ but made agnostic. - Tox support for running tests with different Python types. - Use pytest for unit tests. v0.1.6 ------ - Use curly-braces for proxied values since shells will attempt to resolve dollar-sign values themselves. Dollar-sign style is still supported, but deprecated and will be removed in a 1.0 release. envparse-04322a90035c2bddb36b18b2e58dc0aaecc56e64/LICENSE000066400000000000000000000021451262414713500214600ustar00rootroot00000000000000Copyright (c) 2012 Rick Harris Copyright (c) 2013 Daniele Faraglia Copyright (c) 2015 Russell Davies Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. envparse-04322a90035c2bddb36b18b2e58dc0aaecc56e64/MANIFEST.in000066400000000000000000000000331262414713500222030ustar00rootroot00000000000000include README.rst LICENSE envparse-04322a90035c2bddb36b18b2e58dc0aaecc56e64/Makefile000066400000000000000000000004061262414713500221110ustar00rootroot00000000000000SHELL := /bin/bash help: @echo 'Makefile for envparse' @echo '' @echo 'Usage:' @echo ' make release push to the PyPI' @echo ' make test run the test suite' @echo '' release: python setup.py register sdist bdist_wheel upload test: tox envparse-04322a90035c2bddb36b18b2e58dc0aaecc56e64/README.rst000066400000000000000000000127401262414713500221440ustar00rootroot00000000000000envparse ======== ``envparse`` is a simple utility to parse environment variables. If you use Heroku and/or subscribe to the tenets of the `12 Factor App `_ you'll be using a lot of environment variable-based configuration in your app. ``os.environ`` is a great choice to start off with but over time you'll find yourself duplicating quite a bit of code around handling raw environment variables. ``envparse`` aims to eliminate this duplicated, often inconsistent parsing code and instead provide a single, easy-to-use wrapper. Ideas, and code portions, have been taken from `django-environ `_ project but made framework agnostic. Installing ---------- Through PyPI:: $ pip install envparse Manually:: $ pip install git+https://github.com/rconradharris/envparse.git OR $ git clone https://github.com/rconradharris/envparse && cd envparse $ python setup.py install Usage ----- In your settings or configuration module, first either import the standard parser or one with a schema: .. code-block:: python # Standard from envparse import env # Schema from envparse import Env env = Env(BOOLEAN_VAR=bool, LIST_VAR=dict(type=list, subtype=int)) ``env`` can then be called in two ways: * Type explicit: ``env('ENV_VAR_NAME', type=TYPE, ...)`` * Type implicit (for Python builtin types only): ``env.TYPE('ENV_VAR_NAME', ...)`` If type is not specified, explicitly or implicitly, then the default type is ``str``. Casting to a specified type: .. code-block:: python # Environment variable: MAIL_ENABLED=1 mail_enabled = env('MAIL_ENABLED', type=bool) # OR mail_enabled = env.bool('MAIL_ENABLED') assert mail_enabled is True Casting nested types: .. code-block:: python # Environment variable: FOO=1,2,3 foo = env('FOO'), subtype=int) # OR: foo = env('FOO', type=list, subtype=int) # Note that there is no way to implicitly call subtypes. assert foo == [1, 2, 3] Specifying defaults: .. code-block:: python # Environment variable MAX_ROWS has not been defined max_rows = env.int('MAX_ROWS', default=100) assert max_rows == 100 Proxying values, useful in Heroku for wiring up the environment variables they provide to the ones that your app actually uses: .. code-block:: python # Environment variables: MAILGUN_SMTP_LOGIN=foo, # SMTP_LOGIN='{{MAILGUN_SMTP_LOGIN}}' smtp_login = env('SMTP_LOGIN') assert smtp_login == 'foo' Now if you switch to using Mandrill as an email provider, instead of having to modify your app, you can simply make a configuration change: .. code-block:: bash SMTP_LOGIN='{{MANDRILL_UESRNAME}}' There are also a few convenience methods: * ``env.json``: parses JSON and returns a dict. * ``env.url``: parses a url and returns a ``urlparse.ParseResult`` object. Type specific notes: * list: the expected environment variable format is ``FOO=1,2,3`` and may contain spaces between the commas as well as preceding or trailing whitespace. * dict: the expected environment variable format is ``FOO='key1=val1, key2=val2``. Spaces are also allowed. * json: a regular JSON string such as ``FOO='{"foo": "bar"}'`` is expected. Schemas ~~~~~~~ Define a schema so you can only need to provide the type, subtype, and defaults once: .. code-block:: python # Environment variables: MAIL_ENABLED=0, LIST_INT='1,2,3' # Bind schema to Env object to get schema-based lookups env = Env(MAIL_ENABLED=bool, SMTP_LOGIN=dict(type=str, default='foo'), LIST_INT=dict(type=list, subtype=int)) assert env('MAIL_ENABLED') is False assert env('SMTP_LOGIN') == 'foo' # Not defined so uses default assert env('LIST_INT') == [1, 2, 3] The ``Env`` constructor takes values in the form of either: ``VAR_NAME=type`` or ``VAR_NAME=dict`` where ``dict`` is a dictionary with either one or more of the following keys specified: ``type``, ``subtype``, ``default``. Pre- and Postprocessors ~~~~~~~~~~~~~~~~~~~~~~~ Preprocessors are callables that are run on the environment variable string before any type casting takes place: .. code-block:: python # Environment variables: FOO=bar # Preprocessor to change variable to uppercase to_upper = lambda v: v.upper() foo = env('FOO', preprocessor=to_upper) assert foo == 'BAR' Postprocessors are callables that are run after the type casting takes place. An example of one might be returning a datastructure expected by a framework: .. code-block:: python # Environment variable: REDIS_URL='redis://:redispass@127.0.0.1:6379/0' def django_redis(url): return {'BACKEND': 'django_redis.cache.RedisCache', 'LOCATION': '{}:{}:{}'.format(url.hostname, url.port, url.path.strip('/')), 'OPTIONS': {'PASSWORD': url.password}} redis_config = env('REDIS_URL', postprocessor=django_redis) assert redis_config == {'BACKEND': 'django_redis.cache.RedisCache', 'LOCATION': '127.0.0.1:6379:0', 'OPTIONS': {'PASSWORD': 'redispass'}} Environment File ~~~~~~~~~~~~~~~~ Read from a .env file (line delimited KEY=VALUE): .. code-block:: python # This recurses up the directory tree until a file called '.env' is found. env.read_env() # Manually specifying a path env.read_env('/config/.myenv') # Values can be read as normal env.int('FOO') Tests ----- .. image:: https://secure.travis-ci.org/rconradharris/envparse.png?branch=master To run the tests install tox:: pip install tox Then run them with:: make test envparse-04322a90035c2bddb36b18b2e58dc0aaecc56e64/envparse.py000066400000000000000000000161771262414713500226620ustar00rootroot00000000000000""" envparse is a simple utility to parse environment variables. """ from __future__ import unicode_literals import inspect import json as pyjson import logging import os import re import shlex import warnings try: import urllib.parse as urlparse except ImportError: # Python 2 import urlparse __version__ = '0.2.0' logger = logging.getLogger(__file__) class ConfigurationError(Exception): pass # Cannot rely on None since it may be desired as a return value. NOTSET = type(str('NoValue'), (object,), {}) def shortcut(cast): def method(self, var, **kwargs): return self.__call__(var, cast=cast, **kwargs) return method class Env(object): """ Lookup and cast environment variables with optional schema. Usage::: env = Env() env('foo') env.bool('bar') # Create env with a schema env = Env(MAIL_ENABLED=bool, SMTP_LOGIN=(str, 'DEFAULT')) if env('MAIL_ENABLED'): ... """ BOOLEAN_TRUE_STRINGS = ('true', 'on', 'ok', 'y', 'yes', '1') def __init__(self, **schema): self.schema = schema def __call__(self, var, default=NOTSET, cast=None, subcast=None, force=False, preprocessor=None, postprocessor=None): """ Return value for given environment variable. :param var: Name of variable. :param default: If var not present in environ, return this instead. :param cast: Type or callable to cast return value as. :param subcast: Subtype or callable to cast return values as (used for nested structures). :param force: force to cast to type even if default is set. :param preprocessor: callable to run on pre-casted value. :param postprocessor: callable to run on casted value. :returns: Value from environment or default (if set). """ logger.debug("Get '%s' casted as '%s'/'%s' with default '%s'", var, cast, subcast, default) if var in self.schema: params = self.schema[var] if isinstance(params, dict): if cast is None: cast = params.get('cast', cast) if subcast is None: subcast = params.get('subcast', subcast) if default == NOTSET: default = params.get('default', default) else: if cast is None: cast = params # Default cast is `str` if it is not specified. Most types will be # implicitly strings so reduces having to specify. cast = str if cast is None else cast try: value = os.environ[var] except KeyError: if default is NOTSET: error_msg = "Environment variable '{}' not set.".format(var) raise ConfigurationError(error_msg) else: value = default # Resolve any proxied values if hasattr(value, 'startswith') and value.startswith('{{'): value = self.__call__(value.lstrip('{{}}'), default, cast, subcast, default, force, preprocessor, postprocessor) if preprocessor: value = preprocessor(value) if value != default or force: value = self.cast(value, cast, subcast) if postprocessor: value = postprocessor(value) return value @classmethod def cast(cls, value, cast=str, subcast=None): """ Parse and cast provided value. :param value: Stringed value. :param cast: Type or callable to cast return value as. :param subcast: Subtype or callable to cast return values as (used for nested structures). :returns: Value of type `cast`. """ if cast is bool: value = value.lower() in cls.BOOLEAN_TRUE_STRINGS elif cast is float: # Clean string float_str = re.sub(r'[^\d,\.]', '', value) # Split to handle thousand separator for different locales, i.e. # comma or dot being the placeholder. parts = re.split(r'[,\.]', float_str) if len(parts) == 1: float_str = parts[0] else: float_str = "{0}.{1}".format(''.join(parts[0:-1]), parts[-1]) value = float(float_str) elif type(cast) is type and (issubclass(cast, list) or issubclass(cast, tuple)): value = (subcast(i.strip()) if subcast else i.strip() for i in value.split(',') if i) elif cast is dict: value = {k.strip(): subcast(v.strip()) if subcast else v.strip() for k, v in (i.split('=') for i in value.split(',') if value)} try: return cast(value) except ValueError as error: raise ConfigurationError(*error.args) # Shortcuts bool = shortcut(bool) dict = shortcut(dict) float = shortcut(float) int = shortcut(int) list = shortcut(list) set = shortcut(set) str = shortcut(str) tuple = shortcut(tuple) json = shortcut(pyjson.loads) url = shortcut(urlparse.urlparse) @staticmethod def read_envfile(path=None, **overrides): """ Read a .env file (line delimited KEY=VALUE) into os.environ. If not given a path to the file, recurses up the directory tree until found. Uses code from Honcho (github.com/nickstenning/honcho) for parsing the file. """ if path is None: frame = inspect.currentframe().f_back caller_dir = os.path.dirname(frame.f_code.co_filename) path = os.path.join(os.path.abspath(caller_dir), '.env') try: with open(path, 'r') as f: content = f.read() except getattr(__builtins__, 'FileNotFoundError', IOError): logger.debug('envfile not found at %s, looking in parent dir.', path) filedir, filename = os.path.split(path) pardir = os.path.abspath(os.path.join(filedir, os.pardir)) path = os.path.join(pardir, filename) if filedir != pardir: Env.read_envfile(path, **overrides) else: # Reached top level directory. warnings.warn('Could not any envfile.') return logger.debug('Reading environment variables from: %s', path) for line in content.splitlines(): tokens = list(shlex.shlex(line, posix=True)) # parses the assignment statement if len(tokens) < 3: continue name, op = tokens[:2] value = ''.join(tokens[2:]) if op != '=': continue if not re.match(r'[A-Za-z_][A-Za-z_0-9]*', name): continue value = value.replace(r'\n', '\n').replace(r'\t', '\t') os.environ.setdefault(name, value) for name, value in overrides.items(): os.environ.setdefault(name, value) # Convenience object if no schema is required. env = Env() envparse-04322a90035c2bddb36b18b2e58dc0aaecc56e64/setup.cfg000066400000000000000000000000261262414713500222700ustar00rootroot00000000000000[wheel] universal = 1 envparse-04322a90035c2bddb36b18b2e58dc0aaecc56e64/setup.py000066400000000000000000000044321262414713500221660ustar00rootroot00000000000000# -*- coding: utf-8 -*- from __future__ import print_function from setuptools import setup, find_packages from setuptools.command.test import test as TestCommand import codecs import os import sys import re def read(*parts): here = os.path.abspath(os.path.dirname(__file__)) # intentionally *not* adding an encoding option to open return codecs.open(os.path.join(here, *parts), 'r').read() def find_version(*file_paths): version_file = read(*file_paths) version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M) if version_match: return version_match.group(1) raise RuntimeError("Unable to find version string.") class PyTest(TestCommand): user_options = [('pytest-args=', 'a', "Arguments to pass to py.test")] def initialize_options(self): TestCommand.initialize_options(self) self.pytest_args = [] def finalize_options(self): TestCommand.finalize_options(self) self.test_args = [] self.test_suite = True def run_tests(self): #import here, cause outside the eggs aren't loaded import pytest errno = pytest.main(self.pytest_args) sys.exit(errno) setup( name='envparse', version=find_version('.', 'envparse.py'), url='https://github.com/rconradharris/envparse', license='MIT', author='Rick Harris', author_email='rconradharris@gmail.com', tests_require=['pytest'], install_requires=[''], cmdclass={'test': PyTest}, description='Simple environment variable parsing', long_description=read('README.rst'), py_modules=['envparse'], platforms='any', zip_safe=False, classifiers = [ 'Programming Language :: Python', 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 3', 'Development Status :: 4 - Beta', 'Natural Language :: English', 'Environment :: Web Environment', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', 'Topic :: Software Development :: Libraries :: Python Modules', 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', ], extras_require={ 'testing': ['pytest'], } ) envparse-04322a90035c2bddb36b18b2e58dc0aaecc56e64/tests/000077500000000000000000000000001262414713500216135ustar00rootroot00000000000000envparse-04322a90035c2bddb36b18b2e58dc0aaecc56e64/tests/envfile000066400000000000000000000004601262414713500231660ustar00rootroot00000000000000BLANK='' STR=foo INT=42 FLOAT=33.3 BOOL_TRUE=1 BOOL_FALSE=0 PROXIED={{STR}} LIST_STR='foo,bar' LIST_STR_WITH_SPACES=' foo, bar' LIST_INT=1,2,3 LIST_INT_WITH_SPACES=1, 2,3 DICT_STR=key1=val1, key2=val2 DICT_INT=key1=1, key2=2 JSON='{"foo": "bar", "baz": [1, 2, 3]}' URL=https://example.com/path?query=1 envparse-04322a90035c2bddb36b18b2e58dc0aaecc56e64/tests/test_casts.py000066400000000000000000000115141262414713500243430ustar00rootroot00000000000000# -*- coding: utf-8 -*- import pytest from envparse import Env, env, ConfigurationError, urlparse env_vars = dict( BLANK='', STR='foo', INT='42', FLOAT='33.3', BOOL_TRUE='1', BOOL_FALSE='0', PROXIED='{{STR}}', LIST_STR='foo,bar', LIST_STR_WITH_SPACES=' foo, bar', LIST_INT='1,2,3', LIST_INT_WITH_SPACES=' 1, 2,3', DICT_STR='key1=val1, key2=val2', DICT_INT='key1=1, key2=2', JSON='{"foo": "bar", "baz": [1, 2, 3]}', URL='https://example.com/path?query=1', ) @pytest.fixture(autouse=True, params=['environ', 'envfile']) def environ(monkeypatch, request): """Setup environment with sample variables.""" if request.param == 'environ': for key, val in env_vars.items(): monkeypatch.setenv(key, val) elif request.param == 'envfile': env.read_envfile('tests/envfile') # Helper function def assert_type_value(cast, expected, result): assert cast == type(result) assert expected == result def test_var_not_present(): with pytest.raises(ConfigurationError): env('NOT_PRESENT') def test_var_not_present_with_default(): default_val = 'default val' assert default_val, env('NOT_PRESENT', default=default_val) def test_default_none(): assert_type_value(type(None), None, env('NOT_PRESENT', default=None)) def test_implicit_nonbuiltin_type(): with pytest.raises(AttributeError): env.foo('FOO') def test_str(): expected = str(env_vars['STR']) assert_type_value(str, expected, env('STR')) assert_type_value(str, expected, env.str('STR')) def test_int(): expected = int(env_vars['INT']) assert_type_value(int, expected, env('INT', cast=int)) assert_type_value(int, expected, env.int('INT')) def test_float(): expected = float(env_vars['FLOAT']) assert_type_value(float, expected, env.float('FLOAT')) def test_bool(): assert_type_value(bool, True, env.bool('BOOL_TRUE')) assert_type_value(bool, False, env.bool('BOOL_FALSE')) def test_list(): list_str = ['foo', 'bar'] assert_type_value(list, list_str, env('LIST_STR', cast=list)) assert_type_value(list, list_str, env.list('LIST_STR')) assert_type_value(list, list_str, env.list('LIST_STR_WITH_SPACES')) list_int = [1, 2, 3] assert_type_value(list, list_int, env('LIST_INT', cast=list, subcast=int)) assert_type_value(list, list_int, env.list('LIST_INT', subcast=int)) assert_type_value(list, list_int, env.list('LIST_INT_WITH_SPACES', subcast=int)) assert_type_value(list, [], env.list('BLANK', subcast=int)) def test_dict(): dict_str = dict(key1='val1', key2='val2') assert_type_value(dict, dict_str, env.dict('DICT_STR')) assert_type_value(dict, dict_str, env('DICT_STR', cast=dict)) dict_int = dict(key1=1, key2=2) assert_type_value(dict, dict_int, env('DICT_INT', cast=dict, subcast=int)) assert_type_value(dict, dict_int, env.dict('DICT_INT', subcast=int)) assert_type_value(dict, {}, env.dict('BLANK')) def test_json(): expected = {'foo': 'bar', 'baz': [1, 2, 3]} assert_type_value(dict, expected, env.json('JSON')) def test_url(): url = urlparse.urlparse('https://example.com/path?query=1') assert_type_value(url.__class__, url, env.url('URL')) def proxied_value(): assert_type_value(str, 'bar', env('PROXIED')) def test_preprocessor(): assert_type_value(str, 'FOO', env('STR', preprocessor=lambda v: v.upper())) def test_postprocessor(monkeypatch): """ Test a postprocessor which turns a redis url into a Django compatible cache url. """ redis_url = 'redis://:redispass@127.0.0.1:6379/0' monkeypatch.setenv('redis_url', redis_url) expected = {'BACKEND': 'django_redis.cache.RedisCache', 'LOCATION': '127.0.0.1:6379:0', 'OPTIONS': {'PASSWORD': 'redispass'}} def django_redis(url): return { 'BACKEND': 'django_redis.cache.RedisCache', 'LOCATION': '{}:{}:{}'.format(url.hostname, url.port, url.path.strip('/')), 'OPTIONS': {'PASSWORD': url.password}} assert_type_value(dict, expected, env.url('redis_url', postprocessor=django_redis)) def test_schema(): env = Env(STR=str, STR_DEFAULT=dict(cast=str, default='default'), INT=int, LIST_STR=list, LIST_INT=dict(cast=list, subcast=int)) assert_type_value(str, 'foo', env('STR')) assert_type_value(str, 'default', env('STR_DEFAULT')) assert_type_value(int, 42, env('INT')) assert_type_value(list, ['foo', 'bar'], env('LIST_STR')) assert_type_value(list, [1, 2, 3], env('LIST_INT')) # Overrides assert_type_value(str, '42', env('INT', cast=str)) assert_type_value(str, 'manual_default', env('STR_DEFAULT', default='manual_default')) envparse-04322a90035c2bddb36b18b2e58dc0aaecc56e64/tox.ini000066400000000000000000000006731262414713500217720ustar00rootroot00000000000000[tox] envlist=py27, py32, py33, py34, py35, pypy, pypy3, flake8, coverage [testenv] commands = {envpython} setup.py test deps = pytest [testenv:coverage] deps = coverage {[testenv]deps} commands = coverage run -m pytest tests --strict {posargs} coverage report --include=envparse.py coverage html --include=envparse.py [testenv:flake8] deps = flake8 commands = flake8 envparse.py tests --max-line-length=100