Repository: theskumar/python-dotenv
Pull request #174 (2019-04-10 16:38:28)

Adding Dynaconf to related projects
HI,

Dynaconf heavily uses `python-dotenv` 

https://dynaconf.readthedocs.io

https://github.com/rochacbruno/dynaconf

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

Coverage remained the same at 87.797% when pulling **6d59216525c8043544891ef1f38a5082e9891d4b on rochacbruno:patch-1** into **2767b359038fdb2caae58c8b8e1a593024afb151 on theskumar:master**.


Patch:
diff --git a/.bumpversion.cfg b/.bumpversion.cfg
deleted file mode 100644
index 0c7713c..0000000
--- a/.bumpversion.cfg
+++ /dev/null
@@ -1,7 +0,0 @@
-[bumpversion]
-current_version = 0.10.1
-commit = True
-tag = True
-
-[bumpversion:file:dotenv/version.py]
-
diff --git a/.coveragerc b/.coveragerc
deleted file mode 100644
index 18c6ca7..0000000
--- a/.coveragerc
+++ /dev/null
@@ -1,11 +0,0 @@
-[run]
-source = dotenv
-
-[paths]
-source =
-    src/dotenv
-    .tox/*/lib/python*/site-packages/dotenv
-    .tox/pypy*/site-packages/dotenv
-
-[report]
-show_missing = True
diff --git a/.pyup.yml b/.pyup.yml
deleted file mode 100644
index 604f69f..0000000
--- a/.pyup.yml
+++ /dev/null
@@ -1,4 +0,0 @@
-# autogenerated pyup.io config file 
-# see https://pyup.io/docs/configuration/ for all available options
-
-update: insecure
diff --git a/Makefile b/Makefile
index 50b80b4..5b58c4c 100644
--- a/Makefile
+++ b/Makefile
@@ -5,19 +5,22 @@ clean: clean-build clean-pyc
 clean-build:
 	rm -fr build/
 	rm -fr dist/
