Skip to content

Commit 802496b

Browse files
Hugo Chinchilla Carbonelltheskumar
authored andcommitted
Support POSIX parameter expansion (theskumar#30)
1 parent 03a3851 commit 802496b

File tree

5 files changed

+105
-6
lines changed

5 files changed

+105
-6
lines changed

README.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,17 @@ ignored.
8484
# I am a comment and that is OK
8585
FOO="BAR"
8686
87+
``.env`` can interpolate variables using POSIX variable expansion, variables
88+
are replaced from the environment first or from other values in the ``.env``
89+
file if the variable is not present in the environment.
90+
91+
.. code:: shell
92+
93+
CONFIG_PATH=${HOME}/.config/foo
94+
DOMAIN=example.org
95+
EMAIL=admin@${DOMAIN}
96+
97+
8798
Django
8899
------
89100

@@ -209,6 +220,13 @@ us a pull request.
209220
This project is currently maintained by `Saurabh Kumar <https://saurabh-kumar.com>`__ and
210221
would not have been possible without the support of these `awesome people <https://github.com/theskumar/python-dotenv/graphs/contributors>`__.
211222

223+
Executing the tests:
224+
225+
::
226+
227+
$ flake8
228+
$ pytest
229+
212230
Changelog
213231
=========
214232

@@ -217,6 +235,7 @@ dev
217235
- Drop support for Python 2.6
218236
- Handle escaped charaters and newlines in quoted values. (Thanks `@iameugenejo`_)
219237
- Remove any spaces around unquoted key/value. (Thanks `@paulochf`_)
238+
- Added POSIX variable expansion.
220239

221240
0.5.1
222241
----------

dotenv/cli.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import click
44

5-
from .main import get_key, parse_dotenv, set_key, unset_key
5+
from .main import get_key, dotenv_values, set_key, unset_key
66

77

88
@click.group()
@@ -35,7 +35,7 @@ def cli(ctx, file, quote):
3535
def list(ctx):
3636
'''Display all the stored key/value.'''
3737
file = ctx.obj['FILE']
38-
dotenv_as_dict = parse_dotenv(file)
38+
dotenv_as_dict = dotenv_values(file)
3939
for k, v in dotenv_as_dict:
4040
click.echo('%s="%s"' % (k, v))
4141

dotenv/main.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
import os
66
import sys
77
import warnings
8+
import re
89
from collections import OrderedDict
910

1011
__escape_decoder = codecs.getdecoder('unicode_escape')
12+
__posix_variable = re.compile('\$\{[^\}]*\}')
1113

1214

1315
def decode_escaped(escaped):
@@ -21,7 +23,7 @@ def load_dotenv(dotenv_path):
2123
if not os.path.exists(dotenv_path):
2224
warnings.warn("Not loading %s - it doesn't exist." % dotenv_path)
2325
return None
24-
for k, v in parse_dotenv(dotenv_path):
26+
for k, v in dotenv_values(dotenv_path).items():
2527
os.environ.setdefault(k, v)
2628
return True
2729

@@ -36,7 +38,7 @@ def get_key(dotenv_path, key_to_get):
3638
if not os.path.exists(dotenv_path):
3739
warnings.warn("can't read %s - it doesn't exist." % dotenv_path)
3840
return None
39-
dotenv_as_dict = OrderedDict(parse_dotenv(dotenv_path))
41+
dotenv_as_dict = dotenv_values(dotenv_path)
4042
if key_to_get in dotenv_as_dict:
4143
return dotenv_as_dict[key_to_get]
4244
else:
@@ -73,7 +75,7 @@ def unset_key(dotenv_path, key_to_unset, quote_mode="always"):
7375
if not os.path.exists(dotenv_path):
7476
warnings.warn("can't delete from %s - it doesn't exist." % dotenv_path)
7577
return None, key_to_unset
76-
dotenv_as_dict = OrderedDict(parse_dotenv(dotenv_path))
78+
dotenv_as_dict = dotenv_values(dotenv_path)
7779
if key_to_unset in dotenv_as_dict:
7880
dotenv_as_dict.pop(key_to_unset, None)
7981
else:
@@ -83,6 +85,12 @@ def unset_key(dotenv_path, key_to_unset, quote_mode="always"):
8385
return success, key_to_unset
8486

8587

88+
def dotenv_values(dotenv_path):
89+
values = OrderedDict(parse_dotenv(dotenv_path))
90+
values = resolve_nested_variables(values)
91+
return values
92+
93+
8694
def parse_dotenv(dotenv_path):
8795
with open(dotenv_path) as f:
8896
for line in f:
@@ -103,6 +111,29 @@ def parse_dotenv(dotenv_path):
103111
yield k, v
104112

105113

114+
def resolve_nested_variables(values):
115+
def _replacement(name):
116+
"""
117+
get appropiate value for a variable name.
118+
first search in environ, if not found,
119+
then look into the dotenv variables
120+
"""
121+
ret = os.getenv(name, values.get(name, ""))
122+
return ret
123+
124+
def _re_sub_callback(match_object):
125+
"""
126+
From a match object gets the variable name and returns
127+
the correct replacement
128+
"""
129+
return _replacement(match_object.group()[2:-1])
130+
131+
for k, v in values.items():
132+
values[k] = __posix_variable.sub(_re_sub_callback, v)
133+
134+
return values
135+
136+
106137
def flatten_and_write(dotenv_path, dotenv_as_dict, quote_mode="always"):
107138
with open(dotenv_path, "w") as f:
108139
for k, v in dotenv_as_dict.items():

tests/test_cli.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# -*- coding: utf-8 -*-
22
from __future__ import unicode_literals
33

4+
from os import environ
45
from os.path import dirname, join
56

67
import dotenv
@@ -83,3 +84,37 @@ def test_default_path(cli):
8384
output = sh.dotenv('get', 'HELLO')
8485
assert output == 'HELLO="WORLD"\n'
8586
sh.rm(dotenv_path)
87+
88+
89+
def test_get_key_with_interpolation(cli):
90+
with cli.isolated_filesystem():
91+
sh.touch(dotenv_path)
92+
dotenv.set_key(dotenv_path, 'HELLO', 'WORLD')
93+
dotenv.set_key(dotenv_path, 'FOO', '${HELLO}')
94+
dotenv.set_key(dotenv_path, 'BAR', 'CONCATENATED_${HELLO}_POSIX_VAR')
95+
96+
# test replace from variable in file
97+
stored_value = dotenv.get_key(dotenv_path, 'FOO')
98+
assert stored_value == 'WORLD'
99+
stored_value = dotenv.get_key(dotenv_path, 'BAR')
100+
assert stored_value == 'CONCATENATED_WORLD_POSIX_VAR'
101+
# test replace from environ taking precedence over file
102+
environ["HELLO"] = "TAKES_PRECEDENCE"
103+
stored_value = dotenv.get_key(dotenv_path, 'FOO')
104+
assert stored_value == "TAKES_PRECEDENCE"
105+
sh.rm(dotenv_path)
106+
107+
108+
def test_get_key_with_interpolation_of_unset_variable(cli):
109+
with cli.isolated_filesystem():
110+
sh.touch(dotenv_path)
111+
dotenv.set_key(dotenv_path, 'FOO', '${NOT_SET}')
112+
# test unavailable replacement returns empty string
113+
stored_value = dotenv.get_key(dotenv_path, 'FOO')
114+
assert stored_value == ''
115+
# unless present in environment
116+
environ['NOT_SET'] = 'BAR'
117+
stored_value = dotenv.get_key(dotenv_path, 'FOO')
118+
assert stored_value == 'BAR'
119+
del(environ['NOT_SET'])
120+
sh.rm(dotenv_path)

tests/test_core.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
import pytest
44
import tempfile
55
import warnings
6+
import sh
67

7-
from dotenv import load_dotenv, find_dotenv
8+
from dotenv import load_dotenv, find_dotenv, set_key
89

910

1011
def test_warns_if_file_does_not_exist():
@@ -55,3 +56,16 @@ def test_find_dotenv():
5556
with open(filename, 'w') as f:
5657
f.write("TEST=test\n")
5758
assert find_dotenv(usecwd=True) == filename
59+
60+
61+
def test_load_dotenv(cli):
62+
dotenv_path = '.test_load_dotenv'
63+
with cli.isolated_filesystem():
64+
sh.touch(dotenv_path)
65+
set_key(dotenv_path, 'DOTENV', 'WORKS')
66+
assert 'DOTENV' not in os.environ
67+
success = load_dotenv(dotenv_path)
68+
assert success
69+
assert 'DOTENV' in os.environ
70+
assert os.environ['DOTENV'] == 'WORKS'
71+
sh.rm(dotenv_path)

0 commit comments

Comments
 (0)