Skip to content

Commit eb08e7b

Browse files
committed
Merge pull request #7 from jamieconnolly/enhance-parser
Refactor the parser to handle more use-cases
2 parents 12f9730 + 60b7887 commit eb08e7b

File tree

4 files changed

+178
-14
lines changed

4 files changed

+178
-14
lines changed

.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DOTENV=true

dotenv.py

Lines changed: 76 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,36 @@
11
import os
2+
import re
23
import sys
34
import warnings
45

6+
7+
line_re = re.compile(r"""
8+
^
9+
(?:export\s+)? # optional export
10+
([\w\.]+) # key
11+
(?:\s*=\s*|:\s+?) # separator
12+
( # optional value begin
13+
'(?:\'|[^'])*' # single quoted value
14+
| # or
15+
"(?:\"|[^"])*" # double quoted value
16+
| # or
17+
[^#\n]+ # unquoted value
18+
)? # value end
19+
(?:\s*\#.*)? # optional comment
20+
$
21+
""", re.VERBOSE)
22+
23+
variable_re = re.compile(r"""
24+
(\\)? # is it escaped with a backslash?
25+
(\$) # literal $
26+
( # collect braces with var for sub
27+
\{? # allow brace wrapping
28+
([A-Z0-9_]+) # match the variable
29+
\}? # closing brace
30+
) # braces end
31+
""", re.IGNORECASE | re.VERBOSE)
32+
33+
534
def read_dotenv(dotenv=None):
635
"""
736
Read a .env file into os.environ.
@@ -12,17 +41,50 @@ def read_dotenv(dotenv=None):
1241
if dotenv is None:
1342
frame = sys._getframe()
1443
dotenv = os.path.join(os.path.dirname(frame.f_back.f_code.co_filename), '.env')
15-
if not os.path.exists(dotenv):
16-
warnings.warn("not reading %s - it doesn't exist." % dotenv)
17-
return
18-
for k, v in parse_dotenv(dotenv):
19-
os.environ.setdefault(k, v)
20-
21-
def parse_dotenv(dotenv):
22-
for line in open(dotenv):
23-
line = line.strip()
24-
if not line or line.startswith('#') or '=' not in line:
25-
continue
26-
k, v = line.split('=', 1)
27-
v = v.strip("'").strip('"')
28-
yield k, v
44+
if os.path.exists(dotenv):
45+
file = open(dotenv)
46+
for k, v in parse_dotenv(file.read()).items():
47+
os.environ.setdefault(k, v)
48+
file.close()
49+
else:
50+
warnings.warn("not reading %s - it doesn't exist." % dotenv)
51+
52+
53+
def parse_dotenv(content):
54+
env = {}
55+
for line in content.splitlines():
56+
m1 = line_re.search(line)
57+
if m1:
58+
key, value = m1.groups()
59+
if value is None:
60+
value = ''
61+
62+
# remove leading/trailing whitespace
63+
value = value.strip()
64+
65+
# remove surrounding quotes
66+
m2 = re.match(r'^([\'"])(.*)\1$', value)
67+
if m2:
68+
quotemark, value = m2.groups()
69+
else:
70+
quotemark = None
71+
72+
# unescape all characters except $ so variables can be escaped properly
73+
if quotemark == '"':
74+
value = re.sub(r'\\([^$])', '\1', value)
75+
76+
if quotemark != "'":
77+
# substitute variables in a value
78+
for parts in variable_re.findall(value):
79+
if parts[0] == '\\':
80+
# variable is escaped, don't replace it
81+
replace = ''.join(parts[1:-1])
82+
else:
83+
# replace it with the value from the environment
84+
replace = env.get(parts[-1], os.environ.get(parts[-1], ''))
85+
value = value.replace(''.join(parts[0:-1]), replace)
86+
87+
env[key] = value
88+
elif not re.search(r'^\s*(?:#.*)?$', line): # not comment or blank line
89+
warnings.warn("Line %s doesn't match format" % repr(line), SyntaxWarning)
90+
return env

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
author_email = "[email protected]",
99
url = "http://github.com/jacobian/django-dotenv",
1010
py_modules = ['dotenv'],
11+
test_suite = 'tests',
1112
classifiers = [
1213
'Development Status :: 4 - Beta',
1314
'Environment :: Web Environment',

tests.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import os
2+
import unittest
3+
import warnings
4+
5+
from dotenv import parse_dotenv, read_dotenv
6+
7+
8+
class ParseDotenvTestCase(unittest.TestCase):
9+
def test_parses_unquoted_values(self):
10+
self.assertEqual(parse_dotenv('FOO=bar'), {'FOO': 'bar'})
11+
12+
def test_parses_values_with_spaces_around_equal_sign(self):
13+
self.assertEqual(parse_dotenv('FOO =bar'), {'FOO': 'bar'})
14+
self.assertEqual(parse_dotenv('FOO= bar'), {'FOO': 'bar'})
15+
16+
def test_parses_double_quoted_values(self):
17+
self.assertEqual(parse_dotenv('FOO="bar"'), {'FOO': 'bar'})
18+
19+
def test_parses_single_quoted_values(self):
20+
self.assertEqual(parse_dotenv("FOO='bar'"), {'FOO': 'bar'})
21+
22+
def test_parses_escaped_double_quotes(self):
23+
self.assertEqual(parse_dotenv('FOO="escaped\"bar"'), {'FOO': 'escaped"bar'})
24+
25+
def test_parses_empty_values(self):
26+
self.assertEqual(parse_dotenv('FOO='), {'FOO': ''})
27+
28+
def test_expands_variables_found_in_values(self):
29+
self.assertEqual(parse_dotenv("FOO=test\nBAR=$FOO"), {'FOO': 'test', 'BAR': 'test'})
30+
31+
def test_expands_variables_wrapped_in_brackets(self):
32+
self.assertEqual(parse_dotenv("FOO=test\nBAR=${FOO}bar"), {'FOO': 'test', 'BAR': 'testbar'})
33+
34+
def test_expands_variables_from_environ_if_not_found_in_local_env(self):
35+
os.environ.setdefault('FOO', 'test')
36+
self.assertEqual(parse_dotenv('BAR=$FOO'), {'BAR': 'test'})
37+
38+
def test_expands_undefined_variables_to_an_empty_string(self):
39+
self.assertEqual(parse_dotenv('BAR=$FOO'), {'BAR': ''})
40+
41+
def test_expands_variables_in_double_quoted_values(self):
42+
self.assertEqual(parse_dotenv("FOO=test\nBAR=\"quote $FOO\""), {'FOO': 'test', 'BAR': 'quote test'})
43+
44+
def test_does_not_expand_variables_in_single_quoted_values(self):
45+
self.assertEqual(parse_dotenv("BAR='quote $FOO'"), {'BAR': 'quote $FOO'})
46+
47+
def test_does_not_expand_escaped_variables(self):
48+
self.assertEqual(parse_dotenv('FOO="foo\\$BAR"'), {'FOO': 'foo$BAR'})
49+
self.assertEqual(parse_dotenv('FOO="foo\${BAR}"'), {'FOO': 'foo${BAR}'})
50+
51+
def test_parses_export_keyword(self):
52+
self.assertEqual(parse_dotenv('export FOO=bar'), {'FOO': 'bar'})
53+
54+
def test_parses_key_with_dot_in_the_name(self):
55+
self.assertEqual(parse_dotenv('FOO.BAR=foobar'), {'FOO.BAR': 'foobar'})
56+
57+
def test_strips_unquoted_values(self):
58+
self.assertEqual(parse_dotenv('foo=bar '), {'foo': 'bar'}) # not 'bar '
59+
60+
def test_warns_if_line_format_is_incorrect(self):
61+
with warnings.catch_warnings(record=True) as w:
62+
parse_dotenv('lol$wut')
63+
64+
self.assertEqual(len(w), 1)
65+
self.assertIs(w[0].category, SyntaxWarning)
66+
self.assertEqual(str(w[0].message), "Line 'lol$wut' doesn't match format")
67+
68+
def test_ignores_empty_lines(self):
69+
self.assertEqual(parse_dotenv("\n \t \nfoo=bar\n \nfizz=buzz"), {'foo': 'bar', 'fizz': 'buzz'})
70+
71+
def test_ignores_inline_comments(self):
72+
self.assertEqual(parse_dotenv('foo=bar # this is foo'), {'foo': 'bar'})
73+
74+
def test_allows_hash_in_quoted_values(self):
75+
self.assertEqual(parse_dotenv('foo="bar#baz" # comment '), {'foo': 'bar#baz'})
76+
77+
def test_ignores_comment_lines(self):
78+
self.assertEqual(parse_dotenv("\n\n\n # HERE GOES FOO \nfoo=bar"), {'foo': 'bar'})
79+
80+
def test_parses_hash_in_quoted_values(self):
81+
self.assertEqual(parse_dotenv('foo="ba#r"'), {'foo': 'ba#r'})
82+
self.assertEqual(parse_dotenv("foo='ba#r'"), {'foo': 'ba#r'})
83+
84+
85+
class ReadDotenvTestCase(unittest.TestCase):
86+
def test_defaults_to_dotenv(self):
87+
read_dotenv()
88+
self.assertEqual(os.environ.get('DOTENV'), 'true')
89+
90+
def test_reads_the_file(self):
91+
read_dotenv('.env')
92+
self.assertEqual(os.environ.get('DOTENV'), 'true')
93+
94+
def test_warns_if_file_does_not_exist(self):
95+
with warnings.catch_warnings(record=True) as w:
96+
read_dotenv('.does_not_exist')
97+
98+
self.assertEqual(len(w), 1)
99+
self.assertIs(w[0].category, UserWarning)
100+
self.assertEqual(str(w[0].message), "not reading .does_not_exist - it doesn't exist.")

0 commit comments

Comments
 (0)