-	rm -fr *.egg-info
+	rm -fr src/*.egg-info
 
 clean-pyc:
 	find . -name '*.pyc' -exec rm -f {} +
 	find . -name '*.pyo' -exec rm -f {} +
 	find . -name '*~' -exec rm -f {} +
 
-release: clean
-	python setup.py sdist upload
-	python setup.py bdist_wheel upload
+release: sdist
+	twine check dist/*
+	twine upload dist/*
+
+release-test: sdist
+	twine upload --repository-url https://test.pypi.org/legacy/ dist/*
 
 sdist: clean
-	python setup.py sdist
+	python setup.py sdist bdist_wheel
 	ls -l dist
 
 test:
diff --git a/README.md b/README.md
index 77cc7ff..ecc8304 100644
--- a/README.md
+++ b/README.md
@@ -44,7 +44,7 @@ MEANING_OF_LIFE=42
 MULTILINE_VAR="hello\nworld"
 ```
 
-You can optionally prefix each line with the word `export`, which is totally ignore by this library, but might allow you to [`source`](https://bash.cyberciti.biz/guide/Source_command) the file in bash.
+You can optionally prefix each line with the word `export`, which is totally ignored by this library, but might allow you to [`source`](https://bash.cyberciti.biz/guide/Source_command) the file in bash.
 
 ```
 export S3_BUCKET=YOURS3BUCKET
@@ -103,6 +103,8 @@ DATABASE_PASSWORD = os.getenv("DATABASE_PASSWORD")
 `load_dotenv` do not override existing System environment variables. To
 override, pass `override=True` to `load_dotenv()`.
 
+`load_dotenv` also accepts `encoding` parameter to open the `.env` file. The default encoding is platform dependent (whatever `locale.getpreferredencoding()` returns), but any encoding supported by Python can be used. See the [codecs](https://docs.python.org/3/library/codecs.html#standard-encodings) module for the list of supported encodings.
+
 You can use `find_dotenv()` method that will try to find a `.env` file
 by (a) guessing where to start using `__file__` or the working directory
 -- allowing this to work in non-file contexts such as IPython notebooks
@@ -268,6 +270,7 @@ Related Projects
 -   [django-configuration](https://github.com/jezdez/django-configurations)
 -   [dump-env](https://github.com/sobolevn/dump-env)
 -   [environs](https://github.com/sloria/environs)
+-   [dynaconf](https://github.com/rochacbruno/dynaconf)
 
 Contributing
 ============
@@ -297,7 +300,24 @@ Changelog
 Unreleased
 -----
 
+- ...
+
+0.10.3
+-----
+
+- Improve interactive mode detection ([@andrewsmith])([#183]).
+- Refactor parser to fix parsing inconsistencies ([@bbc2])([#170]).
+  - Interpret escapes as control characters only in double-quoted strings.
+  - Interpret `#` as start of comment only if preceded by whitespace.
+
+0.10.2
+-----
+
 - Add type hints and expose them to users ([@qnighy])([#172])
+- `load_dotenv` and `dotenv_values` now accept an `encoding` parameter, defaults to `None`
+  ([@theskumar])([@earlbread])([#161])
+- Fix `str`/`unicode` inconsistency in Python 2: values are always `str` now. ([@bbc2])([#121])
+- Fix Unicode error in Python 2, introduced in 0.10.0. ([@bbc2])([#176])
 
 0.10.1
 -----
@@ -409,11 +429,17 @@ Unreleased
     [@hugochinchilla](https://github.com/hugochinchilla)).
 -   Improved test coverage.
 
+[#161]: https://github.com/theskumar/python-dotenv/issues/161
 [#78]: https://github.com/theskumar/python-dotenv/issues/78
 [#148]: https://github.com/theskumar/python-dotenv/issues/148
 [#158]: https://github.com/theskumar/python-dotenv/issues/158
 [#172]: https://github.com/theskumar/python-dotenv/issues/172
+[#121]: https://github.com/theskumar/python-dotenv/issues/121
+[#176]: https://github.com/theskumar/python-dotenv/issues/176
+[#170]: https://github.com/theskumar/python-dotenv/issues/170
+[#183]: https://github.com/theskumar/python-dotenv/issues/183
 
+[@andrewsmith]: https://github.com/andrewsmith
 [@asyncee]: https://github.com/asyncee
 [@greyli]: https://github.com/greyli
 [@venthur]: https://github.com/venthur
@@ -423,3 +449,4 @@ Unreleased
 [@cjauvin]: https://github.com/cjauvin
 [@bbc2]: https://github.com/bbc2
 [@qnighy]: https://github.com/qnighy
+[@earlbread]: https://github.com/earlbread
diff --git a/_config.yml b/_config.yml
deleted file mode 100644
index 2f7efbe..0000000
--- a/_config.yml
+++ /dev/null
@@ -1,1 +0,0 @@
-theme: jekyll-theme-minimal
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index bd49777..5216573 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,9 +3,9 @@ typing; python_version<"3.5"
 click
 flake8>=2.2.3
 ipython
-pypandoc
 pytest-cov
 pytest>=3.9
 sh>=1.09
 tox
 wheel
+twine
diff --git a/setup.cfg b/setup.cfg
index 7f78459..216e56a 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,3 +1,11 @@
+[bumpversion]
+current_version = 0.10.3
+commit = True
+tag = True
+
+[bumpversion:file:src/dotenv/version.py]
+
+
 [bdist_wheel]
 universal = 1
 
@@ -5,8 +13,23 @@ universal = 1
 max-line-length = 120
 exclude = .tox,.git,docs,venv,.venv
 
+[mypy]
+ignore_missing_imports = true
+
 [metadata]
 description-file = README.rst
 
 [tool:pytest]
 testpaths = tests
+
+[coverage:run]
+source = dotenv
+
+[coverage:paths]
+source =
+    src/dotenv
+    .tox/*/lib/python*/site-packages/dotenv
+    .tox/pypy*/site-packages/dotenv
+
+[coverage:report]
+show_missing = True
diff --git a/setup.py b/setup.py
index 8f60172..defa1f4 100644
--- a/setup.py
+++ b/setup.py
@@ -1,17 +1,8 @@
 # -*- coding: utf-8 -*-
 from setuptools import setup
 
-# https://github.com/theskumar/python-dotenv/issues/45#issuecomment-277135416
-try:
-    import pypandoc
-    long_description = pypandoc.convert('README.md', 'rst')
-    long_description = long_description.replace("\r", "")  # YOU  NEED THIS LINE
-except (OSError, ImportError):
-    print("Pandoc not found. Long_description conversion failure.")
-    import io
-    # pandoc is not installed, fallback to using raw contents
-    with io.open('README.md', encoding="utf-8") as f:
-        long_description = f.read()
+with open('README.md') as f:
+    long_description = f.read()
 
 meta = {}
 with open('./src/dotenv/version.py') as f:
