pax_global_header 0000666 0000000 0000000 00000000064 12624147135 0014517 g ustar 00root root 0000000 0000000 52 comment=04322a90035c2bddb36b18b2e58dc0aaecc56e64
envparse-04322a90035c2bddb36b18b2e58dc0aaecc56e64/ 0000775 0000000 0000000 00000000000 12624147135 0020451 5 ustar 00root root 0000000 0000000 envparse-04322a90035c2bddb36b18b2e58dc0aaecc56e64/.gitignore 0000664 0000000 0000000 00000000121 12624147135 0022433 0 ustar 00root root 0000000 0000000 *.py[cod]
build
dist
*.egg-info
.tox
.cache
.eggs
# coverage
.coverage
htmlcov
envparse-04322a90035c2bddb36b18b2e58dc0aaecc56e64/.travis.yml 0000664 0000000 0000000 00000000405 12624147135 0022561 0 ustar 00root root 0000000 0000000 language: 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.rst 0000664 0000000 0000000 00000000624 12624147135 0022474 0 ustar 00root root 0000000 0000000 CHANGELOG
=========
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/LICENSE 0000664 0000000 0000000 00000002145 12624147135 0021460 0 ustar 00root root 0000000 0000000 Copyright (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.in 0000664 0000000 0000000 00000000033 12624147135 0022203 0 ustar 00root root 0000000 0000000 include README.rst LICENSE
envparse-04322a90035c2bddb36b18b2e58dc0aaecc56e64/Makefile 0000664 0000000 0000000 00000000406 12624147135 0022111 0 ustar 00root root 0000000 0000000 SHELL := /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.rst 0000664 0000000 0000000 00000012740 12624147135 0022144 0 ustar 00root root 0000000 0000000 envparse
========
``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.py 0000664 0000000 0000000 00000016177 12624147135 0022662 0 ustar 00root root 0000000 0000000 """
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.cfg 0000664 0000000 0000000 00000000026 12624147135 0022270 0 ustar 00root root 0000000 0000000 [wheel]
universal = 1
envparse-04322a90035c2bddb36b18b2e58dc0aaecc56e64/setup.py 0000664 0000000 0000000 00000004432 12624147135 0022166 0 ustar 00root root 0000000 0000000 # -*- 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/ 0000775 0000000 0000000 00000000000 12624147135 0021613 5 ustar 00root root 0000000 0000000 envparse-04322a90035c2bddb36b18b2e58dc0aaecc56e64/tests/envfile 0000664 0000000 0000000 00000000460 12624147135 0023166 0 ustar 00root root 0000000 0000000 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
envparse-04322a90035c2bddb36b18b2e58dc0aaecc56e64/tests/test_casts.py 0000664 0000000 0000000 00000011514 12624147135 0024343 0 ustar 00root root 0000000 0000000 # -*- 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.ini 0000664 0000000 0000000 00000000673 12624147135 0021772 0 ustar 00root root 0000000 0000000 [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