Repository: theskumar/python-dotenv

Issue:
Parsing rules are somewhat unexpected
`A=1 2` (one space) works, but `A=1  2` (two spaces) produces an error. I've run into it with `docker-compose`:

`.env`:

```yaml
A=1  2
```

`docker-compose.yml`:

```yaml
version: '3'
services:
  app:
    image: alpine:3.12
    command: /app/1.sh
    env_file: .env
    volumes:
      - .:/app
```

`1.sh`:

```sh
#!/bin/sh -eux
set | grep ^A
```

```
$ docker-compose up -d && docker-compose logs -f
WARNING: Python-dotenv could not parse statement starting at line 1
WARNING: Python-dotenv could not parse statement starting at line 1
WARNING: Python-dotenv could not parse statement starting at line 1
WARNING: Python-dotenv could not parse statement starting at line 1
Creating network "overriding-vars_default" with the default driver
Creating overriding-vars_app_1 ... done
WARNING: Python-dotenv could not parse statement starting at line 1
WARNING: Python-dotenv could not parse statement starting at line 1
Attaching to overriding-vars_app_1
app_1  | + set
app_1  | + grep ^A
overriding-vars_app_1 exited with code 1
```

With `A=1 2`:

```
$ docker-compose up -d && docker-compose logs -f
Recreating overriding-vars_app_1 ... done
Attaching to overriding-vars_app_1
app_1  | + set
app_1  | + grep ^A
app_1  | A='1 2'
overriding-vars_app_1 exited with code 0
```

I'll provide a Python test case, if need be.

https://github.com/theskumar/python-dotenv/blob/v0.14.0/src/dotenv/parser.py#L175-L176