@@ -21,6 +12,7 @@ setup(
     name="python-dotenv",
     description="Add .env support to your django/flask apps in development and deployments",
     long_description=long_description,
+    long_description_content_type='text/markdown',
     version=meta['__version__'],
     author="Saurabh Kumar",
     author_email="me+github@saurabh-kumar.com",
@@ -52,20 +44,21 @@ setup(
         # 'Development Status :: 6 - Mature',
         # 'Development Status :: 7 - Inactive',
         'Programming Language :: Python',
-        'Programming Language :: Python :: 2',
+        # 'Programming Language :: Python :: 2',
         # 'Programming Language :: Python :: 2.3',
         # 'Programming Language :: Python :: 2.4',
         # 'Programming Language :: Python :: 2.5',
         # 'Programming Language :: Python :: 2.6',
         'Programming Language :: Python :: 2.7',
-        'Programming Language :: Python :: 3',
-        'Programming Language :: Python :: 3.0',
-        'Programming Language :: Python :: 3.1',
-        'Programming Language :: Python :: 3.2',
-        'Programming Language :: Python :: 3.3',
+        # 'Programming Language :: Python :: 3',
+        # 'Programming Language :: Python :: 3.0',
+        # 'Programming Language :: Python :: 3.1',
+        # 'Programming Language :: Python :: 3.2',
+        # 'Programming Language :: Python :: 3.3',
         'Programming Language :: Python :: 3.4',
         'Programming Language :: Python :: 3.5',
         'Programming Language :: Python :: 3.6',
+        'Programming Language :: Python :: 3.7',
         'Programming Language :: Python :: Implementation :: PyPy',
         'Intended Audience :: Developers',
         'Intended Audience :: System Administrators',
diff --git a/src/dotenv/__init__.py b/src/dotenv/__init__.py
index 1867868..105a32a 100644
--- a/src/dotenv/__init__.py
+++ b/src/dotenv/__init__.py
@@ -1,4 +1,4 @@
-from typing import Any, Optional
+from typing import Any, Optional  # noqa
 from .main import load_dotenv, get_key, set_key, unset_key, find_dotenv, dotenv_values
 
 
diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py
index 45f4b76..235f329 100644
--- a/src/dotenv/cli.py
+++ b/src/dotenv/cli.py
@@ -1,6 +1,7 @@
 import os
 import sys
-from typing import Any, List
+from subprocess import Popen
+from typing import Any, Dict, List  # noqa
 
 try:
     import click
@@ -9,7 +10,7 @@ except ImportError:
                      'Run pip install "python-dotenv[cli]" to fix this.')
     sys.exit(1)
 
-from .main import dotenv_values, get_key, set_key, unset_key, run_command
+from .main import dotenv_values, get_key, set_key, unset_key
 from .version import __version__
 
 
@@ -101,5 +102,40 @@ def run(ctx, commandline):
     exit(ret)
 
 
+def run_command(command, env):
+    # type: (List[str], Dict[str, str]) -> int
+    """Run command in sub process.
+
+    Runs the command in a sub process with the variables from `env`
+    added in the current environment variables.
+
+    Parameters
+    ----------
+    command: List[str]
+        The command and it's parameters
+    env: Dict
+        The additional environment variables
+
+    Returns
+    -------
+    int
+        The return code of the command
+
+    """
+    # copy the current environment variables and add the vales from
+    # `env`
+    cmd_env = os.environ.copy()
+    cmd_env.update(env)
+
+    p = Popen(command,
+              universal_newlines=True,
+              bufsize=0,
+              shell=False,
+              env=cmd_env)
+    _, _ = p.communicate()
+
+    return p.returncode
+
+
 if __name__ == "__main__":
     cli()
diff --git a/src/dotenv/compat.py b/src/dotenv/compat.py
index a033930..394d3a3 100644
--- a/src/dotenv/compat.py
+++ b/src/dotenv/compat.py
@@ -1,10 +1,34 @@
-from typing import Text, Type
 import sys
-if sys.version_info >= (3, 0):
-    from io import StringIO  # noqa
-else:
-    from StringIO import StringIO  # noqa
+from typing import Text  # noqa
 
 PY2 = sys.version_info[0] == 2  # type: bool
-WIN = sys.platform.startswith('win')  # type: bool
-text_type = Text  # type: Type[Text]
+
+if PY2:
+    from StringIO import StringIO  # noqa
+else:
+    from io import StringIO  # noqa
+
+
+def to_env(text):
+    # type: (Text) -> str
+    """
+    Encode a string the same way whether it comes from the environment or a `.env` file.
+    """
+    if PY2:
+        return text.encode(sys.getfilesystemencoding() or "utf-8")
+    else:
+        return text
+
+
+def to_text(string):
+    # type: (str) -> Text
+    """
+    Make a string Unicode if it isn't already.
+
+    This is useful for defining raw unicode strings because `ur"foo"` isn't valid in
+    Python 3.
+    """
+    if PY2:
+        return string.decode("utf-8")
+    else:
+        return string
diff --git a/src/dotenv/main.py b/src/dotenv/main.py
index c1c3622..04d2241 100644
--- a/src/dotenv/main.py
+++ b/src/dotenv/main.py
@@ -1,21 +1,20 @@
 # -*- coding: utf-8 -*-
 from __future__ import absolute_import, print_function, unicode_literals
 
-import codecs
 import io
 import os
 import re
 import shutil
 import sys
-from subprocess import Popen
 import tempfile
-from typing import (Any, Dict, Iterator, List, Match, NamedTuple, Optional,  # noqa
-                    Pattern, Union, TYPE_CHECKING, Text, IO, Tuple)  # noqa
+from typing import (Dict, Iterator, List, Match, Optional,  # noqa
+                    Pattern, Union, TYPE_CHECKING, Text, IO, Tuple)
 import warnings
 from collections import OrderedDict
 from contextlib import contextmanager
 
-from .compat import StringIO, PY2, WIN, text_type
+from .compat import StringIO, PY2, to_env
+from .parser import parse_stream
 
 if TYPE_CHECKING:  # pragma: no cover
     if sys.version_info >= (3, 6):
@@ -30,92 +29,15 @@ if TYPE_CHECKING:  # pragma: no cover
 
 __posix_variable = re.compile(r'\$\{[^\}]*\}')  # type: Pattern[Text]
 
-_binding = re.compile(
-    r"""
-        (
-            \s*                     # leading whitespace
-            (?:export{0}+)?         # export
-
-            ( '[^']+'               # single-quoted key
-            | [^=\#\s]+             # or unquoted key
-            )?
-
-            (?:
-                (?:{0}*={0}*)       # equal sign
-
-                ( '(?:\\'|[^'])*'   # single-quoted value
-                | "(?:\\"|[^"])*"   # or double-quoted value
-                | [^\#\r\n]*        # or unquoted value
-                )
-            )?
-
-            \s*                     # trailing whitespace
-            (?:\#[^\r\n]*)?         # comment
-            (?:\r|\n|\r\n)?         # newline
-        )
-    """.format(r'[^\S\r\n]'),
-    re.MULTILINE | re.VERBOSE,
-)  # type: Pattern[Text]
-
-_escape_sequence = re.compile(r"\\[\\'\"abfnrtv]")  # type: Pattern[Text]
-
-
-Binding = NamedTuple("Binding", [("key", Optional[Text]),
-                                 ("value", Optional[Text]),
-                                 ("original", Text)])
-
-
-def decode_escapes(string):
-    # type: (Text) -> Text
-    def decode_match(match):
-        # type: (Match[Text]) -> Text
-        return codecs.decode(match.group(0), 'unicode-escape')  # type: ignore
-
-    return _escape_sequence.sub(decode_match, string)
-
-
-def is_surrounded_by(string, char):
-    # type: (Text, Text) -> bool
-    return (
-        len(string) > 1
-        and string[0] == string[-1] == char
-    )
-
-
-def parse_binding(string, position):
-    # type: (Text, int) -> Tuple[Binding, int]
-    match = _binding.match(string, position)
-    assert match is not None
-    (matched, key, value) = match.groups()
-    if key is None or value is None:
-        key = None
-        value = None
-    else:
-        value_quoted = is_surrounded_by(value, "'") or is_surrounded_by(value, '"')
-        if value_quoted:
-            value = decode_escapes(value[1:-1])
-        else:
-            value = value.strip()
-    return (Binding(key=key, value=value, original=matched), match.end())
-
-
-def parse_stream(stream):
-    # type:(IO[Text]) -> Iterator[Binding]
-    string = stream.read()
-    position = 0
-    length = len(string)
-    while position < length:
-        (binding, position) = parse_binding(string, position)
-        yield binding
-
 
 class DotEnv():
 
-    def __init__(self, dotenv_path, verbose=False):
-        # type: (Union[Text, _PathLike, _StringIO], bool) -> None
+    def __init__(self, dotenv_path, verbose=False, encoding=None):
+        # type: (Union[Text, _PathLike, _StringIO], bool, Union[None, Text]) -> None
         self.dotenv_path = dotenv_path  # type: Union[Text,_PathLike, _StringIO]
         self._dict = None  # type: Optional[Dict[Text, Text]]
         self.verbose = verbose  # type: bool
+        self.encoding = encoding  # type: Union[None, Text]
 
     @contextmanager
     def _get_stream(self):
@@ -123,7 +45,7 @@ class DotEnv():
         if isinstance(self.dotenv_path, StringIO):
             yield self.dotenv_path
         elif os.path.isfile(self.dotenv_path):
-            with io.open(self.dotenv_path) as stream:
+            with io.open(self.dotenv_path, encoding=self.encoding) as stream:
                 yield stream
         else:
             if self.verbose:
@@ -155,13 +77,7 @@ class DotEnv():
         for k, v in self.dict().items():
             if k in os.environ and not override:
                 continue
-            # With Python2 on Windows, force environment variables to str to avoid
-            # "TypeError: environment can only contain strings" in Python's subprocess.py.
-            if PY2 and WIN:
-                if isinstance(k, text_type) or isinstance(v, text_type):
-                    k = k.encode('ascii')
-                    v = v.encode('ascii')
-            os.environ[k] = v  # type: ignore
+            os.environ[to_env(k)] = to_env(v)
 
         return True
 
@@ -319,8 +235,14 @@ def find_dotenv(filename='.env', raise_error_if_not_found=False, usecwd=False):
 
     Returns path to the file if found, or an empty string otherwise
     """
-    if usecwd or '__file__' not in globals():
-        # should work without __file__, e.g. in REPL or IPython notebook
+
+    def _is_interactive():
+        """ Decide whether this is running in a REPL or IPython notebook """
+        main = __import__('__main__', None, None, fromlist=['__file__'])
+        return not hasattr(main, '__file__')
+
+    if usecwd or _is_interactive():
+        # Should work without __file__, e.g. in REPL or IPython notebook.
         path = os.getcwd()
     else:
         # will work for .py files
@@ -349,48 +271,13 @@ def find_dotenv(filename='.env', raise_error_if_not_found=False, usecwd=False):
     return ''
 
 
-def load_dotenv(dotenv_path=None, stream=None, verbose=False, override=False):
-    # type: (Union[Text, _PathLike, None], Optional[_StringIO], bool, bool) -> bool
+def load_dotenv(dotenv_path=None, stream=None, verbose=False, override=False, **kwargs):
+    # type: (Union[Text, _PathLike, None], Optional[_StringIO], bool, bool, Union[None, Text]) -> bool
     f = dotenv_path or stream or find_dotenv()
-    return DotEnv(f, verbose=verbose).set_as_environment_variables(override=override)
+    return DotEnv(f, verbose=verbose, **kwargs).set_as_environment_variables(override=override)
 
 
-def dotenv_values(dotenv_path=None, stream=None, verbose=False):
-    # type: (Union[Text, _PathLike, None], Optional[_StringIO], bool) -> Dict[Text, Text]
+def dotenv_values(dotenv_path=None, stream=None, verbose=False, **kwargs):
+    # type: (Union[Text, _PathLike, None], Optional[_StringIO], bool, Union[None, Text]) -> Dict[Text, Text]
     f = dotenv_path or stream or find_dotenv()
-    return DotEnv(f, verbose=verbose).dict()
-
-
-def run_command(command, env):
-    # type: (List[str], Dict[str, str]) -> int
-    """Run command in sub process.
-
-    Runs the command in a sub process with the variables from `env`
-    added in the current environment variables.
-
-    Parameters
-    ----------
-    command: List[str]
-        The command and it's parameters
-    env: Dict
-        The additional environment variables
-
-    Returns
-    -------
-    int
-        The return code of the command
-
-    """
-    # copy the current environment variables and add the vales from
-    # `env`
-    cmd_env = os.environ.copy()
-    cmd_env.update(env)
-
-    p = Popen(command,
-              universal_newlines=True,
-              bufsize=0,
-              shell=False,
-              env=cmd_env)
-    _, _ = p.communicate()
-
-    return p.returncode
+    return DotEnv(f, verbose=verbose, **kwargs).dict()
diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py
new file mode 100644
index 0000000..b63cb3a
--- /dev/null
+++ b/src/dotenv/parser.py
@@ -0,0 +1,147 @@
+import codecs
+import re
+from typing import (IO, Iterator, Match, NamedTuple, Optional, Pattern,  # noqa
+                    Sequence, Text)
+
+from .compat import to_text
+
+
+def make_regex(string, extra_flags=0):
+    # type: (str, int) -> Pattern[Text]
+    return re.compile(to_text(string), re.UNICODE | extra_flags)
+
+
+_whitespace = make_regex(r"\s*", extra_flags=re.MULTILINE)
+_export = make_regex(r"(?:export[^\S\r\n]+)?")
+_single_quoted_key = make_regex(r"'([^']+)'")
+_unquoted_key = make_regex(r"([^=\#\s]+)")
+_equal_sign = make_regex(r"[^\S\r\n]*=[^\S\r\n]*")
+_single_quoted_value = make_regex(r"'((?:\\'|[^'])*)'")
+_double_quoted_value = make_regex(r'"((?:\\"|[^"])*)"')
+_unquoted_value_part = make_regex(r"([^ \r\n]*)")
+_comment = make_regex(r"(?:\s*#[^\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)?")
+_double_quote_escapes = make_regex(r"\\[\\'\"abfnrtv]")
+_single_quote_escapes = make_regex(r"\\[\\']")
+
+Binding = NamedTuple("Binding", [("key", Optional[Text]),
+                                 ("value", Optional[Text]),
+                                 ("original", Text)])
+
+
+class Error(Exception):
+    pass
+
+
+class Reader:
+    def __init__(self, stream):
+        # type: (IO[Text]) -> None
+        self.string = stream.read()
+        self.position = 0
+        self.mark = 0
+
+    def has_next(self):
+        # type: () -> bool
+        return self.position < len(self.string)
+
+    def set_mark(self):
+        # type: () -> None
+        self.mark = self.position
+
+    def get_marked(self):
+        # type: () -> Text
+        return self.string[self.mark:self.position]
+
+    def peek(self, count):
+        # type: (int) -> Text
+        return self.string[self.position:self.position + count]
+
+    def read(self, count):
+        # type: (int) -> Text
+        result = self.string[self.position:self.position + count]
+        if len(result) < count:
+            raise Error("read: End of string")
+        self.position += count
+        return result
+
+    def read_regex(self, regex):
+        # type: (Pattern[Text]) -> Sequence[Text]
+        match = regex.match(self.string, self.position)
+        if match is None:
+            raise Error("read_regex: Pattern not found")
+        self.position = match.end()
+        return match.groups()
+
+
+def decode_escapes(regex, string):
+    # type: (Pattern[Text], Text) -> Text
+    def decode_match(match):
+        # type: (Match[Text]) -> Text
+        return codecs.decode(match.group(0), 'unicode-escape')  # type: ignore
+
+    return regex.sub(decode_match, string)
+
+
+def parse_key(reader):
+    # type: (Reader) -> Text
+    char = reader.peek(1)
+    if char == "'":
+        (key,) = reader.read_regex(_single_quoted_key)
+    else:
+        (key,) = reader.read_regex(_unquoted_key)
+    return key
+
+
+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)
+
+
+def parse_value(reader):
+    # type: (Reader) -> Text
+    char = reader.peek(1)
+    if char == u"'":
+        (value,) = reader.read_regex(_single_quoted_value)
+        return decode_escapes(_single_quote_escapes, value)
+    elif char == u'"':
+        (value,) = reader.read_regex(_double_quoted_value)
+        return decode_escapes(_double_quote_escapes, value)
+    elif char in (u"", u"\n", u"\r"):
+        return u""
+    else:
+        return parse_unquoted_value(reader)
+
+
+def parse_binding(reader):
+    # type: (Reader) -> Binding
+    reader.set_mark()
+    try:
+        reader.read_regex(_whitespace)
+        reader.read_regex(_export)
+        key = parse_key(reader)
+        reader.read_regex(_equal_sign)
+        value = parse_value(reader)
+        reader.read_regex(_comment)
+        reader.read_regex(_end_of_line)
+        return Binding(key=key, value=value, original=reader.get_marked())
+    except Error:
+        reader.read_regex(_rest_of_line)
+        return Binding(key=None, value=None, original=reader.get_marked())
+
+
+def parse_stream(stream):
+    # type:(IO[Text]) -> Iterator[Binding]
+    reader = Reader(stream)
+    while reader.has_next():
+        try:
+            yield parse_binding(reader)
+        except Error:
+            return
diff --git a/src/dotenv/version.py b/src/dotenv/version.py
index 1f4c4d4..b2385cb 100644
--- a/src/dotenv/version.py
+++ b/src/dotenv/version.py
@@ -1,1 +1,1 @@
-__version__ = "0.10.1"
+__version__ = "0.10.3"
diff --git a/tox.ini b/tox.ini
index 56c8732..077780f 100644
--- a/tox.ini
+++ b/tox.ini
@@ -19,11 +19,11 @@ deps =
   mypy
 commands =
   flake8 src tests
-  mypy --python-version=3.7 src
-  mypy --python-version=3.6 src
-  mypy --python-version=3.5 src
-  mypy --python-version=3.4 src
-  mypy --python-version=2.7 src
+  mypy --python-version=3.7 src tests
+  mypy --python-version=3.6 src tests
+  mypy --python-version=3.5 src tests
+  mypy --python-version=3.4 src tests
+  mypy --python-version=2.7 src tests
 
 [testenv:manifest]
 deps = check-manifest


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
index eed46f9..349c58b 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -12,8 +12,7 @@ import sh
 from IPython.terminal.embed import InteractiveShellEmbed
 
 from dotenv import dotenv_values, find_dotenv, load_dotenv, set_key
-from dotenv.compat import StringIO
-from dotenv.main import Binding, parse_stream
+from dotenv.compat import PY2, StringIO
 
 
 @contextlib.contextmanager
@@ -26,81 +25,6 @@ def restore_os_environ():
         os.environ.update(environ)
 
 
-@pytest.mark.parametrize("test_input,expected", [
-    ("", []),
-    ("a=b", [Binding(key="a", value="b", original="a=b")]),
-    ("'a'=b", [Binding(key="'a'", value="b", original="'a'=b")]),
-    ("[=b", [Binding(key="[", value="b", original="[=b")]),
-    (" a = b ", [Binding(key="a", value="b", original=" a = b ")]),
-    ("export a=b", [Binding(key="a", value="b", original="export a=b")]),
-    (" export 'a'=b", [Binding(key="'a'", value="b", original=" export 'a'=b")]),
-    (" export 'a'=b", [Binding(key="'a'", value="b", original=" export 'a'=b")]),
-    ("# a=b", [Binding(key=None, value=None, original="# a=b")]),
-    ('a=b # comment', [Binding(key="a", value="b", original="a=b # comment")]),
-    ("a=b space ", [Binding(key="a", value="b space", original="a=b space ")]),
-    ("a='b space '", [Binding(key="a", value="b space ", original="a='b space '")]),
-    ('a="b space "', [Binding(key="a", value="b space ", original='a="b space "')]),
-    ("export export_a=1", [Binding(key="export_a", value="1", original="export export_a=1")]),
-    ("export port=8000", [Binding(key="port", value="8000", original="export port=8000")]),
-    ('a="b\nc"', [Binding(key="a", value="b\nc", original='a="b\nc"')]),
-    ("a='b\nc'", [Binding(key="a", value="b\nc", original="a='b\nc'")]),
-    ('a="b\nc"', [Binding(key="a", value="b\nc", original='a="b\nc"')]),
-    ('a="b\\nc"', [Binding(key="a", value='b\nc', original='a="b\\nc"')]),
-    ('a="b\\"c"', [Binding(key="a", value='b"c', original='a="b\\"c"')]),
-    ("a='b\\'c'", [Binding(key="a", value="b'c", original="a='b\\'c'")]),
-    ("a=à", [Binding(key="a", value="à", original="a=à")]),
-    ('a="à"', [Binding(key="a", value="à", original='a="à"')]),
-    ('garbage', [Binding(key=None, value=None, original="garbage")]),
-    (
-        "a=b\nc=d",
-        [
-            Binding(key="a", value="b", original="a=b\n"),
-            Binding(key="c", value="d", original="c=d"),
-        ],
-    ),
-    (
-        "a=b\r\nc=d",
-        [
-            Binding(key="a", value="b", original="a=b\r\n"),
-            Binding(key="c", value="d", original="c=d"),
-        ],
-    ),
-    (
-        'a=\nb=c',
-        [
-            Binding(key="a", value='', original='a=\n'),
-            Binding(key="b", value='c', original="b=c"),
-        ]
-    ),
-    (
-        'a="\nb=c',
-        [
-            Binding(key="a", value='"', original='a="\n'),
-            Binding(key="b", value='c', original="b=c"),
-        ]
-    ),
-    (
-        '# comment\na="b\nc"\nd=e\n',
-        [
-            Binding(key=None, value=None, original="# comment\n"),
-            Binding(key="a", value="b\nc", original='a="b\nc"\n'),
-            Binding(key="d", value="e", original="d=e\n"),
-        ],
-    ),
-    (
-        'garbage[%$#\na=b',
-        [
-            Binding(key=None, value=None, original="garbage[%$#\n"),
-            Binding(key="a", value="b", original='a=b'),
-        ],
-    ),
-])
-def test_parse_stream(test_input, expected):
-    result = parse_stream(StringIO(test_input))
-
-    assert list(result) == expected
-
-
 def test_warns_if_file_does_not_exist():
     with warnings.catch_warnings(record=True) as w:
         load_dotenv('.does_not_exist', verbose=True)
@@ -229,6 +153,15 @@ def test_dotenv_values_export():
     assert os.environ['foo'] == 'bar'
 
 
+def test_dotenv_values_utf_8():
+    stream = StringIO(u"a=à\n")
+    load_dotenv(stream=stream)
+    if PY2:
+        assert os.environ["a"] == u"à".encode(sys.getfilesystemencoding())
+    else:
+        assert os.environ["a"] == "à"
+
+
 def test_dotenv_empty_selfreferential_interpolation():
     stream = StringIO(u'some_path="${some_path}:a/b/c"\n')
     stream.seek(0)
diff --git a/tests/test_parser.py b/tests/test_parser.py
new file mode 100644
index 0000000..f191f90
--- /dev/null
+++ b/tests/test_parser.py
@@ -0,0 +1,88 @@
+# -*- coding: utf-8 -*-
+import pytest
+
+from dotenv.compat import StringIO
+from dotenv.parser import Binding, parse_stream
+
+
+@pytest.mark.parametrize("test_input,expected", [
+    (u"", []),
+    (u"a=b", [Binding(key=u"a", value=u"b", original=u"a=b")]),
+    (u"'a'=b", [Binding(key=u"a", value=u"b", original=u"'a'=b")]),
+    (u"[=b", [Binding(key=u"[", value=u"b", original=u"[=b")]),
+    (u" a = b ", [Binding(key=u"a", value=u"b", original=u" a = b ")]),
+    (u"export a=b", [Binding(key=u"a", value=u"b", original=u"export a=b")]),
+    (u" export 'a'=b", [Binding(key=u"a", value=u"b", original=u" export 'a'=b")]),
+    (u"# a=b", [Binding(key=None, value=None, original=u"# a=b")]),
+    (u"a=b#c", [Binding(key=u"a", value=u"b#c", original=u"a=b#c")]),
+    (u'a=b # comment', [Binding(key=u"a", value=u"b", original=u"a=b # comment")]),
+    (u"a=b space ", [Binding(key=u"a", value=u"b space", original=u"a=b space ")]),
+    (u"a='b space '", [Binding(key=u"a", value=u"b space ", original=u"a='b space '")]),
+    (u'a="b space "', [Binding(key=u"a", value=u"b space ", original=u'a="b space "')]),
+    (u"export export_a=1", [Binding(key=u"export_a", value=u"1", original=u"export export_a=1")]),
+    (u"export port=8000", [Binding(key=u"port", value=u"8000", original=u"export port=8000")]),
+    (u'a="b\nc"', [Binding(key=u"a", value=u"b\nc", original=u'a="b\nc"')]),
+    (u"a='b\nc'", [Binding(key=u"a", value=u"b\nc", original=u"a='b\nc'")]),
+    (u'a="b\nc"', [Binding(key=u"a", value=u"b\nc", original=u'a="b\nc"')]),
+    (u'a="b\\nc"', [Binding(key=u"a", value=u'b\nc', original=u'a="b\\nc"')]),
+    (u"a='b\\nc'", [Binding(key=u"a", value=u'b\\nc', original=u"a='b\\nc'")]),
+    (u'a="b\\"c"', [Binding(key=u"a", value=u'b"c', original=u'a="b\\"c"')]),
+    (u"a='b\\'c'", [Binding(key=u"a", value=u"b'c", original=u"a='b\\'c'")]),
+    (u"a=à", [Binding(key=u"a", value=u"à", original=u"a=à")]),
+    (u'a="à"', [Binding(key=u"a", value=u"à", original=u'a="à"')]),
+    (u'garbage', [Binding(key=None, value=None, original=u"garbage")]),
+    (
+        u"a=b\nc=d",
+        [
+            Binding(key=u"a", value=u"b", original=u"a=b\n"),
+            Binding(key=u"c", value=u"d", original=u"c=d"),
+        ],
+    ),
+    (
+        u"a=b\r\nc=d",
+        [
+            Binding(key=u"a", value=u"b", original=u"a=b\r\n"),
+            Binding(key=u"c", value=u"d", original=u"c=d"),
+        ],
+    ),
+    (
+        u'a=\nb=c',
+        [
+            Binding(key=u"a", value=u'', original=u'a=\n'),
+            Binding(key=u"b", value=u'c', original=u"b=c"),
+        ]
+    ),
+    (
+        u'a=b\n\nc=d',
+        [
+            Binding(key=u"a", value=u"b", original=u"a=b\n"),
+            Binding(key=u"c", value=u"d", original=u"\nc=d"),
+        ]
+    ),
+    (
+        u'a="\nb=c',
+        [
+            Binding(key=None, value=None, original=u'a="\n'),
+            Binding(key=u"b", value=u"c", original=u"b=c"),
+        ]
+    ),
+    (
+        u'# comment\na="b\nc"\nd=e\n',
+        [
+            Binding(key=None, value=None, original=u"# comment\n"),
+            Binding(key=u"a", value=u"b\nc", original=u'a="b\nc"\n'),
+            Binding(key=u"d", value=u"e", original=u"d=e\n"),
+        ],
+    ),
+    (
+        u'garbage[%$#\na=b',
+        [
+            Binding(key=None, value=None, original=u"garbage[%$#\n"),
+            Binding(key=u"a", value=u"b", original=u'a=b'),
+        ],
+    ),
+])
+def test_parse_stream(test_input, expected):
+    result = parse_stream(StringIO(test_input))
+
+    assert list(result) == expected