Skip to content

Commit f628767

Browse files
author
Saurabh Kumar
committed
feat: add Env to read and parse environment variables
1 parent f9863d3 commit f628767

9 files changed

Lines changed: 333 additions & 14 deletions

File tree

LICENSE

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,15 @@ PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
8585
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
8686
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
8787
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
88+
89+
90+
python-decouple
91+
The MIT License (MIT)
92+
93+
Copyright (c) 2017 Henrique Bastos <henrique at bastos dot net>
94+
95+
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:
96+
97+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
98+
99+
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.

README.md

Lines changed: 106 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,18 @@ in production using [12-factor](http://12factor.net/) principles.
2626

2727
> Hey just wanted to let you know that since I've started writing 12-factor apps I've found python-dotenv to be invaluable for all my projects. It's super useful and “just works.” --Daniel Fridkin
2828
29+
Installation
30+
============
31+
32+
pip install -U python-dotenv
33+
2934
Usages
3035
======
3136

3237
The easiest and most common usage consists on calling `load_dotenv` when
3338
the application starts, which will load environment variables from a
34-
file named `.env` in the current directory or any of its parents or from
35-
the path specificied; after that, you can just call the
39+
file named `.env` in the current directory, any of its parents or from
40+
the path specified; after that, you can just call the
3641
environment-related method you need as provided by `os.getenv`.
3742

3843
`.env` looks like this:
@@ -42,6 +47,8 @@ environment-related method you need as provided by `os.getenv`.
4247
REDIS_ADDRESS=localhost:6379
4348
MEANING_OF_LIFE=42
4449
MULTILINE_VAR="hello\nworld"
50+
MULTILINE_VAR2="hello
51+
world"
4552
```
4653

4754
You can optionally prefix each line with the word `export`, which will
@@ -96,6 +103,8 @@ SECRET_KEY = os.getenv("EMAIL")
96103
DATABASE_PASSWORD = os.getenv("DATABASE_PASSWORD")
97104
```
98105

106+
`os.getenv` works but it can be tricky as times as the returned value is always a string. dotenv provides it's own version of [`getenv`](#reading-envvars-in-your-application) that handle type casting like `bool`, `int`, etc.
107+
99108
`load_dotenv` do not override existing System environment variables. To
100109
override, pass `override=True` to `load_dotenv()`.
101110

@@ -139,11 +148,6 @@ Django
139148
If you are using django you should add the above loader script at the
140149
top of `wsgi.py` and `manage.py`.
141150

142-
Installation
143-
============
144-
145-
pip install -U python-dotenv
146-
147151
iPython Support
148152
---------------
149153

@@ -254,6 +258,97 @@ commands like so
254258

255259
$ fab config:set,hello,world config:set,foo,bar config:set,fizz=buzz
256260

261+
262+
Reading envvars in your application
263+
==============================================
264+
265+
Envvars works, but since `os.environ` or `os.getenv` only returns strings, it’s tricky.
266+
267+
Let’s say you have an envvar `DEBUG=False`. If you run:
268+
269+
```
270+
if os.environ['DEBUG']:
271+
print True
272+
else:
273+
print False
274+
```
275+
276+
It will print `True`, because `os.environ['DEBUG']` returns the string `"False"`. Since it’s a non-empty string, it will be evaluated as `True`.
277+
278+
python-dotenv provides a solution that doesn’t look like a workaround: `getenv('DEBUG', cast=bool)`.
279+
280+
```
281+
from dotenv import env
282+
283+
SECRET_KEY = env('SECRET_KEY')
284+
DEBUG = env.bool('DEBUG', default=False)
285+
EMAIL_HOST = env('EMAIL_HOST', default='localhost')
286+
EMAIL_PORT = env.int('EMAIL_PORT', default=25)
287+
```
288+
289+
**Understanding the CAST argument**
290+
291+
By default, all values returned by `env` are strings, after all they are read from the envvars.
292+
293+
However, your Python code may expect some other value type, for example:
294+
295+
* Django’s DEBUG expects a boolean True or False.
296+
* Django’s EMAIL_PORT expects an integer.
297+
* Django’s ALLOWED_HOSTS expects a list of hostnames.
298+
* Django’s SECURE_PROXY_SSL_HEADER expects a tuple with two elements, the name of the header to look for and the required value.
299+
300+
To meet this need, the `env` function accepts a `cast` argument which receives any callable, that will be used to transform the string value into something else.
301+
302+
Let’s see some examples for the above mentioned cases:
303+
304+
```
305+
>>> os.environ['DEBUG'] = 'False'
306+
>>> env('DEBUG', cast=bool)
307+
False
308+
309+
>>> os.environ['EMAIL_PORT'] = '42'
310+
>>> env('EMAIL_PORT', cast=int)
311+
42
312+
313+
>>> os.environ['ALLOWED_HOSTS'] = '.localhost, .herokuapp.com'
314+
>>> env('ALLOWED_HOSTS', cast=lambda v: [s.strip() for s in v.split(',')])
315+
['.localhost', '.herokuapp.com']
316+
```
317+
318+
As you can see, cast is very flexible. But the last example got a bit complex.
319+
320+
**Built in Csv Helper**
321+
322+
To address the complexity of the last example, Decouple comes with an extensible Csv helper.
323+
324+
Let’s improve the last example:
325+
326+
```
327+
>>> os.environ['ALLOWED_HOSTS'] = '.localhost, .herokuapp.com'
328+
>>> env.csv('ALLOWED_HOSTS')
329+
['.localhost', '.herokuapp.com']
330+
```
331+
332+
You can also parametrize the csv Helper to return other types of data.
333+
334+
```
335+
>>> os.environ['LIST_OF_INTEGERS'] = '1,2,3,4,5'
336+
>>> env.csv('LIST_OF_INTEGERS', cast=int)
337+
[1, 2, 3, 4, 5]
338+
339+
>>> os.environ['COMPLEX_STRING'] = '%virtual_env%\t *important stuff*\t trailing spaces '
340+
>>> env.csv('COMPLEX_STRING', cast=lambda s: s.upper(), delimiter='\t', strip=' %*')
341+
['VIRTUAL_ENV', 'IMPORTANT STUFF', 'TRAILING SPACES']
342+
```
343+
344+
By default `Csv` returns a `list`, but you can get a tuple or whatever you want using the `post_process` argument:
345+
346+
```
347+
>>> os.environ['SECURE_PROXY_SSL_HEADER'] = 'HTTP_X_FORWARDED_PROTO, https'
348+
>>> env.csv('SECURE_PROXY_SSL_HEADER', post_process=tuple)
349+
('HTTP_X_FORWARDED_PROTO', 'https')
350+
```
351+
257352
Related Projects
258353
================
259354

@@ -284,6 +379,10 @@ Executing the tests:
284379
Changelog
285380
=========
286381

382+
dev
383+
-----
384+
- Add `dotenv.env()` function to parse envvars
385+
287386
0.10.1
288387
-----
289388
- Fix parsing of variable without a value ([@asyncee])([@bbc2])([#158])

dotenv/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .main import load_dotenv, get_key, set_key, unset_key, find_dotenv, dotenv_values
2+
from .environ import env
23

34

45
def load_ipython_extension(ipython):
@@ -36,5 +37,6 @@ def get_cli_string(path=None, action=None, key=None, value=None, quote=None):
3637
'get_key',
3738
'set_key',
3839
'unset_key',
40+
'env',
3941
'find_dotenv',
4042
'load_ipython_extension']

dotenv/environ.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# -*- coding: utf-8 -*-
2+
import os
3+
import json
4+
import string
5+
6+
from .helpers import Csv
7+
from .compat import text_type
8+
9+
10+
class UndefinedValueError(Exception):
11+
pass
12+
13+
14+
class Undefined(object):
15+
"""Class to represent undefined type. """
16+
pass
17+
18+
19+
def _cast_boolean(value):
20+
"""
21+
Helper to convert config values to boolean as ConfigParser do.
22+
"""
23+
_BOOLEANS = {'1': True, 'yes': True, 'true': True, 'on': True,
24+
'0': False, 'no': False, 'false': False, 'off': False, '': False}
25+
value = str(value)
26+
if value.lower() not in _BOOLEANS:
27+
raise ValueError('Not a boolean: %s' % value)
28+
29+
return _BOOLEANS[value.lower()]
30+
31+
32+
class Env():
33+
ENVIRON = os.environ
34+
NOTSET = Undefined()
35+
36+
def __call__(self, var, default=NOTSET, cast=NOTSET):
37+
return self.get_value(var, default=default, cast=cast)
38+
39+
def __contains__(self, var):
40+
return var in self.ENVIRON
41+
42+
def get_value(self, var, default=NOTSET, cast=NOTSET):
43+
"""
44+
Return the value for option or default if defined.
45+
"""
46+
47+
# We can't avoid __contains__ because value may be empty.
48+
try:
49+
value = self.ENVIRON[var]
50+
except KeyError:
51+
if isinstance(default, Undefined):
52+
error_msg = '{} not found. Declare it as envvar or define a default value.'.format(var)
53+
raise UndefinedValueError(error_msg)
54+
55+
value = default
56+
57+
if cast is self.NOTSET:
58+
return value
59+
60+
if cast is bool:
61+
value = _cast_boolean(value)
62+
elif cast is list:
63+
value = [x for x in value.split(',') if x]
64+
else:
65+
value = cast(value)
66+
67+
return value
68+
69+
# shortcuts
70+
def int(self, var, default=NOTSET):
71+
return self.get_value(var, default, cast=int)
72+
73+
def str(self, var, default=NOTSET):
74+
return self.get_value(var, default)
75+
76+
def bool(self, var, default=NOTSET):
77+
return self.get_value(var, default, cast=bool)
78+
79+
def float(self, var, default=NOTSET):
80+
return self.get_value(var, default, cast=float)
81+
82+
def json(self, var, default=NOTSET):
83+
return self.get_value(var, default, cast=json.loads)
84+
85+
def csv(self, var, default=NOTSET, cast=text_type, delimiter=',', strip=string.whitespace, post_process=list):
86+
return self.get_value(var, default, cast=Csv(cast, delimiter, strip, post_process))
87+
88+
89+
env = Env()

dotenv/helpers.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import string
2+
from shlex import shlex
3+
4+
from .compat import text_type
5+
6+
7+
class Csv(object):
8+
"""Produces a csv parser that return a list of transformed elements.
9+
"""
10+
11+
def __init__(self, cast=text_type, delimiter=',', strip=string.whitespace, post_process=list):
12+
"""
13+
Parameters:
14+
cast -- callable that transforms the item just before it's added to the list.
15+
delimiter -- string of delimiters chars passed to shlex.
16+
strip -- string of non-relevant characters to be passed to str.strip after the split.
17+
tuple_ -- boolean to check if it is to return in tuple format.
18+
"""
19+
self.cast = cast
20+
self.delimiter = delimiter
21+
self.strip = strip
22+
self.post_process = post_process
23+
24+
def __call__(self, value):
25+
"""The actual transformation"""
26+
transform = lambda s: self.cast(s.strip(self.strip))
27+
28+
splitter = shlex(value, posix=True)
29+
splitter.whitespace = self.delimiter
30+
splitter.whitespace_split = True
31+
32+
return self.post_process(transform(s) for s in splitter)

dotenv/main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from contextlib import contextmanager
1515

1616
from .compat import StringIO, PY2, WIN, text_type
17+
from .environ import env
1718

1819
__posix_variable = re.compile(r'\$\{[^\}]*\}')
1920

@@ -306,7 +307,8 @@ def find_dotenv(filename='.env', raise_error_if_not_found=False, usecwd=False):
306307

307308
def load_dotenv(dotenv_path=None, stream=None, verbose=False, override=False):
308309
f = dotenv_path or stream or find_dotenv()
309-
return DotEnv(f, verbose=verbose).set_as_environment_variables(override=override)
310+
DotEnv(f, verbose=verbose).set_as_environment_variables(override=override)
311+
return env
310312

311313

312314
def dotenv_values(dotenv_path=None, stream=None, verbose=False):

tests/dotenv

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
FOO="BAR"

tests/test_core.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,7 @@ def test_load_dotenv(cli):
155155
sh.touch(dotenv_path)
156156
set_key(dotenv_path, 'DOTENV', 'WORKS')
157157
assert 'DOTENV' not in os.environ
158-
success = load_dotenv(dotenv_path)
159-
assert success
158+
load_dotenv(dotenv_path)
160159
assert 'DOTENV' in os.environ
161160
assert os.environ['DOTENV'] == 'WORKS'
162161
sh.rm(dotenv_path)
@@ -170,8 +169,7 @@ def test_load_dotenv_override(cli):
170169
sh.touch(dotenv_path)
171170
os.environ[key_name] = "OVERRIDE"
172171
set_key(dotenv_path, key_name, 'WORKS')
173-
success = load_dotenv(dotenv_path, override=True)
174-
assert success
172+
load_dotenv(dotenv_path, override=True)
175173
assert key_name in os.environ
176174
assert os.environ[key_name] == 'WORKS'
177175
sh.rm(dotenv_path)
@@ -184,8 +182,7 @@ def test_load_dotenv_in_current_dir():
184182
with open(dotenv_path, 'w') as f:
185183
f.write("TOTO=bla\n")
186184
assert 'TOTO' not in os.environ
187-
success = load_dotenv(verbose=True)
188-
assert success
185+
load_dotenv(verbose=True)
189186
assert os.environ['TOTO'] == 'bla'
190187
sh.rm(dotenv_path)
191188

0 commit comments

Comments
 (0)