Discussion:
coveralls: 
[![Coverage Status](https://coveralls.io/builds/33419427/badge)](https://coveralls.io/builds/33419427)

Coverage decreased (-1.07%) to 89.286% when pulling **34d5fe41a9fa05f6f2fd306b728483c880222ae9 on bbc2:fix-unquoted-double-space** into **6ca2e2ab399c7e41be276bc830f21af3092f5d28 on theskumar:master**.

x-yuri: I was reverse-engineering the parsing rules. So I thought, maybe the documentation can be improved in this PR? Here's what I've managed to find out:

> Keys can be unquoted, and single-quoted. Values can be unquoted, single- and double-quoted. Spaces before and after keys, equal signs, and values are ignored. Values can be followed by a comment.
> 
> Allowed escape sequences in single-quoted values are: `\\`, `\'`. \
In double-quoted ones: `\\`, `\'`, `\"`, `\a`, `\b`, `\f`, `\n`, `\r`, `\t`, `\v`.

bbc2: Your reverse-engineering looks correct to me.  However, if we're going to describe quoting, there are many other features that we should also describe, like variable interpolation, so I'd rather not do it right now.

> On yet another hand, env files parsers are incompatible as it is.

Yes.  Before I fully refactored the parser months ago, I had a look at popular parsers for other languages and it's a mess.  You can see some of that mess at https://bbc2.github.io/dotenv-parser-comparisons/.

> And well, people might be doing it as we speak :)

Indeed they are.  It's common to see new GitHub issues created right after a subtle parsing change was made.  I've tried to be careful about breaking changes but sometimes you really need them (e.g. when you're the only parser in the world with some weird behavior).

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 34cdb32..b5305f1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,8 +19,10 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### Fixed
 
-- Fix potentially empty expanded value for duplicate key (#260 by [@bbc]).
+- Fix potentially empty expanded value for duplicate key (#260 by [@bbc2]).
 - Fix import error on Python 3.5.0 and 3.5.1 (#267 by [@gongqingkui]).
+- Fix parsing of unquoted values containing several adjacent space or tab characters
+  (#277 by [@bbc2], review by [@x-yuri]).
 
 ## [0.14.0] - 2020-07-03
 
@@ -226,6 +228,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 [@theskumar]: https://github.com/theskumar
 [@ulyssessouza]: https://github.com/ulyssessouza
 [@venthur]: https://github.com/venthur
+[@x-yuri]: https://github.com/x-yuri
 [@yannham]: https://github.com/yannham
 
 [Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.14.0...HEAD
diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py
index 4eba0ac..5cb1cdf 100644
--- a/src/dotenv/parser.py
+++ b/src/dotenv/parser.py
@@ -24,7 +24,7 @@ _unquoted_key = make_regex(r"([^=\#\s]+)")
 _equal_sign = make_regex(r"(=[^\S\r\n]*)")
 _single_quoted_value = make_regex(r"'((?:\\'|[^'])*)'")
 _double_quoted_value = make_regex(r'"((?:\\"|[^"])*)"')
-_unquoted_value_part = make_regex(r"([^ \r\n]*)")
+_unquoted_value = make_regex(r"([^\r\n]*)")
 _comment = make_regex(r"(?:[^\S\r\n]*#[^\r\n]*)?")
 _end_of_line = make_regex(r"[^\S\r\n]*(?:\r\n|\n|\r|$)")
 _rest_of_line = make_regex(r"[^\r\n]*(?:\r|\n|\r\n)?")
@@ -167,14 +167,8 @@ def parse_key(reader):
 
 def parse_unquoted_value(reader):
     # type: (Reader) -> Text
-    value = u""
-    while True:
-        (part,) = reader.read_regex(_unquoted_value_part)
-        value += part
-        after = reader.peek(2)
-        if len(after) < 2 or after[0] in u"\r\n" or after[1] in u" #\r\n":
-            return value
-        value += reader.read(2)
+    (part,) = reader.read_regex(_unquoted_value)
+    return re.sub(r"\s+#.*", "", part).rstrip()
 
 
 def parse_value(reader):


Test patch:
diff --git a/tests/test_parser.py b/tests/test_parser.py
index f807513..48cecdc 100644
--- a/tests/test_parser.py
+++ b/tests/test_parser.py
@@ -19,20 +19,40 @@ from dotenv.parser import Binding, Original, parse_stream
     (u"# a=b", [Binding(key=None, value=None, original=Original(string=u"# a=b", line=1), error=False)]),
     (u"a=b#c", [Binding(key=u"a", value=u"b#c", original=Original(string=u"a=b#c", line=1), error=False)]),
     (
-        u'a=b # comment',
-        [Binding(key=u"a", value=u"b", original=Original(string=u"a=b # comment", line=1), error=False)],
+        u'a=b #c',
+        [Binding(key=u"a", value=u"b", original=Original(string=u"a=b #c", line=1), error=False)],
     ),
     (
-        u"a=b space ",
-        [Binding(key=u"a", value=u"b space", original=Original(string=u"a=b space ", line=1), error=False)],
+        u'a=b\t#c',
+        [Binding(key=u"a", value=u"b", original=Original(string=u"a=b\t#c", line=1), error=False)],
     ),
     (
-        u"a='b space '",
-        [Binding(key=u"a", value=u"b space ", original=Original(string=u"a='b space '", line=1), error=False)],
+        u"a=b c",
+        [Binding(key=u"a", value=u"b c", original=Original(string=u"a=b c", line=1), error=False)],
     ),
     (
-        u'a="b space "',
-        [Binding(key=u"a", value=u"b space ", original=Original(string=u'a="b space "', line=1), error=False)],
+        u"a=b\tc",
+        [Binding(key=u"a", value=u"b\tc", original=Original(string=u"a=b\tc", line=1), error=False)],
+    ),
+    (
+        u"a=b  c",
+        [Binding(key=u"a", value=u"b  c", original=Original(string=u"a=b  c", line=1), error=False)],
+    ),
+    (
+        u"a=b\u00a0 c",
+        [Binding(key=u"a", value=u"b\u00a0 c", original=Original(string=u"a=b\u00a0 c", line=1), error=False)],
+    ),
+    (
+        u"a=b c ",
+        [Binding(key=u"a", value=u"b c", original=Original(string=u"a=b c ", line=1), error=False)],
+    ),
+    (
+        u"a='b c '",
+        [Binding(key=u"a", value=u"b c ", original=Original(string=u"a='b c '", line=1), error=False)],
+    ),
+    (
+        u'a="b c "',
+        [Binding(key=u"a", value=u"b c ", original=Original(string=u'a="b c "', line=1), error=False)],
     ),
     (
         u"export export_a=1",