Repository: pypa/pipenv

Issue:
Update internal dotenv (or make it a dependency)
Variables in a `.env` file that have single-quoted spaces are not loaded into the environment. If double quotes are used, then the variables are loaded into the environment.

##### Describe your environment

1. ubuntu 17.10
1. Python version: `$ python -V` - 3.6.4
1. Pipenv version: `$ pipenv --version` - 9.0.3

##### Expected result

All variables in `.env` would be loaded

##### Actual result

Variables in `.env` that are single-quoted and contain spaces are not loaded

##### Steps to replicate

1. Given the following `.env` file:

```
FOO='Hello World'
BAR='Test'
BAZ="Hello World"
```

2. Running `pipenv shell`
3. `FOO` will not be set in the environment.
4. This happens because the `FOO` line doesn't match the regex used (`'^\s*(\w*)\s*=\s*("[^"]*"|[^\s]*)\s*$'`) in the parsing algorithm. 

##### Additional information

I tracked this issue down by stepping through the dotenv loading code used by pipenv. When I found this line of code, I learned that only double quotes would allow spaces:

https://github.com/pypa/pipenv/blob/master/pipenv/patched/dotenv/main.py#L100

This parsing algorithm diverges a bit from the official dotenv logic:

https://github.com/theskumar/python-dotenv/blob/master/dotenv/main.py#L98


Discussion:
pull requests accepted!

Resolving patch:
diff --git a/pipenv/patched/dotenv/main.py b/pipenv/patched/dotenv/main.py
--- a/pipenv/patched/dotenv/main.py
+++ b/pipenv/patched/dotenv/main.py
@@ -10,6 +10,8 @@
 
 __escape_decoder = codecs.getdecoder('unicode_escape')
 __posix_variable = re.compile('\$\{[^\}]*\}')
+__variable_declaration = re.compile('^\s*(\w*)\s*=\s*("[^"]*"|\'[^\']*\'|[^\s]*)\s*$',
+                                    flags=re.MULTILINE)
 
 
 def decode_escaped(escaped):
@@ -97,7 +99,7 @@ def dotenv_values(dotenv_path):
 
 def parse_dotenv(dotenv_path):
     with open(dotenv_path) as f:
-        for k, v in re.findall('^\s*(\w*)\s*=\s*("[^"]*"|[^\s]*)\s*$', f.read(), flags=re.MULTILINE):
+        for k, v in __variable_declaration.findall(f.read()):
             if len(v) > 0:
                 quoted = v[0] == v[len(v) - 1] in ['"', "'"]
 


Test patch:
diff --git a/pipenv/patched/dotenv/test_main.py b/pipenv/patched/dotenv/test_main.py
new file mode 100644
--- /dev/null
+++ b/pipenv/patched/dotenv/test_main.py
@@ -0,0 +1,85 @@
+import os
+from textwrap import dedent
+import unittest
+
+from main import parse_dotenv
+
+
+class TestParseDotenv(unittest.TestCase):
+    filename = 'testfile.conf'
+
+    def tearDown(self):
+        if os.path.exists(self.filename):
+            os.remove(self.filename)
+
+    def write_file(self, contents):
+        with open(self.filename, 'w') as f:
+            f.write(contents)
+
+    def assert_parsed(self, *expected):
+        parsed = parse_dotenv(self.filename)
+        for expected_key, expected_val in expected:
+            actual_key, actual_val = next(parsed)
+            self.assertEqual(actual_key, expected_key)
+            self.assertEqual(actual_val, expected_val)
+
+        with self.assertRaises(StopIteration):
+            next(parsed)
+
+    def test_value_unquoted(self):
+        self.write_file(dedent("""
+        var1 = value1
+        var2=value2
+        """))
+        self.assert_parsed(('var1', 'value1'), ('var2', 'value2'))
+
+    def test_value_double_quoted(self):
+        self.write_file(dedent("""
+        var1 = "value1"
+        var2="value2"
+        """))
+        self.assert_parsed(('var1', 'value1'), ('var2', 'value2'))
+
+    def test_value_single_quoted(self):
+        self.write_file(dedent("""
+        var1 = 'value1'
+        var2='value2'
+        """))
+        self.assert_parsed(('var1', 'value1'), ('var2', 'value2'))
+
+    def test_value_with_space_double_quoted(self):
+        self.write_file(dedent("""
+        var1 = "value1 with spaces"
+        var2 = "othervalue"
+        """))
+        self.assert_parsed(('var1', 'value1 with spaces'),
+                           ('var2', 'othervalue'))
+
+    def test_value_with_space_single_quoted(self):
+        self.write_file(dedent("""
+        var1 = 'value with spaces'
+        var2 = 'othervalue'
+        """))
+        self.assert_parsed(('var1', 'value with spaces'),
+                           ('var2', 'othervalue'))
+
+    def test_values_with_mixed_quotes_and_spaces(self):
+        self.write_file(dedent("""
+        var1 = 'value with spaces'
+        var2=   othervalue
+        var3="double-quoted value with spaces"
+        var4 = "double-quoted value
+        with
+        newlines"
+        var5='single quote
+        and newline'
+        """))
+        self.assert_parsed(('var1', 'value with spaces'),
+                           ('var2', 'othervalue'),
+                           ('var3', 'double-quoted value with spaces'),
+                           ('var4', 'double-quoted value\nwith\nnewlines'),
+                           ('var5', 'single quote\nand newline'))
+
+
+if __name__ == '__main__':
+    unittest.main()