From 62df65a63e5d683d0d9dd6305bd75a977f407633 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 12 May 2019 12:40:27 +0200 Subject: [PATCH 001/253] Fix unicode/str inconsistency in Python 2 (#177) This encodes environment keys and values for `os.environ` in Python 2 and fixes the following issues: * Values loaded from `.env` being `unicode` whereas regular values being `str` (i.e. `bytes`) in Python 2. * Error when loading `.env` with UTF-8 characters in Python 2. * `# type: ignore` hiding the issue from the type checker. `sys.getfilesystemencoding()` is used because it seems to match the encoding used for environment variables with Python 2. Since it can apparently return `None`, we fall back to a default encoding (UTF-8 here) if that's the case. I tested this on Windows where `"mbcs"` is the file system encoding. Hopefully, this will also work on other systems. --- README.md | 7 ++++++- src/dotenv/compat.py | 4 +--- src/dotenv/main.py | 21 +++++++++++++-------- tests/test_core.py | 11 ++++++++++- 4 files changed, 30 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 4458cb5f..8d348cec 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,10 @@ Unreleased ----- - Add type hints and expose them to users ([@qnighy])([#172]) -- `load_dotenv` and `dotenv_values` now accepts `encoding` paramater, defaults to `None` ([@theskumar])([@earlbread]) (#161) +- `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 ----- @@ -417,6 +420,8 @@ Unreleased [#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 [@asyncee]: https://github.com/asyncee [@greyli]: https://github.com/greyli diff --git a/src/dotenv/compat.py b/src/dotenv/compat.py index a0339303..99ffb39b 100644 --- a/src/dotenv/compat.py +++ b/src/dotenv/compat.py @@ -1,10 +1,8 @@ -from typing import Text, Type import sys + if sys.version_info >= (3, 0): from io import StringIO # noqa else: from StringIO import StringIO # noqa PY2 = sys.version_info[0] == 2 # type: bool -WIN = sys.platform.startswith('win') # type: bool -text_type = Text # type: Type[Text] diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 715a08e2..08122825 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -15,7 +15,7 @@ from collections import OrderedDict from contextlib import contextmanager -from .compat import StringIO, PY2, WIN, text_type +from .compat import StringIO, PY2 if TYPE_CHECKING: # pragma: no cover if sys.version_info >= (3, 6): @@ -109,6 +109,17 @@ def parse_stream(stream): yield binding +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 + + class DotEnv(): def __init__(self, dotenv_path, verbose=False, encoding=None): @@ -156,13 +167,7 @@ def set_as_environment_variables(self, override=False): 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 diff --git a/tests/test_core.py b/tests/test_core.py index eed46f97..daf0f59e 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -12,7 +12,7 @@ from IPython.terminal.embed import InteractiveShellEmbed from dotenv import dotenv_values, find_dotenv, load_dotenv, set_key -from dotenv.compat import StringIO +from dotenv.compat import PY2, StringIO from dotenv.main import Binding, parse_stream @@ -229,6 +229,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) From 8923b044fe3068b8f978d9e870acb938663d256d Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 12 May 2019 16:19:07 +0530 Subject: [PATCH 002/253] readme: Add new release stub --- .bumpversion.cfg | 2 +- README.md | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 0c7713ca..148b1343 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -3,5 +3,5 @@ current_version = 0.10.1 commit = True tag = True -[bumpversion:file:dotenv/version.py] +[bumpversion:file:src/dotenv/version.py] diff --git a/README.md b/README.md index 8d348cec..64fa64f7 100644 --- a/README.md +++ b/README.md @@ -299,6 +299,12 @@ Changelog Unreleased ----- +- ... + + +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]) From 915f7940e34f3238b504dc2124d81016dbac4f9c Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 12 May 2019 16:23:28 +0530 Subject: [PATCH 003/253] =?UTF-8?q?Bump=20version:=200.10.1=20=E2=86=92=20?= =?UTF-8?q?0.10.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- src/dotenv/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 148b1343..b2c60bf0 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.10.1 +current_version = 0.10.2 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 1f4c4d43..17c1a626 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.10.1" +__version__ = "0.10.2" From 73124de45fcc21010ed9e90d4e5f576ba018496b Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 12 May 2019 17:27:39 +0530 Subject: [PATCH 004/253] chore(pypi): switch to markdown and twine for pypi upload + update supported version --- Makefile | 13 ++++++++----- requirements.txt | 2 +- setup.py | 27 ++++++++++----------------- 3 files changed, 19 insertions(+), 23 deletions(-) diff --git a/Makefile b/Makefile index 50b80b40..5b58c4c2 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/requirements.txt b/requirements.txt index bd49777d..5216573c 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.py b/setup.py index 8f601724..defa1f49 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 @@ 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 @@ # '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', From 57f9639d025efbf108f6e1ef3394d0b157230f52 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Wed, 22 May 2019 10:15:51 +0200 Subject: [PATCH 005/253] Refactor parser to fix inconsistencies (#180) * Move parser to separate module * Add tests * Use unicode strings for unit tests in Python 2 Using `str` (e.g. `bytes`) is inconsistent with the types and the implementation. * Refactor parser This fixes inconsistencies reported after the release of version 0.10.0: * Valid escapes were interpreted as control characters even when in single-quoted strings. * `#` was interpreted as the start of a comment even if there was no whitespace preceding it. However, we are keeping the interpretation of escapes in double-quoted strings as they didn't make sense in versions before 0.10.0. The single large regular expression is replaced with a handwritten top-down parser using smaller regular expressions. The reason for this change is that it would have been very difficult or impossible to satisfy the parsing requirements with a single regex. --- README.md | 6 +- setup.cfg | 3 + src/dotenv/compat.py | 15 +++++ src/dotenv/main.py | 84 +------------------------ src/dotenv/parser.py | 147 +++++++++++++++++++++++++++++++++++++++++++ tests/test_core.py | 76 ---------------------- tests/test_parser.py | 88 ++++++++++++++++++++++++++ tox.ini | 10 +-- 8 files changed, 265 insertions(+), 164 deletions(-) create mode 100644 src/dotenv/parser.py create mode 100644 tests/test_parser.py diff --git a/README.md b/README.md index 64fa64f7..7ca64778 100644 --- a/README.md +++ b/README.md @@ -299,8 +299,9 @@ Changelog Unreleased ----- -- ... - +- 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 ----- @@ -428,6 +429,7 @@ Unreleased [#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 [@asyncee]: https://github.com/asyncee [@greyli]: https://github.com/greyli diff --git a/setup.cfg b/setup.cfg index 7f784591..f0847b32 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,6 +5,9 @@ universal = 1 max-line-length = 120 exclude = .tox,.git,docs,venv,.venv +[mypy] +ignore_missing_imports = true + [metadata] description-file = README.rst diff --git a/src/dotenv/compat.py b/src/dotenv/compat.py index 99ffb39b..1a145345 100644 --- a/src/dotenv/compat.py +++ b/src/dotenv/compat.py @@ -1,4 +1,5 @@ import sys +from typing import Text if sys.version_info >= (3, 0): from io import StringIO # noqa @@ -6,3 +7,17 @@ from StringIO import StringIO # noqa PY2 = sys.version_info[0] == 2 # type: bool + + +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 08122825..5b619b11 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import, print_function, unicode_literals -import codecs import io import os import re @@ -9,13 +8,14 @@ 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 +from .parser import parse_stream if TYPE_CHECKING: # pragma: no cover if sys.version_info >= (3, 6): @@ -30,84 +30,6 @@ __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 - def to_env(text): # type: (Text) -> str diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py new file mode 100644 index 00000000..b63cb3a0 --- /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/tests/test_core.py b/tests/test_core.py index daf0f59e..349c58b8 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -13,7 +13,6 @@ from dotenv import dotenv_values, find_dotenv, load_dotenv, set_key from dotenv.compat import PY2, StringIO -from dotenv.main import Binding, parse_stream @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) diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 00000000..f191f902 --- /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 diff --git a/tox.ini b/tox.ini index 56c8732a..077780f4 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 From 0681a878a2530908ef1ef64dff17ed19cfe20465 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Wed, 22 May 2019 19:12:16 +0530 Subject: [PATCH 006/253] Refractor: move 'to_env' to compat.py --- src/dotenv/compat.py | 21 ++++++++++++++++----- src/dotenv/main.py | 13 +------------ 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/dotenv/compat.py b/src/dotenv/compat.py index 1a145345..394d3a3f 100644 --- a/src/dotenv/compat.py +++ b/src/dotenv/compat.py @@ -1,12 +1,23 @@ import sys -from typing import Text +from typing import Text # noqa -if sys.version_info >= (3, 0): - from io import StringIO # noqa -else: +PY2 = sys.version_info[0] == 2 # type: bool + +if PY2: from StringIO import StringIO # noqa +else: + from io import StringIO # noqa -PY2 = sys.version_info[0] == 2 # type: bool + +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): diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 5b619b11..1b44a0c2 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -14,7 +14,7 @@ from collections import OrderedDict from contextlib import contextmanager -from .compat import StringIO, PY2 +from .compat import StringIO, PY2, to_env from .parser import parse_stream if TYPE_CHECKING: # pragma: no cover @@ -31,17 +31,6 @@ __posix_variable = re.compile(r'\$\{[^\}]*\}') # type: Pattern[Text] -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 - - class DotEnv(): def __init__(self, dotenv_path, verbose=False, encoding=None): From 8e37a5f0e427678c69066813a6dd204272ad486a Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Wed, 22 May 2019 20:24:55 +0530 Subject: [PATCH 007/253] Refactor move run_command in cli --- src/dotenv/__init__.py | 2 +- src/dotenv/cli.py | 40 ++++++++++++++++++++++++++++++++++++++-- src/dotenv/main.py | 36 ------------------------------------ 3 files changed, 39 insertions(+), 39 deletions(-) diff --git a/src/dotenv/__init__.py b/src/dotenv/__init__.py index 1867868f..105a32a5 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 45f4b765..235f329b 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 @@ '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/main.py b/src/dotenv/main.py index 1b44a0c2..ef4ae7df 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -6,7 +6,6 @@ import re import shutil import sys -from subprocess import Popen import tempfile from typing import (Dict, Iterator, List, Match, Optional, # noqa Pattern, Union, TYPE_CHECKING, Text, IO, Tuple) @@ -276,38 +275,3 @@ 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, **kwargs).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 From 436b1beb88e80bb1c7cdd21c42b4819b474b5e10 Mon Sep 17 00:00:00 2001 From: Nicolas Spehler Date: Sun, 2 Jun 2019 17:18:30 +0200 Subject: [PATCH 008/253] Fix typo in README.md (#181) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7ca64778..bfd926d6 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 From a1c6eb5825e4b363c5d06edc683daac8489bf9c8 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 2 Jun 2019 22:45:31 +0530 Subject: [PATCH 009/253] Improve interactive mode detection (#183) * Improve interactive mode detection Previously, this checked whether __file__ was defined in globals(). globals() is tied to the current module, so this will always be defined. Fix this by importing __main__ and asking whether it has __file__ defined. This approach is outlined in stackoverflow.com/a/2356420. Thanks @andrewsmith --- README.md | 3 +++ src/dotenv/main.py | 10 ++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bfd926d6..e9cd2205 100644 --- a/README.md +++ b/README.md @@ -299,6 +299,7 @@ Changelog Unreleased ----- +- 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. @@ -430,7 +431,9 @@ Unreleased [#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 diff --git a/src/dotenv/main.py b/src/dotenv/main.py index ef4ae7df..04d22412 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -235,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 From e67796720403941538f826463f9fc610a431e899 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 2 Jun 2019 22:47:32 +0530 Subject: [PATCH 010/253] =?UTF-8?q?Bump=20version:=200.10.2=20=E2=86=92=20?= =?UTF-8?q?0.10.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- src/dotenv/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index b2c60bf0..d034b2be 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.10.2 +current_version = 0.10.3 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 17c1a626..b2385cb4 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.10.2" +__version__ = "0.10.3" From ed3570dade5ad067816c9a56c752b1eada60f21e Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 2 Jun 2019 22:48:57 +0530 Subject: [PATCH 011/253] update doc --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index e9cd2205..32e779e6 100644 --- a/README.md +++ b/README.md @@ -299,6 +299,11 @@ 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. From d0a219d180ec62bfb1bead5849aecca91eead3d9 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Mon, 3 Jun 2019 16:29:06 +0530 Subject: [PATCH 012/253] Move .coveragerc and .bumpversion to setup.cfg (#184) --- .bumpversion.cfg | 7 ------- .coveragerc | 11 ----------- setup.cfg | 20 ++++++++++++++++++++ 3 files changed, 20 insertions(+), 18 deletions(-) delete mode 100644 .bumpversion.cfg delete mode 100644 .coveragerc diff --git a/.bumpversion.cfg b/.bumpversion.cfg deleted file mode 100644 index d034b2be..00000000 --- a/.bumpversion.cfg +++ /dev/null @@ -1,7 +0,0 @@ -[bumpversion] -current_version = 0.10.3 -commit = True -tag = True - -[bumpversion:file:src/dotenv/version.py] - diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 18c6ca7f..00000000 --- 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/setup.cfg b/setup.cfg index f0847b32..216e56ad 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 @@ -13,3 +21,15 @@ 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 From a7205a4faf82df4fd1940a5585faa2c0b425b10f Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Mon, 24 Jun 2019 22:46:27 +0530 Subject: [PATCH 013/253] cleanup files --- .pyup.yml | 4 ---- _config.yml | 1 - 2 files changed, 5 deletions(-) delete mode 100644 .pyup.yml delete mode 100644 _config.yml diff --git a/.pyup.yml b/.pyup.yml deleted file mode 100644 index 604f69f7..00000000 --- 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/_config.yml b/_config.yml deleted file mode 100644 index 2f7efbea..00000000 --- a/_config.yml +++ /dev/null @@ -1 +0,0 @@ -theme: jekyll-theme-minimal \ No newline at end of file From 5fd9680cf7f7adfe69bc0a3e6b5800196ec9dab9 Mon Sep 17 00:00:00 2001 From: Bruno Rocha Date: Wed, 10 Apr 2019 13:38:15 -0300 Subject: [PATCH 014/253] Adding Dynaconf to related projects HI, Dynaconf heavily uses `python-dotenv` https://dynaconf.readthedocs.io https://github.com/rochacbruno/dynaconf --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 32e779e6..ecc83040 100644 --- a/README.md +++ b/README.md @@ -270,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 ============ From e0f68c2651d426a97a70e08212ea2cbac271820e Mon Sep 17 00:00:00 2001 From: Young Lee Date: Thu, 18 Jul 2019 13:12:47 -0700 Subject: [PATCH 015/253] Update README.md (minor typos and grammar) Just wanted to help proofread! --- README.md | 64 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index ecc83040..ec387c38 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ python-dotenv | [![Build Status](https://travis-ci.org/theskumar/python-dotenv.svg?branch=master)](https://travis-ci.org/theskumar/python-dotenv) [![Coverage Status](https://coveralls.io/repos/theskumar/python-dotenv/badge.svg?branch=master)](https://coveralls.io/r/theskumar/python-dotenv?branch=master) [![PyPI version](https://badge.fury.io/py/python-dotenv.svg)](http://badge.fury.io/py/python-dotenv) [![Say Thanks!](https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg)](https://saythanks.io/to/theskumar) =============================================================================== -Reads the key,value pair from `.env` file and adds them to environment +Reads the key-value pair from `.env` file and adds them to environment variable. It is great for managing app settings during development and in production using [12-factor](http://12factor.net/) principles. @@ -18,7 +18,7 @@ in production using [12-factor](http://12factor.net/) principles. - [Usages](#usages) - [Installation](#installation) - [Command-line interface](#command-line-interface) -- [iPython Support](#ipython-support) +- [IPython Support](#ipython-support) - [Setting config on remote servers](#setting-config-on-remote-servers) - [Related Projects](#related-projects) - [Contributing](#contributing) @@ -38,7 +38,7 @@ environment-related method you need as provided by `os.getenv`. `.env` looks like this: ```shell -# a comment and that will be ignored. +# a comment that will be ignored. REDIS_ADDRESS=localhost:6379 MEANING_OF_LIFE=42 MULTILINE_VAR="hello\nworld" @@ -54,7 +54,7 @@ export SECRET_KEY=YOURSECRETKEYGOESHERE `.env` can interpolate variables using POSIX variable expansion, variables are replaced from the environment first or from other values in the `.env` file if the variable is not present in the environment. -(`Note`: Default Value Expansion is not supported as of yet, see +(**Note**: Default Value Expansion is not supported as of yet, see [\#30](https://github.com/theskumar/python-dotenv/pull/30#issuecomment-244036604).) ```shell @@ -73,14 +73,14 @@ module. ├── .env └── settings.py -Add the following code to your `settings.py` +Add the following code to your `settings.py`: ```python # settings.py from dotenv import load_dotenv load_dotenv() -# OR, the same with increased verbosity: +# OR, the same with increased verbosity load_dotenv(verbose=True) # OR, explicitly providing path to '.env' @@ -89,9 +89,9 @@ env_path = Path('.') / '.env' load_dotenv(dotenv_path=env_path) ``` -At this point, parsed key/value from the .env file is now present as +At this point, parsed key/value from the `.env` file is now present as system environment variable and they can be conveniently accessed via -`os.getenv()` +`os.getenv()`: ```python # settings.py @@ -134,7 +134,7 @@ before passing. 'EGGS' ``` -The returned value is dictionary with key value pair. +The returned value is dictionary with key-value pair. `dotenv_values` could be useful if you need to *consume* the envfile but not *apply* it directly into the system environment. @@ -142,19 +142,19 @@ not *apply* it directly into the system environment. Django ------ -If you are using django you should add the above loader script at the +If you are using Django, you should add the above loader script at the top of `wsgi.py` and `manage.py`. Installation ============ - - pip install -U python-dotenv - -iPython Support +```shell +pip install -U python-dotenv +``` +IPython Support --------------- -You can use dotenv with iPython. You can either let the dotenv search -for .env with %dotenv or provide the path to .env file explicitly, see +You can use dotenv with IPython. You can either let the dotenv search +for `.env` with `%dotenv` or provide the path to the `.env` file explicitly; see below for usages. %load_ext dotenv @@ -171,17 +171,19 @@ below for usages. # Use '-v' to turn verbose mode on %dotenv -v -Command-line interface +Command-line Interface ====================== -For commandline support, use the cli option during installation: +For command-line support, use the CLI option during installation: - pip install -U "python-dotenv[cli]" +```shell +pip install -U "python-dotenv[cli]" +``` -A cli interface `dotenv` is also included, which helps you manipulate -the `.env` file without manually opening it. The same cli installed on +A CLI interface `dotenv` is also included, which helps you manipulate +the `.env` file without manually opening it. The same CLI installed on remote machine combined with fabric (discussed later) will enable you to -update your settings on remote server, handy isn't it! +update your settings on a remote server; handy, isn't it! ``` Usage: dotenv [OPTIONS] COMMAND [ARGS]... @@ -205,11 +207,11 @@ Commands: unset Removes the given key. ``` -Setting config on remote servers +Setting config on Remote Servers -------------------------------- -We make use of excellent [Fabric](http://www.fabfile.org/) to acomplish -this. Add a config task to your local fabfile, `dotenv_path` is the +We make use of excellent [Fabric](http://www.fabfile.org/) to accomplish +this. Add a config task to your local fabfile; `dotenv_path` is the location of the absolute path of `.env` file on the remote server. ```python @@ -235,27 +237,27 @@ def config(action=None, key=None, value=None): run(command) ``` -Usage is designed to mirror the heroku config api very closely. +Usage is designed to mirror the Heroku config API very closely. -Get all your remote config info with `fab config` +Get all your remote config info with `fab config`: $ fab config foo="bar" -Set remote config variables with `fab config:set,,` +Set remote config variables with `fab config:set,,`: $ fab config:set,hello,world -Get a single remote config variables with `fab config:get,` +Get a single remote config variables with `fab config:get,`: $ fab config:get,hello -Delete a remote config variables with `fab config:unset,` +Delete a remote config variables with `fab config:unset,`: $ fab config:unset,hello Thanks entirely to fabric and not one bit to this project, you can chain -commands like so +commands like so: `fab config:set,, config:set,,` $ fab config:set,hello,world config:set,foo,bar config:set,fizz=buzz From b7759b7b62bce8f658aab5a1b8968a539d10208d Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Tue, 10 Sep 2019 16:49:51 +0530 Subject: [PATCH 016/253] Update documentation to suite the new documentation site --- CHANGELOG.md | 171 ++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 18 ++++ README.md | 224 ++++----------------------------------------- pyproject.toml | 5 + requirements.txt | 1 + setup.py | 12 ++- src/dotenv/main.py | 7 ++ 7 files changed, 231 insertions(+), 207 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 pyproject.toml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..1a7eb6ed --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,171 @@ +# Install the latest + +To install the latest version of `python-dotenv` simply run: + +`pip install python-dotenv` + +OR + +`poetry add python-dotenv` + +OR + +`pipenv install python-dotenv` + + +Changelog +========= + +Latest +----- + +- ... + +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 +----- +- Fix parsing of variable without a value ([@asyncee])([@bbc2])([#158]) + +0.10.0 +----- + +- Add support for UTF-8 in unquoted values ([@bbc2])([#148]) +- Add support for trailing comments ([@bbc2])([#148]) +- Add backslashes support in values ([@bbc2])([#148]) +- Add support for newlines in values ([@bbc2])([#148]) +- Force environment variables to str with Python2 on Windows ([@greyli]) +- Drop Python 3.3 support ([@greyli]) +- Fix stderr/-out/-in redirection ([@venthur]) + + +0.9.0 +----- +- Add `--version` parameter to cli ([@venthur]) +- Enable loading from current directory ([@cjauvin]) +- Add 'dotenv run' command for calling arbitrary shell script with .env ([@venthur]) + +0.8.1 +----- + +- Add tests for docs ([@Flimm]) +- Make 'cli' support optional. Use `pip install python-dotenv[cli]`. ([@theskumar]) + +0.8.0 +----- + +- `set_key` and `unset_key` only modified the affected file instead of + parsing and re-writing file, this causes comments and other file + entact as it is. +- Add support for `export` prefix in the line. +- Internal refractoring ([@theskumar]) +- Allow `load_dotenv` and `dotenv_values` to work with `StringIO())` ([@alanjds])([@theskumar])([#78]) + +0.7.1 +----- + +- Remove hard dependency on iPython ([@theskumar]) + +0.7.0 +----- + +- Add support to override system environment variable via .env. + ([@milonimrod](https://github.com/milonimrod)) + ([\#63](https://github.com/theskumar/python-dotenv/issues/63)) +- Disable ".env not found" warning by default + ([@maxkoryukov](https://github.com/maxkoryukov)) + ([\#57](https://github.com/theskumar/python-dotenv/issues/57)) + +0.6.5 +----- + +- Add support for special characters `\`. + ([@pjona](https://github.com/pjona)) + ([\#60](https://github.com/theskumar/python-dotenv/issues/60)) + +0.6.4 +----- + +- Fix issue with single quotes ([@Flimm]) + ([\#52](https://github.com/theskumar/python-dotenv/issues/52)) + +0.6.3 +----- + +- Handle unicode exception in setup.py + ([\#46](https://github.com/theskumar/python-dotenv/issues/46)) + +0.6.2 +----- + +- Fix dotenv list command ([@ticosax](https://github.com/ticosax)) +- Add iPython Suport + ([@tillahoffmann](https://github.com/tillahoffmann)) + +0.6.0 +----- + +- Drop support for Python 2.6 +- Handle escaped charaters and newlines in quoted values. (Thanks + [@iameugenejo](https://github.com/iameugenejo)) +- Remove any spaces around unquoted key/value. (Thanks + [@paulochf](https://github.com/paulochf)) +- Added POSIX variable expansion. (Thanks + [@hugochinchilla](https://github.com/hugochinchilla)) + +0.5.1 +----- + +- Fix find\_dotenv - it now start search from the file where this + function is called from. + +0.5.0 +----- + +- Add `find_dotenv` method that will try to find a `.env` file. + (Thanks [@isms](https://github.com/isms)) + +0.4.0 +----- + +- cli: Added `-q/--quote` option to control the behaviour of quotes + around values in `.env`. (Thanks + [@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 +[@Flimm]: https://github.com/Flimm +[@theskumar]: https://github.com/theskumar +[@alanjds]: https://github.com/alanjds +[@cjauvin]: https://github.com/cjauvin +[@bbc2]: https://github.com/bbc2 +[@qnighy]: https://github.com/qnighy +[@earlbread]: https://github.com/earlbread diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..d989d87f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,18 @@ +Contributing +============ + +All the contributions are welcome! Please open [an +issue](https://github.com/theskumar/python-dotenv/issues/new) or send us +a pull request. + +Executing the tests: + + $ pip install -r requirements.txt + $ pip install -e . + $ flake8 + $ pytest + +or with [tox](https://pypi.org/project/tox/) installed: + + $ tox + diff --git a/README.md b/README.md index ec387c38..bceabae4 100644 --- a/README.md +++ b/README.md @@ -15,19 +15,7 @@ in production using [12-factor](http://12factor.net/) principles. > Do one thing, do it well! -- [Usages](#usages) -- [Installation](#installation) -- [Command-line interface](#command-line-interface) -- [IPython Support](#ipython-support) -- [Setting config on remote servers](#setting-config-on-remote-servers) -- [Related Projects](#related-projects) -- [Contributing](#contributing) -- [Changelog](#changelog) - -> Hey just wanted to let you know that since I've started writing 12-factor apps I've found python-dotenv to be invaluable for all my projects. It's super useful and “just works.” --Daniel Fridkin - -Usages -====== +## Usages The easiest and most common usage consists on calling `load_dotenv` when the application starts, which will load environment variables from a @@ -63,8 +51,13 @@ DOMAIN=example.org EMAIL=admin@${DOMAIN} ``` -Getting started -=============== +## Getting started + +Install the latest version with: + +```shell +pip install -U python-dotenv +``` Assuming you have created the `.env` file along-side your settings module. @@ -116,8 +109,7 @@ from dotenv import load_dotenv, find_dotenv load_dotenv(find_dotenv()) ``` -In-memory filelikes -------------------- +### In-memory filelikes It is possible to not rely on the filesystem to parse filelikes from other sources (e.g. from a network storage). `load_dotenv` and @@ -139,19 +131,13 @@ The returned value is dictionary with key-value pair. `dotenv_values` could be useful if you need to *consume* the envfile but not *apply* it directly into the system environment. -Django ------- +### Django If you are using Django, you should add the above loader script at the top of `wsgi.py` and `manage.py`. -Installation -============ -```shell -pip install -U python-dotenv -``` -IPython Support ---------------- + +## IPython Support You can use dotenv with IPython. You can either let the dotenv search for `.env` with `%dotenv` or provide the path to the `.env` file explicitly; see @@ -171,8 +157,8 @@ below for usages. # Use '-v' to turn verbose mode on %dotenv -v -Command-line Interface -====================== + +## Command-line Interface For command-line support, use the CLI option during installation: @@ -207,8 +193,8 @@ Commands: unset Removes the given key. ``` -Setting config on Remote Servers --------------------------------- + +### Setting config on Remote Servers We make use of excellent [Fabric](http://www.fabfile.org/) to accomplish this. Add a config task to your local fabfile; `dotenv_path` is the @@ -262,8 +248,8 @@ commands like so: $ fab config:set,hello,world config:set,foo,bar config:set,fizz=buzz -Related Projects -================ + +## Related Projects - [Honcho](https://github.com/nickstenning/honcho) - For managing Procfile-based applications. @@ -274,181 +260,9 @@ Related Projects - [environs](https://github.com/sloria/environs) - [dynaconf](https://github.com/rochacbruno/dynaconf) -Contributing -============ -All the contributions are welcome! Please open [an -issue](https://github.com/theskumar/python-dotenv/issues/new) or send us -a pull request. +## Acknowledgements This project is currently maintained by [Saurabh Kumar](https://saurabh-kumar.com) and [Bertrand Bonnefoy-Claudet](https://github.com/bbc2) and would not have been possible without the support of these [awesome people](https://github.com/theskumar/python-dotenv/graphs/contributors). - -Executing the tests: - - $ pip install -r requirements.txt - $ pip install -e . - $ flake8 - $ pytest - -or with [tox](https://pypi.org/project/tox/) installed: - - $ tox - -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 ------ -- Fix parsing of variable without a value ([@asyncee])([@bbc2])([#158]) - -0.10.0 ------ - -- Add support for UTF-8 in unquoted values ([@bbc2])([#148]) -- Add support for trailing comments ([@bbc2])([#148]) -- Add backslashes support in values ([@bbc2])([#148]) -- Add support for newlines in values ([@bbc2])([#148]) -- Force environment variables to str with Python2 on Windows ([@greyli]) -- Drop Python 3.3 support ([@greyli]) -- Fix stderr/-out/-in redirection ([@venthur]) - - -0.9.0 ------ -- Add `--version` parameter to cli ([@venthur]) -- Enable loading from current directory ([@cjauvin]) -- Add 'dotenv run' command for calling arbitrary shell script with .env ([@venthur]) - -0.8.1 ------ - -- Add tests for docs ([@Flimm]) -- Make 'cli' support optional. Use `pip install python-dotenv[cli]`. ([@theskumar]) - -0.8.0 ------ - -- `set_key` and `unset_key` only modified the affected file instead of - parsing and re-writing file, this causes comments and other file - entact as it is. -- Add support for `export` prefix in the line. -- Internal refractoring ([@theskumar]) -- Allow `load_dotenv` and `dotenv_values` to work with `StringIO())` ([@alanjds])([@theskumar])([#78]) - -0.7.1 ------ - -- Remove hard dependency on iPython ([@theskumar]) - -0.7.0 ------ - -- Add support to override system environment variable via .env. - ([@milonimrod](https://github.com/milonimrod)) - ([\#63](https://github.com/theskumar/python-dotenv/issues/63)) -- Disable ".env not found" warning by default - ([@maxkoryukov](https://github.com/maxkoryukov)) - ([\#57](https://github.com/theskumar/python-dotenv/issues/57)) - -0.6.5 ------ - -- Add support for special characters `\`. - ([@pjona](https://github.com/pjona)) - ([\#60](https://github.com/theskumar/python-dotenv/issues/60)) - -0.6.4 ------ - -- Fix issue with single quotes ([@Flimm]) - ([\#52](https://github.com/theskumar/python-dotenv/issues/52)) - -0.6.3 ------ - -- Handle unicode exception in setup.py - ([\#46](https://github.com/theskumar/python-dotenv/issues/46)) - -0.6.2 ------ - -- Fix dotenv list command ([@ticosax](https://github.com/ticosax)) -- Add iPython Suport - ([@tillahoffmann](https://github.com/tillahoffmann)) - -0.6.0 ------ - -- Drop support for Python 2.6 -- Handle escaped charaters and newlines in quoted values. (Thanks - [@iameugenejo](https://github.com/iameugenejo)) -- Remove any spaces around unquoted key/value. (Thanks - [@paulochf](https://github.com/paulochf)) -- Added POSIX variable expansion. (Thanks - [@hugochinchilla](https://github.com/hugochinchilla)) - -0.5.1 ------ - -- Fix find\_dotenv - it now start search from the file where this - function is called from. - -0.5.0 ------ - -- Add `find_dotenv` method that will try to find a `.env` file. - (Thanks [@isms](https://github.com/isms)) - -0.4.0 ------ - -- cli: Added `-q/--quote` option to control the behaviour of quotes - around values in `.env`. (Thanks - [@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 -[@Flimm]: https://github.com/Flimm -[@theskumar]: https://github.com/theskumar -[@alanjds]: https://github.com/alanjds -[@cjauvin]: https://github.com/cjauvin -[@bbc2]: https://github.com/bbc2 -[@qnighy]: https://github.com/qnighy -[@earlbread]: https://github.com/earlbread diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..64b4431f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,5 @@ +[tool.portray] +modules = ["dotenv"] + +[tool.portray.mkdocs] +repo_url = "https://github.com/theskumar/python-dotenv" diff --git a/requirements.txt b/requirements.txt index 5216573c..f828b576 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ sh>=1.09 tox wheel twine +portray diff --git a/setup.py b/setup.py index defa1f49..09f7c05a 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,16 @@ # -*- coding: utf-8 -*- from setuptools import setup -with open('README.md') as f: - long_description = f.read() + +def read_files(files): + data = [] + for file in files: + with open(file) as f: + data.append(f.read()) + return "\n".join(data) + + +long_description = read_files(['README.md', 'CHANGELOG.md']) meta = {} with open('./src/dotenv/version.py') as f: diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 04d22412..6ae37f3f 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -273,6 +273,13 @@ def _is_interactive(): 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 + """Parse a .env file and then load all the variables found as environment variables. + + - *dotenv_path*: absolute or relative path to .env file. + - *stream*: `StringIO` object with .env content. + - *verbose*: whether to output the warnings related to missing .env file etc. Defaults to `False`. + - *override*: where to override the system environment variables with the variables in `.env` file. Defaults to `False`. + """ f = dotenv_path or stream or find_dotenv() return DotEnv(f, verbose=verbose, **kwargs).set_as_environment_variables(override=override) From d93324ae9e4ec27ed3d62e2dd896699f17320b28 Mon Sep 17 00:00:00 2001 From: Dan Ryan Date: Sat, 21 Dec 2019 10:00:12 -0500 Subject: [PATCH 017/253] Make typing optional (#179) - Fall back to `collections.namedtuple` for `Binding` when typing is not present - Add import guards to all imports - Fixes #178 - Add missing coverage config to setup.cfg --- MANIFEST.in | 2 +- setup.cfg | 3 +++ src/dotenv/__init__.py | 5 ++++- src/dotenv/cli.py | 5 ++++- src/dotenv/compat.py | 17 ++++++++++++++++- src/dotenv/main.py | 12 +++++++----- src/dotenv/parser.py | 28 ++++++++++++++++++++++------ 7 files changed, 57 insertions(+), 15 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index aba1cd49..78e43e9b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ -include LICENSE *.md *.yml +include LICENSE *.md *.yml *.toml include tox.ini recursive-include tests *.py diff --git a/setup.cfg b/setup.cfg index 216e56ad..ecd4df52 100644 --- a/setup.cfg +++ b/setup.cfg @@ -33,3 +33,6 @@ source = [coverage:report] show_missing = True +exclude_lines = + if IS_TYPE_CHECKING: + pragma: no cover diff --git a/src/dotenv/__init__.py b/src/dotenv/__init__.py index 105a32a5..b88d9bc2 100644 --- a/src/dotenv/__init__.py +++ b/src/dotenv/__init__.py @@ -1,6 +1,9 @@ -from typing import Any, Optional # noqa +from .compat import IS_TYPE_CHECKING from .main import load_dotenv, get_key, set_key, unset_key, find_dotenv, dotenv_values +if IS_TYPE_CHECKING: + from typing import Any, Optional + def load_ipython_extension(ipython): # type: (Any) -> None diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 235f329b..d2a021a5 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -1,7 +1,6 @@ import os import sys from subprocess import Popen -from typing import Any, Dict, List # noqa try: import click @@ -10,9 +9,13 @@ 'Run pip install "python-dotenv[cli]" to fix this.') sys.exit(1) +from .compat import IS_TYPE_CHECKING from .main import dotenv_values, get_key, set_key, unset_key from .version import __version__ +if IS_TYPE_CHECKING: + from typing import Any, List, Dict + @click.group() @click.option('-f', '--file', default=os.path.join(os.getcwd(), '.env'), diff --git a/src/dotenv/compat.py b/src/dotenv/compat.py index 394d3a3f..61f555df 100644 --- a/src/dotenv/compat.py +++ b/src/dotenv/compat.py @@ -1,5 +1,4 @@ import sys -from typing import Text # noqa PY2 = sys.version_info[0] == 2 # type: bool @@ -9,6 +8,22 @@ from io import StringIO # noqa +def is_type_checking(): + # type: () -> bool + try: + from typing import TYPE_CHECKING + except ImportError: # pragma: no cover + return False + return TYPE_CHECKING + + +IS_TYPE_CHECKING = is_type_checking() + + +if IS_TYPE_CHECKING: + from typing import Text + + def to_env(text): # type: (Text) -> str """ diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 6ae37f3f..06a210e1 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -7,16 +7,17 @@ import shutil import sys import tempfile -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, to_env +from .compat import StringIO, PY2, to_env, IS_TYPE_CHECKING from .parser import parse_stream -if TYPE_CHECKING: # pragma: no cover +if IS_TYPE_CHECKING: + from typing import ( + Dict, Iterator, Match, Optional, Pattern, Union, Text, IO, Tuple + ) if sys.version_info >= (3, 6): _PathLike = os.PathLike else: @@ -278,7 +279,8 @@ def load_dotenv(dotenv_path=None, stream=None, verbose=False, override=False, ** - *dotenv_path*: absolute or relative path to .env file. - *stream*: `StringIO` object with .env content. - *verbose*: whether to output the warnings related to missing .env file etc. Defaults to `False`. - - *override*: where to override the system environment variables with the variables in `.env` file. Defaults to `False`. + - *override*: where to override the system environment variables with the variables in `.env` file. + Defaults to `False`. """ f = dotenv_path or stream or find_dotenv() return DotEnv(f, verbose=verbose, **kwargs).set_as_environment_variables(override=override) diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index b63cb3a0..034ebfde 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -1,9 +1,14 @@ import codecs import re -from typing import (IO, Iterator, Match, NamedTuple, Optional, Pattern, # noqa - Sequence, Text) -from .compat import to_text +from .compat import to_text, IS_TYPE_CHECKING + + +if IS_TYPE_CHECKING: + from typing import ( # noqa:F401 + IO, Iterator, Match, NamedTuple, Optional, Pattern, Sequence, Text, + Tuple + ) def make_regex(string, extra_flags=0): @@ -25,9 +30,20 @@ def make_regex(string, extra_flags=0): _double_quote_escapes = make_regex(r"\\[\\'\"abfnrtv]") _single_quote_escapes = make_regex(r"\\[\\']") -Binding = NamedTuple("Binding", [("key", Optional[Text]), - ("value", Optional[Text]), - ("original", Text)]) + +try: + # this is necessary because we only import these from typing + # when we are type checking, and the linter is upset if we + # re-import + import typing + Binding = typing.NamedTuple("Binding", [("key", typing.Optional[typing.Text]), + ("value", typing.Optional[typing.Text]), + ("original", typing.Text)]) +except ImportError: # pragma: no cover + from collections import namedtuple + Binding = namedtuple("Binding", ["key", # type: ignore + "value", + "original"]) # type: Tuple[Optional[Text], Optional[Text], Text] class Error(Exception): From 3576225aa796daec434e60c27851642f3c07ff0e Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 21 Dec 2019 15:55:57 +0100 Subject: [PATCH 018/253] Test without typing The dependency on the `typing` package is now optional for Python 3.4. This commit runs (part of) the test suite in when that package is absent, to make sure the dependency isn't reintroduced accidentally. --- .travis.yml | 2 ++ src/dotenv/compat.py | 2 +- src/dotenv/parser.py | 2 +- tests/test_core.py | 3 ++- tox.ini | 9 +++++++-- 5 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index 29ff965f..c8f56f51 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,6 +14,8 @@ matrix: env: TOXENV=py27 - python: "3.4" env: TOXENV=py34 + - python: "3.4" + env: TOXENV=py34-no-typing - python: "3.5" env: TOXENV=py35 - python: "3.6" diff --git a/src/dotenv/compat.py b/src/dotenv/compat.py index 61f555df..f8089bf4 100644 --- a/src/dotenv/compat.py +++ b/src/dotenv/compat.py @@ -12,7 +12,7 @@ def is_type_checking(): # type: () -> bool try: from typing import TYPE_CHECKING - except ImportError: # pragma: no cover + except ImportError: return False return TYPE_CHECKING diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index 034ebfde..5285285c 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -39,7 +39,7 @@ def make_regex(string, extra_flags=0): Binding = typing.NamedTuple("Binding", [("key", typing.Optional[typing.Text]), ("value", typing.Optional[typing.Text]), ("original", typing.Text)]) -except ImportError: # pragma: no cover +except ImportError: from collections import namedtuple Binding = namedtuple("Binding", ["key", # type: ignore "value", diff --git a/tests/test_core.py b/tests/test_core.py index 349c58b8..80dd6dec 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -9,7 +9,6 @@ import pytest import sh -from IPython.terminal.embed import InteractiveShellEmbed from dotenv import dotenv_values, find_dotenv, load_dotenv, set_key from dotenv.compat import PY2, StringIO @@ -117,6 +116,7 @@ def test_load_dotenv_in_current_dir(tmp_path): def test_ipython(tmp_path): + from IPython.terminal.embed import InteractiveShellEmbed os.chdir(str(tmp_path)) dotenv_file = tmp_path / '.env' dotenv_file.write_text("MYNEWVALUE=q1w2e3\n") @@ -127,6 +127,7 @@ def test_ipython(tmp_path): def test_ipython_override(tmp_path): + from IPython.terminal.embed import InteractiveShellEmbed os.chdir(str(tmp_path)) dotenv_file = tmp_path / '.env' os.environ["MYNEWVALUE"] = "OVERRIDE" diff --git a/tox.ini b/tox.ini index 077780f4..2bdc288f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = lint,py{27,34,35,36,37},pypy,pypy3,manifest,coverage-report +envlist = lint,py{27,34,35,36,37,34-no-typing},pypy,pypy3,manifest,coverage-report [testenv] deps = @@ -8,10 +8,15 @@ deps = sh click py{27,py}: ipython<6.0.0 - py34: ipython<7.0.0 + py34{,-no-typing}: ipython<7.0.0 py{35,36,37,py3}: ipython commands = coverage run --parallel -m pytest {posargs} +[testenv:py34-no-typing] +commands = + pip uninstall --yes typing + coverage run --parallel -m pytest -k 'not test_ipython' {posargs} + [testenv:lint] skip_install = true deps = From af192b7956e50c8cc07dc287047a524bb3b10ebb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Franusic?= Date: Wed, 13 Nov 2019 14:13:03 -0800 Subject: [PATCH 019/253] Minor tweaks --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bceabae4..a55051d3 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ SECRET_KEY = os.getenv("EMAIL") DATABASE_PASSWORD = os.getenv("DATABASE_PASSWORD") ``` -`load_dotenv` do not override existing System environment variables. To +`load_dotenv` does 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. @@ -126,7 +126,7 @@ before passing. 'EGGS' ``` -The returned value is dictionary with key-value pair. +The returned value is dictionary with key-value pairs. `dotenv_values` could be useful if you need to *consume* the envfile but not *apply* it directly into the system environment. From b1e83cafa8d0b09a2958106b7a35a0ee4d6aec22 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Fri, 1 Nov 2019 11:49:21 +0100 Subject: [PATCH 020/253] Remove useless try/except --- src/dotenv/parser.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index 5285285c..b265118d 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -154,10 +154,7 @@ def parse_binding(reader): def parse_stream(stream): - # type:(IO[Text]) -> Iterator[Binding] + # type: (IO[Text]) -> Iterator[Binding] reader = Reader(stream) while reader.has_next(): - try: - yield parse_binding(reader) - except Error: - return + yield parse_binding(reader) From 723809fd9e5b6bb5c016c48bc42246f369e8485b Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Fri, 1 Nov 2019 12:16:21 +0100 Subject: [PATCH 021/253] Print a warning on malformed line If a line can't be parsed (e.g. `FOO: BAR` is invalid), it is printed as a warning on stderr. As usual, after such an error, Python-dotenv tries to parse the rest of the file and the application can use the successfully parsed variable bindings. --- src/dotenv/main.py | 28 +++++++++--- src/dotenv/parser.py | 101 ++++++++++++++++++++++++++++++++++--------- tests/test_parser.py | 92 ++++++++++++++++++++++----------------- 3 files changed, 153 insertions(+), 68 deletions(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 06a210e1..6b7f947d 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, print_function, unicode_literals import io +import logging import os import re import shutil @@ -11,8 +12,10 @@ from collections import OrderedDict from contextlib import contextmanager -from .compat import StringIO, PY2, to_env, IS_TYPE_CHECKING -from .parser import parse_stream +from .compat import IS_TYPE_CHECKING, PY2, StringIO, to_env +from .parser import Binding, parse_stream + +logger = logging.getLogger(__name__) if IS_TYPE_CHECKING: from typing import ( @@ -31,6 +34,17 @@ __posix_variable = re.compile(r'\$\{[^\}]*\}') # type: Pattern[Text] +def with_warn_for_invalid_lines(mappings): + # type: (Iterator[Binding]) -> Iterator[Binding] + for mapping in mappings: + if mapping.key is None or mapping.value is None: + logger.warning( + "Python-dotenv could not parse statement starting at line %s", + mapping.original.line, + ) + yield mapping + + class DotEnv(): def __init__(self, dotenv_path, verbose=False, encoding=None): @@ -66,7 +80,7 @@ def dict(self): def parse(self): # type: () -> Iterator[Tuple[Text, Text]] with self._get_stream() as stream: - for mapping in parse_stream(stream): + for mapping in with_warn_for_invalid_lines(parse_stream(stream)): if mapping.key is not None and mapping.value is not None: yield mapping.key, mapping.value @@ -143,12 +157,12 @@ def set_key(dotenv_path, key_to_set, value_to_set, quote_mode="always"): with rewrite(dotenv_path) as (source, dest): replaced = False - for mapping in parse_stream(source): + for mapping in with_warn_for_invalid_lines(parse_stream(source)): if mapping.key == key_to_set: dest.write(line_out) replaced = True else: - dest.write(mapping.original) + dest.write(mapping.original.string) if not replaced: dest.write(line_out) @@ -169,11 +183,11 @@ def unset_key(dotenv_path, key_to_unset, quote_mode="always"): removed = False with rewrite(dotenv_path) as (source, dest): - for mapping in parse_stream(source): + for mapping in with_warn_for_invalid_lines(parse_stream(source)): if mapping.key == key_to_unset: removed = True else: - dest.write(mapping.original) + dest.write(mapping.original.string) if not removed: warnings.warn("key %s not removed from %s - key doesn't exist." % (key_to_unset, dotenv_path)) # type: ignore diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index b265118d..dc8fe008 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -1,8 +1,7 @@ import codecs import re -from .compat import to_text, IS_TYPE_CHECKING - +from .compat import IS_TYPE_CHECKING, to_text if IS_TYPE_CHECKING: from typing import ( # noqa:F401 @@ -16,6 +15,7 @@ def make_regex(string, extra_flags=0): return re.compile(to_text(string), re.UNICODE | extra_flags) +_newline = make_regex(r"(\r\n|\n|\r)") _whitespace = make_regex(r"\s*", extra_flags=re.MULTILINE) _export = make_regex(r"(?:export[^\S\r\n]+)?") _single_quoted_key = make_regex(r"'([^']+)'") @@ -36,14 +36,62 @@ def make_regex(string, extra_flags=0): # when we are type checking, and the linter is upset if we # re-import import typing - Binding = typing.NamedTuple("Binding", [("key", typing.Optional[typing.Text]), - ("value", typing.Optional[typing.Text]), - ("original", typing.Text)]) + + Original = typing.NamedTuple( + "Original", + [ + ("string", typing.Text), + ("line", int), + ], + ) + + Binding = typing.NamedTuple( + "Binding", + [ + ("key", typing.Optional[typing.Text]), + ("value", typing.Optional[typing.Text]), + ("original", Original), + ], + ) except ImportError: from collections import namedtuple - Binding = namedtuple("Binding", ["key", # type: ignore - "value", - "original"]) # type: Tuple[Optional[Text], Optional[Text], Text] + Original = namedtuple( # type: ignore + "Original", + [ + "string", + "line", + ], + ) + Binding = namedtuple( # type: ignore + "Binding", + [ + "key", + "value", + "original", + ], + ) + + +class Position: + def __init__(self, chars, line): + # type: (int, int) -> None + self.chars = chars + self.line = line + + @classmethod + def start(cls): + # type: () -> Position + return cls(chars=0, line=1) + + def set(self, other): + # type: (Position) -> None + self.chars = other.chars + self.line = other.line + + def advance(self, string): + # type: (Text) -> None + self.chars += len(string) + self.line += len(re.findall(_newline, string)) class Error(Exception): @@ -54,39 +102,42 @@ class Reader: def __init__(self, stream): # type: (IO[Text]) -> None self.string = stream.read() - self.position = 0 - self.mark = 0 + self.position = Position.start() + self.mark = Position.start() def has_next(self): # type: () -> bool - return self.position < len(self.string) + return self.position.chars < len(self.string) def set_mark(self): # type: () -> None - self.mark = self.position + self.mark.set(self.position) def get_marked(self): - # type: () -> Text - return self.string[self.mark:self.position] + # type: () -> Original + return Original( + string=self.string[self.mark.chars:self.position.chars], + line=self.mark.line, + ) def peek(self, count): # type: (int) -> Text - return self.string[self.position:self.position + count] + return self.string[self.position.chars:self.position.chars + count] def read(self, count): # type: (int) -> Text - result = self.string[self.position:self.position + count] + result = self.string[self.position.chars:self.position.chars + count] if len(result) < count: raise Error("read: End of string") - self.position += count + self.position.advance(result) return result def read_regex(self, regex): # type: (Pattern[Text]) -> Sequence[Text] - match = regex.match(self.string, self.position) + match = regex.match(self.string, self.position.chars) if match is None: raise Error("read_regex: Pattern not found") - self.position = match.end() + self.position.advance(self.string[match.start():match.end()]) return match.groups() @@ -147,10 +198,18 @@ def parse_binding(reader): value = parse_value(reader) reader.read_regex(_comment) reader.read_regex(_end_of_line) - return Binding(key=key, value=value, original=reader.get_marked()) + 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()) + return Binding( + key=None, + value=None, + original=reader.get_marked(), + ) def parse_stream(stream): diff --git a/tests/test_parser.py b/tests/test_parser.py index f191f902..aac19302 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -2,83 +2,95 @@ import pytest from dotenv.compat import StringIO -from dotenv.parser import Binding, parse_stream +from dotenv.parser import Binding, Original, 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", [Binding(key=u"a", value=u"b", original=Original(string=u"a=b", line=1))]), + (u"'a'=b", [Binding(key=u"a", value=u"b", original=Original(string=u"'a'=b", line=1))]), + (u"[=b", [Binding(key=u"[", value=u"b", original=Original(string=u"[=b", line=1))]), + (u" a = b ", [Binding(key=u"a", value=u"b", original=Original(string=u" a = b ", line=1))]), + (u"export a=b", [Binding(key=u"a", value=u"b", original=Original(string=u"export a=b", line=1))]), + (u" export 'a'=b", [Binding(key=u"a", value=u"b", original=Original(string=u" export 'a'=b", line=1))]), + (u"# a=b", [Binding(key=None, value=None, original=Original(string=u"# a=b", line=1))]), + (u"a=b#c", [Binding(key=u"a", value=u"b#c", original=Original(string=u"a=b#c", line=1))]), + (u'a=b # comment', [Binding(key=u"a", value=u"b", original=Original(string=u"a=b # comment", line=1))]), + (u"a=b space ", [Binding(key=u"a", value=u"b space", original=Original(string=u"a=b space ", line=1))]), + (u"a='b space '", [Binding(key=u"a", value=u"b space ", original=Original(string=u"a='b space '", line=1))]), + (u'a="b space "', [Binding(key=u"a", value=u"b space ", original=Original(string=u'a="b space "', line=1))]), + ( + u"export export_a=1", + [ + Binding(key=u"export_a", value=u"1", original=Original(string=u"export export_a=1", line=1)) + ], + ), + (u"export port=8000", [Binding(key=u"port", value=u"8000", original=Original(string=u"export port=8000", line=1))]), + (u'a="b\nc"', [Binding(key=u"a", value=u"b\nc", original=Original(string=u'a="b\nc"', line=1))]), + (u"a='b\nc'", [Binding(key=u"a", value=u"b\nc", original=Original(string=u"a='b\nc'", line=1))]), + (u'a="b\nc"', [Binding(key=u"a", value=u"b\nc", original=Original(string=u'a="b\nc"', line=1))]), + (u'a="b\\nc"', [Binding(key=u"a", value=u'b\nc', original=Original(string=u'a="b\\nc"', line=1))]), + (u"a='b\\nc'", [Binding(key=u"a", value=u'b\\nc', original=Original(string=u"a='b\\nc'", line=1))]), + (u'a="b\\"c"', [Binding(key=u"a", value=u'b"c', original=Original(string=u'a="b\\"c"', line=1))]), + (u"a='b\\'c'", [Binding(key=u"a", value=u"b'c", original=Original(string=u"a='b\\'c'", line=1))]), + (u"a=à", [Binding(key=u"a", value=u"à", original=Original(string=u"a=à", line=1))]), + (u'a="à"', [Binding(key=u"a", value=u"à", original=Original(string=u'a="à"', line=1))]), + (u'garbage', [Binding(key=None, value=None, original=Original(string=u"garbage", line=1))]), ( 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"), + Binding(key=u"a", value=u"b", original=Original(string=u"a=b\n", line=1)), + Binding(key=u"c", value=u"d", original=Original(string=u"c=d", line=2)), + ], + ), + ( + u"a=b\rc=d", + [ + Binding(key=u"a", value=u"b", original=Original(string=u"a=b\r", line=1)), + Binding(key=u"c", value=u"d", original=Original(string=u"c=d", line=2)), ], ), ( 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"), + Binding(key=u"a", value=u"b", original=Original(string=u"a=b\r\n", line=1)), + Binding(key=u"c", value=u"d", original=Original(string=u"c=d", line=2)), ], ), ( 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"), + Binding(key=u"a", value=u'', original=Original(string=u'a=\n', line=1)), + Binding(key=u"b", value=u'c', original=Original(string=u"b=c", line=2)), ] ), ( 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"), + Binding(key=u"a", value=u"b", original=Original(string=u"a=b\n", line=1)), + Binding(key=u"c", value=u"d", original=Original(string=u"\nc=d", line=2)), ] ), ( u'a="\nb=c', [ - Binding(key=None, value=None, original=u'a="\n'), - Binding(key=u"b", value=u"c", original=u"b=c"), + Binding(key=None, value=None, original=Original(string=u'a="\n', line=1)), + Binding(key=u"b", value=u"c", original=Original(string=u"b=c", line=2)), ] ), ( 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"), + Binding(key=None, value=None, original=Original(string=u"# comment\n", line=1)), + Binding(key=u"a", value=u"b\nc", original=Original(string=u'a="b\nc"\n', line=2)), + Binding(key=u"d", value=u"e", original=Original(string=u"d=e\n", line=4)), ], ), ( u'garbage[%$#\na=b', [ - Binding(key=None, value=None, original=u"garbage[%$#\n"), - Binding(key=u"a", value=u"b", original=u'a=b'), + Binding(key=None, value=None, original=Original(string=u"garbage[%$#\n", line=1)), + Binding(key=u"a", value=u"b", original=Original(string=u'a=b', line=2)), ], ), ]) From 23509f2f8856305f18c3e6dd28c42623ba320ae1 Mon Sep 17 00:00:00 2001 From: ulyssessouza Date: Sat, 11 Jan 2020 17:02:23 +0100 Subject: [PATCH 022/253] Support no value keys Signed-off-by: ulyssessouza --- src/dotenv/main.py | 19 ++++++++++--------- src/dotenv/parser.py | 6 +++--- tests/test_parser.py | 8 +++++--- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 6b7f947d..5a1c3250 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -50,7 +50,7 @@ class DotEnv(): 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._dict = None # type: Optional[Dict[Text, Optional[Text]]] self.verbose = verbose # type: bool self.encoding = encoding # type: Union[None, Text] @@ -68,7 +68,7 @@ def _get_stream(self): yield StringIO('') def dict(self): - # type: () -> Dict[Text, Text] + # type: () -> Dict[Text, Optional[Text]] """Return dotenv as dict""" if self._dict: return self._dict @@ -78,10 +78,10 @@ def dict(self): return self._dict def parse(self): - # type: () -> Iterator[Tuple[Text, Text]] + # type: () -> Iterator[Tuple[Text, Optional[Text]]] with self._get_stream() as stream: for mapping in with_warn_for_invalid_lines(parse_stream(stream)): - if mapping.key is not None and mapping.value is not None: + if mapping.key is not None: yield mapping.key, mapping.value def set_as_environment_variables(self, override=False): @@ -92,7 +92,8 @@ def set_as_environment_variables(self, override=False): for k, v in self.dict().items(): if k in os.environ and not override: continue - os.environ[to_env(k)] = to_env(v) + if v is not None: + os.environ[to_env(k)] = to_env(v) return True @@ -197,7 +198,7 @@ def unset_key(dotenv_path, key_to_unset, quote_mode="always"): def resolve_nested_variables(values): - # type: (Dict[Text, Text]) -> Dict[Text, Text] + # type: (Dict[Text, Optional[Text]]) -> Dict[Text, Optional[Text]] def _replacement(name): # type: (Text) -> Text """ @@ -206,7 +207,7 @@ def _replacement(name): then look into the dotenv variables """ ret = os.getenv(name, new_values.get(name, "")) - return ret + return ret # type: ignore def _re_sub_callback(match_object): # type: (Match[Text]) -> Text @@ -219,7 +220,7 @@ def _re_sub_callback(match_object): new_values = {} for k, v in values.items(): - new_values[k] = __posix_variable.sub(_re_sub_callback, v) + new_values[k] = __posix_variable.sub(_re_sub_callback, v) if v is not None else None return new_values @@ -301,6 +302,6 @@ def load_dotenv(dotenv_path=None, stream=None, verbose=False, override=False, ** 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] + # type: (Union[Text, _PathLike, None], Optional[_StringIO], bool, Union[None, Text]) -> Dict[Text, Optional[Text]] f = dotenv_path or stream or find_dotenv() return DotEnv(f, verbose=verbose, **kwargs).dict() diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index dc8fe008..bd60cab4 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -20,7 +20,7 @@ def make_regex(string, extra_flags=0): _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]*") +_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]*)") @@ -194,8 +194,8 @@ def parse_binding(reader): reader.read_regex(_whitespace) reader.read_regex(_export) key = parse_key(reader) - reader.read_regex(_equal_sign) - value = parse_value(reader) + (sign,) = reader.read_regex(_equal_sign) + value = parse_value(reader) if sign else None reader.read_regex(_comment) reader.read_regex(_end_of_line) return Binding( diff --git a/tests/test_parser.py b/tests/test_parser.py index aac19302..c19ed502 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -35,7 +35,7 @@ (u"a='b\\'c'", [Binding(key=u"a", value=u"b'c", original=Original(string=u"a='b\\'c'", line=1))]), (u"a=à", [Binding(key=u"a", value=u"à", original=Original(string=u"a=à", line=1))]), (u'a="à"', [Binding(key=u"a", value=u"à", original=Original(string=u'a="à"', line=1))]), - (u'garbage', [Binding(key=None, value=None, original=Original(string=u"garbage", line=1))]), + (u'no_value_var', [Binding(key=u'no_value_var', value=None, original=Original(string=u"no_value_var", line=1))]), ( u"a=b\nc=d", [ @@ -87,9 +87,11 @@ ], ), ( - u'garbage[%$#\na=b', + u'uglyKey[%$=\"S3cr3t_P4ssw#rD\" #\na=b', [ - Binding(key=None, value=None, original=Original(string=u"garbage[%$#\n", line=1)), + Binding(key=u'uglyKey[%$', + value=u'S3cr3t_P4ssw#rD', + original=Original(string=u"uglyKey[%$=\"S3cr3t_P4ssw#rD\" #\n", line=1)), Binding(key=u"a", value=u"b", original=Original(string=u'a=b', line=2)), ], ), From c00ccbb7ebdd0715717d13bf8b1685ae62d1e2f6 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Tue, 14 Jan 2020 18:09:57 +0100 Subject: [PATCH 023/253] Remove Python 3.4 in .travis (EOL mid 2019) Signed-off-by: Ulysses Souza --- .travis.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index c8f56f51..5588c7ff 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,10 +12,6 @@ matrix: - python: "2.7" env: TOXENV=py27 - - python: "3.4" - env: TOXENV=py34 - - python: "3.4" - env: TOXENV=py34-no-typing - python: "3.5" env: TOXENV=py35 - python: "3.6" From c5b47f42f9b21be3cae1545bc4c758b30eb4b3dc Mon Sep 17 00:00:00 2001 From: Tim Hughes Date: Thu, 16 Jan 2020 01:44:25 +0000 Subject: [PATCH 024/253] Clarify env var substitution format (#221) --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a55051d3..1ffa855d 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,9 @@ export SECRET_KEY=YOURSECRETKEYGOESHERE `.env` can interpolate variables using POSIX variable expansion, variables are replaced from the environment first or from other values -in the `.env` file if the variable is not present in the environment. +in the `.env` file if the variable is not present in the environment. +Ensure that variables are surrounded with `{}` like `${HOME}` as bare +variables such as `$HOME` are not expanded. (**Note**: Default Value Expansion is not supported as of yet, see [\#30](https://github.com/theskumar/python-dotenv/pull/30#issuecomment-244036604).) From ae1f1a906feddb23f4fa05c968c52df99c487ead Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Fri, 17 Jan 2020 21:07:27 +0100 Subject: [PATCH 025/253] =?UTF-8?q?Bump=20version:=200.10.3=20=E2=86=92=20?= =?UTF-8?q?0.10.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ setup.cfg | 2 +- src/dotenv/version.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a7eb6ed..93331a29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,13 @@ Latest - ... +0.10.4 +----- + +- Make typing optional ([@techalchemy])([#179]). +- Print a warning on malformed line ([@bbc2])([#211]). +- Support keys without a value ([@ulyssessouza])([#220]). + 0.10.3 ----- diff --git a/setup.cfg b/setup.cfg index ecd4df52..4f9b7717 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.10.3 +current_version = 0.10.4 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index b2385cb4..d9b054ab 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.10.3" +__version__ = "0.10.4" From c68e78fe8dddbe7453f5e14251097f7d0be3beee Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Fri, 17 Jan 2020 21:45:29 +0100 Subject: [PATCH 026/253] Fix handling of malformed lines --- CHANGELOG.md | 4 +++- src/dotenv/main.py | 2 +- src/dotenv/parser.py | 17 +++++++++++------ tests/test_parser.py | 1 + 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93331a29..3b3a4290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,9 @@ Changelog Latest ----- -- ... +- Fix handling of malformed lines and lines without a value: + - Don't print warning when key has no value. + - Reject more malformed lines (e.g. "A: B"). 0.10.4 ----- diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 5a1c3250..7051cd30 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -37,7 +37,7 @@ def with_warn_for_invalid_lines(mappings): # type: (Iterator[Binding]) -> Iterator[Binding] for mapping in mappings: - if mapping.key is None or mapping.value is None: + if mapping.key is None: logger.warning( "Python-dotenv could not parse statement starting at line %s", mapping.original.line, diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index bd60cab4..516bacc7 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -16,16 +16,17 @@ def make_regex(string, extra_flags=0): _newline = make_regex(r"(\r\n|\n|\r)") -_whitespace = make_regex(r"\s*", extra_flags=re.MULTILINE) +_multiline_whitespace = make_regex(r"\s*", extra_flags=re.MULTILINE) +_whitespace = make_regex(r"[^\S\r\n]*") _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]*)?") +_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]*)") _comment = make_regex(r"(?:\s*#[^\r\n]*)?") -_end_of_line = make_regex(r"[^\S\r\n]*(?:\r\n|\n|\r)?") +_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"\\[\\']") @@ -191,11 +192,15 @@ def parse_binding(reader): # type: (Reader) -> Binding reader.set_mark() try: - reader.read_regex(_whitespace) + reader.read_regex(_multiline_whitespace) reader.read_regex(_export) key = parse_key(reader) - (sign,) = reader.read_regex(_equal_sign) - value = parse_value(reader) if sign else None + reader.read_regex(_whitespace) + if reader.peek(1) == "=": + reader.read_regex(_equal_sign) + value = parse_value(reader) # type: Optional[Text] + else: + value = None reader.read_regex(_comment) reader.read_regex(_end_of_line) return Binding( diff --git a/tests/test_parser.py b/tests/test_parser.py index c19ed502..837717de 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -36,6 +36,7 @@ (u"a=à", [Binding(key=u"a", value=u"à", original=Original(string=u"a=à", line=1))]), (u'a="à"', [Binding(key=u"a", value=u"à", original=Original(string=u'a="à"', line=1))]), (u'no_value_var', [Binding(key=u'no_value_var', value=None, original=Original(string=u"no_value_var", line=1))]), + (u'a: b', [Binding(key=None, value=None, original=Original(string=u"a: b", line=1))]), ( u"a=b\nc=d", [ From 70d0c19cfae688dc333f753f880a8062b43b9333 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Fri, 17 Jan 2020 21:45:29 +0100 Subject: [PATCH 027/253] Fix handling of lines with just a comment A .env file like the following would trigger a warning: # comment The problem is that with the previous `Binding` structure, it was impossible to distinguish the parsing of such a line and that of a malformed line. Therefore, this commit adds an `error` field so that error reporting works correctly in this case. --- CHANGELOG.md | 1 + src/dotenv/main.py | 2 +- src/dotenv/parser.py | 12 +++-- tests/test_parser.py | 119 ++++++++++++++++++++++++++++--------------- 4 files changed, 88 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b3a4290..78b65cc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Latest - Fix handling of malformed lines and lines without a value: - Don't print warning when key has no value. - Reject more malformed lines (e.g. "A: B"). +- Fix handling of lines with just a comment. 0.10.4 ----- diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 7051cd30..3bf4a2c5 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -37,7 +37,7 @@ def with_warn_for_invalid_lines(mappings): # type: (Iterator[Binding]) -> Iterator[Binding] for mapping in mappings: - if mapping.key is None: + if mapping.error: logger.warning( "Python-dotenv could not parse statement starting at line %s", mapping.original.line, diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index 516bacc7..2904af86 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -25,7 +25,7 @@ def make_regex(string, extra_flags=0): _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]*)?") +_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)?") _double_quote_escapes = make_regex(r"\\[\\'\"abfnrtv]") @@ -52,6 +52,7 @@ def make_regex(string, extra_flags=0): ("key", typing.Optional[typing.Text]), ("value", typing.Optional[typing.Text]), ("original", Original), + ("error", bool), ], ) except ImportError: @@ -69,6 +70,7 @@ def make_regex(string, extra_flags=0): "key", "value", "original", + "error", ], ) @@ -152,9 +154,11 @@ def decode_match(match): def parse_key(reader): - # type: (Reader) -> Text + # type: (Reader) -> Optional[Text] char = reader.peek(1) - if char == "'": + if char == "#": + return None + elif char == "'": (key,) = reader.read_regex(_single_quoted_key) else: (key,) = reader.read_regex(_unquoted_key) @@ -207,6 +211,7 @@ def parse_binding(reader): key=key, value=value, original=reader.get_marked(), + error=False, ) except Error: reader.read_regex(_rest_of_line) @@ -214,6 +219,7 @@ def parse_binding(reader): key=None, value=None, original=reader.get_marked(), + error=True, ) diff --git a/tests/test_parser.py b/tests/test_parser.py index 837717de..dae51d32 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -7,84 +7,119 @@ @pytest.mark.parametrize("test_input,expected", [ (u"", []), - (u"a=b", [Binding(key=u"a", value=u"b", original=Original(string=u"a=b", line=1))]), - (u"'a'=b", [Binding(key=u"a", value=u"b", original=Original(string=u"'a'=b", line=1))]), - (u"[=b", [Binding(key=u"[", value=u"b", original=Original(string=u"[=b", line=1))]), - (u" a = b ", [Binding(key=u"a", value=u"b", original=Original(string=u" a = b ", line=1))]), - (u"export a=b", [Binding(key=u"a", value=u"b", original=Original(string=u"export a=b", line=1))]), - (u" export 'a'=b", [Binding(key=u"a", value=u"b", original=Original(string=u" export 'a'=b", line=1))]), - (u"# a=b", [Binding(key=None, value=None, original=Original(string=u"# a=b", line=1))]), - (u"a=b#c", [Binding(key=u"a", value=u"b#c", original=Original(string=u"a=b#c", line=1))]), - (u'a=b # comment', [Binding(key=u"a", value=u"b", original=Original(string=u"a=b # comment", line=1))]), - (u"a=b space ", [Binding(key=u"a", value=u"b space", original=Original(string=u"a=b space ", line=1))]), - (u"a='b space '", [Binding(key=u"a", value=u"b space ", original=Original(string=u"a='b space '", line=1))]), - (u'a="b space "', [Binding(key=u"a", value=u"b space ", original=Original(string=u'a="b space "', line=1))]), + (u"a=b", [Binding(key=u"a", value=u"b", original=Original(string=u"a=b", line=1), error=False)]), + (u"'a'=b", [Binding(key=u"a", value=u"b", original=Original(string=u"'a'=b", line=1), error=False)]), + (u"[=b", [Binding(key=u"[", value=u"b", original=Original(string=u"[=b", line=1), error=False)]), + (u" a = b ", [Binding(key=u"a", value=u"b", original=Original(string=u" a = b ", line=1), error=False)]), + (u"export a=b", [Binding(key=u"a", value=u"b", original=Original(string=u"export a=b", line=1), error=False)]), + ( + u" export 'a'=b", + [Binding(key=u"a", value=u"b", original=Original(string=u" export 'a'=b", line=1), error=False)], + ), + (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 space ", + [Binding(key=u"a", value=u"b space", original=Original(string=u"a=b space ", 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 space "', + [Binding(key=u"a", value=u"b space ", original=Original(string=u'a="b space "', line=1), error=False)], + ), ( u"export export_a=1", [ - Binding(key=u"export_a", value=u"1", original=Original(string=u"export export_a=1", line=1)) + Binding(key=u"export_a", value=u"1", original=Original(string=u"export export_a=1", line=1), error=False) ], ), - (u"export port=8000", [Binding(key=u"port", value=u"8000", original=Original(string=u"export port=8000", line=1))]), - (u'a="b\nc"', [Binding(key=u"a", value=u"b\nc", original=Original(string=u'a="b\nc"', line=1))]), - (u"a='b\nc'", [Binding(key=u"a", value=u"b\nc", original=Original(string=u"a='b\nc'", line=1))]), - (u'a="b\nc"', [Binding(key=u"a", value=u"b\nc", original=Original(string=u'a="b\nc"', line=1))]), - (u'a="b\\nc"', [Binding(key=u"a", value=u'b\nc', original=Original(string=u'a="b\\nc"', line=1))]), - (u"a='b\\nc'", [Binding(key=u"a", value=u'b\\nc', original=Original(string=u"a='b\\nc'", line=1))]), - (u'a="b\\"c"', [Binding(key=u"a", value=u'b"c', original=Original(string=u'a="b\\"c"', line=1))]), - (u"a='b\\'c'", [Binding(key=u"a", value=u"b'c", original=Original(string=u"a='b\\'c'", line=1))]), - (u"a=à", [Binding(key=u"a", value=u"à", original=Original(string=u"a=à", line=1))]), - (u'a="à"', [Binding(key=u"a", value=u"à", original=Original(string=u'a="à"', line=1))]), - (u'no_value_var', [Binding(key=u'no_value_var', value=None, original=Original(string=u"no_value_var", line=1))]), - (u'a: b', [Binding(key=None, value=None, original=Original(string=u"a: b", line=1))]), + ( + u"export port=8000", + [Binding(key=u"port", value=u"8000", original=Original(string=u"export port=8000", line=1), error=False)], + ), + (u'a="b\nc"', [Binding(key=u"a", value=u"b\nc", original=Original(string=u'a="b\nc"', line=1), error=False)]), + (u"a='b\nc'", [Binding(key=u"a", value=u"b\nc", original=Original(string=u"a='b\nc'", line=1), error=False)]), + (u'a="b\nc"', [Binding(key=u"a", value=u"b\nc", original=Original(string=u'a="b\nc"', line=1), error=False)]), + (u'a="b\\nc"', [Binding(key=u"a", value=u'b\nc', original=Original(string=u'a="b\\nc"', line=1), error=False)]), + (u"a='b\\nc'", [Binding(key=u"a", value=u'b\\nc', original=Original(string=u"a='b\\nc'", 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=à", [Binding(key=u"a", value=u"à", original=Original(string=u"a=à", line=1), error=False)]), + (u'a="à"', [Binding(key=u"a", value=u"à", original=Original(string=u'a="à"', line=1), error=False)]), + ( + u'no_value_var', + [Binding(key=u'no_value_var', value=None, original=Original(string=u"no_value_var", line=1), error=False)], + ), + (u'a: b', [Binding(key=None, value=None, original=Original(string=u"a: b", line=1), error=True)]), ( u"a=b\nc=d", [ - Binding(key=u"a", value=u"b", original=Original(string=u"a=b\n", line=1)), - Binding(key=u"c", value=u"d", original=Original(string=u"c=d", line=2)), + Binding(key=u"a", value=u"b", original=Original(string=u"a=b\n", line=1), error=False), + Binding(key=u"c", value=u"d", original=Original(string=u"c=d", line=2), error=False), ], ), ( u"a=b\rc=d", [ - Binding(key=u"a", value=u"b", original=Original(string=u"a=b\r", line=1)), - Binding(key=u"c", value=u"d", original=Original(string=u"c=d", line=2)), + Binding(key=u"a", value=u"b", original=Original(string=u"a=b\r", line=1), error=False), + Binding(key=u"c", value=u"d", original=Original(string=u"c=d", line=2), error=False), ], ), ( u"a=b\r\nc=d", [ - Binding(key=u"a", value=u"b", original=Original(string=u"a=b\r\n", line=1)), - Binding(key=u"c", value=u"d", original=Original(string=u"c=d", line=2)), + Binding(key=u"a", value=u"b", original=Original(string=u"a=b\r\n", line=1), error=False), + Binding(key=u"c", value=u"d", original=Original(string=u"c=d", line=2), error=False), ], ), ( u'a=\nb=c', [ - Binding(key=u"a", value=u'', original=Original(string=u'a=\n', line=1)), - Binding(key=u"b", value=u'c', original=Original(string=u"b=c", line=2)), + Binding(key=u"a", value=u'', original=Original(string=u'a=\n', line=1), error=False), + Binding(key=u"b", value=u'c', original=Original(string=u"b=c", line=2), error=False), ] ), ( u'a=b\n\nc=d', [ - Binding(key=u"a", value=u"b", original=Original(string=u"a=b\n", line=1)), - Binding(key=u"c", value=u"d", original=Original(string=u"\nc=d", line=2)), + Binding(key=u"a", value=u"b", original=Original(string=u"a=b\n", line=1), error=False), + Binding(key=u"c", value=u"d", original=Original(string=u"\nc=d", line=2), error=False), ] ), ( u'a="\nb=c', [ - Binding(key=None, value=None, original=Original(string=u'a="\n', line=1)), - Binding(key=u"b", value=u"c", original=Original(string=u"b=c", line=2)), + Binding(key=None, value=None, original=Original(string=u'a="\n', line=1), error=True), + Binding(key=u"b", value=u"c", original=Original(string=u"b=c", line=2), error=False), ] ), ( u'# comment\na="b\nc"\nd=e\n', [ - Binding(key=None, value=None, original=Original(string=u"# comment\n", line=1)), - Binding(key=u"a", value=u"b\nc", original=Original(string=u'a="b\nc"\n', line=2)), - Binding(key=u"d", value=u"e", original=Original(string=u"d=e\n", line=4)), + Binding(key=None, value=None, original=Original(string=u"# comment\n", line=1), error=False), + Binding(key=u"a", value=u"b\nc", original=Original(string=u'a="b\nc"\n', line=2), error=False), + Binding(key=u"d", value=u"e", original=Original(string=u"d=e\n", line=4), error=False), + ], + ), + ( + u'a=b\n# comment 1', + [ + Binding(key="a", value="b", original=Original(string=u"a=b\n", line=1), error=False), + Binding(key=None, value=None, original=Original(string=u"# comment 1", line=2), error=False), + ], + ), + ( + u'# comment 1\n# comment 2', + [ + Binding(key=None, value=None, original=Original(string=u"# comment 1\n", line=1), error=False), + Binding(key=None, value=None, original=Original(string=u"# comment 2", line=2), error=False), ], ), ( @@ -92,8 +127,8 @@ [ Binding(key=u'uglyKey[%$', value=u'S3cr3t_P4ssw#rD', - original=Original(string=u"uglyKey[%$=\"S3cr3t_P4ssw#rD\" #\n", line=1)), - Binding(key=u"a", value=u"b", original=Original(string=u'a=b', line=2)), + original=Original(string=u"uglyKey[%$=\"S3cr3t_P4ssw#rD\" #\n", line=1), error=False), + Binding(key=u"a", value=u"b", original=Original(string=u'a=b', line=2), error=False), ], ), ]) From 3637b3e114b9a3084457594ccfa3cb3e6d3d4ee4 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 19 Jan 2020 17:42:04 +0100 Subject: [PATCH 028/253] =?UTF-8?q?Bump=20version:=200.10.4=20=E2=86=92=20?= =?UTF-8?q?0.10.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 +++--- setup.cfg | 2 +- src/dotenv/version.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78b65cc2..25927fb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,13 +16,13 @@ OR Changelog ========= -Latest +0.10.5 ----- -- Fix handling of malformed lines and lines without a value: +- Fix handling of malformed lines and lines without a value ([@bbc2])([#222]): - Don't print warning when key has no value. - Reject more malformed lines (e.g. "A: B"). -- Fix handling of lines with just a comment. +- Fix handling of lines with just a comment ([@bbc2])([#224]). 0.10.4 ----- diff --git a/setup.cfg b/setup.cfg index 4f9b7717..7a143ab5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.10.4 +current_version = 0.10.5 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index d9b054ab..a67aac09 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.10.4" +__version__ = "0.10.5" From cbd017a50fefed910b0075d433456cf7479f9882 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Sun, 19 Jan 2020 23:10:50 -0500 Subject: [PATCH 029/253] Set UTF-8 encoding when reading files in setup.py https://github.com/altendky/pyqt5-tools/issues/38 --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 09f7c05a..77cc7855 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ def read_files(files): data = [] for file in files: - with open(file) as f: + with open(file, encoding='utf-8') as f: data.append(f.read()) return "\n".join(data) @@ -13,7 +13,7 @@ def read_files(files): long_description = read_files(['README.md', 'CHANGELOG.md']) meta = {} -with open('./src/dotenv/version.py') as f: +with open('./src/dotenv/version.py', encoding='utf-8') as f: exec(f.read(), meta) setup( From aeaec2619d48b0636d34dc2d2dbead22406c82ae Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Mon, 20 Jan 2020 09:54:55 -0500 Subject: [PATCH 030/253] Use io.open for py2 compatibility --- setup.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 77cc7855..82b425bf 100644 --- a/setup.py +++ b/setup.py @@ -1,11 +1,12 @@ # -*- coding: utf-8 -*- +import io from setuptools import setup def read_files(files): data = [] for file in files: - with open(file, encoding='utf-8') as f: + with io.open(file, encoding='utf-8') as f: data.append(f.read()) return "\n".join(data) @@ -13,7 +14,7 @@ def read_files(files): long_description = read_files(['README.md', 'CHANGELOG.md']) meta = {} -with open('./src/dotenv/version.py', encoding='utf-8') as f: +with io.open('./src/dotenv/version.py', encoding='utf-8') as f: exec(f.read(), meta) setup( From 72d1db53f5528031802113e3552c6b75d953526d Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Tue, 21 Jan 2020 19:33:06 +0100 Subject: [PATCH 031/253] Fix upload to PyPI by Travis CI * Add `bdist_wheel` to the uploaded distributions. This avoids the need for pip to run `setup.py` when installing the package, speeding up installation and avoiding potential environment issues. * `skip_existing` avoids CI failures due to "deploy" tasks being run several times by Travis for one tag pushed. --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index 5588c7ff..8419000f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -43,6 +43,8 @@ deploy: user: theskumar password: secure: DXUkl4YSC2RCltChik1csvQulnVMQQpD/4i4u+6pEyUfBMYP65zFYSNwLh+jt+URyX+MpN/Er20+TZ/F/fu7xkru6/KBqKLugeXihNbwGhbHUIkjZT/0dNSo03uAz6s5fWgqr8EJk9Ll71GexAsBPx2yqsjc2BMgOjwcNly40Co= + distributions: "sdist bdist_wheel" + skip_existing: true on: tags: true repo: theskumar/python-dotenv From 4e68b7f689489c5642908fd7d79f08102d485f10 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Tue, 21 Jan 2020 19:42:51 +0100 Subject: [PATCH 032/253] Add CI for Python 3.8 --- .travis.yml | 2 ++ setup.py | 1 + tox.ini | 5 +++-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 8419000f..61d6fdaf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,6 +18,8 @@ matrix: env: TOXENV=py36 - python: "3.7" env: TOXENV=py37 + - python: "3.8" + env: TOXENV=py38 - python: "pypy" env: TOXENV=pypy dist: trusty diff --git a/setup.py b/setup.py index 82b425bf..08451a1a 100644 --- a/setup.py +++ b/setup.py @@ -68,6 +68,7 @@ def read_files(files): 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: Implementation :: PyPy', 'Intended Audience :: Developers', 'Intended Audience :: System Administrators', diff --git a/tox.ini b/tox.ini index 2bdc288f..f48149ca 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = lint,py{27,34,35,36,37,34-no-typing},pypy,pypy3,manifest,coverage-report +envlist = lint,py{27,34,35,36,37,38,34-no-typing},pypy,pypy3,manifest,coverage-report [testenv] deps = @@ -9,7 +9,7 @@ deps = click py{27,py}: ipython<6.0.0 py34{,-no-typing}: ipython<7.0.0 - py{35,36,37,py3}: ipython + py{35,36,37,38,py3}: ipython commands = coverage run --parallel -m pytest {posargs} [testenv:py34-no-typing] @@ -24,6 +24,7 @@ deps = mypy commands = flake8 src tests + mypy --python-version=3.8 src tests mypy --python-version=3.7 src tests mypy --python-version=3.6 src tests mypy --python-version=3.5 src tests From 558d435aa580c28dff3408d2868b0918cebcd84e Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Tue, 21 Jan 2020 19:48:08 +0100 Subject: [PATCH 033/253] Fix PyPI classifiers * Remove Python 3.4. * Add Python 2 and 3. --- setup.py | 26 ++------------------------ 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/setup.py b/setup.py index 08451a1a..db69a9ce 100644 --- a/setup.py +++ b/setup.py @@ -44,27 +44,11 @@ def read_files(files): dotenv=dotenv.cli:cli ''', classifiers=[ - # As from https://pypi.python.org/pypi?%3Aaction=list_classifiers - # 'Development Status :: 1 - Planning', - # 'Development Status :: 2 - Pre-Alpha', - # 'Development Status :: 3 - Alpha', - # 'Development Status :: 4 - Beta', 'Development Status :: 5 - Production/Stable', - # 'Development Status :: 6 - Mature', - # 'Development Status :: 7 - Inactive', 'Programming Language :: Python', - # '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', '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.4', + 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', @@ -79,9 +63,3 @@ def read_files(files): 'Environment :: Web Environment', ] ) - -# (*) Please direct queries to the discussion group, rather than to me directly -# Doing so helps ensure your question is helpful to other users. -# Queries directly to my email are likely to receive a canned response. -# -# Many thanks for your understanding. From a8daa7c526631d8aab864f862775002191b5ab18 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 3 Nov 2019 16:59:16 +0100 Subject: [PATCH 034/253] Use logging instead of warnings Problems with the `warnings` module: * Undesirable output in tests, although it could be filtered out. * Output unsuited for users: shows source code of Python-dotenv. Users don't know what to do with that. * Rather meant for programming issues (e.g. deprecation) rather than runtime issues (file not found). Problems with the `logging` module: * Slightly less easy to test, unlike warnings with `catch_warnings(record=True)`, because you need to use the `mock` package for compatibility with Python 2. Despite this last issue, I think it makes more sense to use the `logging` module as a base for warnings. Of course, `logging` is not suited to all forms of output, so this change doesn't mean `logging` has to be used everywhere. For instance, if we want to improve the output of the `dotenv` CLI later, we might want to use `print`, `click.echo` or other printing facilities. --- requirements.txt | 1 + src/dotenv/main.py | 11 +++++------ tests/test_core.py | 11 ++++++----- tox.ini | 3 ++- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/requirements.txt b/requirements.txt index f828b576..e5e4de12 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ typing; python_version<"3.5" click flake8>=2.2.3 ipython +mock pytest-cov pytest>=3.9 sh>=1.09 diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 3bf4a2c5..d6be1bab 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -8,7 +8,6 @@ import shutil import sys import tempfile -import warnings from collections import OrderedDict from contextlib import contextmanager @@ -64,7 +63,7 @@ def _get_stream(self): yield stream else: if self.verbose: - warnings.warn("File doesn't exist {}".format(self.dotenv_path)) # type: ignore + logger.warning("File doesn't exist %s", self.dotenv_path) yield StringIO('') def dict(self): @@ -107,7 +106,7 @@ def get(self, key): return data[key] if self.verbose: - warnings.warn("key %s not found in %s." % (key, self.dotenv_path)) # type: ignore + logger.warning("Key %s not found in %s.", key, self.dotenv_path) return None @@ -147,7 +146,7 @@ def set_key(dotenv_path, key_to_set, value_to_set, quote_mode="always"): """ value_to_set = value_to_set.strip("'").strip('"') if not os.path.exists(dotenv_path): - warnings.warn("can't write to %s - it doesn't exist." % dotenv_path) # type: ignore + logger.warning("Can't write to %s - it doesn't exist.", dotenv_path) return None, key_to_set, value_to_set if " " in value_to_set: @@ -179,7 +178,7 @@ def unset_key(dotenv_path, key_to_unset, quote_mode="always"): If the given key doesn't exist in the .env, fails """ if not os.path.exists(dotenv_path): - warnings.warn("can't delete from %s - it doesn't exist." % dotenv_path) # type: ignore + logger.warning("Can't delete from %s - it doesn't exist.", dotenv_path) return None, key_to_unset removed = False @@ -191,7 +190,7 @@ def unset_key(dotenv_path, key_to_unset, quote_mode="always"): dest.write(mapping.original.string) if not removed: - warnings.warn("key %s not removed from %s - key doesn't exist." % (key_to_unset, dotenv_path)) # type: ignore + logger.warning("Key %s not removed from %s - key doesn't exist.", key_to_unset, dotenv_path) return None, key_to_unset return removed, key_to_unset diff --git a/tests/test_core.py b/tests/test_core.py index 80dd6dec..822ec393 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -5,8 +5,9 @@ import os import sys import textwrap -import warnings +import logging +import mock import pytest import sh @@ -25,12 +26,12 @@ def restore_os_environ(): def test_warns_if_file_does_not_exist(): - with warnings.catch_warnings(record=True) as w: + logger = logging.getLogger("dotenv.main") + + with mock.patch.object(logger, "warning") as mock_warning: load_dotenv('.does_not_exist', verbose=True) - assert len(w) == 1 - assert w[0].category is UserWarning - assert str(w[0].message) == "File doesn't exist .does_not_exist" + mock_warning.assert_called_once_with("File doesn't exist %s", ".does_not_exist") def test_find_dotenv(tmp_path): diff --git a/tox.ini b/tox.ini index f48149ca..2dd61864 100644 --- a/tox.ini +++ b/tox.ini @@ -2,7 +2,8 @@ envlist = lint,py{27,34,35,36,37,38,34-no-typing},pypy,pypy3,manifest,coverage-report [testenv] -deps = +deps = + mock pytest coverage sh From a02bfac154e8a296f87372a0959f674aa622ebac Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 29 Jan 2020 11:49:19 +0100 Subject: [PATCH 035/253] Add control over interpolation Signed-off-by: Ulysses Souza --- src/dotenv/main.py | 19 ++++++++++--------- tests/test_core.py | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index d6be1bab..c89a7070 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -46,12 +46,13 @@ def with_warn_for_invalid_lines(mappings): class DotEnv(): - def __init__(self, dotenv_path, verbose=False, encoding=None): - # type: (Union[Text, _PathLike, _StringIO], bool, Union[None, Text]) -> None + def __init__(self, dotenv_path, verbose=False, encoding=None, interpolate=True): + # type: (Union[Text, _PathLike, _StringIO], bool, Union[None, Text], bool) -> None self.dotenv_path = dotenv_path # type: Union[Text,_PathLike, _StringIO] self._dict = None # type: Optional[Dict[Text, Optional[Text]]] self.verbose = verbose # type: bool self.encoding = encoding # type: Union[None, Text] + self.interpolate = interpolate # type: bool @contextmanager def _get_stream(self): @@ -73,7 +74,7 @@ def dict(self): return self._dict values = OrderedDict(self.parse()) - self._dict = resolve_nested_variables(values) + self._dict = resolve_nested_variables(values) if self.interpolate else values return self._dict def parse(self): @@ -286,8 +287,8 @@ def _is_interactive(): return '' -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 +def load_dotenv(dotenv_path=None, stream=None, verbose=False, override=False, interpolate=True, **kwargs): + # type: (Union[Text, _PathLike, None], Optional[_StringIO], bool, bool, bool, Union[None, Text]) -> bool """Parse a .env file and then load all the variables found as environment variables. - *dotenv_path*: absolute or relative path to .env file. @@ -297,10 +298,10 @@ def load_dotenv(dotenv_path=None, stream=None, verbose=False, override=False, ** Defaults to `False`. """ f = dotenv_path or stream or find_dotenv() - return DotEnv(f, verbose=verbose, **kwargs).set_as_environment_variables(override=override) + return DotEnv(f, verbose=verbose, interpolate=interpolate, **kwargs).set_as_environment_variables(override=override) -def dotenv_values(dotenv_path=None, stream=None, verbose=False, **kwargs): - # type: (Union[Text, _PathLike, None], Optional[_StringIO], bool, Union[None, Text]) -> Dict[Text, Optional[Text]] +def dotenv_values(dotenv_path=None, stream=None, verbose=False, interpolate=True, **kwargs): + # type: (Union[Text, _PathLike, None], Optional[_StringIO], bool, bool, Union[None, Text]) -> Dict[Text, Optional[Text]] # noqa: E501 f = dotenv_path or stream or find_dotenv() - return DotEnv(f, verbose=verbose, **kwargs).dict() + return DotEnv(f, verbose=verbose, interpolate=interpolate, **kwargs).dict() diff --git a/tests/test_core.py b/tests/test_core.py index 822ec393..11500a1b 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -147,6 +147,22 @@ def test_dotenv_values_stream(): assert parsed_dict['DOTENV'] == u'it works!😃' +def test_dotenv_values_no_interpolate(): + stream = StringIO(u'no_interpolate=$MYVAR') + stream.seek(0) + parsed_dict = dotenv_values(stream=stream, interpolate=False) + assert 'no_interpolate' in parsed_dict + assert parsed_dict['no_interpolate'] == u'$MYVAR' + + +def test_dotenv_values_no_interpolate_strict(): + stream = StringIO(u'no_interpolate_strict=${MYVAR}') + stream.seek(0) + parsed_dict = dotenv_values(stream=stream, interpolate=False) + assert 'no_interpolate_strict' in parsed_dict + assert parsed_dict['no_interpolate_strict'] == u'${MYVAR}' + + def test_dotenv_values_export(): stream = StringIO('export foo=bar\n') stream.seek(0) From d67735509fb38545be9449201cf26ae9fd10e69a Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Fri, 7 Feb 2020 20:42:00 +0100 Subject: [PATCH 036/253] Release v0.11.0 --- .travis.yml | 7 +-- CHANGELOG.md | 128 +++++++++++++++++++++--------------------- setup.cfg | 2 +- src/dotenv/version.py | 2 +- 4 files changed, 70 insertions(+), 69 deletions(-) diff --git a/.travis.yml b/.travis.yml index 61d6fdaf..4b1f886e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,15 +1,14 @@ language: python cache: pip -sudo: false +os: linux dist: xenial -matrix: +jobs: include: - python: "3.6" env: TOXENV=lint - python: "3.6" env: TOXENV=manifest - - python: "2.7" env: TOXENV=py27 - python: "3.5" @@ -42,7 +41,7 @@ after_success: deploy: provider: pypi - user: theskumar + username: theskumar password: secure: DXUkl4YSC2RCltChik1csvQulnVMQQpD/4i4u+6pEyUfBMYP65zFYSNwLh+jt+URyX+MpN/Er20+TZ/F/fu7xkru6/KBqKLugeXihNbwGhbHUIkjZT/0dNSo03uAz6s5fWgqr8EJk9Ll71GexAsBPx2yqsjc2BMgOjwcNly40Co= distributions: "sdist bdist_wheel" diff --git a/CHANGELOG.md b/CHANGELOG.md index 25927fb9..08fd03a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,46 +1,55 @@ -# Install the latest +# Changelog -To install the latest version of `python-dotenv` simply run: +All notable changes to this project will be documented in this file. -`pip install python-dotenv` +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this +project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -OR +## [Unreleased] -`poetry add python-dotenv` +No unreleased change at the moment. -OR +## [0.11.0] - 2020-02-07 -`pipenv install python-dotenv` +### Added +- Add `interpolate` argument to `load_dotenv` and `dotenv_values` to disable interpolation + (#232 by [@ulyssessouza]). -Changelog -========= +### Changed -0.10.5 ------ +- Use logging instead of warnings (#231 by [@bbc2]). -- Fix handling of malformed lines and lines without a value ([@bbc2])([#222]): +### Fixed + +- Fix installation in non-UTF-8 environments (#225 by [@altendky]). +- Fix PyPI classifiers (#228 by [@bbc2]). + +## [0.10.5] - 2020-01-19 + +### Fixed + +- Fix handling of malformed lines and lines without a value (#222 by [@bbc2]): - Don't print warning when key has no value. - - Reject more malformed lines (e.g. "A: B"). -- Fix handling of lines with just a comment ([@bbc2])([#224]). + - Reject more malformed lines (e.g. "A: B", "a='b',c"). +- Fix handling of lines with just a comment (#224 by [@bbc2]). -0.10.4 ------ +## [0.10.4] - 2020-01-17 -- Make typing optional ([@techalchemy])([#179]). -- Print a warning on malformed line ([@bbc2])([#211]). -- Support keys without a value ([@ulyssessouza])([#220]). +### Added -0.10.3 ------ +- Make typing optional (#179 by [@techalchemy]). +- Print a warning on malformed line (#211 by [@bbc2]). +- Support keys without a value (#220 by [@ulyssessouza]). + +## 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 ------ +## 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` @@ -48,12 +57,10 @@ Changelog - 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 ------ +## 0.10.1 - Fix parsing of variable without a value ([@asyncee])([@bbc2])([#158]) -0.10.0 ------ +## 0.10.0 - Add support for UTF-8 in unquoted values ([@bbc2])([#148]) - Add support for trailing comments ([@bbc2])([#148]) @@ -64,20 +71,18 @@ Changelog - Fix stderr/-out/-in redirection ([@venthur]) -0.9.0 ------ +## 0.9.0 + - Add `--version` parameter to cli ([@venthur]) - Enable loading from current directory ([@cjauvin]) - Add 'dotenv run' command for calling arbitrary shell script with .env ([@venthur]) -0.8.1 ------ +## 0.8.1 - Add tests for docs ([@Flimm]) - Make 'cli' support optional. Use `pip install python-dotenv[cli]`. ([@theskumar]) -0.8.0 ------ +## 0.8.0 - `set_key` and `unset_key` only modified the affected file instead of parsing and re-writing file, this causes comments and other file @@ -86,13 +91,11 @@ Changelog - Internal refractoring ([@theskumar]) - Allow `load_dotenv` and `dotenv_values` to work with `StringIO())` ([@alanjds])([@theskumar])([#78]) -0.7.1 ------ +## 0.7.1 - Remove hard dependency on iPython ([@theskumar]) -0.7.0 ------ +## 0.7.0 - Add support to override system environment variable via .env. ([@milonimrod](https://github.com/milonimrod)) @@ -101,34 +104,29 @@ Changelog ([@maxkoryukov](https://github.com/maxkoryukov)) ([\#57](https://github.com/theskumar/python-dotenv/issues/57)) -0.6.5 ------ +## 0.6.5 - Add support for special characters `\`. ([@pjona](https://github.com/pjona)) ([\#60](https://github.com/theskumar/python-dotenv/issues/60)) -0.6.4 ------ +## 0.6.4 - Fix issue with single quotes ([@Flimm]) ([\#52](https://github.com/theskumar/python-dotenv/issues/52)) -0.6.3 ------ +## 0.6.3 - Handle unicode exception in setup.py ([\#46](https://github.com/theskumar/python-dotenv/issues/46)) -0.6.2 ------ +## 0.6.2 - Fix dotenv list command ([@ticosax](https://github.com/ticosax)) - Add iPython Suport ([@tillahoffmann](https://github.com/tillahoffmann)) -0.6.0 ------ +## 0.6.0 - Drop support for Python 2.6 - Handle escaped charaters and newlines in quoted values. (Thanks @@ -138,44 +136,48 @@ Changelog - Added POSIX variable expansion. (Thanks [@hugochinchilla](https://github.com/hugochinchilla)) -0.5.1 ------ +## 0.5.1 - Fix find\_dotenv - it now start search from the file where this function is called from. -0.5.0 ------ +## 0.5.0 - Add `find_dotenv` method that will try to find a `.env` file. (Thanks [@isms](https://github.com/isms)) -0.4.0 ------ +## 0.4.0 - cli: Added `-q/--quote` option to control the behaviour of quotes around values in `.env`. (Thanks [@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 +[#121]: https://github.com/theskumar/python-dotenv/issues/121 [#148]: https://github.com/theskumar/python-dotenv/issues/148 [#158]: https://github.com/theskumar/python-dotenv/issues/158 +[#170]: https://github.com/theskumar/python-dotenv/issues/170 [#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 [@Flimm]: https://github.com/Flimm -[@theskumar]: https://github.com/theskumar [@alanjds]: https://github.com/alanjds -[@cjauvin]: https://github.com/cjauvin +[@altendky]: https://github.com/altendky +[@andrewsmith]: https://github.com/andrewsmith +[@asyncee]: https://github.com/asyncee [@bbc2]: https://github.com/bbc2 -[@qnighy]: https://github.com/qnighy +[@cjauvin]: https://github.com/cjauvin [@earlbread]: https://github.com/earlbread +[@greyli]: https://github.com/greyli +[@qnighy]: https://github.com/qnighy +[@techalchemy]: https://github.com/techalchemy +[@theskumar]: https://github.com/theskumar +[@ulyssessouza]: https://github.com/ulyssessouza +[@venthur]: https://github.com/venthur + +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.11.0...HEAD +[0.11.0]: https://github.com/theskumar/python-dotenv/compare/v0.10.5...v0.11.0 +[0.10.5]: https://github.com/theskumar/python-dotenv/compare/v0.10.4...v0.10.5 +[0.10.4]: https://github.com/theskumar/python-dotenv/compare/v0.10.3...v0.10.4 diff --git a/setup.cfg b/setup.cfg index 7a143ab5..b10affd5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.10.5 +current_version = 0.11.0 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index a67aac09..ae6db5f1 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.10.5" +__version__ = "0.11.0" From a1bdf79da6b618f3e6a6fb1be4de8fea77e23708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Grzegorz=20Kraso=C5=84?= Date: Thu, 21 Nov 2019 16:39:02 +0100 Subject: [PATCH 037/253] make compatible with pyinstaller --- src/dotenv/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index c89a7070..ce83155d 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -257,7 +257,7 @@ def _is_interactive(): main = __import__('__main__', None, None, fromlist=['__file__']) return not hasattr(main, '__file__') - if usecwd or _is_interactive(): + if usecwd or _is_interactive() or getattr(sys, 'frozen', False): # Should work without __file__, e.g. in REPL or IPython notebook. path = os.getcwd() else: From 6b7c37ee1d578e944123dc5544637a4e0cba64dd Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Wed, 22 Jan 2020 23:04:15 +0100 Subject: [PATCH 038/253] Improve tests * Move tests to the right test file (e.g. tests for `dotenv.main.get_key` go to `tests.test_main.py`, not `tests.test_cli.py`. * Check logged warnings for more functions. For instance, that uncovered unexpected extra warnings when passing a non-existent file as argument. * Remove tests for properties already checked at a lower level. For instance, it's unnecessary to call `get_key` with weird file content because that's already tested with the parser. * Follow the Act/Arrange/Assert (AAA) pattern. This clarifies what is tested in each test function. For instance, using this, we test only one thing at a time, which makes the test easier to read and potential failures easier to interpret. --- tests/test_cli.py | 255 +++++++++--------------------- tests/test_core.py | 198 ------------------------ tests/test_ipython.py | 52 +++++++ tests/test_main.py | 350 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 472 insertions(+), 383 deletions(-) delete mode 100644 tests/test_core.py create mode 100644 tests/test_ipython.py create mode 100644 tests/test_main.py diff --git a/tests/test_cli.py b/tests/test_cli.py index 91f967a3..61c9d509 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,4 @@ # -*- coding: utf-8 -*- -import os - import pytest import sh @@ -9,143 +7,65 @@ from dotenv.version import __version__ -def test_get_key(dotenv_file): - success, key_to_set, value_to_set = dotenv.set_key(dotenv_file, 'HELLO', 'WORLD') - stored_value = dotenv.get_key(dotenv_file, 'HELLO') - assert stored_value == 'WORLD' - sh.rm(dotenv_file) - assert dotenv.get_key(dotenv_file, 'HELLO') is None - success, key_to_set, value_to_set = dotenv.set_key(dotenv_file, 'HELLO', 'WORLD') - assert success is None - - -def test_set_key(dotenv_file): - success, key_to_set, value_to_set = dotenv.set_key(dotenv_file, 'HELLO', 'WORLD') - success, key_to_set, value_to_set = dotenv.set_key(dotenv_file, 'foo', 'bar') - assert dotenv.get_key(dotenv_file, 'HELLO') == 'WORLD' - - with open(dotenv_file, 'r') as fp: - assert 'HELLO="WORLD"\nfoo="bar"' == fp.read().strip() - - success, key_to_set, value_to_set = dotenv.set_key(dotenv_file, 'HELLO', 'WORLD 2') - assert dotenv.get_key(dotenv_file, 'HELLO') == 'WORLD 2' - assert dotenv.get_key(dotenv_file, 'foo') == 'bar' - - with open(dotenv_file, 'r') as fp: - assert 'HELLO="WORLD 2"\nfoo="bar"' == fp.read().strip() - - success, key_to_set, value_to_set = dotenv.set_key(dotenv_file, "HELLO", "WORLD\n3") - - with open(dotenv_file, "r") as fp: - assert 'HELLO="WORLD\n3"\nfoo="bar"' == fp.read().strip() - - -def test_set_key_permission_error(dotenv_file): - os.chmod(dotenv_file, 0o000) - - with pytest.raises(Exception): - dotenv.set_key(dotenv_file, "HELLO", "WORLD") - - os.chmod(dotenv_file, 0o600) - with open(dotenv_file, "r") as fp: - assert fp.read() == "" - - def test_list(cli, dotenv_file): - success, key_to_set, value_to_set = dotenv.set_key(dotenv_file, 'HELLO', 'WORLD') + with open(dotenv_file, "w") as f: + f.write("a=b") + result = cli.invoke(dotenv_cli, ['--file', dotenv_file, 'list']) - assert result.exit_code == 0, result.output - assert result.output == 'HELLO=WORLD\n' + assert (result.exit_code, result.output) == (0, result.output) -def test_get_cli(cli, dotenv_file): - cli.invoke(dotenv_cli, ['--file', dotenv_file, 'set', 'HELLO', "WORLD 1"]) - result = cli.invoke(dotenv_cli, ['--file', dotenv_file, 'get', 'HELLO']) - assert result.exit_code == 0, result.output - assert result.output == 'HELLO=WORLD 1\n' +def test_list_non_existent_file(cli): + result = cli.invoke(dotenv_cli, ['--file', 'nx_file', 'list']) -def test_list_wo_file(cli): - result = cli.invoke(dotenv_cli, ['--file', 'doesnotexists', 'list']) assert result.exit_code == 2, result.output assert 'Invalid value for "-f"' in result.output -def test_empty_value(dotenv_file): - with open(dotenv_file, "w") as f: - f.write("TEST=") - assert dotenv.get_key(dotenv_file, "TEST") == "" - - -def test_key_value_without_quotes(dotenv_file): - with open(dotenv_file, 'w') as f: - f.write("TEST = value \n") - assert dotenv.get_key(dotenv_file, 'TEST') == "value" +def test_list_no_file(cli): + result = cli.invoke(dotenv.cli.list, []) + assert (result.exit_code, result.output) == (1, "") -def test_key_value_without_quotes_with_spaces(dotenv_file): - with open(dotenv_file, 'w') as f: - f.write('TEST = " with spaces " \n') - assert dotenv.get_key(dotenv_file, 'TEST') == " with spaces " +def test_get_existing_value(cli, dotenv_file): + with open(dotenv_file, "w") as f: + f.write("a=b") -def test_value_with_double_quotes(dotenv_file): - with open(dotenv_file, 'w') as f: - f.write('TEST="two words"\n') - assert dotenv.get_key(dotenv_file, 'TEST') == 'two words' - + result = cli.invoke(dotenv_cli, ['--file', dotenv_file, 'get', 'a']) -def test_value_with_simple_quotes(dotenv_file): - with open(dotenv_file, 'w') as f: - f.write("TEST='two words'\n") - assert dotenv.get_key(dotenv_file, 'TEST') == 'two words' + assert (result.exit_code, result.output) == (0, "a=b\n") -def test_value_with_special_characters(dotenv_file): - with open(dotenv_file, 'w') as f: - f.write(r'TEST="}=&~{,(\5%{&;"') - assert dotenv.get_key(dotenv_file, 'TEST') == r'}=&~{,(\5%{&;' +def test_get_non_existent_value(cli, dotenv_file): + result = cli.invoke(dotenv_cli, ['--file', dotenv_file, 'get', 'a']) + assert (result.exit_code, result.output) == (1, "") -def test_value_with_new_lines(dotenv_file): - with open(dotenv_file, 'w') as f: - f.write('TEST="a\nb"') - assert dotenv.get_key(dotenv_file, 'TEST') == "a\nb" +def test_get_no_file(cli): + result = cli.invoke(dotenv_cli, ['--file', 'nx_file', 'get', 'a']) -def test_value_after_comment(dotenv_file): - with open(dotenv_file, "w") as f: - f.write("# comment\nTEST=a") - assert dotenv.get_key(dotenv_file, "TEST") == "a" + assert result.exit_code == 2 + assert 'Invalid value for "-f"' in result.output -def test_unset_ok(dotenv_file): +def test_unset_existing_value(cli, dotenv_file): with open(dotenv_file, "w") as f: - f.write("a=b\nc=d") - - success, key_to_unset = dotenv.unset_key(dotenv_file, "a") + f.write("a=b") - assert success is True - assert key_to_unset == "a" - with open(dotenv_file, "r") as f: - assert f.read() == "c=d" + result = cli.invoke(dotenv_cli, ['--file', dotenv_file, 'unset', 'a']) + assert (result.exit_code, result.output) == (0, "Successfully removed a\n") + assert open(dotenv_file, "r").read() == "" -def test_unset_non_existing_file(): - success, key_to_unset = dotenv.unset_key('/non-existing', 'HELLO') - assert success is None +def test_unset_non_existent_value(cli, dotenv_file): + result = cli.invoke(dotenv_cli, ['--file', dotenv_file, 'unset', 'a']) - -def test_unset_cli(cli, dotenv_file): - success, key_to_set, value_to_set = dotenv.set_key(dotenv_file, 'TESTHELLO', 'WORLD') - dotenv.get_key(dotenv_file, 'TESTHELLO') == 'WORLD' - result = cli.invoke(dotenv_cli, ['--file', dotenv_file, 'unset', 'TESTHELLO']) - assert result.exit_code == 0, result.output - assert result.output == 'Successfully removed TESTHELLO\n' - dotenv.get_key(dotenv_file, 'TESTHELLO') is None - result = cli.invoke(dotenv_cli, ['--file', dotenv_file, 'unset', 'TESTHELLO']) - assert result.exit_code == 1, result.output + assert (result.exit_code, result.output) == (1, "") + assert open(dotenv_file, "r").read() == "" @pytest.mark.parametrize( @@ -157,110 +77,75 @@ def test_unset_cli(cli, dotenv_file): ("auto", "HELLO", "HELLO WORLD", 'HELLO="HELLO WORLD"\n'), ) ) -def test_console_script(quote_mode, variable, value, expected, dotenv_file): - sh.dotenv('-f', dotenv_file, '-q', quote_mode, 'set', variable, value) +def test_set_options(cli, dotenv_file, quote_mode, variable, value, expected): + result = cli.invoke( + dotenv_cli, + ["--file", dotenv_file, "--quote", quote_mode, "set", variable, value] + ) - result = sh.cat(dotenv_file) + assert (result.exit_code, result.output) == (0, "{}={}\n".format(variable, value)) + assert open(dotenv_file, "r").read() == expected - assert result == expected +def test_set_non_existent_file(cli): + result = cli.invoke(dotenv.cli.set, ["a", "b"]) -def test_set_non_existing_file(cli): - result = cli.invoke(dotenv.cli.set, ['my_key', 'my_value']) + assert (result.exit_code, result.output) == (1, "") - assert result.exit_code != 0 +def test_set_no_file(cli): + result = cli.invoke(dotenv_cli, ["--file", "nx_file", "set"]) -def test_get_non_existing_file(cli): - result = cli.invoke(dotenv.cli.get, ['my_key']) + assert result.exit_code == 2 + assert 'Invalid value for "-f"' in result.output - assert result.exit_code != 0 +def test_get_default_path(tmp_path): + sh.cd(str(tmp_path)) + with open(str(tmp_path / ".env"), "w") as f: + f.write("a=b") -def test_list_non_existing_file(cli): - result = cli.invoke(dotenv.cli.set, []) + result = sh.dotenv("get", "a") - assert result.exit_code != 0 + assert result == "a=b\n" -def test_default_path(tmp_path): +def test_run(tmp_path): sh.cd(str(tmp_path)) - sh.touch(tmp_path / '.env') - sh.dotenv('set', 'HELLO', 'WORLD') - - result = sh.dotenv('get', 'HELLO') - - assert result == 'HELLO=WORLD\n' - - -def test_get_key_with_interpolation(dotenv_file): - sh.touch(dotenv_file) - dotenv.set_key(dotenv_file, 'HELLO', 'WORLD') - dotenv.set_key(dotenv_file, 'FOO', '${HELLO}') - dotenv.set_key(dotenv_file, 'BAR', 'CONCATENATED_${HELLO}_POSIX_VAR') - - with open(dotenv_file) as f: - lines = f.readlines() - assert lines == [ - 'HELLO="WORLD"\n', - 'FOO="${HELLO}"\n', - 'BAR="CONCATENATED_${HELLO}_POSIX_VAR"\n', - ] - - # test replace from variable in file - stored_value = dotenv.get_key(dotenv_file, 'FOO') - assert stored_value == 'WORLD' - stored_value = dotenv.get_key(dotenv_file, 'BAR') - assert stored_value == 'CONCATENATED_WORLD_POSIX_VAR' - # test replace from environ taking precedence over file - os.environ["HELLO"] = "TAKES_PRECEDENCE" - stored_value = dotenv.get_key(dotenv_file, 'FOO') - assert stored_value == "TAKES_PRECEDENCE" - - -def test_get_key_with_interpolation_of_unset_variable(dotenv_file): - dotenv.set_key(dotenv_file, 'FOO', '${NOT_SET}') - # test unavailable replacement returns empty string - stored_value = dotenv.get_key(dotenv_file, 'FOO') - assert stored_value == '' - # unless present in environment - os.environ['NOT_SET'] = 'BAR' - stored_value = dotenv.get_key(dotenv_file, 'FOO') - assert stored_value == 'BAR' - del(os.environ['NOT_SET']) + dotenv_file = str(tmp_path / ".env") + with open(dotenv_file, "w") as f: + f.write("a=b") + result = sh.dotenv("run", "printenv", "a") -def test_run(tmp_path): - dotenv_file = tmp_path / '.env' - dotenv_file.touch() - sh.cd(str(tmp_path)) - dotenv.set_key(str(dotenv_file), 'FOO', 'BAR') - result = sh.dotenv('run', 'printenv', 'FOO').strip() - assert result == 'BAR' + assert result == "b\n" -def test_run_with_other_env(tmp_path): - dotenv_name = 'dotenv' - dotenv_file = tmp_path / dotenv_name - dotenv_file.touch() - sh.cd(str(tmp_path)) - sh.dotenv('--file', dotenv_name, 'set', 'FOO', 'BAR') - result = sh.dotenv('--file', dotenv_name, 'run', 'printenv', 'FOO').strip() - assert result == 'BAR' +def test_run_with_other_env(dotenv_file): + with open(dotenv_file, "w") as f: + f.write("a=b") + + result = sh.dotenv("--file", dotenv_file, "run", "printenv", "a") + + assert result == "b\n" def test_run_without_cmd(cli): result = cli.invoke(dotenv_cli, ['run']) - assert result.exit_code != 0 + + assert result.exit_code == 2 + assert 'Invalid value for "-f"' in result.output def test_run_with_invalid_cmd(cli): result = cli.invoke(dotenv_cli, ['run', 'i_do_not_exist']) - assert result.exit_code != 0 + + assert result.exit_code == 2 + assert 'Invalid value for "-f"' in result.output def test_run_with_version(cli): result = cli.invoke(dotenv_cli, ['--version']) - print(vars(result)) + assert result.exit_code == 0 assert result.output.strip().endswith(__version__) diff --git a/tests/test_core.py b/tests/test_core.py deleted file mode 100644 index 11500a1b..00000000 --- a/tests/test_core.py +++ /dev/null @@ -1,198 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -import contextlib -import os -import sys -import textwrap - -import logging -import mock -import pytest -import sh - -from dotenv import dotenv_values, find_dotenv, load_dotenv, set_key -from dotenv.compat import PY2, StringIO - - -@contextlib.contextmanager -def restore_os_environ(): - environ = dict(os.environ) - - try: - yield - finally: - os.environ.update(environ) - - -def test_warns_if_file_does_not_exist(): - logger = logging.getLogger("dotenv.main") - - with mock.patch.object(logger, "warning") as mock_warning: - load_dotenv('.does_not_exist', verbose=True) - - mock_warning.assert_called_once_with("File doesn't exist %s", ".does_not_exist") - - -def test_find_dotenv(tmp_path): - """ - Create a temporary folder structure like the following: - - test_find_dotenv0/ - └── child1 - ├── child2 - │   └── child3 - │   └── child4 - └── .env - - Then try to automatically `find_dotenv` starting in `child4` - """ - - curr_dir = tmp_path - dirs = [] - for f in ['child1', 'child2', 'child3', 'child4']: - curr_dir /= f - dirs.append(curr_dir) - curr_dir.mkdir() - - child1, child4 = dirs[0], dirs[-1] - - # change the working directory for testing - os.chdir(str(child4)) - - # try without a .env file and force error - with pytest.raises(IOError): - find_dotenv(raise_error_if_not_found=True, usecwd=True) - - # try without a .env file and fail silently - assert find_dotenv(usecwd=True) == '' - - # now place a .env file a few levels up and make sure it's found - dotenv_file = child1 / '.env' - dotenv_file.write_bytes(b"TEST=test\n") - assert find_dotenv(usecwd=True) == str(dotenv_file) - - -def test_load_dotenv(tmp_path): - os.chdir(str(tmp_path)) - dotenv_path = '.test_load_dotenv' - sh.touch(dotenv_path) - set_key(dotenv_path, 'DOTENV', 'WORKS') - assert 'DOTENV' not in os.environ - success = load_dotenv(dotenv_path) - assert success - assert 'DOTENV' in os.environ - assert os.environ['DOTENV'] == 'WORKS' - - -def test_load_dotenv_override(tmp_path): - os.chdir(str(tmp_path)) - dotenv_path = '.test_load_dotenv_override' - key_name = "DOTENV_OVER" - sh.touch(dotenv_path) - os.environ[key_name] = "OVERRIDE" - set_key(dotenv_path, key_name, 'WORKS') - success = load_dotenv(dotenv_path, override=True) - assert success - assert key_name in os.environ - assert os.environ[key_name] == 'WORKS' - - -def test_load_dotenv_in_current_dir(tmp_path): - dotenv_path = tmp_path / '.env' - dotenv_path.write_bytes(b'a=b') - code_path = tmp_path / 'code.py' - code_path.write_text(textwrap.dedent(""" - import dotenv - import os - - dotenv.load_dotenv(verbose=True) - print(os.environ['a']) - """)) - os.chdir(str(tmp_path)) - - result = sh.Command(sys.executable)(code_path) - - assert result == 'b\n' - - -def test_ipython(tmp_path): - from IPython.terminal.embed import InteractiveShellEmbed - os.chdir(str(tmp_path)) - dotenv_file = tmp_path / '.env' - dotenv_file.write_text("MYNEWVALUE=q1w2e3\n") - ipshell = InteractiveShellEmbed() - ipshell.magic("load_ext dotenv") - ipshell.magic("dotenv") - assert os.environ["MYNEWVALUE"] == 'q1w2e3' - - -def test_ipython_override(tmp_path): - from IPython.terminal.embed import InteractiveShellEmbed - os.chdir(str(tmp_path)) - dotenv_file = tmp_path / '.env' - os.environ["MYNEWVALUE"] = "OVERRIDE" - dotenv_file.write_text("MYNEWVALUE=q1w2e3\n") - ipshell = InteractiveShellEmbed() - ipshell.magic("load_ext dotenv") - ipshell.magic("dotenv -o") - assert os.environ["MYNEWVALUE"] == 'q1w2e3' - - -def test_dotenv_values_stream(): - stream = StringIO(u'hello="it works!😃"\nDOTENV=${hello}\n') - stream.seek(0) - parsed_dict = dotenv_values(stream=stream) - assert 'DOTENV' in parsed_dict - assert parsed_dict['DOTENV'] == u'it works!😃' - - -def test_dotenv_values_no_interpolate(): - stream = StringIO(u'no_interpolate=$MYVAR') - stream.seek(0) - parsed_dict = dotenv_values(stream=stream, interpolate=False) - assert 'no_interpolate' in parsed_dict - assert parsed_dict['no_interpolate'] == u'$MYVAR' - - -def test_dotenv_values_no_interpolate_strict(): - stream = StringIO(u'no_interpolate_strict=${MYVAR}') - stream.seek(0) - parsed_dict = dotenv_values(stream=stream, interpolate=False) - assert 'no_interpolate_strict' in parsed_dict - assert parsed_dict['no_interpolate_strict'] == u'${MYVAR}' - - -def test_dotenv_values_export(): - stream = StringIO('export foo=bar\n') - stream.seek(0) - load_dotenv(stream=stream) - assert 'foo' in os.environ - 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) - assert u'some_path' not in os.environ - parsed_dict = dotenv_values(stream=stream) - assert {u'some_path': u':a/b/c'} == parsed_dict - - -def test_dotenv_nonempty_selfreferential_interpolation(): - stream = StringIO(u'some_path="${some_path}:a/b/c"\n') - stream.seek(0) - assert u'some_path' not in os.environ - with restore_os_environ(): - os.environ[u'some_path'] = u'x/y/z' - parsed_dict = dotenv_values(stream=stream) - assert {u'some_path': u'x/y/z:a/b/c'} == parsed_dict diff --git a/tests/test_ipython.py b/tests/test_ipython.py new file mode 100644 index 00000000..afbf4797 --- /dev/null +++ b/tests/test_ipython.py @@ -0,0 +1,52 @@ +from __future__ import unicode_literals + +import os + +import mock + + +@mock.patch.dict(os.environ, {}, clear=True) +def test_ipython_existing_variable_no_override(tmp_path): + from IPython.terminal.embed import InteractiveShellEmbed + + dotenv_file = tmp_path / ".env" + dotenv_file.write_text("a=b\n") + os.chdir(str(tmp_path)) + os.environ["a"] = "c" + + ipshell = InteractiveShellEmbed() + ipshell.magic("load_ext dotenv") + ipshell.magic("dotenv") + + assert os.environ == {"a": "c"} + + +@mock.patch.dict(os.environ, {}, clear=True) +def test_ipython_existing_variable_override(tmp_path): + from IPython.terminal.embed import InteractiveShellEmbed + + dotenv_file = tmp_path / ".env" + dotenv_file.write_text("a=b\n") + os.chdir(str(tmp_path)) + os.environ["a"] = "c" + + ipshell = InteractiveShellEmbed() + ipshell.magic("load_ext dotenv") + ipshell.magic("dotenv -o") + + assert os.environ == {"a": "b"} + + +@mock.patch.dict(os.environ, {}, clear=True) +def test_ipython_new_variable(tmp_path): + from IPython.terminal.embed import InteractiveShellEmbed + + dotenv_file = tmp_path / ".env" + dotenv_file.write_text("a=b\n") + os.chdir(str(tmp_path)) + + ipshell = InteractiveShellEmbed() + ipshell.magic("load_ext dotenv") + ipshell.magic("dotenv") + + assert os.environ == {"a": "b"} diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 00000000..3416e2c5 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,350 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import logging +import os +import sys +import textwrap + +import mock +import pytest +import sh + +import dotenv +from dotenv.compat import PY2, StringIO + + +def test_set_key_no_file(tmp_path): + nx_file = str(tmp_path / "nx") + logger = logging.getLogger("dotenv.main") + + with mock.patch.object(logger, "warning") as mock_warning: + result = dotenv.set_key(nx_file, "foo", "bar") + + assert result == (None, "foo", "bar") + assert not os.path.exists(nx_file) + mock_warning.assert_called_once_with( + "Can't write to %s - it doesn't exist.", + nx_file, + ) + + +def test_set_key_new(dotenv_file): + logger = logging.getLogger("dotenv.main") + + with mock.patch.object(logger, "warning") as mock_warning: + result = dotenv.set_key(dotenv_file, "foo", "bar") + + assert result == (True, "foo", "bar") + assert open(dotenv_file, "r").read() == 'foo="bar"\n' + mock_warning.assert_not_called() + + +def test_set_key_new_with_other_values(dotenv_file): + logger = logging.getLogger("dotenv.main") + with open(dotenv_file, "w") as f: + f.write("a=b\n") + + with mock.patch.object(logger, "warning") as mock_warning: + result = dotenv.set_key(dotenv_file, "foo", "bar") + + assert result == (True, "foo", "bar") + assert open(dotenv_file, "r").read() == 'a=b\nfoo="bar"\n' + mock_warning.assert_not_called() + + +def test_set_key_existing(dotenv_file): + logger = logging.getLogger("dotenv.main") + with open(dotenv_file, "w") as f: + f.write("foo=bar") + + with mock.patch.object(logger, "warning") as mock_warning: + result = dotenv.set_key(dotenv_file, "foo", "baz") + + assert result == (True, "foo", "baz") + assert open(dotenv_file, "r").read() == 'foo="baz"\n' + mock_warning.assert_not_called() + + +def test_set_key_existing_with_other_values(dotenv_file): + logger = logging.getLogger("dotenv.main") + with open(dotenv_file, "w") as f: + f.write("a=b\nfoo=bar\nc=d") + + with mock.patch.object(logger, "warning") as mock_warning: + result = dotenv.set_key(dotenv_file, "foo", "baz") + + assert result == (True, "foo", "baz") + assert open(dotenv_file, "r").read() == 'a=b\nfoo="baz"\nc=d' + mock_warning.assert_not_called() + + +def test_set_key_permission_error(dotenv_file): + os.chmod(dotenv_file, 0o000) + + with pytest.raises(Exception): + dotenv.set_key(dotenv_file, "a", "b") + + os.chmod(dotenv_file, 0o600) + with open(dotenv_file, "r") as fp: + assert fp.read() == "" + + +def test_get_key_no_file(tmp_path): + nx_file = str(tmp_path / "nx") + logger = logging.getLogger("dotenv.main") + + with mock.patch.object(logger, "warning") as mock_warning: + result = dotenv.get_key(nx_file, "foo") + + assert result is None + mock_warning.assert_has_calls( + calls=[ + mock.call("File doesn't exist %s", nx_file), + mock.call("Key %s not found in %s.", "foo", nx_file), + ], + ) + + +def test_get_key_not_found(dotenv_file): + logger = logging.getLogger("dotenv.main") + + with mock.patch.object(logger, "warning") as mock_warning: + result = dotenv.get_key(dotenv_file, "foo") + + assert result is None + mock_warning.assert_called_once_with("Key %s not found in %s.", "foo", dotenv_file) + + +def test_get_key_ok(dotenv_file): + logger = logging.getLogger("dotenv.main") + with open(dotenv_file, "w") as f: + f.write("foo=bar") + + with mock.patch.object(logger, "warning") as mock_warning: + result = dotenv.get_key(dotenv_file, "foo") + + assert result == "bar" + mock_warning.assert_not_called() + + +def test_get_key_none(dotenv_file): + logger = logging.getLogger("dotenv.main") + with open(dotenv_file, "w") as f: + f.write("foo") + + with mock.patch.object(logger, "warning") as mock_warning: + result = dotenv.get_key(dotenv_file, "foo") + + assert result is None + mock_warning.assert_not_called() + + +def test_unset_with_value(dotenv_file): + logger = logging.getLogger("dotenv.main") + with open(dotenv_file, "w") as f: + f.write("a=b\nc=d") + + with mock.patch.object(logger, "warning") as mock_warning: + result = dotenv.unset_key(dotenv_file, "a") + + assert result == (True, "a") + with open(dotenv_file, "r") as f: + assert f.read() == "c=d" + mock_warning.assert_not_called() + + +def test_unset_no_value(dotenv_file): + logger = logging.getLogger("dotenv.main") + with open(dotenv_file, "w") as f: + f.write("foo") + + with mock.patch.object(logger, "warning") as mock_warning: + result = dotenv.unset_key(dotenv_file, "foo") + + assert result == (True, "foo") + with open(dotenv_file, "r") as f: + assert f.read() == "" + mock_warning.assert_not_called() + + +def test_unset_non_existent_file(tmp_path): + nx_file = str(tmp_path / "nx") + logger = logging.getLogger("dotenv.main") + + with mock.patch.object(logger, "warning") as mock_warning: + result = dotenv.unset_key(nx_file, "foo") + + assert result == (None, "foo") + mock_warning.assert_called_once_with( + "Can't delete from %s - it doesn't exist.", + nx_file, + ) + + +def prepare_file_hierarchy(path): + """ + Create a temporary folder structure like the following: + + test_find_dotenv0/ + └── child1 + ├── child2 + │   └── child3 + │   └── child4 + └── .env + + Then try to automatically `find_dotenv` starting in `child4` + """ + + curr_dir = path + dirs = [] + for f in ['child1', 'child2', 'child3', 'child4']: + curr_dir /= f + dirs.append(curr_dir) + curr_dir.mkdir() + + return (dirs[0], dirs[-1]) + + +def test_find_dotenv_no_file_raise(tmp_path): + (root, leaf) = prepare_file_hierarchy(tmp_path) + os.chdir(str(leaf)) + + with pytest.raises(IOError): + dotenv.find_dotenv(raise_error_if_not_found=True, usecwd=True) + + +def test_find_dotenv_no_file_no_raise(tmp_path): + (root, leaf) = prepare_file_hierarchy(tmp_path) + os.chdir(str(leaf)) + + result = dotenv.find_dotenv(usecwd=True) + + assert result == "" + + +def test_find_dotenv_found(tmp_path): + (root, leaf) = prepare_file_hierarchy(tmp_path) + os.chdir(str(leaf)) + dotenv_file = root / ".env" + dotenv_file.write_bytes(b"TEST=test\n") + + result = dotenv.find_dotenv(usecwd=True) + + assert result == str(dotenv_file) + + +@mock.patch.dict(os.environ, {}, clear=True) +def test_load_dotenv_existing_file(dotenv_file): + with open(dotenv_file, "w") as f: + f.write("a=b") + + result = dotenv.load_dotenv(dotenv_file) + + assert result is True + assert os.environ == {"a": "b"} + + +def test_load_dotenv_no_file_verbose(): + logger = logging.getLogger("dotenv.main") + + with mock.patch.object(logger, "warning") as mock_warning: + dotenv.load_dotenv('.does_not_exist', verbose=True) + + mock_warning.assert_called_once_with("File doesn't exist %s", ".does_not_exist") + + +@mock.patch.dict(os.environ, {"a": "c"}, clear=True) +def test_load_dotenv_existing_variable_no_override(dotenv_file): + with open(dotenv_file, "w") as f: + f.write("a=b") + + result = dotenv.load_dotenv(dotenv_file, override=False) + + assert result is True + assert os.environ == {"a": "c"} + + +@mock.patch.dict(os.environ, {"a": "c"}, clear=True) +def test_load_dotenv_existing_variable_override(dotenv_file): + with open(dotenv_file, "w") as f: + f.write("a=b") + + result = dotenv.load_dotenv(dotenv_file, override=True) + + assert result is True + assert os.environ == {"a": "b"} + + +@mock.patch.dict(os.environ, {}, clear=True) +def test_load_dotenv_utf_8(): + stream = StringIO("a=à") + + result = dotenv.load_dotenv(stream=stream) + + assert result is True + if PY2: + assert os.environ == {"a": "à".encode(sys.getfilesystemencoding())} + else: + assert os.environ == {"a": "à"} + + +def test_load_dotenv_in_current_dir(tmp_path): + dotenv_path = tmp_path / '.env' + dotenv_path.write_bytes(b'a=b') + code_path = tmp_path / 'code.py' + code_path.write_text(textwrap.dedent(""" + import dotenv + import os + + dotenv.load_dotenv(verbose=True) + print(os.environ['a']) + """)) + os.chdir(str(tmp_path)) + + result = sh.Command(sys.executable)(code_path) + + assert result == 'b\n' + + +def test_dotenv_values_file(dotenv_file): + with open(dotenv_file, "w") as f: + f.write("a=b") + + result = dotenv.dotenv_values(dotenv_file) + + assert result == {"a": "b"} + + +@pytest.mark.parametrize( + "env,string,interpolate,expected", + [ + # Defined in environment, with and without interpolation + ({"b": "c"}, "a=$b", False, {"a": "$b"}), + ({"b": "c"}, "a=$b", True, {"a": "$b"}), + ({"b": "c"}, "a=${b}", False, {"a": "${b}"}), + ({"b": "c"}, "a=${b}", True, {"a": "c"}), + + # Defined in file + ({}, "b=c\na=${b}", True, {"a": "c", "b": "c"}), + + # Undefined + ({}, "a=${b}", True, {"a": ""}), + + # With quotes + ({"b": "c"}, 'a="${b}"', True, {"a": "c"}), + ({"b": "c"}, "a='${b}'", True, {"a": "c"}), + + # Self-referential + ({"a": "b"}, "a=${a}", True, {"a": "b"}), + ({}, "a=${a}", True, {"a": ""}), + ], +) +def test_dotenv_values_stream(env, string, interpolate, expected): + with mock.patch.dict(os.environ, env, clear=True): + stream = StringIO(string) + stream.seek(0) + + result = dotenv.dotenv_values(stream=stream, interpolate=interpolate) + + assert result == expected From ba5a16e0e349a6a1f26927c5f833a95c4c645647 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Fri, 7 Feb 2020 23:22:42 +0100 Subject: [PATCH 039/253] Update changelog --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08fd03a5..00ce4dc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,10 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -No unreleased change at the moment. +### Changed + +- Use current working directory to find `.env` when bundled by PyInstaller (#213 by + [@gergelyk]). ## [0.11.0] - 2020-02-07 @@ -170,6 +173,7 @@ No unreleased change at the moment. [@bbc2]: https://github.com/bbc2 [@cjauvin]: https://github.com/cjauvin [@earlbread]: https://github.com/earlbread +[@gergelyk]: https://github.com/gergelyk [@greyli]: https://github.com/greyli [@qnighy]: https://github.com/qnighy [@techalchemy]: https://github.com/techalchemy From b5b8ae20d0bc0852b22e428d138ce7346c7a11bc Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 15 Feb 2020 03:30:56 +0100 Subject: [PATCH 040/253] Fix escaping of quoted values written by `set_key` (#236) * Fix PyPy jobs in CI * Add test cases for `dotenv.set_key` * Escape values before quoting in `dotenv.set_key` --- .travis.yml | 3 --- CHANGELOG.md | 4 ++++ src/dotenv/main.py | 7 +++++-- tests/test_main.py | 19 +++++++++++++++---- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/.travis.yml b/.travis.yml index 4b1f886e..b26433a7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,6 @@ language: python cache: pip os: linux -dist: xenial jobs: include: @@ -21,10 +20,8 @@ jobs: env: TOXENV=py38 - python: "pypy" env: TOXENV=pypy - dist: trusty - python: "pypy3" env: TOXENV=pypy3 - dist: trusty install: - pip install tox diff --git a/CHANGELOG.md b/CHANGELOG.md index 00ce4dc6..b693ba74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Use current working directory to find `.env` when bundled by PyInstaller (#213 by [@gergelyk]). +### Fixed + +- Fix escaping of quoted values written by `set_key` (#236 by [@bbc2]). + ## [0.11.0] - 2020-02-07 ### Added diff --git a/src/dotenv/main.py b/src/dotenv/main.py index ce83155d..93d617d6 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -153,8 +153,11 @@ def set_key(dotenv_path, key_to_set, value_to_set, quote_mode="always"): if " " in value_to_set: quote_mode = "always" - line_template = '{}="{}"\n' if quote_mode == "always" else '{}={}\n' - line_out = line_template.format(key_to_set, value_to_set) + if quote_mode == "always": + value_out = '"{}"'.format(value_to_set.replace('"', '\\"')) + else: + value_out = value_to_set + line_out = "{}={}\n".format(key_to_set, value_out) with rewrite(dotenv_path) as (source, dest): replaced = False diff --git a/tests/test_main.py b/tests/test_main.py index 3416e2c5..a4fb5b4d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -29,14 +29,25 @@ def test_set_key_no_file(tmp_path): ) -def test_set_key_new(dotenv_file): +@pytest.mark.parametrize( + "key,value,expected,content", + [ + ("a", "", (True, "a", ""), 'a=""\n'), + ("a", "b", (True, "a", "b"), 'a="b"\n'), + ("a", "'b'", (True, "a", "b"), 'a="b"\n'), + ("a", "\"b\"", (True, "a", "b"), 'a="b"\n'), + ("a", "b'c", (True, "a", "b'c"), 'a="b\'c"\n'), + ("a", "b\"c", (True, "a", "b\"c"), 'a="b\\\"c"\n'), + ], +) +def test_set_key_new(dotenv_file, key, value, expected, content): logger = logging.getLogger("dotenv.main") with mock.patch.object(logger, "warning") as mock_warning: - result = dotenv.set_key(dotenv_file, "foo", "bar") + result = dotenv.set_key(dotenv_file, key, value) - assert result == (True, "foo", "bar") - assert open(dotenv_file, "r").read() == 'foo="bar"\n' + assert result == expected + assert open(dotenv_file, "r").read() == content mock_warning.assert_not_called() From bc439674fe718bcff3039e3239602de3c23e42e4 Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Fri, 21 Feb 2020 15:54:53 +0100 Subject: [PATCH 041/253] Fix dotenv run crashes on variable without values --- CHANGELOG.md | 1 + src/dotenv/cli.py | 7 ++++--- tests/test_cli.py | 11 +++++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b693ba74..06ea3ce7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed - Fix escaping of quoted values written by `set_key` (#236 by [@bbc2]). +- Fix dotenv run crashing on environment variables without values ## [0.11.0] - 2020-02-07 diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index d2a021a5..91a8e3d3 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -9,7 +9,7 @@ 'Run pip install "python-dotenv[cli]" to fix this.') sys.exit(1) -from .compat import IS_TYPE_CHECKING +from .compat import IS_TYPE_CHECKING, to_env from .main import dotenv_values, get_key, set_key, unset_key from .version import __version__ @@ -97,11 +97,12 @@ def run(ctx, commandline): # type: (click.Context, List[str]) -> None """Run command with environment variables present.""" file = ctx.obj['FILE'] - dotenv_as_dict = dotenv_values(file) + dotenv_as_dict = {to_env(k): to_env(v) for (k, v) in dotenv_values(file).items() if v is not None} + if not commandline: click.echo('No command given.') exit(1) - ret = run_command(commandline, dotenv_as_dict) # type: ignore + ret = run_command(commandline, dotenv_as_dict) exit(ret) diff --git a/tests/test_cli.py b/tests/test_cli.py index 61c9d509..18e53323 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -121,6 +121,17 @@ def test_run(tmp_path): assert result == "b\n" +def test_run_with_none_value(tmp_path): + sh.cd(str(tmp_path)) + dotenv_file = str(tmp_path / ".env") + with open(dotenv_file, "w") as f: + f.write("a=b\nc") + + result = sh.dotenv("run", "printenv", "a") + + assert result == "b\n" + + def test_run_with_other_env(dotenv_file): with open(dotenv_file, "w") as f: f.write("a=b") From 2f58bccad26e0b728d32ec9bf9493671212dc24f Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Fri, 21 Feb 2020 23:01:26 +0100 Subject: [PATCH 042/253] Update changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06ea3ce7..47163f37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed - Fix escaping of quoted values written by `set_key` (#236 by [@bbc2]). -- Fix dotenv run crashing on environment variables without values +- Fix `dotenv run` crashing on environment variables without values (#237 by [@yannham]). ## [0.11.0] - 2020-02-07 @@ -185,6 +185,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 +[@yannham]: https://github.com/yannham [Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.11.0...HEAD [0.11.0]: https://github.com/theskumar/python-dotenv/compare/v0.10.5...v0.11.0 From bd9f2c055ab5e572d09cc57099a86257dc07dd73 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Fri, 21 Feb 2020 23:39:03 +0100 Subject: [PATCH 043/253] Remove warning when last line is empty This also simplifies tests and adds test cases for `set_key`. --- CHANGELOG.md | 1 + src/dotenv/parser.py | 7 +++++ tests/test_main.py | 65 ++++++++++++-------------------------------- tests/test_parser.py | 13 +++++++++ 4 files changed, 38 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47163f37..8566a50a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Fix escaping of quoted values written by `set_key` (#236 by [@bbc2]). - Fix `dotenv run` crashing on environment variables without values (#237 by [@yannham]). +- Remove warning when last line is empty (#238 by [@bbc2]). ## [0.11.0] - 2020-02-07 diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index 2904af86..2c93cbd0 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -197,6 +197,13 @@ def parse_binding(reader): reader.set_mark() try: reader.read_regex(_multiline_whitespace) + if not reader.has_next(): + return Binding( + key=None, + value=None, + original=reader.get_marked(), + error=False, + ) reader.read_regex(_export) key = parse_key(reader) reader.read_regex(_whitespace) diff --git a/tests/test_main.py b/tests/test_main.py index a4fb5b4d..d8678589 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -30,63 +30,32 @@ def test_set_key_no_file(tmp_path): @pytest.mark.parametrize( - "key,value,expected,content", + "before,key,value,expected,after", [ - ("a", "", (True, "a", ""), 'a=""\n'), - ("a", "b", (True, "a", "b"), 'a="b"\n'), - ("a", "'b'", (True, "a", "b"), 'a="b"\n'), - ("a", "\"b\"", (True, "a", "b"), 'a="b"\n'), - ("a", "b'c", (True, "a", "b'c"), 'a="b\'c"\n'), - ("a", "b\"c", (True, "a", "b\"c"), 'a="b\\\"c"\n'), + ("", "a", "", (True, "a", ""), 'a=""\n'), + ("", "a", "b", (True, "a", "b"), 'a="b"\n'), + ("", "a", "'b'", (True, "a", "b"), 'a="b"\n'), + ("", "a", "\"b\"", (True, "a", "b"), 'a="b"\n'), + ("", "a", "b'c", (True, "a", "b'c"), 'a="b\'c"\n'), + ("", "a", "b\"c", (True, "a", "b\"c"), 'a="b\\\"c"\n'), + ("a=b", "a", "c", (True, "a", "c"), 'a="c"\n'), + ("a=b\n", "a", "c", (True, "a", "c"), 'a="c"\n'), + ("a=b\n\n", "a", "c", (True, "a", "c"), 'a="c"\n\n'), + ("a=b\nc=d", "a", "e", (True, "a", "e"), 'a="e"\nc=d'), + ("a=b\nc=d\ne=f", "c", "g", (True, "c", "g"), 'a=b\nc="g"\ne=f'), + ("a=b\n", "c", "d", (True, "c", "d"), 'a=b\nc="d"\n'), ], ) -def test_set_key_new(dotenv_file, key, value, expected, content): +def test_set_key(dotenv_file, before, key, value, expected, after): logger = logging.getLogger("dotenv.main") + with open(dotenv_file, "w") as f: + f.write(before) with mock.patch.object(logger, "warning") as mock_warning: result = dotenv.set_key(dotenv_file, key, value) assert result == expected - assert open(dotenv_file, "r").read() == content - mock_warning.assert_not_called() - - -def test_set_key_new_with_other_values(dotenv_file): - logger = logging.getLogger("dotenv.main") - with open(dotenv_file, "w") as f: - f.write("a=b\n") - - with mock.patch.object(logger, "warning") as mock_warning: - result = dotenv.set_key(dotenv_file, "foo", "bar") - - assert result == (True, "foo", "bar") - assert open(dotenv_file, "r").read() == 'a=b\nfoo="bar"\n' - mock_warning.assert_not_called() - - -def test_set_key_existing(dotenv_file): - logger = logging.getLogger("dotenv.main") - with open(dotenv_file, "w") as f: - f.write("foo=bar") - - with mock.patch.object(logger, "warning") as mock_warning: - result = dotenv.set_key(dotenv_file, "foo", "baz") - - assert result == (True, "foo", "baz") - assert open(dotenv_file, "r").read() == 'foo="baz"\n' - mock_warning.assert_not_called() - - -def test_set_key_existing_with_other_values(dotenv_file): - logger = logging.getLogger("dotenv.main") - with open(dotenv_file, "w") as f: - f.write("a=b\nfoo=bar\nc=d") - - with mock.patch.object(logger, "warning") as mock_warning: - result = dotenv.set_key(dotenv_file, "foo", "baz") - - assert result == (True, "foo", "baz") - assert open(dotenv_file, "r").read() == 'a=b\nfoo="baz"\nc=d' + assert open(dotenv_file, "r").read() == after mock_warning.assert_not_called() diff --git a/tests/test_parser.py b/tests/test_parser.py index dae51d32..f8075138 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -86,6 +86,19 @@ Binding(key=u"b", value=u'c', original=Original(string=u"b=c", line=2), error=False), ] ), + ( + u"\n\n", + [ + Binding(key=None, value=None, original=Original(string=u"\n\n", line=1), error=False), + ] + ), + ( + u"a=b\n\n", + [ + Binding(key=u"a", value=u"b", original=Original(string=u"a=b\n", line=1), error=False), + Binding(key=None, value=None, original=Original(string=u"\n", line=2), error=False), + ] + ), ( u'a=b\n\nc=d', [ From a4d39f7cd852c0aabee3b0dc5d9403496406034e Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Fri, 28 Feb 2020 22:51:04 +0100 Subject: [PATCH 044/253] Release v0.12.0 --- CHANGELOG.md | 7 ++++++- setup.cfg | 2 +- src/dotenv/version.py | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8566a50a..ffb9d58a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +*No unreleased change at this time.* + +## [0.12.0] - 2020-02-28 + ### Changed - Use current working directory to find `.env` when bundled by PyInstaller (#213 by @@ -188,7 +192,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@venthur]: https://github.com/venthur [@yannham]: https://github.com/yannham -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.11.0...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.12.0...HEAD +[0.12.0]: https://github.com/theskumar/python-dotenv/compare/v0.11.0...v0.12.0 [0.11.0]: https://github.com/theskumar/python-dotenv/compare/v0.10.5...v0.11.0 [0.10.5]: https://github.com/theskumar/python-dotenv/compare/v0.10.4...v0.10.5 [0.10.4]: https://github.com/theskumar/python-dotenv/compare/v0.10.3...v0.10.4 diff --git a/setup.cfg b/setup.cfg index b10affd5..62f11006 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.11.0 +current_version = 0.12.0 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index ae6db5f1..ea370a8e 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.11.0" +__version__ = "0.12.0" From ff51469ce221279a0c341dda6903206b208ed859 Mon Sep 17 00:00:00 2001 From: "Artem.Angelchev" Date: Thu, 5 Mar 2020 23:35:11 +0300 Subject: [PATCH 045/253] fix coveralls error --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index b26433a7..d4f28f3a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -30,7 +30,7 @@ script: - tox before_install: - - pip install python-coveralls + - pip install coveralls after_success: - tox -e coverage-report From aa4ccc9bbb50e4e3891d40f9eaedf7794b625126 Mon Sep 17 00:00:00 2001 From: ulyssessouza Date: Thu, 12 Mar 2020 01:48:50 +0100 Subject: [PATCH 046/253] Pin click==7.0 as last working one Signed-off-by: ulyssessouza --- requirements.txt | 2 +- tox.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index e5e4de12..a2d892a4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ bumpversion typing; python_version<"3.5" -click +click==7.0 flake8>=2.2.3 ipython mock diff --git a/tox.ini b/tox.ini index 2dd61864..a5dc050b 100644 --- a/tox.ini +++ b/tox.ini @@ -7,7 +7,7 @@ deps = pytest coverage sh - click + click==7.0 py{27,py}: ipython<6.0.0 py34{,-no-typing}: ipython<7.0.0 py{35,36,37,38,py3}: ipython From 12439aac2d7c98b5acaf51ae3044b1659dc086ae Mon Sep 17 00:00:00 2001 From: ulyssessouza Date: Thu, 12 Mar 2020 03:09:13 +0100 Subject: [PATCH 047/253] Pin mypy==0.761 Signed-off-by: ulyssessouza --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index a5dc050b..0c69795c 100644 --- a/tox.ini +++ b/tox.ini @@ -22,7 +22,7 @@ commands = skip_install = true deps = flake8 - mypy + mypy==0.761 commands = flake8 src tests mypy --python-version=3.8 src tests From eb3eab86bc14ef0ed511f8545833e4da6cee721a Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 12 Apr 2020 16:36:46 +0200 Subject: [PATCH 048/253] Fix type checking with latest Mypy (0.770) --- src/dotenv/main.py | 1 + tox.ini | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 93d617d6..68ee02a0 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -275,6 +275,7 @@ def _is_interactive(): current_file = __file__ while frame.f_code.co_filename == current_file: + assert frame.f_back is not None frame = frame.f_back frame_filename = frame.f_code.co_filename path = os.path.dirname(os.path.abspath(frame_filename)) diff --git a/tox.ini b/tox.ini index 0c69795c..a5dc050b 100644 --- a/tox.ini +++ b/tox.ini @@ -22,7 +22,7 @@ commands = skip_install = true deps = flake8 - mypy==0.761 + mypy commands = flake8 src tests mypy --python-version=3.8 src tests From b491bbcd00caedf5b8cf5449b991704cab6a5306 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 12 Apr 2020 16:42:02 +0200 Subject: [PATCH 049/253] Fix tests for latest Click (7.1.1) --- requirements.txt | 2 +- tests/test_cli.py | 10 +++++----- tox.ini | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/requirements.txt b/requirements.txt index a2d892a4..e5e4de12 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ bumpversion typing; python_version<"3.5" -click==7.0 +click flake8>=2.2.3 ipython mock diff --git a/tests/test_cli.py b/tests/test_cli.py index 18e53323..edc62fff 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -20,7 +20,7 @@ def test_list_non_existent_file(cli): result = cli.invoke(dotenv_cli, ['--file', 'nx_file', 'list']) assert result.exit_code == 2, result.output - assert 'Invalid value for "-f"' in result.output + assert "Invalid value for '-f'" in result.output def test_list_no_file(cli): @@ -48,7 +48,7 @@ def test_get_no_file(cli): result = cli.invoke(dotenv_cli, ['--file', 'nx_file', 'get', 'a']) assert result.exit_code == 2 - assert 'Invalid value for "-f"' in result.output + assert "Invalid value for '-f'" in result.output def test_unset_existing_value(cli, dotenv_file): @@ -97,7 +97,7 @@ def test_set_no_file(cli): result = cli.invoke(dotenv_cli, ["--file", "nx_file", "set"]) assert result.exit_code == 2 - assert 'Invalid value for "-f"' in result.output + assert "Invalid value for '-f'" in result.output def test_get_default_path(tmp_path): @@ -145,14 +145,14 @@ def test_run_without_cmd(cli): result = cli.invoke(dotenv_cli, ['run']) assert result.exit_code == 2 - assert 'Invalid value for "-f"' in result.output + assert "Invalid value for '-f'" in result.output def test_run_with_invalid_cmd(cli): result = cli.invoke(dotenv_cli, ['run', 'i_do_not_exist']) assert result.exit_code == 2 - assert 'Invalid value for "-f"' in result.output + assert "Invalid value for '-f'" in result.output def test_run_with_version(cli): diff --git a/tox.ini b/tox.ini index a5dc050b..2dd61864 100644 --- a/tox.ini +++ b/tox.ini @@ -7,7 +7,7 @@ deps = pytest coverage sh - click==7.0 + click py{27,py}: ipython<6.0.0 py34{,-no-typing}: ipython<7.0.0 py{35,36,37,38,py3}: ipython From c389b4537392c36fc666b989383c2c1636d136cb Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Tue, 14 Apr 2020 16:31:13 +0200 Subject: [PATCH 050/253] Add support for Bash-like default values When interpolation is active, variable expansion can use a default value, like in Bash. --- CHANGELOG.md | 2 +- README.md | 15 ++++++++++----- src/dotenv/main.py | 24 ++++++++++++++++++------ tests/test_main.py | 11 +++++++++++ 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffb9d58a..fbcbd6fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -*No unreleased change at this time.* +- Add support for a Bash-like default value in variable expansion (#248 by [@bbc2]). ## [0.12.0] - 2020-02-28 diff --git a/README.md b/README.md index 1ffa855d..7374d05c 100644 --- a/README.md +++ b/README.md @@ -39,18 +39,23 @@ export S3_BUCKET=YOURS3BUCKET export SECRET_KEY=YOURSECRETKEYGOESHERE ``` -`.env` can interpolate variables using POSIX variable expansion, -variables are replaced from the environment first or from other values -in the `.env` file if the variable is not present in the environment. +Python-dotenv can interpolate variables using POSIX variable expansion. + +The value of a variable is the first of the values defined in the following list: + +- Value of that variable in the environment. +- Value of that variable in the `.env` file. +- Default value, if provided. +- Empty string. + Ensure that variables are surrounded with `{}` like `${HOME}` as bare variables such as `$HOME` are not expanded. -(**Note**: Default Value Expansion is not supported as of yet, see -[\#30](https://github.com/theskumar/python-dotenv/pull/30#issuecomment-244036604).) ```shell CONFIG_PATH=${HOME}/.config/foo DOMAIN=example.org EMAIL=admin@${DOMAIN} +DEBUG=${DEBUG:-false} ``` ## Getting started diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 68ee02a0..7fbd24f8 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -30,7 +30,17 @@ else: _StringIO = StringIO[Text] -__posix_variable = re.compile(r'\$\{[^\}]*\}') # type: Pattern[Text] +__posix_variable = re.compile( + r""" + \$\{ + (?P[^\}:]*) + (?::- + (?P[^\}]*) + )? + \} + """, + re.VERBOSE, +) # type: Pattern[Text] def with_warn_for_invalid_lines(mappings): @@ -202,23 +212,25 @@ def unset_key(dotenv_path, key_to_unset, quote_mode="always"): def resolve_nested_variables(values): # type: (Dict[Text, Optional[Text]]) -> Dict[Text, Optional[Text]] - def _replacement(name): - # type: (Text) -> Text + def _replacement(name, default): + # type: (Text, Optional[Text]) -> Text """ get appropriate value for a variable name. first search in environ, if not found, then look into the dotenv variables """ - ret = os.getenv(name, new_values.get(name, "")) + default = default if default is not None else "" + ret = os.getenv(name, new_values.get(name, default)) return ret # type: ignore - def _re_sub_callback(match_object): + def _re_sub_callback(match): # type: (Match[Text]) -> Text """ From a match object gets the variable name and returns the correct replacement """ - return _replacement(match_object.group()[2:-1]) + matches = match.groupdict() + return _replacement(name=matches["name"], default=matches["default"]) # type: ignore new_values = {} diff --git a/tests/test_main.py b/tests/test_main.py index d8678589..f877d21a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -304,20 +304,31 @@ def test_dotenv_values_file(dotenv_file): ({"b": "c"}, "a=$b", True, {"a": "$b"}), ({"b": "c"}, "a=${b}", False, {"a": "${b}"}), ({"b": "c"}, "a=${b}", True, {"a": "c"}), + ({"b": "c"}, "a=${b:-d}", False, {"a": "${b:-d}"}), + ({"b": "c"}, "a=${b:-d}", True, {"a": "c"}), # Defined in file ({}, "b=c\na=${b}", True, {"a": "c", "b": "c"}), # Undefined ({}, "a=${b}", True, {"a": ""}), + ({}, "a=${b:-d}", True, {"a": "d"}), # With quotes ({"b": "c"}, 'a="${b}"', True, {"a": "c"}), ({"b": "c"}, "a='${b}'", True, {"a": "c"}), + # With surrounding text + ({"b": "c"}, "a=x${b}y", True, {"a": "xcy"}), + # Self-referential ({"a": "b"}, "a=${a}", True, {"a": "b"}), ({}, "a=${a}", True, {"a": ""}), + ({"a": "b"}, "a=${a:-c}", True, {"a": "b"}), + ({}, "a=${a:-c}", True, {"a": "c"}), + + # Reused + ({"b": "c"}, "a=${b}${b}", True, {"a": "cc"}), ], ) def test_dotenv_values_stream(env, string, interpolate, expected): From 784102284471252f418e797c1874a00063426210 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Thu, 16 Apr 2020 23:36:06 +0200 Subject: [PATCH 051/253] Release v0.13.0 --- .travis.yml | 1 + CHANGELOG.md | 9 ++++++++- setup.cfg | 2 +- src/dotenv/version.py | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index d4f28f3a..483f6d40 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,7 @@ language: python cache: pip os: linux +dist: xenial jobs: include: diff --git a/CHANGELOG.md b/CHANGELOG.md index fbcbd6fd..fc2f7c9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +*No unreleased change at this time.* + +## [0.13.0] - 2020-04-16 + +### Added + - Add support for a Bash-like default value in variable expansion (#248 by [@bbc2]). ## [0.12.0] - 2020-02-28 @@ -192,7 +198,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@venthur]: https://github.com/venthur [@yannham]: https://github.com/yannham -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.12.0...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.13.0...HEAD +[0.13.0]: https://github.com/theskumar/python-dotenv/compare/v0.12.0...v0.13.0 [0.12.0]: https://github.com/theskumar/python-dotenv/compare/v0.11.0...v0.12.0 [0.11.0]: https://github.com/theskumar/python-dotenv/compare/v0.10.5...v0.11.0 [0.10.5]: https://github.com/theskumar/python-dotenv/compare/v0.10.4...v0.10.5 diff --git a/setup.cfg b/setup.cfg index 62f11006..c19d6bb7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.12.0 +current_version = 0.13.0 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index ea370a8e..f23a6b39 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.12.0" +__version__ = "0.13.0" From 335dedc7d94bd69236c496ebb09e2de2b2da2dd7 Mon Sep 17 00:00:00 2001 From: Ewoud Kohl van Wijngaarden Date: Wed, 29 Apr 2020 12:22:46 +0200 Subject: [PATCH 052/253] Use https in setup.py's URL metadata (#251) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index db69a9ce..f5c2a507 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ def read_files(files): version=meta['__version__'], author="Saurabh Kumar", author_email="me+github@saurabh-kumar.com", - url="http://github.com/theskumar/python-dotenv", + url="https://github.com/theskumar/python-dotenv", keywords=['environment variables', 'deployments', 'settings', 'env', 'dotenv', 'configurations', 'python'], packages=['dotenv'], From e92ab1f18087c2ca058203f36535c43a0f824d8a Mon Sep 17 00:00:00 2001 From: Adrian Calinescu Date: Wed, 29 Apr 2020 22:00:57 +0300 Subject: [PATCH 053/253] Fix file not found message to have some context (#245) 'File doesn't exist' doesn't really tell the user much, let's add some context. --- src/dotenv/main.py | 2 +- tests/test_main.py | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 7fbd24f8..c821ef73 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -74,7 +74,7 @@ def _get_stream(self): yield stream else: if self.verbose: - logger.warning("File doesn't exist %s", self.dotenv_path) + logger.info("Python-dotenv could not find configuration file %s.", self.dotenv_path or '.env') yield StringIO('') def dict(self): diff --git a/tests/test_main.py b/tests/test_main.py index f877d21a..04d86509 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -74,14 +74,19 @@ def test_get_key_no_file(tmp_path): nx_file = str(tmp_path / "nx") logger = logging.getLogger("dotenv.main") - with mock.patch.object(logger, "warning") as mock_warning: + with mock.patch.object(logger, "info") as mock_info, \ + mock.patch.object(logger, "warning") as mock_warning: result = dotenv.get_key(nx_file, "foo") assert result is None + mock_info.assert_has_calls( + calls=[ + mock.call("Python-dotenv could not find configuration file %s.", nx_file) + ], + ) mock_warning.assert_has_calls( calls=[ - mock.call("File doesn't exist %s", nx_file), - mock.call("Key %s not found in %s.", "foo", nx_file), + mock.call("Key %s not found in %s.", "foo", nx_file) ], ) @@ -228,10 +233,10 @@ def test_load_dotenv_existing_file(dotenv_file): def test_load_dotenv_no_file_verbose(): logger = logging.getLogger("dotenv.main") - with mock.patch.object(logger, "warning") as mock_warning: + with mock.patch.object(logger, "info") as mock_info: dotenv.load_dotenv('.does_not_exist', verbose=True) - mock_warning.assert_called_once_with("File doesn't exist %s", ".does_not_exist") + mock_info.assert_called_once_with("Python-dotenv could not find configuration file %s.", ".does_not_exist") @mock.patch.dict(os.environ, {"a": "c"}, clear=True) From d83c1b65ceab5cf1223c41649a25a142755fe703 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 9 May 2020 12:56:13 +0200 Subject: [PATCH 054/253] Refine Python version constraint in readme example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7374d05c..98aac03b 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ load_dotenv() load_dotenv(verbose=True) # OR, explicitly providing path to '.env' -from pathlib import Path # python3 only +from pathlib import Path # Python 3.6+ only env_path = Path('.') / '.env' load_dotenv(dotenv_path=env_path) ``` From 6712bc8a12db1e8d3f09b7bf7d3c25338992e422 Mon Sep 17 00:00:00 2001 From: Abdelrahman Elbehery Date: Sat, 27 Jun 2020 20:31:32 +0200 Subject: [PATCH 055/253] fix interpolation order add interpolation order test --- CHANGELOG.md | 6 +++++- src/dotenv/main.py | 7 +------ tests/test_main.py | 3 +++ 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc2f7c9b..1c0eceb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,10 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -*No unreleased change at this time.* +### Fixed + +- Privilege definition in file over the environment in variable expansion (#256 by + [@elbehery95]). ## [0.13.0] - 2020-04-16 @@ -197,6 +200,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@ulyssessouza]: https://github.com/ulyssessouza [@venthur]: https://github.com/venthur [@yannham]: https://github.com/yannham +[@elbehery95]: https://github.com/elbehery95 [Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.13.0...HEAD [0.13.0]: https://github.com/theskumar/python-dotenv/compare/v0.12.0...v0.13.0 diff --git a/src/dotenv/main.py b/src/dotenv/main.py index c821ef73..8f77e831 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -214,13 +214,8 @@ def resolve_nested_variables(values): # type: (Dict[Text, Optional[Text]]) -> Dict[Text, Optional[Text]] def _replacement(name, default): # type: (Text, Optional[Text]) -> Text - """ - get appropriate value for a variable name. - first search in environ, if not found, - then look into the dotenv variables - """ default = default if default is not None else "" - ret = os.getenv(name, new_values.get(name, default)) + ret = new_values.get(name, os.getenv(name, default)) return ret # type: ignore def _re_sub_callback(match): diff --git a/tests/test_main.py b/tests/test_main.py index 04d86509..3a3d059b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -334,6 +334,9 @@ def test_dotenv_values_file(dotenv_file): # Reused ({"b": "c"}, "a=${b}${b}", True, {"a": "cc"}), + + # Re-defined and used in file + ({"b": "c"}, "b=d\na=${b}", True, {"a": "d", "b": "d"}), ], ) def test_dotenv_values_stream(env, string, interpolate, expected): From e4bbb8a2aa881409af6fb92933c18e2af6609da8 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Fri, 3 Jul 2020 11:09:24 +0200 Subject: [PATCH 056/253] Release v0.14.0 --- CHANGELOG.md | 18 +++++++++++++++--- setup.cfg | 2 +- src/dotenv/version.py | 2 +- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c0eceb2..116d97fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,20 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -### Fixed +*No unreleased change at this time.* + +## [0.14.0] - 2020-07-03 + +### Changed - Privilege definition in file over the environment in variable expansion (#256 by [@elbehery95]). +### Fixed + +- Improve error message for when file isn't found (#245 by [@snobu]). +- Use HTTPS URL in package meta data (#251 by [@ekohl]). + ## [0.13.0] - 2020-04-16 ### Added @@ -192,17 +201,20 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@bbc2]: https://github.com/bbc2 [@cjauvin]: https://github.com/cjauvin [@earlbread]: https://github.com/earlbread +[@ekohl]: https://github.com/ekohl +[@elbehery95]: https://github.com/elbehery95 [@gergelyk]: https://github.com/gergelyk [@greyli]: https://github.com/greyli [@qnighy]: https://github.com/qnighy +[@snobu]: https://github.com/snobu [@techalchemy]: https://github.com/techalchemy [@theskumar]: https://github.com/theskumar [@ulyssessouza]: https://github.com/ulyssessouza [@venthur]: https://github.com/venthur [@yannham]: https://github.com/yannham -[@elbehery95]: https://github.com/elbehery95 -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.13.0...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.14.0...HEAD +[0.14.0]: https://github.com/theskumar/python-dotenv/compare/v0.13.0...v0.14.0 [0.13.0]: https://github.com/theskumar/python-dotenv/compare/v0.12.0...v0.13.0 [0.12.0]: https://github.com/theskumar/python-dotenv/compare/v0.11.0...v0.12.0 [0.11.0]: https://github.com/theskumar/python-dotenv/compare/v0.10.5...v0.11.0 diff --git a/setup.cfg b/setup.cfg index c19d6bb7..0f168618 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.13.0 +current_version = 0.14.0 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index f23a6b39..9e78220f 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.13.0" +__version__ = "0.14.0" From 4b434362e1832771fd08c14b8af67a8fd562b854 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Fri, 3 Jul 2020 18:06:39 +0200 Subject: [PATCH 057/253] Fix empty expanded value for duplicate key Example problematic file: ```bash hello=hi greetings=${hello} goodbye=bye greetings=${goodbye} ``` It would result in `greetings` being associated with the empty string instead of `"bye"`. The problem came from the fact that bindings were converted to a dict, and so deduplicated by key, before being interpolated. The dict would be `{"hello": "hi", "greetings": "${goodbye}", "goodbye": "bye"}` in the earlier example, which shows why interpolation wouldn't work: `goodbye` would not be defined when `greetings` was interpolated. This commit fixes that by passing all values in order, even if there are duplicated keys. --- CHANGELOG.md | 4 +++- src/dotenv/main.py | 16 ++++++++++------ tests/test_main.py | 2 ++ 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 116d97fa..a01d3dc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -*No unreleased change at this time.* +### Fixed + +- Fix potentially empty expanded value for duplicate key (#260 by [@bbc]). ## [0.14.0] - 2020-07-03 diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 8f77e831..607299ae 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -18,7 +18,7 @@ if IS_TYPE_CHECKING: from typing import ( - Dict, Iterator, Match, Optional, Pattern, Union, Text, IO, Tuple + Dict, Iterable, Iterator, Match, Optional, Pattern, Union, Text, IO, Tuple ) if sys.version_info >= (3, 6): _PathLike = os.PathLike @@ -83,9 +83,13 @@ def dict(self): if self._dict: return self._dict - values = OrderedDict(self.parse()) - self._dict = resolve_nested_variables(values) if self.interpolate else values - return self._dict + if self.interpolate: + values = resolve_nested_variables(self.parse()) + else: + values = OrderedDict(self.parse()) + + self._dict = values + return values def parse(self): # type: () -> Iterator[Tuple[Text, Optional[Text]]] @@ -211,7 +215,7 @@ def unset_key(dotenv_path, key_to_unset, quote_mode="always"): def resolve_nested_variables(values): - # type: (Dict[Text, Optional[Text]]) -> Dict[Text, Optional[Text]] + # type: (Iterable[Tuple[Text, Optional[Text]]]) -> Dict[Text, Optional[Text]] def _replacement(name, default): # type: (Text, Optional[Text]) -> Text default = default if default is not None else "" @@ -229,7 +233,7 @@ def _re_sub_callback(match): new_values = {} - for k, v in values.items(): + for (k, v) in values: new_values[k] = __posix_variable.sub(_re_sub_callback, v) if v is not None else None return new_values diff --git a/tests/test_main.py b/tests/test_main.py index 3a3d059b..339d00bb 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -337,6 +337,8 @@ def test_dotenv_values_file(dotenv_file): # Re-defined and used in file ({"b": "c"}, "b=d\na=${b}", True, {"a": "d", "b": "d"}), + ({}, "a=b\na=c\nd=${a}", True, {"a": "c", "d": "c"}), + ({}, "a=b\nc=${a}\nd=e\nc=${d}", True, {"a": "b", "c": "e", "d": "e"}), ], ) def test_dotenv_values_stream(env, string, interpolate, expected): From 78be0f865d34c34120456ff0b6b9d34a95ac6879 Mon Sep 17 00:00:00 2001 From: gongqingkui Date: Sat, 4 Jul 2020 22:15:35 +0800 Subject: [PATCH 058/253] Fix import error on Python 3.5.0 and 3.5.1 While `typing` was added in 3.5.0, `typing.Text` was only added in 3.5.2, and so causes an `AttributeError`, not an `ImportError` exception. --- CHANGELOG.md | 2 ++ src/dotenv/parser.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a01d3dc5..7d502f19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ 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 import error on Python 3.5.0 and 3.5.1 (#267 by [@gongqingkui]). ## [0.14.0] - 2020-07-03 @@ -206,6 +207,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@ekohl]: https://github.com/ekohl [@elbehery95]: https://github.com/elbehery95 [@gergelyk]: https://github.com/gergelyk +[@gongqingkui]: https://github.com/gongqingkui [@greyli]: https://github.com/greyli [@qnighy]: https://github.com/qnighy [@snobu]: https://github.com/snobu diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index 2c93cbd0..4eba0ac4 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -55,7 +55,7 @@ def make_regex(string, extra_flags=0): ("error", bool), ], ) -except ImportError: +except (ImportError, AttributeError): from collections import namedtuple Original = namedtuple( # type: ignore "Original", From 92ec3b280db6e9f946994a3bf5815d6b6447892c Mon Sep 17 00:00:00 2001 From: Leon Chen Date: Mon, 13 Jul 2020 14:11:27 -0400 Subject: [PATCH 059/253] Update README.md https://github.com/theskumar/python-dotenv/issues/256 https://github.com/theskumar/python-dotenv/pull/258 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 98aac03b..d3992847 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,8 @@ Python-dotenv can interpolate variables using POSIX variable expansion. The value of a variable is the first of the values defined in the following list: -- Value of that variable in the environment. - Value of that variable in the `.env` file. +- Value of that variable in the environment. - Default value, if provided. - Empty string. From 6ca2e2ab399c7e41be276bc830f21af3092f5d28 Mon Sep 17 00:00:00 2001 From: jadutter <4691511+jadutter@users.noreply.github.com> Date: Tue, 4 Aug 2020 20:43:52 -0400 Subject: [PATCH 060/253] Add --export to `set` and make it create env file - Add `--export` option to `set` to make it prepend the binding with `export`. - Make `set` command create the `.env` file in the current directory if no `.env` file was found. --- CHANGELOG.md | 11 +++++++++++ README.md | 11 +++++++++-- src/dotenv/cli.py | 30 +++++++++++++++++++++++++----- src/dotenv/main.py | 15 +++++++++------ tests/test_cli.py | 27 ++++++++++++++++++++++----- tests/test_main.py | 10 +++------- 6 files changed, 79 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d502f19..34cdb324 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- Add `--export` option to `set` to make it prepend the binding with `export` (#270 by + [@jadutter]). + +### Changed + +- Make `set` command create the `.env` file in the current directory if no `.env` file was + found (#270 by [@jadutter]). + ### Fixed - Fix potentially empty expanded value for duplicate key (#260 by [@bbc]). @@ -209,6 +219,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@gergelyk]: https://github.com/gergelyk [@gongqingkui]: https://github.com/gongqingkui [@greyli]: https://github.com/greyli +[@jadutter]: https://github.com/jadutter [@qnighy]: https://github.com/qnighy [@snobu]: https://github.com/snobu [@techalchemy]: https://github.com/techalchemy diff --git a/README.md b/README.md index d3992847..462a11d3 100644 --- a/README.md +++ b/README.md @@ -186,16 +186,23 @@ Usage: dotenv [OPTIONS] COMMAND [ARGS]... Options: -f, --file PATH Location of the .env file, defaults to .env file in current working directory. + -q, --quote [always|never|auto] Whether to quote or not the variable values. Default mode is always. This does not affect parsing. + + -e, --export BOOLEAN + Whether to write the dot file as an + executable bash script. + + --version Show the version and exit. --help Show this message and exit. Commands: - get Retrive the value for the given key. + get Retrieve the value for the given key. list Display all the stored key/value. - run Run command with environment variables from .env file present + run Run command with environment variables present. set Store the given key/value. unset Removes the given key. ``` diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 91a8e3d3..e17d248f 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -19,19 +19,23 @@ @click.group() @click.option('-f', '--file', default=os.path.join(os.getcwd(), '.env'), - type=click.Path(exists=True), + type=click.Path(file_okay=True), help="Location of the .env file, defaults to .env file in current working directory.") @click.option('-q', '--quote', default='always', type=click.Choice(['always', 'never', 'auto']), help="Whether to quote or not the variable values. Default mode is always. This does not affect parsing.") +@click.option('-e', '--export', default=False, + type=click.BOOL, + help="Whether to write the dot file as an executable bash script.") @click.version_option(version=__version__) @click.pass_context -def cli(ctx, file, quote): - # type: (click.Context, Any, Any) -> None +def cli(ctx, file, quote, export): + # type: (click.Context, Any, Any, Any) -> None '''This script is used to set, get or unset values from a .env file.''' ctx.obj = {} - ctx.obj['FILE'] = file ctx.obj['QUOTE'] = quote + ctx.obj['EXPORT'] = export + ctx.obj['FILE'] = file @cli.command() @@ -40,6 +44,11 @@ def list(ctx): # type: (click.Context) -> None '''Display all the stored key/value.''' file = ctx.obj['FILE'] + if not os.path.isfile(file): + raise click.BadParameter( + 'Path "%s" does not exist.' % (file), + ctx=ctx + ) dotenv_as_dict = dotenv_values(file) for k, v in dotenv_as_dict.items(): click.echo('%s=%s' % (k, v)) @@ -54,7 +63,8 @@ def set(ctx, key, value): '''Store the given key/value.''' file = ctx.obj['FILE'] quote = ctx.obj['QUOTE'] - success, key, value = set_key(file, key, value, quote) + export = ctx.obj['EXPORT'] + success, key, value = set_key(file, key, value, quote, export) if success: click.echo('%s=%s' % (key, value)) else: @@ -68,6 +78,11 @@ def get(ctx, key): # type: (click.Context, Any) -> None '''Retrieve the value for the given key.''' file = ctx.obj['FILE'] + if not os.path.isfile(file): + raise click.BadParameter( + 'Path "%s" does not exist.' % (file), + ctx=ctx + ) stored_value = get_key(file, key) if stored_value: click.echo('%s=%s' % (key, stored_value)) @@ -97,6 +112,11 @@ def run(ctx, commandline): # type: (click.Context, List[str]) -> None """Run command with environment variables present.""" file = ctx.obj['FILE'] + if not os.path.isfile(file): + raise click.BadParameter( + 'Invalid value for \'-f\' "%s" does not exist.' % (file), + ctx=ctx + ) dotenv_as_dict = {to_env(k): to_env(v) for (k, v) in dotenv_values(file).items() if v is not None} if not commandline: diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 607299ae..58a23f3d 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -140,6 +140,9 @@ def get_key(dotenv_path, key_to_get): def rewrite(path): # type: (_PathLike) -> Iterator[Tuple[IO[Text], IO[Text]]] try: + if not os.path.isfile(path): + with io.open(path, "w+") as source: + source.write("") with tempfile.NamedTemporaryFile(mode="w+", delete=False) as dest: with io.open(path) as source: yield (source, dest) # type: ignore @@ -151,8 +154,8 @@ def rewrite(path): shutil.move(dest.name, path) -def set_key(dotenv_path, key_to_set, value_to_set, quote_mode="always"): - # type: (_PathLike, Text, Text, Text) -> Tuple[Optional[bool], Text, Text] +def set_key(dotenv_path, key_to_set, value_to_set, quote_mode="always", export=False): + # type: (_PathLike, Text, Text, Text, bool) -> Tuple[Optional[bool], Text, Text] """ Adds or Updates a key/value to the given .env @@ -160,9 +163,6 @@ def set_key(dotenv_path, key_to_set, value_to_set, quote_mode="always"): an orphan .env somewhere in the filesystem """ value_to_set = value_to_set.strip("'").strip('"') - if not os.path.exists(dotenv_path): - logger.warning("Can't write to %s - it doesn't exist.", dotenv_path) - return None, key_to_set, value_to_set if " " in value_to_set: quote_mode = "always" @@ -171,7 +171,10 @@ def set_key(dotenv_path, key_to_set, value_to_set, quote_mode="always"): value_out = '"{}"'.format(value_to_set.replace('"', '\\"')) else: value_out = value_to_set - line_out = "{}={}\n".format(key_to_set, value_out) + if export: + line_out = 'export {}={}\n'.format(key_to_set, value_out) + else: + line_out = "{}={}\n".format(key_to_set, value_out) with rewrite(dotenv_path) as (source, dest): replaced = False diff --git a/tests/test_cli.py b/tests/test_cli.py index edc62fff..23404e70 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -20,7 +20,7 @@ def test_list_non_existent_file(cli): result = cli.invoke(dotenv_cli, ['--file', 'nx_file', 'list']) assert result.exit_code == 2, result.output - assert "Invalid value for '-f'" in result.output + assert "does not exist" in result.output def test_list_no_file(cli): @@ -48,7 +48,7 @@ def test_get_no_file(cli): result = cli.invoke(dotenv_cli, ['--file', 'nx_file', 'get', 'a']) assert result.exit_code == 2 - assert "Invalid value for '-f'" in result.output + assert "does not exist" in result.output def test_unset_existing_value(cli, dotenv_file): @@ -77,10 +77,27 @@ def test_unset_non_existent_value(cli, dotenv_file): ("auto", "HELLO", "HELLO WORLD", 'HELLO="HELLO WORLD"\n'), ) ) -def test_set_options(cli, dotenv_file, quote_mode, variable, value, expected): +def test_set_quote_options(cli, dotenv_file, quote_mode, variable, value, expected): result = cli.invoke( dotenv_cli, - ["--file", dotenv_file, "--quote", quote_mode, "set", variable, value] + ["--file", dotenv_file, "--export", "false", "--quote", quote_mode, "set", variable, value] + ) + + assert (result.exit_code, result.output) == (0, "{}={}\n".format(variable, value)) + assert open(dotenv_file, "r").read() == expected + + +@pytest.mark.parametrize( + "dotenv_file,export_mode,variable,value,expected", + ( + (".nx_file", "true", "HELLO", "WORLD", "export HELLO=\"WORLD\"\n"), + (".nx_file", "false", "HELLO", "WORLD", "HELLO=\"WORLD\"\n"), + ) +) +def test_set_export(cli, dotenv_file, export_mode, variable, value, expected): + result = cli.invoke( + dotenv_cli, + ["--file", dotenv_file, "--quote", "always", "--export", export_mode, "set", variable, value] ) assert (result.exit_code, result.output) == (0, "{}={}\n".format(variable, value)) @@ -97,7 +114,7 @@ def test_set_no_file(cli): result = cli.invoke(dotenv_cli, ["--file", "nx_file", "set"]) assert result.exit_code == 2 - assert "Invalid value for '-f'" in result.output + assert "Missing argument" in result.output def test_get_default_path(tmp_path): diff --git a/tests/test_main.py b/tests/test_main.py index 339d00bb..6b9458d2 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -18,15 +18,11 @@ def test_set_key_no_file(tmp_path): nx_file = str(tmp_path / "nx") logger = logging.getLogger("dotenv.main") - with mock.patch.object(logger, "warning") as mock_warning: + with mock.patch.object(logger, "warning"): result = dotenv.set_key(nx_file, "foo", "bar") - assert result == (None, "foo", "bar") - assert not os.path.exists(nx_file) - mock_warning.assert_called_once_with( - "Can't write to %s - it doesn't exist.", - nx_file, - ) + assert result == (True, "foo", "bar") + assert os.path.exists(nx_file) @pytest.mark.parametrize( From 7b172fe24830bb8fd8cd943b73570e8cd6515ba1 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Wed, 9 Sep 2020 11:00:13 +0200 Subject: [PATCH 061/253] Fix parsing of unquoted values with two spaces If a value is unquoted and has two or more adjacent spaces (like in `a=b c`), the parser would detect an error. This commit fixes that. Tabs and other whitespace characters are now also considered like space characters in this case and I added relevant test cases. --- CHANGELOG.md | 5 ++++- src/dotenv/parser.py | 12 +++--------- tests/test_parser.py | 36 ++++++++++++++++++++++++++++-------- 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34cdb324..b5305f19 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 4eba0ac4..5cb1cdfa 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -24,7 +24,7 @@ def make_regex(string, extra_flags=0): _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): diff --git a/tests/test_parser.py b/tests/test_parser.py index f8075138..48cecdce 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -19,20 +19,40 @@ (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", From 6fc345833f148762bb8783896d1b1059b146886f Mon Sep 17 00:00:00 2001 From: Peyman Salehi Date: Fri, 16 Oct 2020 00:57:43 +0330 Subject: [PATCH 062/253] Add python 3.9 to CI --- .travis.yml | 2 ++ setup.py | 1 + tox.ini | 5 +++-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 483f6d40..8f51de38 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,6 +19,8 @@ jobs: env: TOXENV=py37 - python: "3.8" env: TOXENV=py38 + - python: "3.9-dev" + env: TOXENV=py39 - python: "pypy" env: TOXENV=pypy - python: "pypy3" diff --git a/setup.py b/setup.py index f5c2a507..530ab129 100644 --- a/setup.py +++ b/setup.py @@ -53,6 +53,7 @@ def read_files(files): 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: Implementation :: PyPy', 'Intended Audience :: Developers', 'Intended Audience :: System Administrators', diff --git a/tox.ini b/tox.ini index 2dd61864..0025c946 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = lint,py{27,34,35,36,37,38,34-no-typing},pypy,pypy3,manifest,coverage-report +envlist = lint,py{27,34,35,36,37,38,39,34-no-typing},pypy,pypy3,manifest,coverage-report [testenv] deps = @@ -10,7 +10,7 @@ deps = click py{27,py}: ipython<6.0.0 py34{,-no-typing}: ipython<7.0.0 - py{35,36,37,38,py3}: ipython + py{35,36,37,38,39,py3}: ipython commands = coverage run --parallel -m pytest {posargs} [testenv:py34-no-typing] @@ -25,6 +25,7 @@ deps = mypy commands = flake8 src tests + mypy --python-version=3.9 src tests mypy --python-version=3.8 src tests mypy --python-version=3.7 src tests mypy --python-version=3.6 src tests From fa354ce5d4c4f515e340265bf7c0f7e32fb0e75d Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 18 Oct 2020 16:49:26 +0530 Subject: [PATCH 063/253] Migrate from travis-ci.org to .com --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 462a11d3..5c9aeaf9 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ __ | |____ | |\ | \ / (__)|_______||__| \__| \__/ ``` -python-dotenv | [![Build Status](https://travis-ci.org/theskumar/python-dotenv.svg?branch=master)](https://travis-ci.org/theskumar/python-dotenv) [![Coverage Status](https://coveralls.io/repos/theskumar/python-dotenv/badge.svg?branch=master)](https://coveralls.io/r/theskumar/python-dotenv?branch=master) [![PyPI version](https://badge.fury.io/py/python-dotenv.svg)](http://badge.fury.io/py/python-dotenv) [![Say Thanks!](https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg)](https://saythanks.io/to/theskumar) +python-dotenv | [![Build Status](https://travis-ci.com/theskumar/python-dotenv.svg?branch=master)](https://travis-ci.com/theskumar/python-dotenv) [![Coverage Status](https://coveralls.io/repos/theskumar/python-dotenv/badge.svg?branch=master)](https://coveralls.io/r/theskumar/python-dotenv?branch=master) [![PyPI version](https://badge.fury.io/py/python-dotenv.svg)](http://badge.fury.io/py/python-dotenv) [![Say Thanks!](https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg)](https://saythanks.io/to/theskumar) =============================================================================== Reads the key-value pair from `.env` file and adds them to environment @@ -48,7 +48,7 @@ The value of a variable is the first of the values defined in the following list - Default value, if provided. - Empty string. -Ensure that variables are surrounded with `{}` like `${HOME}` as bare +Ensure that variables are surrounded with `{}` like `${HOME}` as bare variables such as `$HOME` are not expanded. ```shell From e13d957bf48224453c5d9d9a7a83a13b999e0196 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Wed, 28 Oct 2020 17:57:28 +0100 Subject: [PATCH 064/253] Release v0.15.0 --- CHANGELOG.md | 7 ++++++- setup.cfg | 2 +- src/dotenv/version.py | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5305f19..56a7a94c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +_There are no unreleased changes at this time._ + +## [0.15.0] - 2020-10-28 + ### Added - Add `--export` option to `set` to make it prepend the binding with `export` (#270 by @@ -231,7 +235,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@x-yuri]: https://github.com/x-yuri [@yannham]: https://github.com/yannham -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.14.0...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.15.0...HEAD +[0.15.0]: https://github.com/theskumar/python-dotenv/compare/v0.14.0...v0.15.0 [0.14.0]: https://github.com/theskumar/python-dotenv/compare/v0.13.0...v0.14.0 [0.13.0]: https://github.com/theskumar/python-dotenv/compare/v0.12.0...v0.13.0 [0.12.0]: https://github.com/theskumar/python-dotenv/compare/v0.11.0...v0.12.0 diff --git a/setup.cfg b/setup.cfg index 0f168618..9d69a202 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.14.0 +current_version = 0.15.0 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 9e78220f..9da2f8fc 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.14.0" +__version__ = "0.15.0" From 8815885ee1b8ce9a33c3054df5bd7032bf297d33 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 15 Nov 2020 15:26:26 +0100 Subject: [PATCH 065/253] Fix pypy environment in Travis CI --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 8f51de38..8ccd2405 100644 --- a/.travis.yml +++ b/.travis.yml @@ -33,6 +33,7 @@ script: - tox before_install: + - pip install --upgrade pip - pip install coveralls after_success: From 17dba65244c1d4d10f591fe37c924bd2c6fd1cfc Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 15 Nov 2020 13:06:25 +0100 Subject: [PATCH 066/253] Decouple variable parsing and expansion This is now done in two steps: - Parse the value into a sequence of atoms (literal of variable). - Resolve that sequence into a string. --- src/dotenv/main.py | 59 ++++++++-------------- src/dotenv/variables.py | 106 ++++++++++++++++++++++++++++++++++++++++ tests/test_variables.py | 35 +++++++++++++ 3 files changed, 162 insertions(+), 38 deletions(-) create mode 100644 src/dotenv/variables.py create mode 100644 tests/test_variables.py diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 58a23f3d..ea523d48 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -4,7 +4,6 @@ import io import logging import os -import re import shutil import sys import tempfile @@ -13,13 +12,13 @@ from .compat import IS_TYPE_CHECKING, PY2, StringIO, to_env from .parser import Binding, parse_stream +from .variables import parse_variables logger = logging.getLogger(__name__) if IS_TYPE_CHECKING: - from typing import ( - Dict, Iterable, Iterator, Match, Optional, Pattern, Union, Text, IO, Tuple - ) + from typing import (IO, Dict, Iterable, Iterator, Mapping, Optional, Text, + Tuple, Union) if sys.version_info >= (3, 6): _PathLike = os.PathLike else: @@ -30,18 +29,6 @@ else: _StringIO = StringIO[Text] -__posix_variable = re.compile( - r""" - \$\{ - (?P[^\}:]*) - (?::- - (?P[^\}]*) - )? - \} - """, - re.VERBOSE, -) # type: Pattern[Text] - def with_warn_for_invalid_lines(mappings): # type: (Iterator[Binding]) -> Iterator[Binding] @@ -83,13 +70,14 @@ def dict(self): if self._dict: return self._dict + raw_values = self.parse() + if self.interpolate: - values = resolve_nested_variables(self.parse()) + self._dict = OrderedDict(resolve_variables(raw_values)) else: - values = OrderedDict(self.parse()) + self._dict = OrderedDict(raw_values) - self._dict = values - return values + return self._dict def parse(self): # type: () -> Iterator[Tuple[Text, Optional[Text]]] @@ -217,27 +205,22 @@ def unset_key(dotenv_path, key_to_unset, quote_mode="always"): return removed, key_to_unset -def resolve_nested_variables(values): - # type: (Iterable[Tuple[Text, Optional[Text]]]) -> Dict[Text, Optional[Text]] - def _replacement(name, default): - # type: (Text, Optional[Text]) -> Text - default = default if default is not None else "" - ret = new_values.get(name, os.getenv(name, default)) - return ret # type: ignore +def resolve_variables(values): + # type: (Iterable[Tuple[Text, Optional[Text]]]) -> Mapping[Text, Optional[Text]] - def _re_sub_callback(match): - # type: (Match[Text]) -> Text - """ - From a match object gets the variable name and returns - the correct replacement - """ - matches = match.groupdict() - return _replacement(name=matches["name"], default=matches["default"]) # type: ignore + new_values = {} # type: Dict[Text, Optional[Text]] - new_values = {} + for (name, value) in values: + if value is None: + result = None + else: + atoms = parse_variables(value) + env = {} # type: Dict[Text, Optional[Text]] + env.update(os.environ) # type: ignore + env.update(new_values) + result = "".join(atom.resolve(env) for atom in atoms) - for (k, v) in values: - new_values[k] = __posix_variable.sub(_re_sub_callback, v) if v is not None else None + new_values[name] = result return new_values diff --git a/src/dotenv/variables.py b/src/dotenv/variables.py new file mode 100644 index 00000000..4828dfc2 --- /dev/null +++ b/src/dotenv/variables.py @@ -0,0 +1,106 @@ +import re +from abc import ABCMeta + +from .compat import IS_TYPE_CHECKING + +if IS_TYPE_CHECKING: + from typing import Iterator, Mapping, Optional, Pattern, Text + + +_posix_variable = re.compile( + r""" + \$\{ + (?P[^\}:]*) + (?::- + (?P[^\}]*) + )? + \} + """, + re.VERBOSE, +) # type: Pattern[Text] + + +class Atom(): + __metaclass__ = ABCMeta + + def __ne__(self, other): + # type: (object) -> bool + result = self.__eq__(other) + if result is NotImplemented: + return NotImplemented + return not result + + def resolve(self, env): + # type: (Mapping[Text, Optional[Text]]) -> Text + raise NotImplementedError + + +class Literal(Atom): + def __init__(self, value): + # type: (Text) -> None + self.value = value + + def __repr__(self): + # type: () -> str + return "Literal(value={})".format(self.value) + + def __eq__(self, other): + # type: (object) -> bool + if not isinstance(other, self.__class__): + return NotImplemented + return self.value == other.value + + def __hash__(self): + # type: () -> int + return hash((self.__class__, self.value)) + + def resolve(self, env): + # type: (Mapping[Text, Optional[Text]]) -> Text + return self.value + + +class Variable(Atom): + def __init__(self, name, default): + # type: (Text, Optional[Text]) -> None + self.name = name + self.default = default + + def __repr__(self): + # type: () -> str + return "Variable(name={}, default={})".format(self.name, self.default) + + def __eq__(self, other): + # type: (object) -> bool + if not isinstance(other, self.__class__): + return NotImplemented + return (self.name, self.default) == (other.name, other.default) + + def __hash__(self): + # type: () -> int + return hash((self.__class__, self.name, self.default)) + + def resolve(self, env): + # type: (Mapping[Text, Optional[Text]]) -> Text + default = self.default if self.default is not None else "" + result = env.get(self.name, default) + return result if result is not None else "" + + +def parse_variables(value): + # type: (Text) -> Iterator[Atom] + cursor = 0 + + for match in _posix_variable.finditer(value): + (start, end) = match.span() + name = match.groupdict()["name"] + default = match.groupdict()["default"] + + if start > cursor: + yield Literal(value=value[cursor:start]) + + yield Variable(name=name, default=default) + cursor = end + + length = len(value) + if cursor < length: + yield Literal(value=value[cursor:length]) diff --git a/tests/test_variables.py b/tests/test_variables.py new file mode 100644 index 00000000..86b06466 --- /dev/null +++ b/tests/test_variables.py @@ -0,0 +1,35 @@ +import pytest + +from dotenv.variables import Literal, Variable, parse_variables + + +@pytest.mark.parametrize( + "value,expected", + [ + ("", []), + ("a", [Literal(value="a")]), + ("${a}", [Variable(name="a", default=None)]), + ("${a:-b}", [Variable(name="a", default="b")]), + ( + "${a}${b}", + [ + Variable(name="a", default=None), + Variable(name="b", default=None), + ], + ), + ( + "a${b}c${d}e", + [ + Literal(value="a"), + Variable(name="b", default=None), + Literal(value="c"), + Variable(name="d", default=None), + Literal(value="e"), + ], + ), + ] +) +def test_parse_variables(value, expected): + result = parse_variables(value) + + assert list(result) == expected From 26ff5b74c676dbed391eb535ad2a591ecf98d3c6 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 15 Nov 2020 12:25:27 +0100 Subject: [PATCH 067/253] Fix variable expansion order without override This fixes an issue when a variable is resolved differently in two bindings. For instance, take the following env file: ``` PORT=8000 URL=http://localhost:${PORT} ``` With `PORT` set to `1234` in the environment, the environment resulting from `dotenv_load(override=False)` would be: ``` PORT=1234 URL=http://localhost:8000 ``` This was inconsistent and is fixed by this commit. The environment would now be: ``` PORT=1234 URL=http://localhost:1234 ``` with override, and ``` PORT=8000 URL=http://localhost:8000 ``` without override. The behavior of `load_dotenv` is unchanged and always assumes `override=True`. --- CHANGELOG.md | 2 +- README.md | 11 ++++++++++- src/dotenv/main.py | 30 ++++++++++++++++++------------ tests/test_main.py | 22 ++++++++++++++++++++++ 4 files changed, 51 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56a7a94c..effa2510 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -_There are no unreleased changes at this time._ +- Fix resolution order in variable expansion with `override=False` (#? by [@bbc2]). ## [0.15.0] - 2020-10-28 diff --git a/README.md b/README.md index 5c9aeaf9..36f3b2b0 100644 --- a/README.md +++ b/README.md @@ -41,13 +41,22 @@ export SECRET_KEY=YOURSECRETKEYGOESHERE Python-dotenv can interpolate variables using POSIX variable expansion. -The value of a variable is the first of the values defined in the following list: +With `load_dotenv(override=True)` or `dotenv_values()`, the value of a variable is the +first of the values defined in the following list: - Value of that variable in the `.env` file. - Value of that variable in the environment. - Default value, if provided. - Empty string. +With `load_dotenv(override=False)`, the value of a variable is the first of the values +defined in the following list: + +- Value of that variable in the environment. +- Value of that variable in the `.env` file. +- Default value, if provided. +- Empty string. + Ensure that variables are surrounded with `{}` like `${HOME}` as bare variables such as `$HOME` are not expanded. diff --git a/src/dotenv/main.py b/src/dotenv/main.py index ea523d48..b366b18e 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -43,13 +43,14 @@ def with_warn_for_invalid_lines(mappings): class DotEnv(): - def __init__(self, dotenv_path, verbose=False, encoding=None, interpolate=True): - # type: (Union[Text, _PathLike, _StringIO], bool, Union[None, Text], bool) -> None + def __init__(self, dotenv_path, verbose=False, encoding=None, interpolate=True, override=True): + # type: (Union[Text, _PathLike, _StringIO], bool, Union[None, Text], bool, bool) -> None self.dotenv_path = dotenv_path # type: Union[Text,_PathLike, _StringIO] self._dict = None # type: Optional[Dict[Text, Optional[Text]]] self.verbose = verbose # type: bool self.encoding = encoding # type: Union[None, Text] self.interpolate = interpolate # type: bool + self.override = override # type: bool @contextmanager def _get_stream(self): @@ -73,7 +74,7 @@ def dict(self): raw_values = self.parse() if self.interpolate: - self._dict = OrderedDict(resolve_variables(raw_values)) + self._dict = OrderedDict(resolve_variables(raw_values, override=self.override)) else: self._dict = OrderedDict(raw_values) @@ -86,13 +87,13 @@ def parse(self): if mapping.key is not None: yield mapping.key, mapping.value - def set_as_environment_variables(self, override=False): - # type: (bool) -> bool + def set_as_environment_variables(self): + # type: () -> bool """ Load the current dotenv as system environemt variable. """ for k, v in self.dict().items(): - if k in os.environ and not override: + if k in os.environ and not self.override: continue if v is not None: os.environ[to_env(k)] = to_env(v) @@ -205,8 +206,8 @@ def unset_key(dotenv_path, key_to_unset, quote_mode="always"): return removed, key_to_unset -def resolve_variables(values): - # type: (Iterable[Tuple[Text, Optional[Text]]]) -> Mapping[Text, Optional[Text]] +def resolve_variables(values, override): + # type: (Iterable[Tuple[Text, Optional[Text]]], bool) -> Mapping[Text, Optional[Text]] new_values = {} # type: Dict[Text, Optional[Text]] @@ -216,8 +217,12 @@ def resolve_variables(values): else: atoms = parse_variables(value) env = {} # type: Dict[Text, Optional[Text]] - env.update(os.environ) # type: ignore - env.update(new_values) + if override: + env.update(os.environ) # type: ignore + env.update(new_values) + else: + env.update(new_values) + env.update(os.environ) # type: ignore result = "".join(atom.resolve(env) for atom in atoms) new_values[name] = result @@ -299,10 +304,11 @@ def load_dotenv(dotenv_path=None, stream=None, verbose=False, override=False, in Defaults to `False`. """ f = dotenv_path or stream or find_dotenv() - return DotEnv(f, verbose=verbose, interpolate=interpolate, **kwargs).set_as_environment_variables(override=override) + dotenv = DotEnv(f, verbose=verbose, interpolate=interpolate, override=override, **kwargs) + return dotenv.set_as_environment_variables() def dotenv_values(dotenv_path=None, stream=None, verbose=False, interpolate=True, **kwargs): # type: (Union[Text, _PathLike, None], Optional[_StringIO], bool, bool, Union[None, Text]) -> Dict[Text, Optional[Text]] # noqa: E501 f = dotenv_path or stream or find_dotenv() - return DotEnv(f, verbose=verbose, interpolate=interpolate, **kwargs).dict() + return DotEnv(f, verbose=verbose, interpolate=interpolate, override=True, **kwargs).dict() diff --git a/tests/test_main.py b/tests/test_main.py index 6b9458d2..b927d7f2 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -257,6 +257,28 @@ def test_load_dotenv_existing_variable_override(dotenv_file): assert os.environ == {"a": "b"} +@mock.patch.dict(os.environ, {"a": "c"}, clear=True) +def test_load_dotenv_redefine_var_used_in_file_no_override(dotenv_file): + with open(dotenv_file, "w") as f: + f.write('a=b\nd="${a}"') + + result = dotenv.load_dotenv(dotenv_file) + + assert result is True + assert os.environ == {"a": "c", "d": "c"} + + +@mock.patch.dict(os.environ, {"a": "c"}, clear=True) +def test_load_dotenv_redefine_var_used_in_file_with_override(dotenv_file): + with open(dotenv_file, "w") as f: + f.write('a=b\nd="${a}"') + + result = dotenv.load_dotenv(dotenv_file, override=True) + + assert result is True + assert os.environ == {"a": "b", "d": "b"} + + @mock.patch.dict(os.environ, {}, clear=True) def test_load_dotenv_utf_8(): stream = StringIO("a=à") From 2e0ea4873ba39192db97fcafdb16ae03ffcaf951 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 27 Dec 2020 20:28:16 +0100 Subject: [PATCH 068/253] Rewrite readme (#294) This mainly reorganizes the readme based on questions and feedback from users on GitHub over the years: - Getting Started: Short section which covers the main use case and doesn't go into details. - Pip command at the very beginning so that users are less likely to mistakenly install another package. - Basic application code to load the .env file into the environment. - Introduction to the syntax of .env files with a short example. - Other common use cases: - Load configuration without altering the environment - Parse configuration as a stream - Load .env files in IPython - Command-line Interface - File format: Details about the syntax of .env files, previously scattered around. - Related Projects: I'm not sure we really need that one but I guess we can keep it for now. - Acknowledgements Minor changes: - I removed the "saythanks" link since it is dead. - I removed the banner made in ASCII art since it read ".env" and not "python-dotenv", which I found distracting. We could make another one but I don't have time right now. It also saves the user some scrolling. --- README.md | 339 +++++++++++++++++++++--------------------------------- 1 file changed, 132 insertions(+), 207 deletions(-) diff --git a/README.md b/README.md index 36f3b2b0..03638adc 100644 --- a/README.md +++ b/README.md @@ -1,276 +1,193 @@ -``` - _______ .__ __. ____ ____ - | ____|| \ | | \ \ / / - | |__ | \| | \ \/ / - | __| | . ` | \ / - __ | |____ | |\ | \ / - (__)|_______||__| \__| \__/ -``` -python-dotenv | [![Build Status](https://travis-ci.com/theskumar/python-dotenv.svg?branch=master)](https://travis-ci.com/theskumar/python-dotenv) [![Coverage Status](https://coveralls.io/repos/theskumar/python-dotenv/badge.svg?branch=master)](https://coveralls.io/r/theskumar/python-dotenv?branch=master) [![PyPI version](https://badge.fury.io/py/python-dotenv.svg)](http://badge.fury.io/py/python-dotenv) [![Say Thanks!](https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg)](https://saythanks.io/to/theskumar) -=============================================================================== - -Reads the key-value pair from `.env` file and adds them to environment -variable. It is great for managing app settings during development and -in production using [12-factor](http://12factor.net/) principles. +# python-dotenv -> Do one thing, do it well! +[![Build Status][build_status_badge]][build_status_link] +[![Coverage Status][coverage_status_badge]][coverage_status_link] +[![PyPI version][pypi_badge]][pypi_link] -## Usages +Python-dotenv reads key-value pairs from a `.env` file and can set them as environment +variables. It helps in the development of applications following the +[12-factor](http://12factor.net/) principles. -The easiest and most common usage consists on calling `load_dotenv` when -the application starts, which will load environment variables from a -file named `.env` in the current directory or any of its parents or from -the path specificied; after that, you can just call the -environment-related method you need as provided by `os.getenv`. - -`.env` looks like this: +## Getting Started ```shell -# a comment that will be ignored. -REDIS_ADDRESS=localhost:6379 -MEANING_OF_LIFE=42 -MULTILINE_VAR="hello\nworld" +pip install python-dotenv ``` -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. +If your application takes its configuration from environment variables, like a 12-factor +application, launching it in development is not very practical because you have to set +those environment variables yourself. -``` -export S3_BUCKET=YOURS3BUCKET -export SECRET_KEY=YOURSECRETKEYGOESHERE -``` +To help you with that, you can add Python-dotenv to your application to make it load the +configuration from a `.env` file when it is present (e.g. in development) while remaining +configurable via the environment: -Python-dotenv can interpolate variables using POSIX variable expansion. +```python +from dotenv import load_dotenv -With `load_dotenv(override=True)` or `dotenv_values()`, the value of a variable is the -first of the values defined in the following list: +load_dotenv() # take environment variables from .env. -- Value of that variable in the `.env` file. -- Value of that variable in the environment. -- Default value, if provided. -- Empty string. +# Code of your application, which uses environment variables (e.g. from `os.environ` or +# `os.getenv`) as if they came from the actual environment. +``` -With `load_dotenv(override=False)`, the value of a variable is the first of the values -defined in the following list: +By default, `load_dotenv` doesn't override existing environment variables. -- Value of that variable in the environment. -- Value of that variable in the `.env` file. -- Default value, if provided. -- Empty string. +To configure the development environment, add a `.env` in the root directory of your +project: -Ensure that variables are surrounded with `{}` like `${HOME}` as bare -variables such as `$HOME` are not expanded. +``` +. +├── .env +└── foo.py +``` -```shell -CONFIG_PATH=${HOME}/.config/foo +The syntax of `.env` files supported by python-dotenv is similar to that of Bash: + +```bash +# Development settings DOMAIN=example.org -EMAIL=admin@${DOMAIN} -DEBUG=${DEBUG:-false} +ADMIN_EMAIL=admin@${DOMAIN} +ROOT_URL=${DOMAIN}/app ``` -## Getting started +If you use variables in values, ensure they are surrounded with `{` and `}`, like +`${DOMAIN}`, as bare variables such as `$DOMAIN` are not expanded. -Install the latest version with: +You will probably want to add `.env` to your `.gitignore`, especially if it contains +secrets like a password. -```shell -pip install -U python-dotenv -``` +See the section "File format" below for more information about what you can write in a +`.env` file. -Assuming you have created the `.env` file along-side your settings -module. +## Other Use Cases - . - ├── .env - └── settings.py +### Load configuration without altering the environment -Add the following code to your `settings.py`: +The function `dotenv_values` works more or less the same way as `load_dotenv`, except it +doesn't touch the environment, it just returns a `dict` with the values parsed from the +`.env` file. ```python -# settings.py -from dotenv import load_dotenv -load_dotenv() - -# OR, the same with increased verbosity -load_dotenv(verbose=True) +from dotenv import dotenv_values -# OR, explicitly providing path to '.env' -from pathlib import Path # Python 3.6+ only -env_path = Path('.') / '.env' -load_dotenv(dotenv_path=env_path) +config = dotenv_values(".env") # config = {"USER": "foo", "EMAIL": "foo@example.org"} ``` -At this point, parsed key/value from the `.env` file is now present as -system environment variable and they can be conveniently accessed via -`os.getenv()`: +This notably enables advanced configuration management: ```python -# settings.py import os -SECRET_KEY = os.getenv("EMAIL") -DATABASE_PASSWORD = os.getenv("DATABASE_PASSWORD") -``` - -`load_dotenv` does 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. +from dotenv import dotenv_values -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 -and the REPL, and then (b) walking up the directory tree looking for the -specified file -- called `.env` by default. - -```python -from dotenv import load_dotenv, find_dotenv -load_dotenv(find_dotenv()) +config = { + **dotenv_values(".env.shared"), # load shared development variables + **dotenv_values(".env.secret"), # load sensitive variables + **os.environ, # override loaded values with environment variables +} ``` -### In-memory filelikes +### Parse configuration as a stream -It is possible to not rely on the filesystem to parse filelikes from -other sources (e.g. from a network storage). `load_dotenv` and -`dotenv_values` accepts a filelike `stream`. Just be sure to rewind it -before passing. +`load_dotenv` and `dotenv_values` accept [streams][python_streams] via their `stream` +argument. It is thus possible to load the variables from sources other than the +filesystem (e.g. the network). ```python ->>> from io import StringIO # Python2: from StringIO import StringIO ->>> from dotenv import dotenv_values ->>> filelike = StringIO('SPAM=EGGS\n') ->>> filelike.seek(0) ->>> parsed = dotenv_values(stream=filelike) ->>> parsed['SPAM'] -'EGGS' -``` - -The returned value is dictionary with key-value pairs. - -`dotenv_values` could be useful if you need to *consume* the envfile but -not *apply* it directly into the system environment. - -### Django - -If you are using Django, you should add the above loader script at the -top of `wsgi.py` and `manage.py`. +from io import StringIO +from dotenv import load_dotenv -## IPython Support +config = StringIO("USER=foo\nEMAIL=foo@example.org") +load_dotenv(stream=stream) +``` -You can use dotenv with IPython. You can either let the dotenv search -for `.env` with `%dotenv` or provide the path to the `.env` file explicitly; see -below for usages. +### Load .env files in IPython - %load_ext dotenv +You can use dotenv in IPython. By default, it will use `find_dotenv` to search for a +`.env` file: - # Use find_dotenv to locate the file - %dotenv +```python +%load_ext dotenv +%dotenv +``` - # Specify a particular file - %dotenv relative/or/absolute/path/to/.env +You can also specify a path: - # Use '-o' to indicate override of existing variables - %dotenv -o +```python +%dotenv relative/or/absolute/path/to/.env +``` - # Use '-v' to turn verbose mode on - %dotenv -v +Optional flags: +- `-o` to override existing variables. +- `-v` for increased verbosity. ## Command-line Interface -For command-line support, use the CLI option during installation: +A CLI interface `dotenv` is also included, which helps you manipulate the `.env` file +without manually opening it. ```shell -pip install -U "python-dotenv[cli]" +$ pip install "python-dotenv[cli]" +$ dotenv set USER=foo +$ dotenv set EMAIL=foo@example.org +$ dotenv list +USER=foo +EMAIL=foo@example.org +$ dotenv run -- python foo.py ``` -A CLI interface `dotenv` is also included, which helps you manipulate -the `.env` file without manually opening it. The same CLI installed on -remote machine combined with fabric (discussed later) will enable you to -update your settings on a remote server; handy, isn't it! +Run `dotenv --help` for more information about the options and subcommands. -``` -Usage: dotenv [OPTIONS] COMMAND [ARGS]... - - This script is used to set, get or unset values from a .env file. - -Options: - -f, --file PATH Location of the .env file, defaults to .env - file in current working directory. +## File format - -q, --quote [always|never|auto] - Whether to quote or not the variable values. - Default mode is always. This does not affect - parsing. +The format is not formally specified and still improves over time. That being said, +`.env` files should mostly look like Bash files. - -e, --export BOOLEAN - Whether to write the dot file as an - executable bash script. +Keys can be unquoted or single-quoted. Values can be unquoted, single- or double-quoted. +Spaces before and after keys, equal signs, and values are ignored. Values can be followed +by a comment. Lines can start with the `export` directive, which has no effect on their +interpretation. - --version Show the version and exit. - --help Show this message and exit. - -Commands: - get Retrieve the value for the given key. - list Display all the stored key/value. - run Run command with environment variables present. - set Store the given key/value. - unset Removes the given key. -``` +Allowed escape sequences: +- in single-quoted values: `\\`, `\'` +- in double-quoted values: `\\`, `\'`, `\"`, `\a`, `\b`, `\f`, `\n`, `\r`, `\t`, `\v` -### Setting config on Remote Servers +### Multiline values -We make use of excellent [Fabric](http://www.fabfile.org/) to accomplish -this. Add a config task to your local fabfile; `dotenv_path` is the -location of the absolute path of `.env` file on the remote server. +It is possible for single- or double-quoted values to span multiple lines. The following +examples are equivalent: -```python -# fabfile.py - -import dotenv -from fabric.api import task, run, env - -# absolute path to the location of .env on remote server. -env.dotenv_path = '/opt/myapp/.env' - -@task -def config(action=None, key=None, value=None): - '''Manage project configuration via .env - - e.g: fab config:set,, - fab config:get, - fab config:unset, - fab config:list - ''' - run('touch %(dotenv_path)s' % env) - command = dotenv.get_cli_string(env.dotenv_path, action, key, value) - run(command) +```bash +FOO="first line +second line" ``` -Usage is designed to mirror the Heroku config API very closely. - -Get all your remote config info with `fab config`: - - $ fab config - foo="bar" - -Set remote config variables with `fab config:set,,`: - - $ fab config:set,hello,world - -Get a single remote config variables with `fab config:get,`: +```bash +FOO="first line\nsecond line" +``` - $ fab config:get,hello +### Variable expansion -Delete a remote config variables with `fab config:unset,`: +Python-dotenv can interpolate variables using POSIX variable expansion. - $ fab config:unset,hello +With `load_dotenv(override=True)` or `dotenv_values()`, the value of a variable is the +first of the values defined in the following list: -Thanks entirely to fabric and not one bit to this project, you can chain -commands like so: -`fab config:set,, config:set,,` +- Value of that variable in the `.env` file. +- Value of that variable in the environment. +- Default value, if provided. +- Empty string. - $ fab config:set,hello,world config:set,foo,bar config:set,fizz=buzz +With `load_dotenv(override=False)`, the value of a variable is the first of the values +defined in the following list: +- Value of that variable in the environment. +- Value of that variable in the `.env` file. +- Default value, if provided. +- Empty string. ## Related Projects @@ -283,9 +200,17 @@ commands like so: - [environs](https://github.com/sloria/environs) - [dynaconf](https://github.com/rochacbruno/dynaconf) - ## Acknowledgements -This project is currently maintained by [Saurabh Kumar](https://saurabh-kumar.com) and [Bertrand Bonnefoy-Claudet](https://github.com/bbc2) and would not -have been possible without the support of these [awesome +This project is currently maintained by [Saurabh Kumar](https://saurabh-kumar.com) and +[Bertrand Bonnefoy-Claudet](https://github.com/bbc2) and would not have been possible +without the support of these [awesome people](https://github.com/theskumar/python-dotenv/graphs/contributors). + +[build_status_badge]: https://travis-ci.com/theskumar/python-dotenv.svg?branch=master +[build_status_link]: https://travis-ci.com/theskumar/python-dotenv +[coverage_status_badge]: https://coveralls.io/repos/theskumar/python-dotenv/badge.svg?branch=master +[coverage_status_link]: https://coveralls.io/r/theskumar/python-dotenv?branch=master +[pypi_badge]: https://badge.fury.io/py/python-dotenv.svg +[pypi_link]: http://badge.fury.io/py/python-dotenv +[python_streams]: https://docs.python.org/3/library/io.html From 192722508a62fc4e25293a9e6061744773a3fdf7 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Mon, 28 Dec 2020 01:20:57 +0530 Subject: [PATCH 069/253] doc: add table of content --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 03638adc..127d46f0 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,18 @@ Python-dotenv reads key-value pairs from a `.env` file and can set them as envir variables. It helps in the development of applications following the [12-factor](http://12factor.net/) principles. +- [Getting Started](#getting-started) +- [Other Use Cases](#other-use-cases) + * [Load configuration without altering the environment](#load-configuration-without-altering-the-environment) + * [Parse configuration as a stream](#parse-configuration-as-a-stream) + * [Load .env files in IPython](#load-env-files-in-ipython) +- [Command-line Interface](#command-line-interface) +- [File format](#file-format) + * [Multiline values](#multiline-values) + * [Variable expansion](#variable-expansion) +- [Related Projects](#related-projects) +- [Acknowledgements](#acknowledgements) + ## Getting Started ```shell From ac670cf993fd622bfc0a5ea961681341b291779e Mon Sep 17 00:00:00 2001 From: Serghei Iakovlev Date: Wed, 27 Jan 2021 02:36:45 +0200 Subject: [PATCH 070/253] Fix misspelling --- src/dotenv/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index b366b18e..31c41ee7 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -90,7 +90,7 @@ def parse(self): def set_as_environment_variables(self): # type: () -> bool """ - Load the current dotenv as system environemt variable. + Load the current dotenv as system environment variable. """ for k, v in self.dict().items(): if k in os.environ and not self.override: From a7fe93f6cc73ab9de28191e3854f1a713d53363b Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 18 Oct 2020 17:29:07 +0530 Subject: [PATCH 071/253] Add GitHub actions to replace Travis CI --- .github/workflows/release.yml | 25 +++++++++++++++++ .github/workflows/test.yml | 25 +++++++++++++++++ .travis.yml | 52 ----------------------------------- setup.cfg | 4 ++- tox.ini | 19 ++++++++----- 5 files changed, 65 insertions(+), 60 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/test.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..a3abd994 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,25 @@ +name: Upload Python Package + +on: + release: + types: [created] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + make release diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..04805932 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: Run Tests + +on: [push, pull_request] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + max-parallel: 8 + matrix: + os: + - ubuntu-latest + python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, pypy2, pypy3] + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox tox-gh-actions + - name: Test with tox + run: tox diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 8ccd2405..00000000 --- a/.travis.yml +++ /dev/null @@ -1,52 +0,0 @@ -language: python -cache: pip -os: linux -dist: xenial - -jobs: - include: - - python: "3.6" - env: TOXENV=lint - - python: "3.6" - env: TOXENV=manifest - - python: "2.7" - env: TOXENV=py27 - - python: "3.5" - env: TOXENV=py35 - - python: "3.6" - env: TOXENV=py36 - - python: "3.7" - env: TOXENV=py37 - - python: "3.8" - env: TOXENV=py38 - - python: "3.9-dev" - env: TOXENV=py39 - - python: "pypy" - env: TOXENV=pypy - - python: "pypy3" - env: TOXENV=pypy3 - -install: - - pip install tox - -script: - - tox - -before_install: - - pip install --upgrade pip - - pip install coveralls - -after_success: - - tox -e coverage-report - - coveralls - -deploy: - provider: pypi - username: theskumar - password: - secure: DXUkl4YSC2RCltChik1csvQulnVMQQpD/4i4u+6pEyUfBMYP65zFYSNwLh+jt+URyX+MpN/Er20+TZ/F/fu7xkru6/KBqKLugeXihNbwGhbHUIkjZT/0dNSo03uAz6s5fWgqr8EJk9Ll71GexAsBPx2yqsjc2BMgOjwcNly40Co= - distributions: "sdist bdist_wheel" - skip_existing: true - on: - tags: true - repo: theskumar/python-dotenv diff --git a/setup.cfg b/setup.cfg index 9d69a202..e882b8db 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,12 +17,13 @@ exclude = .tox,.git,docs,venv,.venv ignore_missing_imports = true [metadata] -description-file = README.rst +description-file = README.md [tool:pytest] testpaths = tests [coverage:run] +relative_files = True source = dotenv [coverage:paths] @@ -33,6 +34,7 @@ source = [coverage:report] show_missing = True +include = */site-packages/dotenv/* exclude_lines = if IS_TYPE_CHECKING: pragma: no cover diff --git a/tox.ini b/tox.ini index 0025c946..e4d6f638 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,16 @@ [tox] -envlist = lint,py{27,34,35,36,37,38,39,34-no-typing},pypy,pypy3,manifest,coverage-report +envlist = lint,py{27,35,36,37,38,39},pypy,pypy3,manifest,coverage-report + +[gh-actions] +python = + 2.7: py27, coverage-report + 3.5: py35, coverage-report + 3.6: py36, coverage-report + 3.7: py37, coverage-report + 3.8: py38, coverage-report + 3.9: py39, mypy, lint, manifest, coverage-report + pypy2: pypy, coverage-report + pypy3: pypy3, coverage-report [testenv] deps = @@ -9,15 +20,9 @@ deps = sh click py{27,py}: ipython<6.0.0 - py34{,-no-typing}: ipython<7.0.0 py{35,36,37,38,39,py3}: ipython commands = coverage run --parallel -m pytest {posargs} -[testenv:py34-no-typing] -commands = - pip uninstall --yes typing - coverage run --parallel -m pytest -k 'not test_ipython' {posargs} - [testenv:lint] skip_install = true deps = From b158aa721cdf625c72f79f3583e5cc1f0cea2950 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Wed, 10 Mar 2021 22:36:01 +0100 Subject: [PATCH 072/253] Use UTF-8 as default encoding The default value for the `encoding` paramter of `load_dotenv` and `dotenv_values` is now `"utf-8"` instead of `None` (which selected the encoding based on the user's locale). It is passed directly to `io.open`. The rationale for this change is that the encoding of a project file like `.env` should not depend on the user's locale by default. UTF-8 makes sense as the default encoding since it is also used for Python source files. The main drawback is that it departs from `open`'s default value of `None` for the `encoding` parameter. The default value of `None` was a source of confusion for some users. The Flask and Docker Compose projects already use `encoding="utf-8"` to enforce the use of UTF-8 and avoid that sort of confusion. This is a breaking change but only for users with a non-UTF-8 locale and non-UTF-8 characters in their .env files. --- CHANGELOG.md | 6 ++++- src/dotenv/main.py | 61 ++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index effa2510..1db10901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,11 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -- Fix resolution order in variable expansion with `override=False` (#? by [@bbc2]). +### Changed + +- The default value of the `encoding` parameter for `load_dotenv` and `dotenv_values` is + now `"utf-8"` instead of `None` (#? by [@bbc2]). +- Fix resolution order in variable expansion with `override=False` (#287 by [@bbc2]). ## [0.15.0] - 2020-10-28 diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 31c41ee7..16f22d2c 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -293,22 +293,63 @@ def _is_interactive(): return '' -def load_dotenv(dotenv_path=None, stream=None, verbose=False, override=False, interpolate=True, **kwargs): - # type: (Union[Text, _PathLike, None], Optional[_StringIO], bool, bool, bool, Union[None, Text]) -> bool +def load_dotenv( + dotenv_path=None, + stream=None, + verbose=False, + override=False, + interpolate=True, + encoding="utf-8", +): + # type: (Union[Text, _PathLike, None], Optional[_StringIO], bool, bool, bool, Optional[Text]) -> bool # noqa """Parse a .env file and then load all the variables found as environment variables. - *dotenv_path*: absolute or relative path to .env file. - - *stream*: `StringIO` object with .env content. - - *verbose*: whether to output the warnings related to missing .env file etc. Defaults to `False`. - - *override*: where to override the system environment variables with the variables in `.env` file. - Defaults to `False`. + - *stream*: `StringIO` object with .env content, used if `dotenv_path` is `None`. + - *verbose*: whether to output a warning the .env file is missing. Defaults to + `False`. + - *override*: whether to override the system environment variables with the variables + in `.env` file. Defaults to `False`. + - *encoding*: encoding to be used to read the file. + + If both `dotenv_path` and `stream`, `find_dotenv()` is used to find the .env file. """ f = dotenv_path or stream or find_dotenv() - dotenv = DotEnv(f, verbose=verbose, interpolate=interpolate, override=override, **kwargs) + dotenv = DotEnv( + f, + verbose=verbose, + interpolate=interpolate, + override=override, + encoding=encoding, + ) return dotenv.set_as_environment_variables() -def dotenv_values(dotenv_path=None, stream=None, verbose=False, interpolate=True, **kwargs): - # type: (Union[Text, _PathLike, None], Optional[_StringIO], bool, bool, Union[None, Text]) -> Dict[Text, Optional[Text]] # noqa: E501 +def dotenv_values( + dotenv_path=None, + stream=None, + verbose=False, + interpolate=True, + encoding="utf-8", +): + # type: (Union[Text, _PathLike, None], Optional[_StringIO], bool, bool, Optional[Text]) -> Dict[Text, Optional[Text]] # noqa: E501 + """ + Parse a .env file and return its content as a dict. + + - *dotenv_path*: absolute or relative path to .env file. + - *stream*: `StringIO` object with .env content, used if `dotenv_path` is `None`. + - *verbose*: whether to output a warning the .env file is missing. Defaults to + `False`. + in `.env` file. Defaults to `False`. + - *encoding*: encoding to be used to read the file. + + If both `dotenv_path` and `stream`, `find_dotenv()` is used to find the .env file. + """ f = dotenv_path or stream or find_dotenv() - return DotEnv(f, verbose=verbose, interpolate=interpolate, override=True, **kwargs).dict() + return DotEnv( + f, + verbose=verbose, + interpolate=interpolate, + override=True, + encoding=encoding, + ).dict() From b96db46dc8b66adba8afcfd3ec3b8ed2b4d6cefe Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 27 Mar 2021 10:00:25 +0100 Subject: [PATCH 073/253] Release version v0.16.0 --- CHANGELOG.md | 9 +++++++-- setup.cfg | 2 +- setup.py | 2 +- src/dotenv/version.py | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1db10901..14f10d37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,14 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +_There are no unreleased changes at this time._ + +## [0.16.0] - 2021-03-27 + ### Changed - The default value of the `encoding` parameter for `load_dotenv` and `dotenv_values` is - now `"utf-8"` instead of `None` (#? by [@bbc2]). + now `"utf-8"` instead of `None` (#306 by [@bbc2]). - Fix resolution order in variable expansion with `override=False` (#287 by [@bbc2]). ## [0.15.0] - 2020-10-28 @@ -239,7 +243,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@x-yuri]: https://github.com/x-yuri [@yannham]: https://github.com/yannham -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.15.0...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.16.0...HEAD +[0.16.0]: https://github.com/theskumar/python-dotenv/compare/v0.15.0...v0.16.0 [0.15.0]: https://github.com/theskumar/python-dotenv/compare/v0.14.0...v0.15.0 [0.14.0]: https://github.com/theskumar/python-dotenv/compare/v0.13.0...v0.14.0 [0.13.0]: https://github.com/theskumar/python-dotenv/compare/v0.12.0...v0.13.0 diff --git a/setup.cfg b/setup.cfg index e882b8db..03e6644f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.15.0 +current_version = 0.16.0 commit = True tag = True diff --git a/setup.py b/setup.py index 530ab129..3fc452c5 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ def read_files(files): setup( name="python-dotenv", - description="Add .env support to your django/flask apps in development and deployments", + description="Read key-value pairs from a .env file and set them as environment variables", long_description=long_description, long_description_content_type='text/markdown', version=meta['__version__'], diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 9da2f8fc..5a313cc7 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.15.0" +__version__ = "0.16.0" From efc51829f8e7693dbf4b7655169a9f852e9943d2 Mon Sep 17 00:00:00 2001 From: zueve Date: Thu, 28 Jan 2021 12:33:48 +0200 Subject: [PATCH 074/253] Add --override/--no-override flag to "dotenv run" This makes it possible to not override previously defined environment variables when running `dotenv run`. It defaults to `--override` for compatibility with the previous behavior. --- CHANGELOG.md | 5 ++++- src/dotenv/cli.py | 15 ++++++++++++--- tests/test_cli.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14f10d37..5a5b276b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -_There are no unreleased changes at this time._ +### Added + +- Add `--override`/`--no-override` option to `dotenv run` (#312 by [@zueve] and [@bbc2]). ## [0.16.0] - 2021-03-27 @@ -242,6 +244,7 @@ _There are no unreleased changes at this time._ [@venthur]: https://github.com/venthur [@x-yuri]: https://github.com/x-yuri [@yannham]: https://github.com/yannham +[@zueve]: https://github.com/zueve [Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.16.0...HEAD [0.16.0]: https://github.com/theskumar/python-dotenv/compare/v0.15.0...v0.16.0 diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index e17d248f..51f25e8d 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -107,9 +107,14 @@ def unset(ctx, key): @cli.command(context_settings={'ignore_unknown_options': True}) @click.pass_context +@click.option( + "--override/--no-override", + default=True, + help="Override variables from the environment file with those from the .env file.", +) @click.argument('commandline', nargs=-1, type=click.UNPROCESSED) -def run(ctx, commandline): - # type: (click.Context, List[str]) -> None +def run(ctx, override, commandline): + # type: (click.Context, bool, List[str]) -> None """Run command with environment variables present.""" file = ctx.obj['FILE'] if not os.path.isfile(file): @@ -117,7 +122,11 @@ def run(ctx, commandline): 'Invalid value for \'-f\' "%s" does not exist.' % (file), ctx=ctx ) - dotenv_as_dict = {to_env(k): to_env(v) for (k, v) in dotenv_values(file).items() if v is not None} + dotenv_as_dict = { + to_env(k): to_env(v) + for (k, v) in dotenv_values(file).items() + if v is not None and (override or to_env(k) not in os.environ) + } if not commandline: click.echo('No command given.') diff --git a/tests/test_cli.py b/tests/test_cli.py index 23404e70..a048ef3b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -138,6 +138,34 @@ def test_run(tmp_path): assert result == "b\n" +def test_run_with_existing_variable(tmp_path): + sh.cd(str(tmp_path)) + dotenv_file = str(tmp_path / ".env") + with open(dotenv_file, "w") as f: + f.write("a=b") + + result = sh.dotenv("run", "printenv", "a", _env={"LANG": "en_US.UTF-8", "a": "c"}) + + assert result == "b\n" + + +def test_run_with_existing_variable_not_overridden(tmp_path): + sh.cd(str(tmp_path)) + dotenv_file = str(tmp_path / ".env") + with open(dotenv_file, "w") as f: + f.write("a=b") + + result = sh.dotenv( + "run", + "--no-override", + "printenv", + "a", + _env={"LANG": "en_US.UTF-8", "a": "c"}, + ) + + assert result == "c\n" + + def test_run_with_none_value(tmp_path): sh.cd(str(tmp_path)) dotenv_file = str(tmp_path / ".env") From 6242550a53efe45ef53d9904fbb8c4257470c276 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 27 Mar 2021 12:55:12 +0100 Subject: [PATCH 075/253] Use badge from GitHub Actions --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 127d46f0..7f0b2eb5 100644 --- a/README.md +++ b/README.md @@ -219,8 +219,8 @@ This project is currently maintained by [Saurabh Kumar](https://saurabh-kumar.co without the support of these [awesome people](https://github.com/theskumar/python-dotenv/graphs/contributors). -[build_status_badge]: https://travis-ci.com/theskumar/python-dotenv.svg?branch=master -[build_status_link]: https://travis-ci.com/theskumar/python-dotenv +[build_status_badge]: https://github.com/theskumar/python-dotenv/actions/workflows/test.yml/badge.svg +[build_status_link]: https://github.com/theskumar/python-dotenv/actions/workflows/test.yml [coverage_status_badge]: https://coveralls.io/repos/theskumar/python-dotenv/badge.svg?branch=master [coverage_status_link]: https://coveralls.io/r/theskumar/python-dotenv?branch=master [pypi_badge]: https://badge.fury.io/py/python-dotenv.svg From f2eba2c1293b92f23bdf0d6a5b2e7210395dfedf Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 27 Mar 2021 12:57:17 +0100 Subject: [PATCH 076/253] Remove outdated Coveralls badge --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 7f0b2eb5..f8d49562 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # python-dotenv [![Build Status][build_status_badge]][build_status_link] -[![Coverage Status][coverage_status_badge]][coverage_status_link] [![PyPI version][pypi_badge]][pypi_link] Python-dotenv reads key-value pairs from a `.env` file and can set them as environment @@ -221,8 +220,6 @@ people](https://github.com/theskumar/python-dotenv/graphs/contributors). [build_status_badge]: https://github.com/theskumar/python-dotenv/actions/workflows/test.yml/badge.svg [build_status_link]: https://github.com/theskumar/python-dotenv/actions/workflows/test.yml -[coverage_status_badge]: https://coveralls.io/repos/theskumar/python-dotenv/badge.svg?branch=master -[coverage_status_link]: https://coveralls.io/r/theskumar/python-dotenv?branch=master [pypi_badge]: https://badge.fury.io/py/python-dotenv.svg [pypi_link]: http://badge.fury.io/py/python-dotenv [python_streams]: https://docs.python.org/3/library/io.html From 48c5c8e16c1dcb2188984f2245559cee37fe9db4 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 28 Mar 2021 17:55:18 +0200 Subject: [PATCH 077/253] Only display value with `dotenv get` The `get` subcommand would return `key=value`, which is impractical to retrieve the value of a key in a script. Since the `key` is already known by the caller, there is no point in showing it. This also makes the output consistent with the documentation for the subcommand. --- CHANGELOG.md | 4 ++++ src/dotenv/cli.py | 2 +- tests/test_cli.py | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a5b276b..8969b4c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed + +- Make `dotenv get ` only show the value, not `key=value` (#313 by [@bbc2]). + ### Added - Add `--override`/`--no-override` option to `dotenv run` (#312 by [@zueve] and [@bbc2]). diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 51f25e8d..bb96c023 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -85,7 +85,7 @@ def get(ctx, key): ) stored_value = get_key(file, key) if stored_value: - click.echo('%s=%s' % (key, stored_value)) + click.echo(stored_value) else: exit(1) diff --git a/tests/test_cli.py b/tests/test_cli.py index a048ef3b..b21725ca 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -35,7 +35,7 @@ def test_get_existing_value(cli, dotenv_file): result = cli.invoke(dotenv_cli, ['--file', dotenv_file, 'get', 'a']) - assert (result.exit_code, result.output) == (0, "a=b\n") + assert (result.exit_code, result.output) == (0, "b\n") def test_get_non_existent_value(cli, dotenv_file): @@ -124,7 +124,7 @@ def test_get_default_path(tmp_path): result = sh.dotenv("get", "a") - assert result == "a=b\n" + assert result == "b\n" def test_run(tmp_path): From cfca79a3cd384710c98651da79d66d964e0a65d1 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Fri, 2 Apr 2021 23:11:33 +0200 Subject: [PATCH 078/253] Release version 0.17.0 --- CHANGELOG.md | 5 +++-- setup.cfg | 2 +- src/dotenv/version.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8969b4c7..85076147 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.17.0] - 2021-04-02 ### Changed @@ -250,7 +250,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@yannham]: https://github.com/yannham [@zueve]: https://github.com/zueve -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.16.0...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.17.0...HEAD +[0.17.0]: https://github.com/theskumar/python-dotenv/compare/v0.16.0...v0.17.0 [0.16.0]: https://github.com/theskumar/python-dotenv/compare/v0.15.0...v0.16.0 [0.15.0]: https://github.com/theskumar/python-dotenv/compare/v0.14.0...v0.15.0 [0.14.0]: https://github.com/theskumar/python-dotenv/compare/v0.13.0...v0.14.0 diff --git a/setup.cfg b/setup.cfg index 03e6644f..a2b27bfc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.16.0 +current_version = 0.17.0 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 5a313cc7..fd86b3ee 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.16.0" +__version__ = "0.17.0" From abde8e5e8409c70c05edadf379ec7308a4965c8c Mon Sep 17 00:00:00 2001 From: David Wesby Date: Tue, 13 Apr 2021 17:35:59 +0100 Subject: [PATCH 079/253] Fix stream parse example in README.md In the existing example, the name "stream" is undefined, causing a NameError. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f8d49562..9757e672 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ from io import StringIO from dotenv import load_dotenv config = StringIO("USER=foo\nEMAIL=foo@example.org") -load_dotenv(stream=stream) +load_dotenv(stream=config) ``` ### Load .env files in IPython From 7d9b45a290b509c31daed780b97a3a3f15d25065 Mon Sep 17 00:00:00 2001 From: Karolina Surma Date: Wed, 28 Apr 2021 14:27:28 +0200 Subject: [PATCH 080/253] Copy existing environment for usage in tests Overriding the whole environment would remove critical variables like `PYTHONPATH`. This would break tests on some systems like during Fedora or Gentoo packaging. --- CHANGELOG.md | 7 +++++++ tests/test_cli.py | 16 ++++++++-------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85076147..f623e61f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Fixed tests for build environments relying on `PYTHONPATH` (#318 by [@befeleme]). + ## [0.17.0] - 2021-04-02 ### Changed @@ -232,6 +238,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@andrewsmith]: https://github.com/andrewsmith [@asyncee]: https://github.com/asyncee [@bbc2]: https://github.com/bbc2 +[@befeleme]: https://github.com/befeleme [@cjauvin]: https://github.com/cjauvin [@earlbread]: https://github.com/earlbread [@ekohl]: https://github.com/ekohl diff --git a/tests/test_cli.py b/tests/test_cli.py index b21725ca..bc6b8d47 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +import os + import pytest import sh @@ -143,8 +145,10 @@ def test_run_with_existing_variable(tmp_path): dotenv_file = str(tmp_path / ".env") with open(dotenv_file, "w") as f: f.write("a=b") + env = dict(os.environ) + env.update({"LANG": "en_US.UTF-8", "a": "c"}) - result = sh.dotenv("run", "printenv", "a", _env={"LANG": "en_US.UTF-8", "a": "c"}) + result = sh.dotenv("run", "printenv", "a", _env=env) assert result == "b\n" @@ -154,14 +158,10 @@ def test_run_with_existing_variable_not_overridden(tmp_path): dotenv_file = str(tmp_path / ".env") with open(dotenv_file, "w") as f: f.write("a=b") + env = dict(os.environ) + env.update({"LANG": "en_US.UTF-8", "a": "c"}) - result = sh.dotenv( - "run", - "--no-override", - "printenv", - "a", - _env={"LANG": "en_US.UTF-8", "a": "c"}, - ) + result = sh.dotenv("run", "--no-override", "printenv", "a", _env=env) assert result == "c\n" From 303423864ae00f8d5f21cb39d6421a7d775a3daf Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Thu, 29 Apr 2021 21:01:14 +0200 Subject: [PATCH 081/253] Release version 0.17.1 --- CHANGELOG.md | 5 +++-- setup.cfg | 2 +- src/dotenv/version.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f623e61f..e4b81353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.17.1] - 2021-04-29 ### Fixed @@ -257,7 +257,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@yannham]: https://github.com/yannham [@zueve]: https://github.com/zueve -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.17.0...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.17.1...HEAD +[0.17.1]: https://github.com/theskumar/python-dotenv/compare/v0.17.0...v0.17.1 [0.17.0]: https://github.com/theskumar/python-dotenv/compare/v0.16.0...v0.17.0 [0.16.0]: https://github.com/theskumar/python-dotenv/compare/v0.15.0...v0.16.0 [0.15.0]: https://github.com/theskumar/python-dotenv/compare/v0.14.0...v0.15.0 diff --git a/setup.cfg b/setup.cfg index a2b27bfc..58054071 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.17.0 +current_version = 0.17.1 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index fd86b3ee..c6eae9f8 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.17.0" +__version__ = "0.17.1" From b3c31954c2cb907935f77cde653783d4e5a05ec0 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 29 May 2021 14:34:12 +0200 Subject: [PATCH 082/253] Improve quoting of values in `set_key` The value of `quote_mode` must now be one of `auto`, `never` or `always`, to ensure that users aren't accidentally relying on any other value for their scripts to work. Surrounding quotes are no longer stripped. This makes it possible for the user to control exactly what goes in the .env file. Note that when doing `dotenv set foo 'bar'` in Bash, the shell will have already removed the quotes. Single quotes are used instead of double quotes. This avoids accidentally having values interpreted by the parser or Bash (e.g. if you set a password with `dotenv set password 'af$rb0'`. Previously, the `auto` mode of quoting had the same effect as `always`. This commit restores the functionality of `auto` by not quoting alphanumeric values (which don't need quotes). Plenty of other kinds of values also don't need quotes but it's hard to know which ones without actually parsing them, so we just omit quotes for alphanumeric values, at least for now. --- CHANGELOG.md | 13 +++++++++++++ src/dotenv/main.py | 13 ++++++++----- tests/test_cli.py | 13 +++++++------ tests/test_main.py | 24 ++++++++++++------------ 4 files changed, 40 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4b81353..0852d66e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- Raise `ValueError` if `quote_mode` isn't one of `always`, `auto` or `never` in + `set_key` (#330 by [@bbc2]). +- When writing a value to a .env file with `set_key` or `dotenv set ` (#330 + by [@bbc2]): + - Use single quotes instead of double quotes. + - Don't strip surrounding quotes. + - In `auto` mode, don't add quotes if the value is only made of alphanumeric characters + (as determined by `string.isalnum`). + ## [0.17.1] - 2021-04-29 ### Fixed diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 16f22d2c..b85836a5 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -151,13 +151,16 @@ def set_key(dotenv_path, key_to_set, value_to_set, quote_mode="always", export=F If the .env path given doesn't exist, fails instead of risking creating an orphan .env somewhere in the filesystem """ - value_to_set = value_to_set.strip("'").strip('"') + if quote_mode not in ("always", "auto", "never"): + raise ValueError("Unknown quote_mode: {}".format(quote_mode)) - if " " in value_to_set: - quote_mode = "always" + quote = ( + quote_mode == "always" + or (quote_mode == "auto" and not value_to_set.isalnum()) + ) - if quote_mode == "always": - value_out = '"{}"'.format(value_to_set.replace('"', '\\"')) + if quote: + value_out = "'{}'".format(value_to_set.replace("'", "\\'")) else: value_out = value_to_set if export: diff --git a/tests/test_cli.py b/tests/test_cli.py index bc6b8d47..d2558234 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -73,10 +73,11 @@ def test_unset_non_existent_value(cli, dotenv_file): @pytest.mark.parametrize( "quote_mode,variable,value,expected", ( - ("always", "HELLO", "WORLD", 'HELLO="WORLD"\n'), - ("never", "HELLO", "WORLD", 'HELLO=WORLD\n'), - ("auto", "HELLO", "WORLD", 'HELLO=WORLD\n'), - ("auto", "HELLO", "HELLO WORLD", 'HELLO="HELLO WORLD"\n'), + ("always", "a", "x", "a='x'\n"), + ("never", "a", "x", 'a=x\n'), + ("auto", "a", "x", "a=x\n"), + ("auto", "a", "x y", "a='x y'\n"), + ("auto", "a", "$", "a='$'\n"), ) ) def test_set_quote_options(cli, dotenv_file, quote_mode, variable, value, expected): @@ -92,8 +93,8 @@ def test_set_quote_options(cli, dotenv_file, quote_mode, variable, value, expect @pytest.mark.parametrize( "dotenv_file,export_mode,variable,value,expected", ( - (".nx_file", "true", "HELLO", "WORLD", "export HELLO=\"WORLD\"\n"), - (".nx_file", "false", "HELLO", "WORLD", "HELLO=\"WORLD\"\n"), + (".nx_file", "true", "a", "x", "export a='x'\n"), + (".nx_file", "false", "a", "x", "a='x'\n"), ) ) def test_set_export(cli, dotenv_file, export_mode, variable, value, expected): diff --git a/tests/test_main.py b/tests/test_main.py index b927d7f2..f36f7340 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -28,18 +28,18 @@ def test_set_key_no_file(tmp_path): @pytest.mark.parametrize( "before,key,value,expected,after", [ - ("", "a", "", (True, "a", ""), 'a=""\n'), - ("", "a", "b", (True, "a", "b"), 'a="b"\n'), - ("", "a", "'b'", (True, "a", "b"), 'a="b"\n'), - ("", "a", "\"b\"", (True, "a", "b"), 'a="b"\n'), - ("", "a", "b'c", (True, "a", "b'c"), 'a="b\'c"\n'), - ("", "a", "b\"c", (True, "a", "b\"c"), 'a="b\\\"c"\n'), - ("a=b", "a", "c", (True, "a", "c"), 'a="c"\n'), - ("a=b\n", "a", "c", (True, "a", "c"), 'a="c"\n'), - ("a=b\n\n", "a", "c", (True, "a", "c"), 'a="c"\n\n'), - ("a=b\nc=d", "a", "e", (True, "a", "e"), 'a="e"\nc=d'), - ("a=b\nc=d\ne=f", "c", "g", (True, "c", "g"), 'a=b\nc="g"\ne=f'), - ("a=b\n", "c", "d", (True, "c", "d"), 'a=b\nc="d"\n'), + ("", "a", "", (True, "a", ""), "a=''\n"), + ("", "a", "b", (True, "a", "b"), "a='b'\n"), + ("", "a", "'b'", (True, "a", "'b'"), "a='\\'b\\''\n"), + ("", "a", "\"b\"", (True, "a", '"b"'), "a='\"b\"'\n"), + ("", "a", "b'c", (True, "a", "b'c"), "a='b\\'c'\n"), + ("", "a", "b\"c", (True, "a", "b\"c"), "a='b\"c'\n"), + ("a=b", "a", "c", (True, "a", "c"), "a='c'\n"), + ("a=b\n", "a", "c", (True, "a", "c"), "a='c'\n"), + ("a=b\n\n", "a", "c", (True, "a", "c"), "a='c'\n\n"), + ("a=b\nc=d", "a", "e", (True, "a", "e"), "a='e'\nc=d"), + ("a=b\nc=d\ne=f", "c", "g", (True, "c", "g"), "a=b\nc='g'\ne=f"), + ("a=b\n", "c", "d", (True, "c", "d"), "a=b\nc='d'\n"), ], ) def test_set_key(dotenv_file, before, key, value, expected, after): From dbf8c7bd50745f2f2e8dd1ead500efb998eda7c4 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 20 Jun 2021 13:45:48 +0200 Subject: [PATCH 083/253] Fix CI Mypy was failing because the new version requires some type packages to be installed even when `ignore_missing_imports` is set to `true`. --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index e4d6f638..0f52ac23 100644 --- a/tox.ini +++ b/tox.ini @@ -8,7 +8,7 @@ python = 3.6: py36, coverage-report 3.7: py37, coverage-report 3.8: py38, coverage-report - 3.9: py39, mypy, lint, manifest, coverage-report + 3.9: py39, lint, manifest, coverage-report pypy2: pypy, coverage-report pypy3: pypy3, coverage-report @@ -27,7 +27,7 @@ commands = coverage run --parallel -m pytest {posargs} skip_install = true deps = flake8 - mypy + mypy<0.900 commands = flake8 src tests mypy --python-version=3.9 src tests From 72bc30773962cb23cabee2c41f4317bf88b896e3 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 20 Jun 2021 17:04:17 +0200 Subject: [PATCH 084/253] Fix setuptools warning --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 58054071..3bb98964 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,7 +17,7 @@ exclude = .tox,.git,docs,venv,.venv ignore_missing_imports = true [metadata] -description-file = README.md +description_file = README.md [tool:pytest] testpaths = tests From 3c08eaf8a0129440613525deef767d3dbd01019d Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 20 Jun 2021 18:18:15 +0200 Subject: [PATCH 085/253] Fix license metadata --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 3fc452c5..fd5785a9 100644 --- a/setup.py +++ b/setup.py @@ -43,6 +43,7 @@ def read_files(files): [console_scripts] dotenv=dotenv.cli:cli ''', + license='BSD-3-Clause', classifiers=[ 'Development Status :: 5 - Production/Stable', 'Programming Language :: Python', From 97615cdcd0b6c6ffcf18b272598e82bfa3a18938 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 20 Jun 2021 18:39:22 +0200 Subject: [PATCH 086/253] Release version 0.18.0 --- CHANGELOG.md | 5 +++-- setup.cfg | 2 +- src/dotenv/version.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0852d66e..7aa4cfd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## [0.18.0] - 2021-06-20 ### Changed @@ -270,7 +270,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@yannham]: https://github.com/yannham [@zueve]: https://github.com/zueve -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.17.1...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.18.0...HEAD +[0.18.0]: https://github.com/theskumar/python-dotenv/compare/v0.17.1...v0.18.0 [0.17.1]: https://github.com/theskumar/python-dotenv/compare/v0.17.0...v0.17.1 [0.17.0]: https://github.com/theskumar/python-dotenv/compare/v0.16.0...v0.17.0 [0.16.0]: https://github.com/theskumar/python-dotenv/compare/v0.15.0...v0.16.0 diff --git a/setup.cfg b/setup.cfg index 3bb98964..9afbc4b3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.17.1 +current_version = 0.18.0 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index c6eae9f8..1317d755 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.17.1" +__version__ = "0.18.0" From 9d777e3907ee1c2d7550228c7089c0b244d25056 Mon Sep 17 00:00:00 2001 From: Serghei Iakovlev Date: Fri, 28 May 2021 17:27:30 +0300 Subject: [PATCH 087/253] Add django-environ-2 to the Related Projects list Added django-environ-2 because this project is developing independently from joke2k's django-environ. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 9757e672..045da075 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,7 @@ defined in the following list: Procfile-based applications. - [django-dotenv](https://github.com/jpadilla/django-dotenv) - [django-environ](https://github.com/joke2k/django-environ) +- [django-environ-2](https://github.com/sergeyklay/django-environ-2) - [django-configuration](https://github.com/jezdez/django-configurations) - [dump-env](https://github.com/sobolevn/dump-env) - [environs](https://github.com/sloria/environs) From 5c7f43f7dc6d351ea7e311b525f979bb24a054b4 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 26 Jun 2021 00:19:55 +0200 Subject: [PATCH 088/253] Avoid leaving any file after running tests This notably prevents the file `.nx_file` from being created and not removed after running tests. That file could also lead to confusing test failures when changing and testing the code of python-dotenv. --- tests/conftest.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 7a9ed7e5..24a82528 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,9 @@ @pytest.fixture def cli(): - yield CliRunner() + runner = CliRunner() + with runner.isolated_filesystem(): + yield runner @pytest.fixture From fbc7a6350e25503aaf8908261a2f2a62157afabb Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 26 Jun 2021 11:21:02 +0200 Subject: [PATCH 089/253] Require Python >= 3.5 This is a big change. It will make it possible to simplify the code, add more features, improve the robustness and lower the barrier to new contributions. As per [Python's packaging documentation][doc], the `python_requires` keyword argument needs `setuptools >= 24.2.0` (released in 2016) and will only have en effect for `pip >= 9.0.0` (released in 2016 as well). [doc]: https://packaging.python.org/guides/distributing-packages-using-setuptools/#python-requires --- .github/workflows/test.yml | 2 +- CHANGELOG.md | 7 +++++++ setup.py | 6 +----- tox.ini | 7 +------ 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 04805932..2865cf85 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,7 +10,7 @@ jobs: matrix: os: - ubuntu-latest - python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, pypy2, pypy3] + python-version: [3.5, 3.6, 3.7, 3.8, 3.9, pypy3] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 7aa4cfd9..2b4340c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- Require Python 3.5 or a later version. Python 2 and 3.4 are no longer supported. (#341 + by [@bbc2]). + ## [0.18.0] - 2021-06-20 ### Changed diff --git a/setup.py b/setup.py index fd5785a9..5e27d26d 100644 --- a/setup.py +++ b/setup.py @@ -33,9 +33,7 @@ def read_files(files): package_data={ 'dotenv': ['py.typed'], }, - install_requires=[ - "typing; python_version<'3.5'", - ], + python_requires=">=3.5", extras_require={ 'cli': ['click>=5.0', ], }, @@ -47,8 +45,6 @@ def read_files(files): classifiers=[ 'Development Status :: 5 - Production/Stable', 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', diff --git a/tox.ini b/tox.ini index 0f52ac23..7c2b4f9d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,15 +1,13 @@ [tox] -envlist = lint,py{27,35,36,37,38,39},pypy,pypy3,manifest,coverage-report +envlist = lint,py{35,36,37,38,39},pypy3,manifest,coverage-report [gh-actions] python = - 2.7: py27, coverage-report 3.5: py35, coverage-report 3.6: py36, coverage-report 3.7: py37, coverage-report 3.8: py38, coverage-report 3.9: py39, lint, manifest, coverage-report - pypy2: pypy, coverage-report pypy3: pypy3, coverage-report [testenv] @@ -19,7 +17,6 @@ deps = coverage sh click - py{27,py}: ipython<6.0.0 py{35,36,37,38,39,py3}: ipython commands = coverage run --parallel -m pytest {posargs} @@ -35,8 +32,6 @@ commands = 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 From 9e522b1221471d9b4bbdba6b0759edfd6a16d941 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 10 Jul 2021 11:51:50 +0200 Subject: [PATCH 090/253] Remove some code specific to Python 2 `to_env` and `to_text` are no longer necessary since they were identity functions with Python 3. --- src/dotenv/cli.py | 6 +++--- src/dotenv/compat.py | 39 --------------------------------------- src/dotenv/main.py | 29 +++++++++-------------------- src/dotenv/parser.py | 4 ++-- tests/test_main.py | 11 ++++------- tests/test_parser.py | 5 +++-- 6 files changed, 21 insertions(+), 73 deletions(-) diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index bb96c023..d15ea53e 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -9,7 +9,7 @@ 'Run pip install "python-dotenv[cli]" to fix this.') sys.exit(1) -from .compat import IS_TYPE_CHECKING, to_env +from .compat import IS_TYPE_CHECKING from .main import dotenv_values, get_key, set_key, unset_key from .version import __version__ @@ -123,9 +123,9 @@ def run(ctx, override, commandline): ctx=ctx ) dotenv_as_dict = { - to_env(k): to_env(v) + k: v for (k, v) in dotenv_values(file).items() - if v is not None and (override or to_env(k) not in os.environ) + if v is not None and (override or k not in os.environ) } if not commandline: diff --git a/src/dotenv/compat.py b/src/dotenv/compat.py index f8089bf4..27b48562 100644 --- a/src/dotenv/compat.py +++ b/src/dotenv/compat.py @@ -1,13 +1,3 @@ -import sys - -PY2 = sys.version_info[0] == 2 # type: bool - -if PY2: - from StringIO import StringIO # noqa -else: - from io import StringIO # noqa - - def is_type_checking(): # type: () -> bool try: @@ -18,32 +8,3 @@ def is_type_checking(): IS_TYPE_CHECKING = is_type_checking() - - -if IS_TYPE_CHECKING: - from typing import Text - - -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 b85836a5..f9cdde3d 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -10,7 +10,7 @@ from collections import OrderedDict from contextlib import contextmanager -from .compat import IS_TYPE_CHECKING, PY2, StringIO, to_env +from .compat import IS_TYPE_CHECKING from .parser import Binding, parse_stream from .variables import parse_variables @@ -24,11 +24,6 @@ else: _PathLike = Text - if sys.version_info >= (3, 0): - _StringIO = StringIO - else: - _StringIO = StringIO[Text] - def with_warn_for_invalid_lines(mappings): # type: (Iterator[Binding]) -> Iterator[Binding] @@ -44,8 +39,8 @@ def with_warn_for_invalid_lines(mappings): class DotEnv(): def __init__(self, dotenv_path, verbose=False, encoding=None, interpolate=True, override=True): - # type: (Union[Text, _PathLike, _StringIO], bool, Union[None, Text], bool, bool) -> None - self.dotenv_path = dotenv_path # type: Union[Text,_PathLike, _StringIO] + # type: (Union[Text, _PathLike, io.StringIO], bool, Union[None, Text], bool, bool) -> None + self.dotenv_path = dotenv_path # type: Union[Text,_PathLike, io.StringIO] self._dict = None # type: Optional[Dict[Text, Optional[Text]]] self.verbose = verbose # type: bool self.encoding = encoding # type: Union[None, Text] @@ -55,7 +50,7 @@ def __init__(self, dotenv_path, verbose=False, encoding=None, interpolate=True, @contextmanager def _get_stream(self): # type: () -> Iterator[IO[Text]] - if isinstance(self.dotenv_path, StringIO): + if isinstance(self.dotenv_path, io.StringIO): yield self.dotenv_path elif os.path.isfile(self.dotenv_path): with io.open(self.dotenv_path, encoding=self.encoding) as stream: @@ -63,7 +58,7 @@ def _get_stream(self): else: if self.verbose: logger.info("Python-dotenv could not find configuration file %s.", self.dotenv_path or '.env') - yield StringIO('') + yield io.StringIO('') def dict(self): # type: () -> Dict[Text, Optional[Text]] @@ -96,7 +91,7 @@ def set_as_environment_variables(self): if k in os.environ and not self.override: continue if v is not None: - os.environ[to_env(k)] = to_env(v) + os.environ[k] = v return True @@ -271,13 +266,7 @@ def _is_interactive(): else: # will work for .py files frame = sys._getframe() - # find first frame that is outside of this file - if PY2 and not __file__.endswith('.py'): - # in Python2 __file__ extension could be .pyc or .pyo (this doesn't account - # for edge case of Python compiled for non-standard extension) - current_file = __file__.rsplit('.', 1)[0] + '.py' - else: - current_file = __file__ + current_file = __file__ while frame.f_code.co_filename == current_file: assert frame.f_back is not None @@ -304,7 +293,7 @@ def load_dotenv( interpolate=True, encoding="utf-8", ): - # type: (Union[Text, _PathLike, None], Optional[_StringIO], bool, bool, bool, Optional[Text]) -> bool # noqa + # type: (Union[Text, _PathLike, None], Optional[io.StringIO], bool, bool, bool, Optional[Text]) -> bool # noqa """Parse a .env file and then load all the variables found as environment variables. - *dotenv_path*: absolute or relative path to .env file. @@ -335,7 +324,7 @@ def dotenv_values( interpolate=True, encoding="utf-8", ): - # type: (Union[Text, _PathLike, None], Optional[_StringIO], bool, bool, Optional[Text]) -> Dict[Text, Optional[Text]] # noqa: E501 + # type: (Union[Text, _PathLike, None], Optional[io.StringIO], bool, bool, Optional[Text]) -> Dict[Text, Optional[Text]] # noqa: E501 """ Parse a .env file and return its content as a dict. diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index 5cb1cdfa..a0b80b23 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -1,7 +1,7 @@ import codecs import re -from .compat import IS_TYPE_CHECKING, to_text +from .compat import IS_TYPE_CHECKING if IS_TYPE_CHECKING: from typing import ( # noqa:F401 @@ -12,7 +12,7 @@ def make_regex(string, extra_flags=0): # type: (str, int) -> Pattern[Text] - return re.compile(to_text(string), re.UNICODE | extra_flags) + return re.compile(string, re.UNICODE | extra_flags) _newline = make_regex(r"(\r\n|\n|\r)") diff --git a/tests/test_main.py b/tests/test_main.py index f36f7340..f417e295 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +import io import logging import os import sys @@ -11,7 +12,6 @@ import sh import dotenv -from dotenv.compat import PY2, StringIO def test_set_key_no_file(tmp_path): @@ -281,15 +281,12 @@ def test_load_dotenv_redefine_var_used_in_file_with_override(dotenv_file): @mock.patch.dict(os.environ, {}, clear=True) def test_load_dotenv_utf_8(): - stream = StringIO("a=à") + stream = io.StringIO("a=à") result = dotenv.load_dotenv(stream=stream) assert result is True - if PY2: - assert os.environ == {"a": "à".encode(sys.getfilesystemencoding())} - else: - assert os.environ == {"a": "à"} + assert os.environ == {"a": "à"} def test_load_dotenv_in_current_dir(tmp_path): @@ -361,7 +358,7 @@ def test_dotenv_values_file(dotenv_file): ) def test_dotenv_values_stream(env, string, interpolate, expected): with mock.patch.dict(os.environ, env, clear=True): - stream = StringIO(string) + stream = io.StringIO(string) stream.seek(0) result = dotenv.dotenv_values(stream=stream, interpolate=interpolate) diff --git a/tests/test_parser.py b/tests/test_parser.py index 48cecdce..bdef9c41 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- +import io + import pytest -from dotenv.compat import StringIO from dotenv.parser import Binding, Original, parse_stream @@ -166,6 +167,6 @@ ), ]) def test_parse_stream(test_input, expected): - result = parse_stream(StringIO(test_input)) + result = parse_stream(io.StringIO(test_input)) assert list(result) == expected From 9292074d13fb1e84887e9b908e014948b21bfa46 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 10 Jul 2021 11:56:33 +0200 Subject: [PATCH 091/253] Remove "coding: utf-8" source declarations Now that we only support Python 3, we know that the encoding of source files is UTF-8 by default: https://www.python.org/dev/peps/pep-3120/. --- setup.py | 1 - src/dotenv/main.py | 1 - tests/test_cli.py | 1 - tests/test_main.py | 1 - tests/test_parser.py | 1 - 5 files changed, 5 deletions(-) diff --git a/setup.py b/setup.py index 5e27d26d..06ad2dd9 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import io from setuptools import setup diff --git a/src/dotenv/main.py b/src/dotenv/main.py index f9cdde3d..9568238e 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from __future__ import absolute_import, print_function, unicode_literals import io diff --git a/tests/test_cli.py b/tests/test_cli.py index d2558234..223476fe 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import os import pytest diff --git a/tests/test_main.py b/tests/test_main.py index f417e295..a7c093b1 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from __future__ import unicode_literals import io diff --git a/tests/test_parser.py b/tests/test_parser.py index bdef9c41..b0621173 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import io import pytest From 4617e39663bbe600ec54a806d380d7a4ec31d98f Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 10 Jul 2021 12:17:58 +0200 Subject: [PATCH 092/253] Remove unnecessary future imports --- src/dotenv/ipython.py | 2 -- src/dotenv/main.py | 2 -- tests/test_ipython.py | 2 -- tests/test_main.py | 2 -- 4 files changed, 8 deletions(-) diff --git a/src/dotenv/ipython.py b/src/dotenv/ipython.py index 7f1b13d6..7df727cd 100644 --- a/src/dotenv/ipython.py +++ b/src/dotenv/ipython.py @@ -1,5 +1,3 @@ -from __future__ import print_function - from IPython.core.magic import Magics, line_magic, magics_class # type: ignore from IPython.core.magic_arguments import (argument, magic_arguments, # type: ignore parse_argstring) # type: ignore diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 9568238e..0ebaf581 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -1,5 +1,3 @@ -from __future__ import absolute_import, print_function, unicode_literals - import io import logging import os diff --git a/tests/test_ipython.py b/tests/test_ipython.py index afbf4797..8983bf13 100644 --- a/tests/test_ipython.py +++ b/tests/test_ipython.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import os import mock diff --git a/tests/test_main.py b/tests/test_main.py index a7c093b1..d612bb25 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import io import logging import os From 1914bac52a32b02361f6a1b5bd07ee2c5826a83b Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 10 Jul 2021 13:32:10 +0200 Subject: [PATCH 093/253] Remove typing guards Since we now require Python 3.5+, we can assume that the `typing` module is available. The guards can also be removed because importing that module is cheap. This simplifies the code. --- requirements.txt | 1 - src/dotenv/__init__.py | 7 ++--- src/dotenv/cli.py | 5 +-- src/dotenv/compat.py | 10 ------ src/dotenv/main.py | 14 ++++----- src/dotenv/parser.py | 68 ++++++++++++----------------------------- src/dotenv/variables.py | 7 +---- 7 files changed, 30 insertions(+), 82 deletions(-) delete mode 100644 src/dotenv/compat.py diff --git a/requirements.txt b/requirements.txt index e5e4de12..952bfdce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ bumpversion -typing; python_version<"3.5" click flake8>=2.2.3 ipython diff --git a/src/dotenv/__init__.py b/src/dotenv/__init__.py index b88d9bc2..1d7a4233 100644 --- a/src/dotenv/__init__.py +++ b/src/dotenv/__init__.py @@ -1,8 +1,7 @@ -from .compat import IS_TYPE_CHECKING -from .main import load_dotenv, get_key, set_key, unset_key, find_dotenv, dotenv_values +from typing import Any, Optional -if IS_TYPE_CHECKING: - from typing import Any, Optional +from .main import (dotenv_values, find_dotenv, get_key, load_dotenv, set_key, + unset_key) def load_ipython_extension(ipython): diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index d15ea53e..bd593a66 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -1,6 +1,7 @@ import os import sys from subprocess import Popen +from typing import Any, Dict, List try: import click @@ -9,13 +10,9 @@ 'Run pip install "python-dotenv[cli]" to fix this.') sys.exit(1) -from .compat import IS_TYPE_CHECKING from .main import dotenv_values, get_key, set_key, unset_key from .version import __version__ -if IS_TYPE_CHECKING: - from typing import Any, List, Dict - @click.group() @click.option('-f', '--file', default=os.path.join(os.getcwd(), '.env'), diff --git a/src/dotenv/compat.py b/src/dotenv/compat.py deleted file mode 100644 index 27b48562..00000000 --- a/src/dotenv/compat.py +++ /dev/null @@ -1,10 +0,0 @@ -def is_type_checking(): - # type: () -> bool - try: - from typing import TYPE_CHECKING - except ImportError: - return False - return TYPE_CHECKING - - -IS_TYPE_CHECKING = is_type_checking() diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 0ebaf581..9e6cb437 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -6,20 +6,18 @@ import tempfile from collections import OrderedDict from contextlib import contextmanager +from typing import (IO, Dict, Iterable, Iterator, Mapping, Optional, Text, + Tuple, Union) -from .compat import IS_TYPE_CHECKING from .parser import Binding, parse_stream from .variables import parse_variables logger = logging.getLogger(__name__) -if IS_TYPE_CHECKING: - from typing import (IO, Dict, Iterable, Iterator, Mapping, Optional, Text, - Tuple, Union) - if sys.version_info >= (3, 6): - _PathLike = os.PathLike - else: - _PathLike = Text +if sys.version_info >= (3, 6): + _PathLike = os.PathLike +else: + _PathLike = Text def with_warn_for_invalid_lines(mappings): diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index a0b80b23..0d9b9d3f 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -1,13 +1,7 @@ import codecs import re - -from .compat import IS_TYPE_CHECKING - -if IS_TYPE_CHECKING: - from typing import ( # noqa:F401 - IO, Iterator, Match, NamedTuple, Optional, Pattern, Sequence, Text, - Tuple - ) +from typing import (IO, Iterator, Match, NamedTuple, Optional, # noqa:F401 + Pattern, Sequence, Text, Tuple) def make_regex(string, extra_flags=0): @@ -32,47 +26,23 @@ def make_regex(string, extra_flags=0): _single_quote_escapes = make_regex(r"\\[\\']") -try: - # this is necessary because we only import these from typing - # when we are type checking, and the linter is upset if we - # re-import - import typing - - Original = typing.NamedTuple( - "Original", - [ - ("string", typing.Text), - ("line", int), - ], - ) - - Binding = typing.NamedTuple( - "Binding", - [ - ("key", typing.Optional[typing.Text]), - ("value", typing.Optional[typing.Text]), - ("original", Original), - ("error", bool), - ], - ) -except (ImportError, AttributeError): - from collections import namedtuple - Original = namedtuple( # type: ignore - "Original", - [ - "string", - "line", - ], - ) - Binding = namedtuple( # type: ignore - "Binding", - [ - "key", - "value", - "original", - "error", - ], - ) +Original = NamedTuple( + "Original", + [ + ("string", Text), + ("line", int), + ], +) + +Binding = NamedTuple( + "Binding", + [ + ("key", Optional[Text]), + ("value", Optional[Text]), + ("original", Original), + ("error", bool), + ], +) class Position: diff --git a/src/dotenv/variables.py b/src/dotenv/variables.py index 4828dfc2..83fe11c1 100644 --- a/src/dotenv/variables.py +++ b/src/dotenv/variables.py @@ -1,11 +1,6 @@ import re from abc import ABCMeta - -from .compat import IS_TYPE_CHECKING - -if IS_TYPE_CHECKING: - from typing import Iterator, Mapping, Optional, Pattern, Text - +from typing import Iterator, Mapping, Optional, Pattern, Text _posix_variable = re.compile( r""" From 4590015cd5190e11d447e57538bc55a702a76c71 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 10 Jul 2021 15:02:28 +0200 Subject: [PATCH 094/253] Use Python 3 type hints for functions Unfortunately, we can't do the same for variables as that is only supported in Python 3.6+. --- src/dotenv/__init__.py | 12 ++++-- src/dotenv/cli.py | 21 +++------ src/dotenv/main.py | 96 ++++++++++++++++++++++------------------- src/dotenv/parser.py | 57 ++++++++---------------- src/dotenv/variables.py | 39 ++++++----------- 5 files changed, 98 insertions(+), 127 deletions(-) diff --git a/src/dotenv/__init__.py b/src/dotenv/__init__.py index 1d7a4233..3512d101 100644 --- a/src/dotenv/__init__.py +++ b/src/dotenv/__init__.py @@ -4,14 +4,18 @@ unset_key) -def load_ipython_extension(ipython): - # type: (Any) -> None +def load_ipython_extension(ipython: Any) -> None: from .ipython import load_ipython_extension load_ipython_extension(ipython) -def get_cli_string(path=None, action=None, key=None, value=None, quote=None): - # type: (Optional[str], Optional[str], Optional[str], Optional[str], Optional[str]) -> str +def get_cli_string( + path: Optional[str] = None, + action: Optional[str] = None, + key: Optional[str] = None, + value: Optional[str] = None, + quote: Optional[str] = None, +): """Returns a string suitable for running as a shell script. Useful for converting a arguments passed to a fabric task diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index bd593a66..b7ae24af 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -26,8 +26,7 @@ help="Whether to write the dot file as an executable bash script.") @click.version_option(version=__version__) @click.pass_context -def cli(ctx, file, quote, export): - # type: (click.Context, Any, Any, Any) -> None +def cli(ctx: click.Context, file: Any, quote: Any, export: Any) -> None: '''This script is used to set, get or unset values from a .env file.''' ctx.obj = {} ctx.obj['QUOTE'] = quote @@ -37,8 +36,7 @@ def cli(ctx, file, quote, export): @cli.command() @click.pass_context -def list(ctx): - # type: (click.Context) -> None +def list(ctx: click.Context) -> None: '''Display all the stored key/value.''' file = ctx.obj['FILE'] if not os.path.isfile(file): @@ -55,8 +53,7 @@ def list(ctx): @click.pass_context @click.argument('key', required=True) @click.argument('value', required=True) -def set(ctx, key, value): - # type: (click.Context, Any, Any) -> None +def set(ctx: click.Context, key: Any, value: Any) -> None: '''Store the given key/value.''' file = ctx.obj['FILE'] quote = ctx.obj['QUOTE'] @@ -71,8 +68,7 @@ def set(ctx, key, value): @cli.command() @click.pass_context @click.argument('key', required=True) -def get(ctx, key): - # type: (click.Context, Any) -> None +def get(ctx: click.Context, key: Any) -> None: '''Retrieve the value for the given key.''' file = ctx.obj['FILE'] if not os.path.isfile(file): @@ -90,8 +86,7 @@ def get(ctx, key): @cli.command() @click.pass_context @click.argument('key', required=True) -def unset(ctx, key): - # type: (click.Context, Any) -> None +def unset(ctx: click.Context, key: Any) -> None: '''Removes the given key.''' file = ctx.obj['FILE'] quote = ctx.obj['QUOTE'] @@ -110,8 +105,7 @@ def unset(ctx, key): help="Override variables from the environment file with those from the .env file.", ) @click.argument('commandline', nargs=-1, type=click.UNPROCESSED) -def run(ctx, override, commandline): - # type: (click.Context, bool, List[str]) -> None +def run(ctx: click.Context, override: bool, commandline: List[str]) -> None: """Run command with environment variables present.""" file = ctx.obj['FILE'] if not os.path.isfile(file): @@ -132,8 +126,7 @@ def run(ctx, override, commandline): exit(ret) -def run_command(command, env): - # type: (List[str], Dict[str, str]) -> int +def run_command(command: List[str], env: Dict[str, str]) -> int: """Run command in sub process. Runs the command in a sub process with the variables from `env` diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 9e6cb437..e4e140f3 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -20,8 +20,7 @@ _PathLike = Text -def with_warn_for_invalid_lines(mappings): - # type: (Iterator[Binding]) -> Iterator[Binding] +def with_warn_for_invalid_lines(mappings: Iterator[Binding]) -> Iterator[Binding]: for mapping in mappings: if mapping.error: logger.warning( @@ -32,9 +31,14 @@ def with_warn_for_invalid_lines(mappings): class DotEnv(): - - def __init__(self, dotenv_path, verbose=False, encoding=None, interpolate=True, override=True): - # type: (Union[Text, _PathLike, io.StringIO], bool, Union[None, Text], bool, bool) -> None + def __init__( + self, + dotenv_path: Union[Text, _PathLike, io.StringIO], + verbose: bool = False, + encoding: Union[None, Text] = None, + interpolate: bool = True, + override: bool = True, + ) -> None: self.dotenv_path = dotenv_path # type: Union[Text,_PathLike, io.StringIO] self._dict = None # type: Optional[Dict[Text, Optional[Text]]] self.verbose = verbose # type: bool @@ -43,8 +47,7 @@ def __init__(self, dotenv_path, verbose=False, encoding=None, interpolate=True, self.override = override # type: bool @contextmanager - def _get_stream(self): - # type: () -> Iterator[IO[Text]] + def _get_stream(self) -> Iterator[IO[Text]]: if isinstance(self.dotenv_path, io.StringIO): yield self.dotenv_path elif os.path.isfile(self.dotenv_path): @@ -55,8 +58,7 @@ def _get_stream(self): logger.info("Python-dotenv could not find configuration file %s.", self.dotenv_path or '.env') yield io.StringIO('') - def dict(self): - # type: () -> Dict[Text, Optional[Text]] + def dict(self) -> Dict[Text, Optional[Text]]: """Return dotenv as dict""" if self._dict: return self._dict @@ -70,15 +72,13 @@ def dict(self): return self._dict - def parse(self): - # type: () -> Iterator[Tuple[Text, Optional[Text]]] + def parse(self) -> Iterator[Tuple[Text, Optional[Text]]]: with self._get_stream() as stream: for mapping in with_warn_for_invalid_lines(parse_stream(stream)): if mapping.key is not None: yield mapping.key, mapping.value - def set_as_environment_variables(self): - # type: () -> bool + def set_as_environment_variables(self) -> bool: """ Load the current dotenv as system environment variable. """ @@ -90,8 +90,7 @@ def set_as_environment_variables(self): return True - def get(self, key): - # type: (Text) -> Optional[Text] + def get(self, key: Text) -> Optional[Text]: """ """ data = self.dict() @@ -105,8 +104,7 @@ def get(self, key): return None -def get_key(dotenv_path, key_to_get): - # type: (Union[Text, _PathLike], Text) -> Optional[Text] +def get_key(dotenv_path: Union[Text, _PathLike], key_to_get: Text) -> Optional[Text]: """ Gets the value of a given key from the given .env @@ -116,8 +114,7 @@ def get_key(dotenv_path, key_to_get): @contextmanager -def rewrite(path): - # type: (_PathLike) -> Iterator[Tuple[IO[Text], IO[Text]]] +def rewrite(path: _PathLike) -> Iterator[Tuple[IO[Text], IO[Text]]]: try: if not os.path.isfile(path): with io.open(path, "w+") as source: @@ -133,8 +130,13 @@ def rewrite(path): shutil.move(dest.name, path) -def set_key(dotenv_path, key_to_set, value_to_set, quote_mode="always", export=False): - # type: (_PathLike, Text, Text, Text, bool) -> Tuple[Optional[bool], Text, Text] +def set_key( + dotenv_path: _PathLike, + key_to_set: Text, + value_to_set: Text, + quote_mode: Text = "always", + export: bool = False, +) -> Tuple[Optional[bool], Text, Text]: """ Adds or Updates a key/value to the given .env @@ -172,8 +174,11 @@ def set_key(dotenv_path, key_to_set, value_to_set, quote_mode="always", export=F return True, key_to_set, value_to_set -def unset_key(dotenv_path, key_to_unset, quote_mode="always"): - # type: (_PathLike, Text, Text) -> Tuple[Optional[bool], Text] +def unset_key( + dotenv_path: _PathLike, + key_to_unset: Text, + quote_mode: Text = "always", +) -> Tuple[Optional[bool], Text]: """ Removes a given key from the given .env @@ -199,9 +204,10 @@ def unset_key(dotenv_path, key_to_unset, quote_mode="always"): return removed, key_to_unset -def resolve_variables(values, override): - # type: (Iterable[Tuple[Text, Optional[Text]]], bool) -> Mapping[Text, Optional[Text]] - +def resolve_variables( + values: Iterable[Tuple[Text, Optional[Text]]], + override: bool, +) -> Mapping[Text, Optional[Text]]: new_values = {} # type: Dict[Text, Optional[Text]] for (name, value) in values: @@ -223,8 +229,7 @@ def resolve_variables(values, override): return new_values -def _walk_to_root(path): - # type: (Text) -> Iterator[Text] +def _walk_to_root(path: Text) -> Iterator[Text]: """ Yield directories starting from the given directory up to the root """ @@ -242,8 +247,11 @@ def _walk_to_root(path): last_dir, current_dir = current_dir, parent_dir -def find_dotenv(filename='.env', raise_error_if_not_found=False, usecwd=False): - # type: (Text, bool, bool) -> Text +def find_dotenv( + filename: Text = '.env', + raise_error_if_not_found: bool = False, + usecwd: bool = False, +) -> Text: """ Search in increasingly higher folders for the given file @@ -281,14 +289,13 @@ def _is_interactive(): def load_dotenv( - dotenv_path=None, - stream=None, - verbose=False, - override=False, - interpolate=True, - encoding="utf-8", -): - # type: (Union[Text, _PathLike, None], Optional[io.StringIO], bool, bool, bool, Optional[Text]) -> bool # noqa + dotenv_path: Union[Text, _PathLike, None] = None, + stream: Optional[io.StringIO] = None, + verbose: bool = False, + override: bool = False, + interpolate: bool = True, + encoding: Optional[Text] = "utf-8", +) -> bool: """Parse a .env file and then load all the variables found as environment variables. - *dotenv_path*: absolute or relative path to .env file. @@ -313,13 +320,12 @@ def load_dotenv( def dotenv_values( - dotenv_path=None, - stream=None, - verbose=False, - interpolate=True, - encoding="utf-8", -): - # type: (Union[Text, _PathLike, None], Optional[io.StringIO], bool, bool, Optional[Text]) -> Dict[Text, Optional[Text]] # noqa: E501 + dotenv_path: Union[Text, _PathLike, None] = None, + stream: Optional[io.StringIO] = None, + verbose: bool = False, + interpolate: bool = True, + encoding: Optional[Text] = "utf-8", +) -> Dict[Text, Optional[Text]]: """ Parse a .env file and return its content as a dict. diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index 0d9b9d3f..8a976c51 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -4,8 +4,7 @@ Pattern, Sequence, Text, Tuple) -def make_regex(string, extra_flags=0): - # type: (str, int) -> Pattern[Text] +def make_regex(string: str, extra_flags: int = 0) -> Pattern[Text]: return re.compile(string, re.UNICODE | extra_flags) @@ -46,23 +45,19 @@ def make_regex(string, extra_flags=0): class Position: - def __init__(self, chars, line): - # type: (int, int) -> None + def __init__(self, chars: int, line: int) -> None: self.chars = chars self.line = line @classmethod - def start(cls): - # type: () -> Position + def start(cls) -> "Position": return cls(chars=0, line=1) - def set(self, other): - # type: (Position) -> None + def set(self, other: "Position") -> None: self.chars = other.chars self.line = other.line - def advance(self, string): - # type: (Text) -> None + def advance(self, string: Text) -> None: self.chars += len(string) self.line += len(re.findall(_newline, string)) @@ -72,41 +67,34 @@ class Error(Exception): class Reader: - def __init__(self, stream): - # type: (IO[Text]) -> None + def __init__(self, stream: IO[Text]) -> None: self.string = stream.read() self.position = Position.start() self.mark = Position.start() - def has_next(self): - # type: () -> bool + def has_next(self) -> bool: return self.position.chars < len(self.string) - def set_mark(self): - # type: () -> None + def set_mark(self) -> None: self.mark.set(self.position) - def get_marked(self): - # type: () -> Original + def get_marked(self) -> Original: return Original( string=self.string[self.mark.chars:self.position.chars], line=self.mark.line, ) - def peek(self, count): - # type: (int) -> Text + def peek(self, count: int) -> Text: return self.string[self.position.chars:self.position.chars + count] - def read(self, count): - # type: (int) -> Text + def read(self, count: int) -> Text: result = self.string[self.position.chars:self.position.chars + count] if len(result) < count: raise Error("read: End of string") self.position.advance(result) return result - def read_regex(self, regex): - # type: (Pattern[Text]) -> Sequence[Text] + def read_regex(self, regex: Pattern[Text]) -> Sequence[Text]: match = regex.match(self.string, self.position.chars) if match is None: raise Error("read_regex: Pattern not found") @@ -114,17 +102,14 @@ def read_regex(self, regex): return match.groups() -def decode_escapes(regex, string): - # type: (Pattern[Text], Text) -> Text - def decode_match(match): - # type: (Match[Text]) -> Text +def decode_escapes(regex: Pattern[Text], string: Text) -> Text: + def decode_match(match: 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) -> Optional[Text] +def parse_key(reader: Reader) -> Optional[Text]: char = reader.peek(1) if char == "#": return None @@ -135,14 +120,12 @@ def parse_key(reader): return key -def parse_unquoted_value(reader): - # type: (Reader) -> Text +def parse_unquoted_value(reader: Reader) -> Text: (part,) = reader.read_regex(_unquoted_value) return re.sub(r"\s+#.*", "", part).rstrip() -def parse_value(reader): - # type: (Reader) -> Text +def parse_value(reader: Reader) -> Text: char = reader.peek(1) if char == u"'": (value,) = reader.read_regex(_single_quoted_value) @@ -156,8 +139,7 @@ def parse_value(reader): return parse_unquoted_value(reader) -def parse_binding(reader): - # type: (Reader) -> Binding +def parse_binding(reader: Reader) -> Binding: reader.set_mark() try: reader.read_regex(_multiline_whitespace) @@ -194,8 +176,7 @@ def parse_binding(reader): ) -def parse_stream(stream): - # type: (IO[Text]) -> Iterator[Binding] +def parse_stream(stream: IO[Text]) -> Iterator[Binding]: reader = Reader(stream) while reader.has_next(): yield parse_binding(reader) diff --git a/src/dotenv/variables.py b/src/dotenv/variables.py index 83fe11c1..bddd07e1 100644 --- a/src/dotenv/variables.py +++ b/src/dotenv/variables.py @@ -18,71 +18,58 @@ class Atom(): __metaclass__ = ABCMeta - def __ne__(self, other): - # type: (object) -> bool + def __ne__(self, other: object) -> bool: result = self.__eq__(other) if result is NotImplemented: return NotImplemented return not result - def resolve(self, env): - # type: (Mapping[Text, Optional[Text]]) -> Text + def resolve(self, env: Mapping[Text, Optional[Text]]) -> Text: raise NotImplementedError class Literal(Atom): - def __init__(self, value): - # type: (Text) -> None + def __init__(self, value: Text) -> None: self.value = value - def __repr__(self): - # type: () -> str + def __repr__(self) -> str: return "Literal(value={})".format(self.value) - def __eq__(self, other): - # type: (object) -> bool + def __eq__(self, other: object) -> bool: if not isinstance(other, self.__class__): return NotImplemented return self.value == other.value - def __hash__(self): - # type: () -> int + def __hash__(self) -> int: return hash((self.__class__, self.value)) - def resolve(self, env): - # type: (Mapping[Text, Optional[Text]]) -> Text + def resolve(self, env: Mapping[Text, Optional[Text]]) -> Text: return self.value class Variable(Atom): - def __init__(self, name, default): - # type: (Text, Optional[Text]) -> None + def __init__(self, name: Text, default: Optional[Text]) -> None: self.name = name self.default = default - def __repr__(self): - # type: () -> str + def __repr__(self) -> str: return "Variable(name={}, default={})".format(self.name, self.default) - def __eq__(self, other): - # type: (object) -> bool + def __eq__(self, other: object) -> bool: if not isinstance(other, self.__class__): return NotImplemented return (self.name, self.default) == (other.name, other.default) - def __hash__(self): - # type: () -> int + def __hash__(self) -> int: return hash((self.__class__, self.name, self.default)) - def resolve(self, env): - # type: (Mapping[Text, Optional[Text]]) -> Text + def resolve(self, env: Mapping[Text, Optional[Text]]) -> Text: default = self.default if self.default is not None else "" result = env.get(self.name, default) return result if result is not None else "" -def parse_variables(value): - # type: (Text) -> Iterator[Atom] +def parse_variables(value: Text) -> Iterator[Atom]: cursor = 0 for match in _posix_variable.finditer(value): From b8fdfba09957c6c4872f98c721b185a4ba1711ec Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 10 Jul 2021 15:27:04 +0200 Subject: [PATCH 095/253] Replace `Text` with `str` Those are synonyms in Python 3. --- src/dotenv/main.py | 66 ++++++++++++++++++++--------------------- src/dotenv/parser.py | 34 ++++++++++----------- src/dotenv/variables.py | 16 +++++----- 3 files changed, 58 insertions(+), 58 deletions(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index e4e140f3..6b29fc90 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -6,8 +6,8 @@ import tempfile from collections import OrderedDict from contextlib import contextmanager -from typing import (IO, Dict, Iterable, Iterator, Mapping, Optional, Text, - Tuple, Union) +from typing import (IO, Dict, Iterable, Iterator, Mapping, Optional, Tuple, + Union) from .parser import Binding, parse_stream from .variables import parse_variables @@ -17,7 +17,7 @@ if sys.version_info >= (3, 6): _PathLike = os.PathLike else: - _PathLike = Text + _PathLike = str def with_warn_for_invalid_lines(mappings: Iterator[Binding]) -> Iterator[Binding]: @@ -33,21 +33,21 @@ def with_warn_for_invalid_lines(mappings: Iterator[Binding]) -> Iterator[Binding class DotEnv(): def __init__( self, - dotenv_path: Union[Text, _PathLike, io.StringIO], + dotenv_path: Union[str, _PathLike, io.StringIO], verbose: bool = False, - encoding: Union[None, Text] = None, + encoding: Union[None, str] = None, interpolate: bool = True, override: bool = True, ) -> None: - self.dotenv_path = dotenv_path # type: Union[Text,_PathLike, io.StringIO] - self._dict = None # type: Optional[Dict[Text, Optional[Text]]] + self.dotenv_path = dotenv_path # type: Union[str,_PathLike, io.StringIO] + self._dict = None # type: Optional[Dict[str, Optional[str]]] self.verbose = verbose # type: bool - self.encoding = encoding # type: Union[None, Text] + self.encoding = encoding # type: Union[None, str] self.interpolate = interpolate # type: bool self.override = override # type: bool @contextmanager - def _get_stream(self) -> Iterator[IO[Text]]: + def _get_stream(self) -> Iterator[IO[str]]: if isinstance(self.dotenv_path, io.StringIO): yield self.dotenv_path elif os.path.isfile(self.dotenv_path): @@ -58,7 +58,7 @@ def _get_stream(self) -> Iterator[IO[Text]]: logger.info("Python-dotenv could not find configuration file %s.", self.dotenv_path or '.env') yield io.StringIO('') - def dict(self) -> Dict[Text, Optional[Text]]: + def dict(self) -> Dict[str, Optional[str]]: """Return dotenv as dict""" if self._dict: return self._dict @@ -72,7 +72,7 @@ def dict(self) -> Dict[Text, Optional[Text]]: return self._dict - def parse(self) -> Iterator[Tuple[Text, Optional[Text]]]: + def parse(self) -> Iterator[Tuple[str, Optional[str]]]: with self._get_stream() as stream: for mapping in with_warn_for_invalid_lines(parse_stream(stream)): if mapping.key is not None: @@ -90,7 +90,7 @@ def set_as_environment_variables(self) -> bool: return True - def get(self, key: Text) -> Optional[Text]: + def get(self, key: str) -> Optional[str]: """ """ data = self.dict() @@ -104,7 +104,7 @@ def get(self, key: Text) -> Optional[Text]: return None -def get_key(dotenv_path: Union[Text, _PathLike], key_to_get: Text) -> Optional[Text]: +def get_key(dotenv_path: Union[str, _PathLike], key_to_get: str) -> Optional[str]: """ Gets the value of a given key from the given .env @@ -114,7 +114,7 @@ def get_key(dotenv_path: Union[Text, _PathLike], key_to_get: Text) -> Optional[T @contextmanager -def rewrite(path: _PathLike) -> Iterator[Tuple[IO[Text], IO[Text]]]: +def rewrite(path: _PathLike) -> Iterator[Tuple[IO[str], IO[str]]]: try: if not os.path.isfile(path): with io.open(path, "w+") as source: @@ -132,11 +132,11 @@ def rewrite(path: _PathLike) -> Iterator[Tuple[IO[Text], IO[Text]]]: def set_key( dotenv_path: _PathLike, - key_to_set: Text, - value_to_set: Text, - quote_mode: Text = "always", + key_to_set: str, + value_to_set: str, + quote_mode: str = "always", export: bool = False, -) -> Tuple[Optional[bool], Text, Text]: +) -> Tuple[Optional[bool], str, str]: """ Adds or Updates a key/value to the given .env @@ -176,9 +176,9 @@ def set_key( def unset_key( dotenv_path: _PathLike, - key_to_unset: Text, - quote_mode: Text = "always", -) -> Tuple[Optional[bool], Text]: + key_to_unset: str, + quote_mode: str = "always", +) -> Tuple[Optional[bool], str]: """ Removes a given key from the given .env @@ -205,17 +205,17 @@ def unset_key( def resolve_variables( - values: Iterable[Tuple[Text, Optional[Text]]], + values: Iterable[Tuple[str, Optional[str]]], override: bool, -) -> Mapping[Text, Optional[Text]]: - new_values = {} # type: Dict[Text, Optional[Text]] +) -> Mapping[str, Optional[str]]: + new_values = {} # type: Dict[str, Optional[str]] for (name, value) in values: if value is None: result = None else: atoms = parse_variables(value) - env = {} # type: Dict[Text, Optional[Text]] + env = {} # type: Dict[str, Optional[str]] if override: env.update(os.environ) # type: ignore env.update(new_values) @@ -229,7 +229,7 @@ def resolve_variables( return new_values -def _walk_to_root(path: Text) -> Iterator[Text]: +def _walk_to_root(path: str) -> Iterator[str]: """ Yield directories starting from the given directory up to the root """ @@ -248,10 +248,10 @@ def _walk_to_root(path: Text) -> Iterator[Text]: def find_dotenv( - filename: Text = '.env', + filename: str = '.env', raise_error_if_not_found: bool = False, usecwd: bool = False, -) -> Text: +) -> str: """ Search in increasingly higher folders for the given file @@ -289,12 +289,12 @@ def _is_interactive(): def load_dotenv( - dotenv_path: Union[Text, _PathLike, None] = None, + dotenv_path: Union[str, _PathLike, None] = None, stream: Optional[io.StringIO] = None, verbose: bool = False, override: bool = False, interpolate: bool = True, - encoding: Optional[Text] = "utf-8", + encoding: Optional[str] = "utf-8", ) -> bool: """Parse a .env file and then load all the variables found as environment variables. @@ -320,12 +320,12 @@ def load_dotenv( def dotenv_values( - dotenv_path: Union[Text, _PathLike, None] = None, + dotenv_path: Union[str, _PathLike, None] = None, stream: Optional[io.StringIO] = None, verbose: bool = False, interpolate: bool = True, - encoding: Optional[Text] = "utf-8", -) -> Dict[Text, Optional[Text]]: + encoding: Optional[str] = "utf-8", +) -> Dict[str, Optional[str]]: """ Parse a .env file and return its content as a dict. diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index 8a976c51..398bd49a 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -1,10 +1,10 @@ import codecs import re from typing import (IO, Iterator, Match, NamedTuple, Optional, # noqa:F401 - Pattern, Sequence, Text, Tuple) + Pattern, Sequence, Tuple) -def make_regex(string: str, extra_flags: int = 0) -> Pattern[Text]: +def make_regex(string: str, extra_flags: int = 0) -> Pattern[str]: return re.compile(string, re.UNICODE | extra_flags) @@ -28,7 +28,7 @@ def make_regex(string: str, extra_flags: int = 0) -> Pattern[Text]: Original = NamedTuple( "Original", [ - ("string", Text), + ("string", str), ("line", int), ], ) @@ -36,8 +36,8 @@ def make_regex(string: str, extra_flags: int = 0) -> Pattern[Text]: Binding = NamedTuple( "Binding", [ - ("key", Optional[Text]), - ("value", Optional[Text]), + ("key", Optional[str]), + ("value", Optional[str]), ("original", Original), ("error", bool), ], @@ -57,7 +57,7 @@ def set(self, other: "Position") -> None: self.chars = other.chars self.line = other.line - def advance(self, string: Text) -> None: + def advance(self, string: str) -> None: self.chars += len(string) self.line += len(re.findall(_newline, string)) @@ -67,7 +67,7 @@ class Error(Exception): class Reader: - def __init__(self, stream: IO[Text]) -> None: + def __init__(self, stream: IO[str]) -> None: self.string = stream.read() self.position = Position.start() self.mark = Position.start() @@ -84,17 +84,17 @@ def get_marked(self) -> Original: line=self.mark.line, ) - def peek(self, count: int) -> Text: + def peek(self, count: int) -> str: return self.string[self.position.chars:self.position.chars + count] - def read(self, count: int) -> Text: + def read(self, count: int) -> str: result = self.string[self.position.chars:self.position.chars + count] if len(result) < count: raise Error("read: End of string") self.position.advance(result) return result - def read_regex(self, regex: Pattern[Text]) -> Sequence[Text]: + def read_regex(self, regex: Pattern[str]) -> Sequence[str]: match = regex.match(self.string, self.position.chars) if match is None: raise Error("read_regex: Pattern not found") @@ -102,14 +102,14 @@ def read_regex(self, regex: Pattern[Text]) -> Sequence[Text]: return match.groups() -def decode_escapes(regex: Pattern[Text], string: Text) -> Text: - def decode_match(match: Match[Text]) -> Text: +def decode_escapes(regex: Pattern[str], string: str) -> str: + def decode_match(match: Match[str]) -> str: return codecs.decode(match.group(0), 'unicode-escape') # type: ignore return regex.sub(decode_match, string) -def parse_key(reader: Reader) -> Optional[Text]: +def parse_key(reader: Reader) -> Optional[str]: char = reader.peek(1) if char == "#": return None @@ -120,12 +120,12 @@ def parse_key(reader: Reader) -> Optional[Text]: return key -def parse_unquoted_value(reader: Reader) -> Text: +def parse_unquoted_value(reader: Reader) -> str: (part,) = reader.read_regex(_unquoted_value) return re.sub(r"\s+#.*", "", part).rstrip() -def parse_value(reader: Reader) -> Text: +def parse_value(reader: Reader) -> str: char = reader.peek(1) if char == u"'": (value,) = reader.read_regex(_single_quoted_value) @@ -155,7 +155,7 @@ def parse_binding(reader: Reader) -> Binding: reader.read_regex(_whitespace) if reader.peek(1) == "=": reader.read_regex(_equal_sign) - value = parse_value(reader) # type: Optional[Text] + value = parse_value(reader) # type: Optional[str] else: value = None reader.read_regex(_comment) @@ -176,7 +176,7 @@ def parse_binding(reader: Reader) -> Binding: ) -def parse_stream(stream: IO[Text]) -> Iterator[Binding]: +def parse_stream(stream: IO[str]) -> Iterator[Binding]: reader = Reader(stream) while reader.has_next(): yield parse_binding(reader) diff --git a/src/dotenv/variables.py b/src/dotenv/variables.py index bddd07e1..d77b700c 100644 --- a/src/dotenv/variables.py +++ b/src/dotenv/variables.py @@ -1,6 +1,6 @@ import re from abc import ABCMeta -from typing import Iterator, Mapping, Optional, Pattern, Text +from typing import Iterator, Mapping, Optional, Pattern _posix_variable = re.compile( r""" @@ -12,7 +12,7 @@ \} """, re.VERBOSE, -) # type: Pattern[Text] +) # type: Pattern[str] class Atom(): @@ -24,12 +24,12 @@ def __ne__(self, other: object) -> bool: return NotImplemented return not result - def resolve(self, env: Mapping[Text, Optional[Text]]) -> Text: + def resolve(self, env: Mapping[str, Optional[str]]) -> str: raise NotImplementedError class Literal(Atom): - def __init__(self, value: Text) -> None: + def __init__(self, value: str) -> None: self.value = value def __repr__(self) -> str: @@ -43,12 +43,12 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash((self.__class__, self.value)) - def resolve(self, env: Mapping[Text, Optional[Text]]) -> Text: + def resolve(self, env: Mapping[str, Optional[str]]) -> str: return self.value class Variable(Atom): - def __init__(self, name: Text, default: Optional[Text]) -> None: + def __init__(self, name: str, default: Optional[str]) -> None: self.name = name self.default = default @@ -63,13 +63,13 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash((self.__class__, self.name, self.default)) - def resolve(self, env: Mapping[Text, Optional[Text]]) -> Text: + def resolve(self, env: Mapping[str, Optional[str]]) -> str: default = self.default if self.default is not None else "" result = env.get(self.name, default) return result if result is not None else "" -def parse_variables(value: Text) -> Iterator[Atom]: +def parse_variables(value: str) -> Iterator[Atom]: cursor = 0 for match in _posix_variable.finditer(value): From 28dbb23b3fc05596877b36b0ea2761af14c4e706 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Wed, 14 Jul 2021 09:23:53 +0200 Subject: [PATCH 096/253] Fix documentation of `dotenv set` --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 045da075..9b56b546 100644 --- a/README.md +++ b/README.md @@ -141,8 +141,8 @@ without manually opening it. ```shell $ pip install "python-dotenv[cli]" -$ dotenv set USER=foo -$ dotenv set EMAIL=foo@example.org +$ dotenv set USER foo +$ dotenv set EMAIL foo@example.org $ dotenv list USER=foo EMAIL=foo@example.org From f5d0c546249321066d2e6a4a81acbbba06c998bf Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Wed, 14 Jul 2021 09:49:01 +0200 Subject: [PATCH 097/253] Enable the use of Mypy 0.900+ Mypy would complain about the missing `types-mock` package, which it now needs to perform accurate type checking and despite `ignore_missing_imports` set to `True`: tests/test_ipython.py:3: error: Library stubs not installed for "mock" (or incompatible with Python 3.9) tests/test_ipython.py:3: note: Hint: "python3 -m pip install types-mock" tests/test_ipython.py:3: note: (or run "mypy --install-types" to install all missing stub packages) tests/test_ipython.py:3: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports tests/test_main.py:7: error: Library stubs not installed for "mock" (or incompatible with Python 3.9) Found 2 errors in 2 files (checked 15 source files) --- requirements.txt | 1 + tox.ini | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 952bfdce..39302b21 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ pytest-cov pytest>=3.9 sh>=1.09 tox +types-mock wheel twine portray diff --git a/tox.ini b/tox.ini index 7c2b4f9d..2cd63024 100644 --- a/tox.ini +++ b/tox.ini @@ -24,7 +24,8 @@ commands = coverage run --parallel -m pytest {posargs} skip_install = true deps = flake8 - mypy<0.900 + mypy + types-mock commands = flake8 src tests mypy --python-version=3.9 src tests From 955e2a4ea6391a322c779e737f5a7aca7eaa963d Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Wed, 14 Jul 2021 09:54:39 +0200 Subject: [PATCH 098/253] Enable checking of "untyped defs" and fix types `set_key` and `unset_key` were more restrictive than other functions such as `dotenv_values` with regards to their `dotenv_path` argument. --- CHANGELOG.md | 5 +++++ setup.cfg | 1 + src/dotenv/main.py | 6 +++--- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b4340c1..f52cf07e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- The `dotenv_path` argument of `set_key` and `unset_key` now has a type of `Union[str, + os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). + ### Changed - Require Python 3.5 or a later version. Python 2 and 3.4 are no longer supported. (#341 diff --git a/setup.cfg b/setup.cfg index 9afbc4b3..2723d8a2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -14,6 +14,7 @@ max-line-length = 120 exclude = .tox,.git,docs,venv,.venv [mypy] +check_untyped_defs = true ignore_missing_imports = true [metadata] diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 6b29fc90..d550f6f8 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -114,7 +114,7 @@ def get_key(dotenv_path: Union[str, _PathLike], key_to_get: str) -> Optional[str @contextmanager -def rewrite(path: _PathLike) -> Iterator[Tuple[IO[str], IO[str]]]: +def rewrite(path: Union[str, _PathLike]) -> Iterator[Tuple[IO[str], IO[str]]]: try: if not os.path.isfile(path): with io.open(path, "w+") as source: @@ -131,7 +131,7 @@ def rewrite(path: _PathLike) -> Iterator[Tuple[IO[str], IO[str]]]: def set_key( - dotenv_path: _PathLike, + dotenv_path: Union[str, _PathLike], key_to_set: str, value_to_set: str, quote_mode: str = "always", @@ -175,7 +175,7 @@ def set_key( def unset_key( - dotenv_path: _PathLike, + dotenv_path: Union[str, _PathLike], key_to_unset: str, quote_mode: str = "always", ) -> Tuple[Optional[bool], str]: From 134ed435c9a0d2a8eebc9e72e1157b3c6f022e33 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Wed, 14 Jul 2021 09:47:42 +0200 Subject: [PATCH 099/253] Allow any text stream (`IO[str]`) as `stream` This applies to the `load_dotenv` and `dotenv_values` functions. This makes it possible to pass a file stream such as `open("foo", "r")` to these functions. --- CHANGELOG.md | 13 ++++++++----- src/dotenv/main.py | 38 +++++++++++++++++++++++++------------- tests/test_main.py | 26 ++++++++++++++++++++++++-- 3 files changed, 57 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f52cf07e..cea20534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,16 +7,19 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -### Added - -- The `dotenv_path` argument of `set_key` and `unset_key` now has a type of `Union[str, - os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). - ### Changed - Require Python 3.5 or a later version. Python 2 and 3.4 are no longer supported. (#341 by [@bbc2]). +### Added + +- The `dotenv_path` argument of `set_key` and `unset_key` now has a type of `Union[str, + os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). +- The `stream` argument of `load_dotenv` and `dotenv_values` can now be a text stream + (`IO[str]`), which includes values like `io.StringIO("foo")` and `open("file.env", + "r")` (#348 by [@bbc2]). + ## [0.18.0] - 2021-06-20 ### Changed diff --git a/src/dotenv/main.py b/src/dotenv/main.py index d550f6f8..b8d0a4e0 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -33,13 +33,15 @@ def with_warn_for_invalid_lines(mappings: Iterator[Binding]) -> Iterator[Binding class DotEnv(): def __init__( self, - dotenv_path: Union[str, _PathLike, io.StringIO], + dotenv_path: Optional[Union[str, _PathLike]], + stream: Optional[IO[str]] = None, verbose: bool = False, encoding: Union[None, str] = None, interpolate: bool = True, override: bool = True, ) -> None: - self.dotenv_path = dotenv_path # type: Union[str,_PathLike, io.StringIO] + self.dotenv_path = dotenv_path # type: Optional[Union[str, _PathLike]] + self.stream = stream # type: Optional[IO[str]] self._dict = None # type: Optional[Dict[str, Optional[str]]] self.verbose = verbose # type: bool self.encoding = encoding # type: Union[None, str] @@ -48,14 +50,17 @@ def __init__( @contextmanager def _get_stream(self) -> Iterator[IO[str]]: - if isinstance(self.dotenv_path, io.StringIO): - yield self.dotenv_path - elif os.path.isfile(self.dotenv_path): + if self.dotenv_path and os.path.isfile(self.dotenv_path): with io.open(self.dotenv_path, encoding=self.encoding) as stream: yield stream + elif self.stream is not None: + yield self.stream else: if self.verbose: - logger.info("Python-dotenv could not find configuration file %s.", self.dotenv_path or '.env') + logger.info( + "Python-dotenv could not find configuration file %s.", + self.dotenv_path or '.env', + ) yield io.StringIO('') def dict(self) -> Dict[str, Optional[str]]: @@ -290,7 +295,7 @@ def _is_interactive(): def load_dotenv( dotenv_path: Union[str, _PathLike, None] = None, - stream: Optional[io.StringIO] = None, + stream: Optional[IO[str]] = None, verbose: bool = False, override: bool = False, interpolate: bool = True, @@ -299,7 +304,8 @@ def load_dotenv( """Parse a .env file and then load all the variables found as environment variables. - *dotenv_path*: absolute or relative path to .env file. - - *stream*: `StringIO` object with .env content, used if `dotenv_path` is `None`. + - *stream*: Text stream (such as `io.StringIO`) with .env content, used if + `dotenv_path` is `None`. - *verbose*: whether to output a warning the .env file is missing. Defaults to `False`. - *override*: whether to override the system environment variables with the variables @@ -308,9 +314,12 @@ def load_dotenv( If both `dotenv_path` and `stream`, `find_dotenv()` is used to find the .env file. """ - f = dotenv_path or stream or find_dotenv() + if dotenv_path is None and stream is None: + dotenv_path = find_dotenv() + dotenv = DotEnv( - f, + dotenv_path=dotenv_path, + stream=stream, verbose=verbose, interpolate=interpolate, override=override, @@ -321,7 +330,7 @@ def load_dotenv( def dotenv_values( dotenv_path: Union[str, _PathLike, None] = None, - stream: Optional[io.StringIO] = None, + stream: Optional[IO[str]] = None, verbose: bool = False, interpolate: bool = True, encoding: Optional[str] = "utf-8", @@ -338,9 +347,12 @@ def dotenv_values( If both `dotenv_path` and `stream`, `find_dotenv()` is used to find the .env file. """ - f = dotenv_path or stream or find_dotenv() + if dotenv_path is None and stream is None: + dotenv_path = find_dotenv() + return DotEnv( - f, + dotenv_path=dotenv_path, + stream=stream, verbose=verbose, interpolate=interpolate, override=True, diff --git a/tests/test_main.py b/tests/test_main.py index d612bb25..13e2791c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -277,7 +277,7 @@ def test_load_dotenv_redefine_var_used_in_file_with_override(dotenv_file): @mock.patch.dict(os.environ, {}, clear=True) -def test_load_dotenv_utf_8(): +def test_load_dotenv_string_io_utf_8(): stream = io.StringIO("a=à") result = dotenv.load_dotenv(stream=stream) @@ -286,6 +286,18 @@ def test_load_dotenv_utf_8(): assert os.environ == {"a": "à"} +@mock.patch.dict(os.environ, {}, clear=True) +def test_load_dotenv_file_stream(dotenv_file): + with open(dotenv_file, "w") as f: + f.write("a=b") + + with open(dotenv_file, "r") as f: + result = dotenv.load_dotenv(stream=f) + + assert result is True + assert os.environ == {"a": "b"} + + def test_load_dotenv_in_current_dir(tmp_path): dotenv_path = tmp_path / '.env' dotenv_path.write_bytes(b'a=b') @@ -353,7 +365,7 @@ def test_dotenv_values_file(dotenv_file): ({}, "a=b\nc=${a}\nd=e\nc=${d}", True, {"a": "b", "c": "e", "d": "e"}), ], ) -def test_dotenv_values_stream(env, string, interpolate, expected): +def test_dotenv_values_string_io(env, string, interpolate, expected): with mock.patch.dict(os.environ, env, clear=True): stream = io.StringIO(string) stream.seek(0) @@ -361,3 +373,13 @@ def test_dotenv_values_stream(env, string, interpolate, expected): result = dotenv.dotenv_values(stream=stream, interpolate=interpolate) assert result == expected + + +def test_dotenv_values_file_stream(dotenv_file): + with open(dotenv_file, "w") as f: + f.write("a=b") + + with open(dotenv_file, "r") as f: + result = dotenv.dotenv_values(stream=f) + + assert result == {"a": "b"} From b043829d810b4bf46ebb4addcf0e8ca97dff3bdd Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 24 Jul 2021 17:57:38 +0200 Subject: [PATCH 100/253] Release version 0.19.0 --- CHANGELOG.md | 5 +++-- setup.cfg | 2 +- src/dotenv/version.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cea20534..5da48f0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.19.0] - 2021-07-24 ### Changed @@ -285,7 +285,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@yannham]: https://github.com/yannham [@zueve]: https://github.com/zueve -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.18.0...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.19.0...HEAD +[0.19.0]: https://github.com/theskumar/python-dotenv/compare/v0.18.0...v0.19.0 [0.18.0]: https://github.com/theskumar/python-dotenv/compare/v0.17.1...v0.18.0 [0.17.1]: https://github.com/theskumar/python-dotenv/compare/v0.17.0...v0.17.1 [0.17.0]: https://github.com/theskumar/python-dotenv/compare/v0.16.0...v0.17.0 diff --git a/setup.cfg b/setup.cfg index 2723d8a2..a20d2498 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.18.0 +current_version = 0.19.0 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 1317d755..11ac8e1a 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.18.0" +__version__ = "0.19.0" From 36516a7e0612c0a28fdd3891fcedbd36eb164af8 Mon Sep 17 00:00:00 2001 From: Christian Clauss Date: Sun, 25 Jul 2021 16:20:05 +0200 Subject: [PATCH 101/253] CHANGELOG.md: Fix typos discovered by codespell `codespell --ignore-words-list=nd` --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5da48f0a..d1305894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -221,13 +221,13 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## 0.6.2 - Fix dotenv list command ([@ticosax](https://github.com/ticosax)) -- Add iPython Suport +- Add iPython Support ([@tillahoffmann](https://github.com/tillahoffmann)) ## 0.6.0 - Drop support for Python 2.6 -- Handle escaped charaters and newlines in quoted values. (Thanks +- Handle escaped characters and newlines in quoted values. (Thanks [@iameugenejo](https://github.com/iameugenejo)) - Remove any spaces around unquoted key/value. (Thanks [@paulochf](https://github.com/paulochf)) From 9b1ab5d333f160c2469e8d247b638c53bd05aa70 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sat, 9 Oct 2021 13:38:53 +0530 Subject: [PATCH 102/253] Add Python 3.10 support (#359) * Add Python 3.10 support * fixup! Add Python 3.10 support * update changelog --- .github/workflows/test.yml | 2 +- CHANGELOG.md | 8 ++++++++ setup.py | 1 + tox.ini | 8 +++++--- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2865cf85..e0b721de 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,7 +10,7 @@ jobs: matrix: os: - ubuntu-latest - python-version: [3.5, 3.6, 3.7, 3.8, 3.9, pypy3] + python-version: [3.5, 3.6, 3.7, 3.8, 3.9, "3.10", pypy3] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index d1305894..d373dfb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Add support for Python 3.10. (#359 by [@theskumar]) + + ## [0.19.0] - 2021-07-24 ### Changed @@ -259,6 +266,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [#172]: https://github.com/theskumar/python-dotenv/issues/172 [#176]: https://github.com/theskumar/python-dotenv/issues/176 [#183]: https://github.com/theskumar/python-dotenv/issues/183 +[#359]: https://github.com/theskumar/python-dotenv/issues/359 [@Flimm]: https://github.com/Flimm [@alanjds]: https://github.com/alanjds diff --git a/setup.py b/setup.py index 06ad2dd9..53ba5a07 100644 --- a/setup.py +++ b/setup.py @@ -50,6 +50,7 @@ def read_files(files): 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: Implementation :: PyPy', 'Intended Audience :: Developers', 'Intended Audience :: System Administrators', diff --git a/tox.ini b/tox.ini index 2cd63024..bf9bf707 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = lint,py{35,36,37,38,39},pypy3,manifest,coverage-report +envlist = lint,py{35,36,37,38,39,310},pypy3,manifest,coverage-report [gh-actions] python = @@ -7,7 +7,8 @@ python = 3.6: py36, coverage-report 3.7: py37, coverage-report 3.8: py38, coverage-report - 3.9: py39, lint, manifest, coverage-report + 3.9: py39, coverage-report + 3.10: py310, lint, manifest, coverage-report pypy3: pypy3, coverage-report [testenv] @@ -17,7 +18,7 @@ deps = coverage sh click - py{35,36,37,38,39,py3}: ipython + py{35,36,37,38,39,310,py3}: ipython commands = coverage run --parallel -m pytest {posargs} [testenv:lint] @@ -28,6 +29,7 @@ deps = types-mock commands = flake8 src tests + mypy --python-version=3.10 src tests mypy --python-version=3.9 src tests mypy --python-version=3.8 src tests mypy --python-version=3.7 src tests From fc138ce8a430b758f4f2c89bc8104f259e2cba38 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sat, 9 Oct 2021 13:47:30 +0530 Subject: [PATCH 103/253] Release version 0.19.1 --- CHANGELOG.md | 6 +++--- setup.cfg | 2 +- src/dotenv/version.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d373dfb6..6b2b2bbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.19.1] - 2021-08-09 ### Added - Add support for Python 3.10. (#359 by [@theskumar]) - ## [0.19.0] - 2021-07-24 ### Changed @@ -293,7 +292,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@yannham]: https://github.com/yannham [@zueve]: https://github.com/zueve -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.19.0...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.19.1...HEAD +[0.19.1]: https://github.com/theskumar/python-dotenv/compare/v0.19.0...v0.19.1 [0.19.0]: https://github.com/theskumar/python-dotenv/compare/v0.18.0...v0.19.0 [0.18.0]: https://github.com/theskumar/python-dotenv/compare/v0.17.1...v0.18.0 [0.17.1]: https://github.com/theskumar/python-dotenv/compare/v0.17.0...v0.17.1 diff --git a/setup.cfg b/setup.cfg index a20d2498..b63622d6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.19.0 +current_version = 0.19.1 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 11ac8e1a..4c1ca3c8 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.19.0" +__version__ = "0.19.1" From 45848bb780c26ef0adf7898656f7d3d3f4e2d8ae Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 23 Oct 2021 11:34:36 +0200 Subject: [PATCH 104/253] Add missing trailing newline when adding new value Sometimes, the source file doesn't have a trailing newline. If we add a new binding in such a case, we need to add a newline before the new binding. --- CHANGELOG.md | 7 +++++++ src/dotenv/main.py | 4 ++++ tests/test_main.py | 1 + 3 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b2b2bbb..811ed1ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Fixed + +- In `set_key`, add missing newline character before new entry if necessary. (#361 by + [@bbc2]) + ## [0.19.1] - 2021-08-09 ### Added diff --git a/src/dotenv/main.py b/src/dotenv/main.py index b8d0a4e0..d867f023 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -167,13 +167,17 @@ def set_key( with rewrite(dotenv_path) as (source, dest): replaced = False + missing_newline = False for mapping in with_warn_for_invalid_lines(parse_stream(source)): if mapping.key == key_to_set: dest.write(line_out) replaced = True else: dest.write(mapping.original.string) + missing_newline = not mapping.original.string.endswith("\n") if not replaced: + if missing_newline: + dest.write("\n") dest.write(line_out) return True, key_to_set, value_to_set diff --git a/tests/test_main.py b/tests/test_main.py index 13e2791c..541ac5ee 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -37,6 +37,7 @@ def test_set_key_no_file(tmp_path): ("a=b\nc=d", "a", "e", (True, "a", "e"), "a='e'\nc=d"), ("a=b\nc=d\ne=f", "c", "g", (True, "c", "g"), "a=b\nc='g'\ne=f"), ("a=b\n", "c", "d", (True, "c", "d"), "a=b\nc='d'\n"), + ("a=b", "c", "d", (True, "c", "d"), "a=b\nc='d'\n"), ], ) def test_set_key(dotenv_file, before, key, value, expected, after): From 2471a5af1027acca27f8d326ddb97b1d43a2ba23 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Thu, 11 Nov 2021 13:25:19 +0100 Subject: [PATCH 105/253] Release version 0.19.2 --- CHANGELOG.md | 5 +++-- setup.cfg | 2 +- src/dotenv/version.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 811ed1ad..9b18856e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## [0.19.2] - 2021-11-11 ### Fixed @@ -299,7 +299,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@yannham]: https://github.com/yannham [@zueve]: https://github.com/zueve -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.19.1...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.19.2...HEAD +[0.19.2]: https://github.com/theskumar/python-dotenv/compare/v0.19.1...v0.19.2 [0.19.1]: https://github.com/theskumar/python-dotenv/compare/v0.19.0...v0.19.1 [0.19.0]: https://github.com/theskumar/python-dotenv/compare/v0.18.0...v0.19.0 [0.18.0]: https://github.com/theskumar/python-dotenv/compare/v0.17.1...v0.18.0 diff --git a/setup.cfg b/setup.cfg index b63622d6..d87b0a6b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.19.1 +current_version = 0.19.2 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 4c1ca3c8..aa070c2c 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.19.1" +__version__ = "0.19.2" From ba9408c5048e8e512318df423541d2b44ac6019f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Fri, 14 Jan 2022 11:23:37 +0200 Subject: [PATCH 106/253] chore: add test with Python 3.11 (#368) --- .github/workflows/test.yml | 2 +- setup.py | 1 + tox.ini | 6 ++++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e0b721de..5135ae4f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,7 +10,7 @@ jobs: matrix: os: - ubuntu-latest - python-version: [3.5, 3.6, 3.7, 3.8, 3.9, "3.10", pypy3] + python-version: [3.5, 3.6, 3.7, 3.8, 3.9, "3.10", "3.11.0-alpha - 3.11", pypy3] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} diff --git a/setup.py b/setup.py index 53ba5a07..a8122d3a 100644 --- a/setup.py +++ b/setup.py @@ -51,6 +51,7 @@ def read_files(files): 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: Implementation :: PyPy', 'Intended Audience :: Developers', 'Intended Audience :: System Administrators', diff --git a/tox.ini b/tox.ini index bf9bf707..c1f89fa1 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = lint,py{35,36,37,38,39,310},pypy3,manifest,coverage-report +envlist = lint,py{35,36,37,38,39,310,311},pypy3,manifest,coverage-report [gh-actions] python = @@ -9,6 +9,7 @@ python = 3.8: py38, coverage-report 3.9: py39, coverage-report 3.10: py310, lint, manifest, coverage-report + 3.11: py311, coverage-report pypy3: pypy3, coverage-report [testenv] @@ -18,7 +19,7 @@ deps = coverage sh click - py{35,36,37,38,39,310,py3}: ipython + py{35,36,37,38,39,310,311,py3}: ipython commands = coverage run --parallel -m pytest {posargs} [testenv:lint] @@ -29,6 +30,7 @@ deps = types-mock commands = flake8 src tests + mypy --python-version=3.11 src tests mypy --python-version=3.10 src tests mypy --python-version=3.9 src tests mypy --python-version=3.8 src tests From 157282ce24d4124e934fd543eeb83fe5a65a4234 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 19 Feb 2022 14:38:01 +0100 Subject: [PATCH 107/253] Add encoding parameter to {get,set,unset}_key The parameter already exists for `dotenv_values` and `load_dotenv` and has the same meaning. --- CHANGELOG.md | 7 +++++++ src/dotenv/main.py | 29 +++++++++++++++++++---------- tests/test_main.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b18856e..3d4d014e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Add `encoding` (`Optional[str]`) parameter to `get_key`, `set_key` and `unset_key`. + (#379 by [@bbc2]) + ## [0.19.2] - 2021-11-11 ### Fixed diff --git a/src/dotenv/main.py b/src/dotenv/main.py index d867f023..20ac61ba 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -109,23 +109,30 @@ def get(self, key: str) -> Optional[str]: return None -def get_key(dotenv_path: Union[str, _PathLike], key_to_get: str) -> Optional[str]: +def get_key( + dotenv_path: Union[str, _PathLike], + key_to_get: str, + encoding: Optional[str] = "utf-8", +) -> Optional[str]: """ - Gets the value of a given key from the given .env + Get the value of a given key from the given .env. - If the .env path given doesn't exist, fails + Returns `None` if the key isn't found or doesn't have a value. """ - return DotEnv(dotenv_path, verbose=True).get(key_to_get) + return DotEnv(dotenv_path, verbose=True, encoding=encoding).get(key_to_get) @contextmanager -def rewrite(path: Union[str, _PathLike]) -> Iterator[Tuple[IO[str], IO[str]]]: +def rewrite( + path: Union[str, _PathLike], + encoding: Optional[str], +) -> Iterator[Tuple[IO[str], IO[str]]]: try: if not os.path.isfile(path): - with io.open(path, "w+") as source: + with io.open(path, "w+", encoding=encoding) as source: source.write("") - with tempfile.NamedTemporaryFile(mode="w+", delete=False) as dest: - with io.open(path) as source: + with tempfile.NamedTemporaryFile(mode="w+", delete=False, encoding=encoding) as dest: + with io.open(path, encoding=encoding) as source: yield (source, dest) # type: ignore except BaseException: if os.path.isfile(dest.name): @@ -141,6 +148,7 @@ def set_key( value_to_set: str, quote_mode: str = "always", export: bool = False, + encoding: Optional[str] = "utf-8", ) -> Tuple[Optional[bool], str, str]: """ Adds or Updates a key/value to the given .env @@ -165,7 +173,7 @@ def set_key( else: line_out = "{}={}\n".format(key_to_set, value_out) - with rewrite(dotenv_path) as (source, dest): + with rewrite(dotenv_path, encoding=encoding) as (source, dest): replaced = False missing_newline = False for mapping in with_warn_for_invalid_lines(parse_stream(source)): @@ -187,6 +195,7 @@ def unset_key( dotenv_path: Union[str, _PathLike], key_to_unset: str, quote_mode: str = "always", + encoding: Optional[str] = "utf-8", ) -> Tuple[Optional[bool], str]: """ Removes a given key from the given .env @@ -199,7 +208,7 @@ def unset_key( return None, key_to_unset removed = False - with rewrite(dotenv_path) as (source, dest): + with rewrite(dotenv_path, encoding=encoding) as (source, dest): for mapping in with_warn_for_invalid_lines(parse_stream(source)): if mapping.key == key_to_unset: removed = True diff --git a/tests/test_main.py b/tests/test_main.py index 541ac5ee..364fc24d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -53,6 +53,15 @@ def test_set_key(dotenv_file, before, key, value, expected, after): mock_warning.assert_not_called() +def test_set_key_encoding(dotenv_file): + encoding = "latin-1" + + result = dotenv.set_key(dotenv_file, "a", "é", encoding=encoding) + + assert result == (True, "a", "é") + assert open(dotenv_file, "r", encoding=encoding).read() == "a='é'\n" + + def test_set_key_permission_error(dotenv_file): os.chmod(dotenv_file, 0o000) @@ -107,6 +116,16 @@ def test_get_key_ok(dotenv_file): mock_warning.assert_not_called() +def test_get_key_encoding(dotenv_file): + encoding = "latin-1" + with open(dotenv_file, "w", encoding=encoding) as f: + f.write("é=è") + + result = dotenv.get_key(dotenv_file, "é", encoding=encoding) + + assert result == "è" + + def test_get_key_none(dotenv_file): logger = logging.getLogger("dotenv.main") with open(dotenv_file, "w") as f: @@ -147,6 +166,18 @@ def test_unset_no_value(dotenv_file): mock_warning.assert_not_called() +def test_unset_encoding(dotenv_file): + encoding = "latin-1" + with open(dotenv_file, "w", encoding=encoding) as f: + f.write("é=x") + + result = dotenv.unset_key(dotenv_file, "é", encoding=encoding) + + assert result == (True, "é") + with open(dotenv_file, "r", encoding=encoding) as f: + assert f.read() == "" + + def test_unset_non_existent_file(tmp_path): nx_file = str(tmp_path / "nx") logger = logging.getLogger("dotenv.main") From 06c645c6d6e20bae18dde51e2bb02c82b6d46133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20G=C3=B3rny?= Date: Tue, 15 Feb 2022 10:29:34 +0100 Subject: [PATCH 108/253] Fix installing entry points Not sure why or when but the string syntax for entry points does not seem to work correctly anymore (no scripts are installed). Use the explicit list-in-dict syntax instead. --- CHANGELOG.md | 6 ++++++ setup.py | 9 +++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d4d014e..78f8fb79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Add `encoding` (`Optional[str]`) parameter to `get_key`, `set_key` and `unset_key`. (#379 by [@bbc2]) +### Fixed + +- Use dict to specify the `entry_points` parameter of `setuptools.setup` (#376 by + [@mgorny]). + ## [0.19.2] - 2021-11-11 ### Fixed @@ -296,6 +301,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@gongqingkui]: https://github.com/gongqingkui [@greyli]: https://github.com/greyli [@jadutter]: https://github.com/jadutter +[@mgorny]: https://github.com/mgorny [@qnighy]: https://github.com/qnighy [@snobu]: https://github.com/snobu [@techalchemy]: https://github.com/techalchemy diff --git a/setup.py b/setup.py index a8122d3a..396cdf61 100644 --- a/setup.py +++ b/setup.py @@ -36,10 +36,11 @@ def read_files(files): extras_require={ 'cli': ['click>=5.0', ], }, - entry_points=''' - [console_scripts] - dotenv=dotenv.cli:cli - ''', + entry_points={ + "console_scripts": [ + "dotenv=dotenv.cli:cli", + ], + }, license='BSD-3-Clause', classifiers=[ 'Development Status :: 5 - Production/Stable', From 38320117cab7b0db0d9b417b2802e147542f80ed Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 13 Mar 2022 18:10:08 +0100 Subject: [PATCH 109/253] Don't mark wheels as universal (#387) --- CHANGELOG.md | 1 + setup.cfg | 4 ---- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78f8fb79..ba0276c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Use dict to specify the `entry_points` parameter of `setuptools.setup` (#376 by [@mgorny]). +- Don't build universal wheels (#387 by [@bbc2]). ## [0.19.2] - 2021-11-11 diff --git a/setup.cfg b/setup.cfg index d87b0a6b..348a5b51 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,10 +5,6 @@ tag = True [bumpversion:file:src/dotenv/version.py] - -[bdist_wheel] -universal = 1 - [flake8] max-line-length = 120 exclude = .tox,.git,docs,venv,.venv From 53cee8c7fb2fe1252606202ec9e2746651df738c Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Thu, 24 Mar 2022 15:23:31 +0100 Subject: [PATCH 110/253] Release version 0.20.0 --- CHANGELOG.md | 5 +++-- setup.cfg | 2 +- src/dotenv/version.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba0276c1..d4251db7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## [0.20.0] - 2022-03-24 ### Added @@ -313,7 +313,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@yannham]: https://github.com/yannham [@zueve]: https://github.com/zueve -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.19.2...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.20.0...HEAD +[0.19.2]: https://github.com/theskumar/python-dotenv/compare/v0.19.2...v0.20.0 [0.19.2]: https://github.com/theskumar/python-dotenv/compare/v0.19.1...v0.19.2 [0.19.1]: https://github.com/theskumar/python-dotenv/compare/v0.19.0...v0.19.1 [0.19.0]: https://github.com/theskumar/python-dotenv/compare/v0.18.0...v0.19.0 diff --git a/setup.cfg b/setup.cfg index 348a5b51..09d61034 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.19.2 +current_version = 0.20.0 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index aa070c2c..5f4bb0b3 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.19.2" +__version__ = "0.20.0" From 65dfa7137fc77405231e1d27673d9f1220a0844e Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Thu, 24 Mar 2022 16:33:57 +0100 Subject: [PATCH 111/253] Fix link typo in changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4251db7..874f2134 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -314,7 +314,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@zueve]: https://github.com/zueve [Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.20.0...HEAD -[0.19.2]: https://github.com/theskumar/python-dotenv/compare/v0.19.2...v0.20.0 +[0.20.0]: https://github.com/theskumar/python-dotenv/compare/v0.19.2...v0.20.0 [0.19.2]: https://github.com/theskumar/python-dotenv/compare/v0.19.1...v0.19.2 [0.19.1]: https://github.com/theskumar/python-dotenv/compare/v0.19.0...v0.19.1 [0.19.0]: https://github.com/theskumar/python-dotenv/compare/v0.18.0...v0.19.0 From 07a2fa15aa74d7cb628f727a6bb1e01e416c6603 Mon Sep 17 00:00:00 2001 From: Rabin Adhikari Date: Fri, 25 Mar 2022 14:47:22 +0545 Subject: [PATCH 112/253] Use `open` instead of `io.open` --- setup.py | 5 ++--- src/dotenv/main.py | 6 +++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/setup.py b/setup.py index 396cdf61..a805c188 100644 --- a/setup.py +++ b/setup.py @@ -1,11 +1,10 @@ -import io from setuptools import setup def read_files(files): data = [] for file in files: - with io.open(file, encoding='utf-8') as f: + with open(file, encoding='utf-8') as f: data.append(f.read()) return "\n".join(data) @@ -13,7 +12,7 @@ def read_files(files): long_description = read_files(['README.md', 'CHANGELOG.md']) meta = {} -with io.open('./src/dotenv/version.py', encoding='utf-8') as f: +with open('./src/dotenv/version.py', encoding='utf-8') as f: exec(f.read(), meta) setup( diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 20ac61ba..54a5c42e 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -51,7 +51,7 @@ def __init__( @contextmanager def _get_stream(self) -> Iterator[IO[str]]: if self.dotenv_path and os.path.isfile(self.dotenv_path): - with io.open(self.dotenv_path, encoding=self.encoding) as stream: + with open(self.dotenv_path, encoding=self.encoding) as stream: yield stream elif self.stream is not None: yield self.stream @@ -129,10 +129,10 @@ def rewrite( ) -> Iterator[Tuple[IO[str], IO[str]]]: try: if not os.path.isfile(path): - with io.open(path, "w+", encoding=encoding) as source: + with open(path, "w+", encoding=encoding) as source: source.write("") with tempfile.NamedTemporaryFile(mode="w+", delete=False, encoding=encoding) as dest: - with io.open(path, encoding=encoding) as source: + with open(path, encoding=encoding) as source: yield (source, dest) # type: ignore except BaseException: if os.path.isfile(dest.name): From b4e0c78ea82878d5e8f22ab4e24aee7bffdc2626 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 26 Mar 2022 19:13:33 +0100 Subject: [PATCH 113/253] Docs: Improve documentation for variables without value (#390) --- README.md | 14 ++++++++++++++ src/dotenv/main.py | 18 ++++++++++++------ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 9b56b546..70de7e09 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,20 @@ second line" FOO="first line\nsecond line" ``` +### Variable without a value + +A variable can have no value: + +```bash +FOO +``` + +It results in `dotenv_values` associating that variable name with the value `None` (e.g. +`{"FOO": None}`. `load_dotenv`, on the other hand, simply ignores such variables. + +This shouldn't be confused with `FOO=`, in which case the variable is associated with the +empty string. + ### Variable expansion Python-dotenv can interpolate variables using POSIX variable expansion. diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 54a5c42e..76ee5993 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -351,14 +351,20 @@ def dotenv_values( """ Parse a .env file and return its content as a dict. - - *dotenv_path*: absolute or relative path to .env file. - - *stream*: `StringIO` object with .env content, used if `dotenv_path` is `None`. - - *verbose*: whether to output a warning the .env file is missing. Defaults to + The returned dict will have `None` values for keys without values in the .env file. + For example, `foo=bar` results in `{"foo": "bar"}` whereas `foo` alone results in + `{"foo": None}` + + Parameters: + + - `dotenv_path`: absolute or relative path to the .env file. + - `stream`: `StringIO` object with .env content, used if `dotenv_path` is `None`. + - `verbose`: whether to output a warning if the .env file is missing. Defaults to `False`. - in `.env` file. Defaults to `False`. - - *encoding*: encoding to be used to read the file. + - `encoding`: encoding to be used to read the file. Defaults to `"utf-8"`. - If both `dotenv_path` and `stream`, `find_dotenv()` is used to find the .env file. + If both `dotenv_path` and `stream` are `None`, `find_dotenv()` is used to find the + .env file. """ if dotenv_path is None and stream is None: dotenv_path = find_dotenv() From 7d9cd4b50904569a2fc828145f4b28186190cc38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20G=C3=B3rny?= Date: Thu, 7 Apr 2022 19:17:37 +0200 Subject: [PATCH 114/253] Skip test_ipython if IPython is not available (#397) --- tests/test_ipython.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_ipython.py b/tests/test_ipython.py index 8983bf13..aa12adfe 100644 --- a/tests/test_ipython.py +++ b/tests/test_ipython.py @@ -2,6 +2,11 @@ import mock +import pytest + + +pytest.importorskip("IPython") + @mock.patch.dict(os.environ, {}, clear=True) def test_ipython_existing_variable_no_override(tmp_path): From 8080d1d999ea38fad101e04a7a78714981029e2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20G=C3=B3rny?= Date: Thu, 7 Apr 2022 15:24:18 +0200 Subject: [PATCH 115/253] Use built-in unittest.mock instead of third-party mock Python 3 has a built-in version of mock available as unittest.mock. Use it instead of installing the third-party package. --- requirements.txt | 2 -- tests/test_ipython.py | 3 +-- tests/test_main.py | 2 +- tox.ini | 2 -- 4 files changed, 2 insertions(+), 7 deletions(-) diff --git a/requirements.txt b/requirements.txt index 39302b21..de374f43 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,12 +2,10 @@ bumpversion click flake8>=2.2.3 ipython -mock pytest-cov pytest>=3.9 sh>=1.09 tox -types-mock wheel twine portray diff --git a/tests/test_ipython.py b/tests/test_ipython.py index aa12adfe..921dfd60 100644 --- a/tests/test_ipython.py +++ b/tests/test_ipython.py @@ -1,6 +1,5 @@ import os - -import mock +from unittest import mock import pytest diff --git a/tests/test_main.py b/tests/test_main.py index 364fc24d..ca14b1ac 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -3,8 +3,8 @@ import os import sys import textwrap +from unittest import mock -import mock import pytest import sh diff --git a/tox.ini b/tox.ini index c1f89fa1..3d7e1b60 100644 --- a/tox.ini +++ b/tox.ini @@ -14,7 +14,6 @@ python = [testenv] deps = - mock pytest coverage sh @@ -27,7 +26,6 @@ skip_install = true deps = flake8 mypy - types-mock commands = flake8 src tests mypy --python-version=3.11 src tests From 769a040af6d27a50bbb1ca5203fbcb74b78b38fc Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Mon, 18 Apr 2022 00:13:07 +0530 Subject: [PATCH 116/253] feat(cli): add support for execution via 'python -m' (#395) Co-authored-by: Saurabh Kumar --- setup.py | 2 +- src/dotenv/__main__.py | 6 ++++++ src/dotenv/cli.py | 4 ---- 3 files changed, 7 insertions(+), 5 deletions(-) create mode 100644 src/dotenv/__main__.py diff --git a/setup.py b/setup.py index a805c188..5a35d188 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ def read_files(files): }, entry_points={ "console_scripts": [ - "dotenv=dotenv.cli:cli", + "dotenv=dotenv.__main__:cli", ], }, license='BSD-3-Clause', diff --git a/src/dotenv/__main__.py b/src/dotenv/__main__.py new file mode 100644 index 00000000..3977f55a --- /dev/null +++ b/src/dotenv/__main__.py @@ -0,0 +1,6 @@ +"""Entry point for cli, enables execution with `python -m dotenv`""" + +from .cli import cli + +if __name__ == "__main__": + cli() diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index b7ae24af..3411e346 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -158,7 +158,3 @@ def run_command(command: List[str], env: Dict[str, str]) -> int: _, _ = p.communicate() return p.returncode - - -if __name__ == "__main__": - cli() From cb53e1e530d9262e327c2b7b09e2c5424d544e3e Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 17 Apr 2022 20:59:58 +0200 Subject: [PATCH 117/253] Improve documentation with direct use of MkDocs (#398) Improvements: - Only the public API is documented - Thanks to `mkdocstrings` with `show_submodules: no`. - Function parameter documentation is parsed and shown in tables. - `None` paragraphs are removed. - This was reported at https://github.com/timothycrosley/pdocs/pull/25 but hasn't been merged. - Footer layout is fixed. - It's currently broken with Portray, even on their own documentation (https://timothycrosley.github.io/portray/). - Fix list levels in table of contents on home page. - Thanks to `mdx_truly_sane_lists`. - Remove broken "edit" links. Portray is great but I think we can do better by directly using MkDocs. The new way to deploy the documentation is: mkdocs gh-deploy --- MANIFEST.in | 1 + docs/changelog.md | 1 + docs/contributing.md | 1 + docs/index.md | 1 + docs/reference.md | 3 +++ mkdocs.yml | 23 +++++++++++++++++++++++ pyproject.toml | 5 ----- requirements.txt | 9 +++++++-- src/dotenv/main.py | 35 +++++++++++++++++------------------ 9 files changed, 54 insertions(+), 25 deletions(-) create mode 120000 docs/changelog.md create mode 120000 docs/contributing.md create mode 120000 docs/index.md create mode 100644 docs/reference.md create mode 100644 mkdocs.yml delete mode 100644 pyproject.toml diff --git a/MANIFEST.in b/MANIFEST.in index 78e43e9b..98eaa40b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,7 @@ include LICENSE *.md *.yml *.toml include tox.ini +recursive-include docs *.md recursive-include tests *.py include .bumpversion.cfg diff --git a/docs/changelog.md b/docs/changelog.md new file mode 120000 index 00000000..04c99a55 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1 @@ +../CHANGELOG.md \ No newline at end of file diff --git a/docs/contributing.md b/docs/contributing.md new file mode 120000 index 00000000..44fcc634 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1 @@ +../CONTRIBUTING.md \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 120000 index 00000000..32d46ee8 --- /dev/null +++ b/docs/index.md @@ -0,0 +1 @@ +../README.md \ No newline at end of file diff --git a/docs/reference.md b/docs/reference.md new file mode 100644 index 00000000..a126448e --- /dev/null +++ b/docs/reference.md @@ -0,0 +1,3 @@ +# Reference + +::: dotenv diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..27063ca2 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,23 @@ +site_name: python-dotenv +repo_url: https://github.com/theskumar/python-dotenv +edit_uri: "" +theme: + name: material + palette: + primary: green +markdown_extensions: + - mdx_truly_sane_lists +plugins: + - mkdocstrings: + handlers: + python: + rendering: + show_root_heading: yes + show_submodules: no + separate_signature: yes + - search +nav: + - Home: index.md + - Changelog: changelog.md + - Contributing: contributing.md + - Reference: reference.md diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 64b4431f..00000000 --- a/pyproject.toml +++ /dev/null @@ -1,5 +0,0 @@ -[tool.portray] -modules = ["dotenv"] - -[tool.portray.mkdocs] -repo_url = "https://github.com/theskumar/python-dotenv" diff --git a/requirements.txt b/requirements.txt index de374f43..54354312 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,16 @@ +black~=22.3.0 bumpversion click flake8>=2.2.3 ipython +mdx_truly_sane_lists~=1.2 +mkdocs-include-markdown-plugin~=3.3.0 +mkdocs-material~=8.2.9 +mkdocstrings[python]~=0.18.1 +mkdocs~=1.3.0 pytest-cov pytest>=3.9 sh>=1.09 tox -wheel twine -portray +wheel diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 76ee5993..e7ad4308 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -198,10 +198,10 @@ def unset_key( encoding: Optional[str] = "utf-8", ) -> Tuple[Optional[bool], str]: """ - Removes a given key from the given .env + Removes a given key from the given `.env` file. - If the .env path given doesn't exist, fails - If the given key doesn't exist in the .env, fails + If the .env path given doesn't exist, fails. + If the given key doesn't exist in the .env, fails. """ if not os.path.exists(dotenv_path): logger.warning("Can't delete from %s - it doesn't exist.", dotenv_path) @@ -316,16 +316,17 @@ def load_dotenv( ) -> bool: """Parse a .env file and then load all the variables found as environment variables. - - *dotenv_path*: absolute or relative path to .env file. - - *stream*: Text stream (such as `io.StringIO`) with .env content, used if - `dotenv_path` is `None`. - - *verbose*: whether to output a warning the .env file is missing. Defaults to - `False`. - - *override*: whether to override the system environment variables with the variables - in `.env` file. Defaults to `False`. - - *encoding*: encoding to be used to read the file. + Parameters: + dotenv_path: Absolute or relative path to .env file. + stream: Text stream (such as `io.StringIO`) with .env content, used if + `dotenv_path` is `None`. + verbose: Whether to output a warning the .env file is missing. + override: Whether to override the system environment variables with the variables + from the `.env` file. + encoding: Encoding to be used to read the file. - If both `dotenv_path` and `stream`, `find_dotenv()` is used to find the .env file. + If both `dotenv_path` and `stream` are `None`, `find_dotenv()` is used to find the + .env file. """ if dotenv_path is None and stream is None: dotenv_path = find_dotenv() @@ -356,12 +357,10 @@ def dotenv_values( `{"foo": None}` Parameters: - - - `dotenv_path`: absolute or relative path to the .env file. - - `stream`: `StringIO` object with .env content, used if `dotenv_path` is `None`. - - `verbose`: whether to output a warning if the .env file is missing. Defaults to - `False`. - - `encoding`: encoding to be used to read the file. Defaults to `"utf-8"`. + dotenv_path: Absolute or relative path to the .env file. + stream: `StringIO` object with .env content, used if `dotenv_path` is `None`. + verbose: Whether to output a warning if the .env file is missing. + encoding: Encoding to be used to read the file. If both `dotenv_path` and `stream` are `None`, `find_dotenv()` is used to find the .env file. From 29bceb836965de5bc498af401fd9d2e95194a5c1 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Mon, 18 Apr 2022 00:34:04 +0530 Subject: [PATCH 118/253] chore: add how to run docs locally --- CONTRIBUTING.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d989d87f..90760961 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,3 +16,14 @@ or with [tox](https://pypi.org/project/tox/) installed: $ tox + +Documentation is published with [mkdocs](): + +```shell +$ pip install -r requirements.txt +$ pip install -e . +$ mkdocs serve +``` + +Open http://127.0.0.1:8000/ to view the documentation locally. + From ee15221e7c58fa7129214135553c2b39540e8856 Mon Sep 17 00:00:00 2001 From: Lars Kellogg-Stedman Date: Sun, 5 Jun 2022 01:50:42 -0400 Subject: [PATCH 119/253] feat: return False when we do not discover any environment variables (#388) * Fix docstring for load_dotenv The docstring for load_dotenv was missing a word, rendering it confusing. This commits modifies it for clarity. * Return False when we do not discover any environment variables This modifies Dotenv.set_as_environment_variables to return False if we have not discovered any environment variables via either `dotenv_path` or `stream`. The return value gets passed through to `load_dotenv`, so this can be used to determine if `dotenv.load_dotenv` was able to set anything. Closes #321 Co-authored-by: Saurabh Kumar --- src/dotenv/main.py | 5 +++++ tests/test_main.py | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index e7ad4308..78410660 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -87,6 +87,9 @@ def set_as_environment_variables(self) -> bool: """ Load the current dotenv as system environment variable. """ + if not self.dict(): + return False + for k, v in self.dict().items(): if k in os.environ and not self.override: continue @@ -324,6 +327,8 @@ def load_dotenv( override: Whether to override the system environment variables with the variables from the `.env` file. encoding: Encoding to be used to read the file. + Returns: + Bool: True if atleast one environment variable is set elese False If both `dotenv_path` and `stream` are `None`, `find_dotenv()` is used to find the .env file. diff --git a/tests/test_main.py b/tests/test_main.py index ca14b1ac..82c73ba1 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -259,8 +259,9 @@ def test_load_dotenv_no_file_verbose(): logger = logging.getLogger("dotenv.main") with mock.patch.object(logger, "info") as mock_info: - dotenv.load_dotenv('.does_not_exist', verbose=True) + result = dotenv.load_dotenv('.does_not_exist', verbose=True) + assert result is False mock_info.assert_called_once_with("Python-dotenv could not find configuration file %s.", ".does_not_exist") From 2cf826f236222d517c847fd5c61d48d3d85333d2 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 5 Jun 2022 12:54:25 +0530 Subject: [PATCH 120/253] chore: add GitHub Actions for deploying docs (#399) Automatically update the documentation when a release is made. Co-authored-by: Saurabh Kumar --- .github/workflows/release.yml | 6 ++++++ CONTRIBUTING.md | 2 +- MANIFEST.in | 1 + requirements-docs.txt | 5 +++++ requirements.txt | 5 ----- 5 files changed, 13 insertions(+), 6 deletions(-) create mode 100644 requirements-docs.txt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a3abd994..7666da09 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,3 +23,9 @@ jobs: TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | make release + + - name: Publish Documentation + run: | + pip install -r requirements-docs.txt + pip install -e . + mkdocs gh-deploy --force diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 90760961..fac71bff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,7 @@ or with [tox](https://pypi.org/project/tox/) installed: Documentation is published with [mkdocs](): ```shell -$ pip install -r requirements.txt +$ pip install -r requirements-docs.txt $ pip install -e . $ mkdocs serve ``` diff --git a/MANIFEST.in b/MANIFEST.in index 98eaa40b..9c457e66 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -9,4 +9,5 @@ include .coveragerc include .editorconfig include Makefile include requirements.txt +include requirements-docs.txt include src/dotenv/py.typed diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 00000000..7f8b71f3 --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1,5 @@ +mdx_truly_sane_lists~=1.2 +mkdocs-include-markdown-plugin~=3.3.0 +mkdocs-material~=8.2.9 +mkdocstrings[python]~=0.18.1 +mkdocs~=1.3.0 diff --git a/requirements.txt b/requirements.txt index 54354312..0206316f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,11 +3,6 @@ bumpversion click flake8>=2.2.3 ipython -mdx_truly_sane_lists~=1.2 -mkdocs-include-markdown-plugin~=3.3.0 -mkdocs-material~=8.2.9 -mkdocstrings[python]~=0.18.1 -mkdocs~=1.3.0 pytest-cov pytest>=3.9 sh>=1.09 From 6b1e68bb8a0b3e96eccb53fcc5ed4b727661dd09 Mon Sep 17 00:00:00 2001 From: Naor Livne Date: Sun, 12 Jun 2022 19:18:56 +0300 Subject: [PATCH 121/253] Add `parse_it` to Related Projects (#410) `parse_it` is similar to `dynaconf` but focus more on giving the enduser the ability to use his preferred way to set config, `.env` being one of the choices it's using and makes available to endusers --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 70de7e09..eb6bb538 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,7 @@ defined in the following list: - [dump-env](https://github.com/sobolevn/dump-env) - [environs](https://github.com/sloria/environs) - [dynaconf](https://github.com/rochacbruno/dynaconf) +- [parse_it](https://github.com/naorlivne/parse_it) ## Acknowledgements From a50a3bf3461e7c295723078ebf2db037d9eecd8b Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sat, 23 Jul 2022 11:21:06 +0530 Subject: [PATCH 122/253] Add .vscode to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 172047ac..ba1234ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store .idea +.vscode/ # Created by https://www.gitignore.io/api/python # Edit at https://www.gitignore.io/?templates=python From 2f36c082c278bad1a84411f1ad61547f95cecdb8 Mon Sep 17 00:00:00 2001 From: eggplants Date: Sat, 23 Jul 2022 15:07:18 +0900 Subject: [PATCH 123/253] Drop Python 3.5 and 3.6 and upgrade GA (#393) * Drop Python 3.5 and 3.6 * Tox and GitHub Actions upgrade * Fix pypy version Co-authored-by: Saurabh Kumar --- .github/workflows/test.yml | 8 ++++---- setup.py | 4 +--- src/dotenv/main.py | 21 ++++++++------------- tox.ini | 8 ++------ 4 files changed, 15 insertions(+), 26 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5135ae4f..b7c6d504 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,15 +10,15 @@ jobs: matrix: os: - ubuntu-latest - python-version: [3.5, 3.6, 3.7, 3.8, 3.9, "3.10", "3.11.0-alpha - 3.11", pypy3] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11.0-beta.4 - 3.11", pypy3.9] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies - run: | + run: python -m pip install --upgrade pip pip install tox tox-gh-actions - name: Test with tox diff --git a/setup.py b/setup.py index 5a35d188..bcf1b0dc 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ def read_files(files): package_data={ 'dotenv': ['py.typed'], }, - python_requires=">=3.5", + python_requires=">=3.7", extras_require={ 'cli': ['click>=5.0', ], }, @@ -45,8 +45,6 @@ def read_files(files): 'Development Status :: 5 - Production/Stable', 'Programming Language :: Python', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 78410660..05d377a9 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -14,11 +14,6 @@ logger = logging.getLogger(__name__) -if sys.version_info >= (3, 6): - _PathLike = os.PathLike -else: - _PathLike = str - def with_warn_for_invalid_lines(mappings: Iterator[Binding]) -> Iterator[Binding]: for mapping in mappings: @@ -33,14 +28,14 @@ def with_warn_for_invalid_lines(mappings: Iterator[Binding]) -> Iterator[Binding class DotEnv(): def __init__( self, - dotenv_path: Optional[Union[str, _PathLike]], + dotenv_path: Optional[Union[str, os.PathLike]], stream: Optional[IO[str]] = None, verbose: bool = False, encoding: Union[None, str] = None, interpolate: bool = True, override: bool = True, ) -> None: - self.dotenv_path = dotenv_path # type: Optional[Union[str, _PathLike]] + self.dotenv_path = dotenv_path # type: Optional[Union[str, os.PathLike]] self.stream = stream # type: Optional[IO[str]] self._dict = None # type: Optional[Dict[str, Optional[str]]] self.verbose = verbose # type: bool @@ -113,7 +108,7 @@ def get(self, key: str) -> Optional[str]: def get_key( - dotenv_path: Union[str, _PathLike], + dotenv_path: Union[str, os.PathLike], key_to_get: str, encoding: Optional[str] = "utf-8", ) -> Optional[str]: @@ -127,7 +122,7 @@ def get_key( @contextmanager def rewrite( - path: Union[str, _PathLike], + path: Union[str, os.PathLike], encoding: Optional[str], ) -> Iterator[Tuple[IO[str], IO[str]]]: try: @@ -146,7 +141,7 @@ def rewrite( def set_key( - dotenv_path: Union[str, _PathLike], + dotenv_path: Union[str, os.PathLike], key_to_set: str, value_to_set: str, quote_mode: str = "always", @@ -195,7 +190,7 @@ def set_key( def unset_key( - dotenv_path: Union[str, _PathLike], + dotenv_path: Union[str, os.PathLike], key_to_unset: str, quote_mode: str = "always", encoding: Optional[str] = "utf-8", @@ -310,7 +305,7 @@ def _is_interactive(): def load_dotenv( - dotenv_path: Union[str, _PathLike, None] = None, + dotenv_path: Union[str, os.PathLike, None] = None, stream: Optional[IO[str]] = None, verbose: bool = False, override: bool = False, @@ -348,7 +343,7 @@ def load_dotenv( def dotenv_values( - dotenv_path: Union[str, _PathLike, None] = None, + dotenv_path: Union[str, os.PathLike, None] = None, stream: Optional[IO[str]] = None, verbose: bool = False, interpolate: bool = True, diff --git a/tox.ini b/tox.ini index 3d7e1b60..cb8a6625 100644 --- a/tox.ini +++ b/tox.ini @@ -3,14 +3,12 @@ envlist = lint,py{35,36,37,38,39,310,311},pypy3,manifest,coverage-report [gh-actions] python = - 3.5: py35, coverage-report - 3.6: py36, coverage-report 3.7: py37, coverage-report 3.8: py38, coverage-report 3.9: py39, coverage-report 3.10: py310, lint, manifest, coverage-report 3.11: py311, coverage-report - pypy3: pypy3, coverage-report + pypy-3.9: pypy3, coverage-report [testenv] deps = @@ -18,7 +16,7 @@ deps = coverage sh click - py{35,36,37,38,39,310,311,py3}: ipython + py{37,38,39,310,311,py3}: ipython commands = coverage run --parallel -m pytest {posargs} [testenv:lint] @@ -33,8 +31,6 @@ commands = mypy --python-version=3.9 src tests mypy --python-version=3.8 src tests mypy --python-version=3.7 src tests - mypy --python-version=3.6 src tests - mypy --python-version=3.5 src tests [testenv:manifest] deps = check-manifest From 914c68ef0e4c2c085d2753f5cbbf304852f37850 Mon Sep 17 00:00:00 2001 From: Sam McKelvie Date: Fri, 22 Jul 2022 23:21:31 -0700 Subject: [PATCH 124/253] feat(cli) add --format= option to list command (#407) Allows dumping of all variables in various formats. Currently defined formats: simple: Each variable is output as = with no quoting or escaping. The output is not parseable. This is the default format for backwards compatibility. shell: Each variable is output as =, where is quoted/escaped with shell-compatible rules, the result may be imported into a shell script with eval "$(dotenv list --format=shell)" export: Similar to "shell" but prefixes each line with "export " so that when imported into a shell script, the variables are exported. json: The entire set of variables is output as a JSON-serialized object Co-authored-by: Saurabh Kumar --- README.md | 5 +++++ src/dotenv/cli.py | 20 +++++++++++++++++--- tests/test_cli.py | 29 ++++++++++++++++++++++++----- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index eb6bb538..a9d19bfa 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,11 @@ $ dotenv set EMAIL foo@example.org $ dotenv list USER=foo EMAIL=foo@example.org +$ dotenv list --format=json +{ + "USER": "foo", + "EMAIL": "foo@example.org" +} $ dotenv run -- python foo.py ``` diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 3411e346..b845b95e 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -1,4 +1,6 @@ +import json import os +import shlex import sys from subprocess import Popen from typing import Any, Dict, List @@ -36,7 +38,11 @@ def cli(ctx: click.Context, file: Any, quote: Any, export: Any) -> None: @cli.command() @click.pass_context -def list(ctx: click.Context) -> None: +@click.option('--format', default='simple', + type=click.Choice(['simple', 'json', 'shell', 'export']), + help="The format in which to display the list. Default format is simple, " + "which displays name=value without quotes.") +def list(ctx: click.Context, format: bool) -> None: '''Display all the stored key/value.''' file = ctx.obj['FILE'] if not os.path.isfile(file): @@ -45,8 +51,16 @@ def list(ctx: click.Context) -> None: ctx=ctx ) dotenv_as_dict = dotenv_values(file) - for k, v in dotenv_as_dict.items(): - click.echo('%s=%s' % (k, v)) + if format == 'json': + click.echo(json.dumps(dotenv_as_dict, indent=2, sort_keys=True)) + else: + prefix = 'export ' if format == 'export' else '' + for k in sorted(dotenv_as_dict): + v = dotenv_as_dict[k] + if v is not None: + if format in ('export', 'shell'): + v = shlex.quote(v) + click.echo('%s%s=%s' % (prefix, k, v)) @cli.command() diff --git a/tests/test_cli.py b/tests/test_cli.py index 223476fe..ca5ba2a1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,19 +2,38 @@ import pytest import sh - +from typing import Optional import dotenv from dotenv.cli import cli as dotenv_cli from dotenv.version import __version__ -def test_list(cli, dotenv_file): +@pytest.mark.parametrize( + "format,content,expected", + ( + (None, "x='a b c'", '''x=a b c\n'''), + ("simple", "x='a b c'", '''x=a b c\n'''), + ("simple", """x='"a b c"'""", '''x="a b c"\n'''), + ("simple", '''x="'a b c'"''', '''x='a b c'\n'''), + ("json", "x='a b c'", '''{\n "x": "a b c"\n}\n'''), + ("shell", "x='a b c'", "x='a b c'\n"), + ("shell", """x='"a b c"'""", '''x='"a b c"'\n'''), + ("shell", '''x="'a b c'"''', '''x=''"'"'a b c'"'"''\n'''), + ("shell", "x='a\nb\nc'", "x='a\nb\nc'\n"), + ("export", "x='a b c'", '''export x='a b c'\n'''), + ) +) +def test_list(cli, dotenv_file, format: Optional[str], content: str, expected: str): with open(dotenv_file, "w") as f: - f.write("a=b") + f.write(content + '\n') + + args = ['--file', dotenv_file, 'list'] + if format is not None: + args.extend(['--format', format]) - result = cli.invoke(dotenv_cli, ['--file', dotenv_file, 'list']) + result = cli.invoke(dotenv_cli, args) - assert (result.exit_code, result.output) == (0, result.output) + assert (result.exit_code, result.output) == (0, expected) def test_list_non_existent_file(cli): From a7c811dd9296fc06524838c08c2ef4a8dec6377b Mon Sep 17 00:00:00 2001 From: harveer07 <71729199+harveer07@users.noreply.github.com> Date: Sat, 3 Sep 2022 06:34:49 -0700 Subject: [PATCH 125/253] Update README.md (#415) fixed grammar error. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a9d19bfa..983b7d15 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ The format is not formally specified and still improves over time. That being s Keys can be unquoted or single-quoted. Values can be unquoted, single- or double-quoted. Spaces before and after keys, equal signs, and values are ignored. Values can be followed -by a comment. Lines can start with the `export` directive, which has no effect on their +by a comment. Lines can start with the `export` directive, which does not affect their interpretation. Allowed escape sequences: From a53d652f0618c27a0fd62c4a7946e609919179f3 Mon Sep 17 00:00:00 2001 From: Ben Li-Sauerwine Date: Sat, 3 Sep 2022 09:58:22 -0400 Subject: [PATCH 126/253] fix: out of scope error when "dest" variable is undefined #413 --- src/dotenv/main.py | 9 +++++---- tests/test_main.py | 5 +++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 05d377a9..33217885 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -125,15 +125,16 @@ def rewrite( path: Union[str, os.PathLike], encoding: Optional[str], ) -> Iterator[Tuple[IO[str], IO[str]]]: + dest = None try: if not os.path.isfile(path): with open(path, "w+", encoding=encoding) as source: source.write("") - with tempfile.NamedTemporaryFile(mode="w+", delete=False, encoding=encoding) as dest: - with open(path, encoding=encoding) as source: - yield (source, dest) # type: ignore + dest = tempfile.NamedTemporaryFile(mode="w+", delete=False, encoding=encoding) + with open(path, encoding=encoding) as source: + yield (source, dest) # type: ignore except BaseException: - if os.path.isfile(dest.name): + if dest and os.path.isfile(dest.name): os.unlink(dest.name) raise else: diff --git a/tests/test_main.py b/tests/test_main.py index 82c73ba1..84a982fe 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -22,6 +22,11 @@ def test_set_key_no_file(tmp_path): assert os.path.exists(nx_file) +def test_set_key_invalid_file(): + with pytest.raises(TypeError): + result = dotenv.set_key(None, "foo", "bar") + + @pytest.mark.parametrize( "before,key,value,expected,after", [ From 6399af6cd8b5f27ae4b68e8758722dff6728bffd Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sat, 3 Sep 2022 19:49:33 +0530 Subject: [PATCH 127/253] chore: fix flake8 issue --- setup.cfg | 2 +- tests/test_main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 09d61034..63d37cd3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -7,7 +7,7 @@ tag = True [flake8] max-line-length = 120 -exclude = .tox,.git,docs,venv,.venv +exclude = .tox,.git,docs,venv,.venv,build [mypy] check_untyped_defs = true diff --git a/tests/test_main.py b/tests/test_main.py index 84a982fe..5f579ccd 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -24,7 +24,7 @@ def test_set_key_no_file(tmp_path): def test_set_key_invalid_file(): with pytest.raises(TypeError): - result = dotenv.set_key(None, "foo", "bar") + dotenv.set_key(None, "foo", "bar") @pytest.mark.parametrize( From b1f041dcef79e796c9b9cda8e13d72ca65727b9b Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sat, 3 Sep 2022 20:14:32 +0530 Subject: [PATCH 128/253] Add release notes for 0.21.0 --- CHANGELOG.md | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 874f2134..d1f7b023 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.21.0] - 2022-09-03 + +### Added +* CLI: add support for invocations via 'python -m'. (#395 by [@theskumar]) +* `load_dotenv` function now returns `False`. (#388 by [@larsks]) +* CLI: add --format= option to list command. (#407 by [@sammck]) + +### Fixed +* Drop Python 3.5 and 3.6 and upgrade GA (#393 by [@eggplants]) +* Fix out of scope error when `dest` variable is undefined. (#414 by [@theGOTOguy]) +* Use `open` instead of `io.open`. (#389 by [@rabinadk1]) +* Improve documentation for variables without a value (#390 by [@bbc2]) +* Add `parse_it` to Related Projects by (#410 by [@naorlivne]) +* Update README.md by (#415 by [@harveer07]) +* Improve documentation with direct use of MkDocs by (#398 by [@bbc2]) + ## [0.20.0] - 2022-03-24 ### Added @@ -287,7 +303,6 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [#183]: https://github.com/theskumar/python-dotenv/issues/183 [#359]: https://github.com/theskumar/python-dotenv/issues/359 -[@Flimm]: https://github.com/Flimm [@alanjds]: https://github.com/alanjds [@altendky]: https://github.com/altendky [@andrewsmith]: https://github.com/andrewsmith @@ -296,16 +311,24 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@befeleme]: https://github.com/befeleme [@cjauvin]: https://github.com/cjauvin [@earlbread]: https://github.com/earlbread +[@eggplants]: https://github.com/@eggplants [@ekohl]: https://github.com/ekohl [@elbehery95]: https://github.com/elbehery95 +[@Flimm]: https://github.com/Flimm [@gergelyk]: https://github.com/gergelyk [@gongqingkui]: https://github.com/gongqingkui [@greyli]: https://github.com/greyli +[@harveer07]: https://github.com/@harveer07 [@jadutter]: https://github.com/jadutter +[@larsks]: https://github.com/@larsks [@mgorny]: https://github.com/mgorny +[@naorlivne]: https://github.com/@naorlivne [@qnighy]: https://github.com/qnighy +[@rabinadk1]: https://github.com/@rabinadk1 +[@sammck]: https://github.com/@sammck [@snobu]: https://github.com/snobu [@techalchemy]: https://github.com/techalchemy +[@theGOTOguy]: https://github.com/@theGOTOguy [@theskumar]: https://github.com/theskumar [@ulyssessouza]: https://github.com/ulyssessouza [@venthur]: https://github.com/venthur @@ -313,7 +336,9 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@yannham]: https://github.com/yannham [@zueve]: https://github.com/zueve -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.20.0...HEAD + +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.21.0...HEAD +[0.21.0]: https://github.com/theskumar/python-dotenv/compare/v0.20.0...v0.21.0 [0.20.0]: https://github.com/theskumar/python-dotenv/compare/v0.19.2...v0.20.0 [0.19.2]: https://github.com/theskumar/python-dotenv/compare/v0.19.1...v0.19.2 [0.19.1]: https://github.com/theskumar/python-dotenv/compare/v0.19.0...v0.19.1 From 490b116e6548e8d1bbc8184e36768f6ae73c06b2 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sat, 3 Sep 2022 20:38:05 +0530 Subject: [PATCH 129/253] Revert "fix: out of scope error when "dest" variable is undefined #413" This reverts commit a53d652f0618c27a0fd62c4a7946e609919179f3. --- src/dotenv/main.py | 9 ++++----- tests/test_main.py | 5 ----- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 33217885..05d377a9 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -125,16 +125,15 @@ def rewrite( path: Union[str, os.PathLike], encoding: Optional[str], ) -> Iterator[Tuple[IO[str], IO[str]]]: - dest = None try: if not os.path.isfile(path): with open(path, "w+", encoding=encoding) as source: source.write("") - dest = tempfile.NamedTemporaryFile(mode="w+", delete=False, encoding=encoding) - with open(path, encoding=encoding) as source: - yield (source, dest) # type: ignore + with tempfile.NamedTemporaryFile(mode="w+", delete=False, encoding=encoding) as dest: + with open(path, encoding=encoding) as source: + yield (source, dest) # type: ignore except BaseException: - if dest and os.path.isfile(dest.name): + if os.path.isfile(dest.name): os.unlink(dest.name) raise else: diff --git a/tests/test_main.py b/tests/test_main.py index 5f579ccd..82c73ba1 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -22,11 +22,6 @@ def test_set_key_no_file(tmp_path): assert os.path.exists(nx_file) -def test_set_key_invalid_file(): - with pytest.raises(TypeError): - dotenv.set_key(None, "foo", "bar") - - @pytest.mark.parametrize( "before,key,value,expected,after", [ From 5d079312ada8fb39643b7789a577e93b415c18b6 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sat, 3 Sep 2022 20:38:55 +0530 Subject: [PATCH 130/253] update changelog --- CHANGELOG.md | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1f7b023..e53060a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,18 +8,17 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.21.0] - 2022-09-03 ### Added -* CLI: add support for invocations via 'python -m'. (#395 by [@theskumar]) -* `load_dotenv` function now returns `False`. (#388 by [@larsks]) -* CLI: add --format= option to list command. (#407 by [@sammck]) +* CLI: add support for invocations via 'python -m'. (#395 by @theskumar) +* `load_dotenv` function now returns `False`. (#388 by @larsks) +* CLI: add --format= option to list command. (#407 by @sammck) ### Fixed -* Drop Python 3.5 and 3.6 and upgrade GA (#393 by [@eggplants]) -* Fix out of scope error when `dest` variable is undefined. (#414 by [@theGOTOguy]) -* Use `open` instead of `io.open`. (#389 by [@rabinadk1]) -* Improve documentation for variables without a value (#390 by [@bbc2]) -* Add `parse_it` to Related Projects by (#410 by [@naorlivne]) -* Update README.md by (#415 by [@harveer07]) -* Improve documentation with direct use of MkDocs by (#398 by [@bbc2]) +* Drop Python 3.5 and 3.6 and upgrade GA (#393 by @eggplants) +* Use `open` instead of `io.open`. (#389 by @rabinadk1) +* Improve documentation for variables without a value (#390 by @bbc2) +* Add `parse_it` to Related Projects by (#410 by @naorlivne) +* Update README.md by (#415 by @harveer07) +* Improve documentation with direct use of MkDocs by (#398 by @bbc2) ## [0.20.0] - 2022-03-24 @@ -328,7 +327,6 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@sammck]: https://github.com/@sammck [@snobu]: https://github.com/snobu [@techalchemy]: https://github.com/techalchemy -[@theGOTOguy]: https://github.com/@theGOTOguy [@theskumar]: https://github.com/theskumar [@ulyssessouza]: https://github.com/ulyssessouza [@venthur]: https://github.com/venthur From b6fe193b4c296a24d1973b741c19ee8b0066bc86 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sat, 3 Sep 2022 20:42:11 +0530 Subject: [PATCH 131/253] =?UTF-8?q?Bump=20version:=200.20.0=20=E2=86=92=20?= =?UTF-8?q?0.21.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 16 ++++++++-------- src/dotenv/version.py | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/setup.cfg b/setup.cfg index 63d37cd3..4d49c291 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.20.0 +current_version = 0.21.0 commit = True tag = True @@ -24,14 +24,14 @@ relative_files = True source = dotenv [coverage:paths] -source = - src/dotenv - .tox/*/lib/python*/site-packages/dotenv - .tox/pypy*/site-packages/dotenv +source = + src/dotenv + .tox/*/lib/python*/site-packages/dotenv + .tox/pypy*/site-packages/dotenv [coverage:report] show_missing = True include = */site-packages/dotenv/* -exclude_lines = - if IS_TYPE_CHECKING: - pragma: no cover +exclude_lines = + if IS_TYPE_CHECKING: + pragma: no cover diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 5f4bb0b3..6a726d85 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.20.0" +__version__ = "0.21.0" From cedd36d395b772adb556ba4635ab42d59ad27567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=82=E1=B4=80=C9=AA=E1=B4=8D=20=E4=B9=87=CA=9Cs?= =?UTF-8?q?=E1=B4=80=C9=B4?= Date: Sun, 18 Sep 2022 16:48:21 +0500 Subject: [PATCH 132/253] Fixed minor spelling mistake (#426) --- src/dotenv/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 05d377a9..83ea3239 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -323,7 +323,7 @@ def load_dotenv( from the `.env` file. encoding: Encoding to be used to read the file. Returns: - Bool: True if atleast one environment variable is set elese False + Bool: True if atleast one environment variable is set else False If both `dotenv_path` and `stream` are `None`, `find_dotenv()` is used to find the .env file. From 718307b8a2fbaca7f511248c23e591bd5e58760a Mon Sep 17 00:00:00 2001 From: Praveensenpai <71966071+Praveensenpai@users.noreply.github.com> Date: Tue, 8 Nov 2022 20:20:45 +0530 Subject: [PATCH 133/253] Update __init__.py fstring --- src/dotenv/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dotenv/__init__.py b/src/dotenv/__init__.py index 3512d101..9dbd2543 100644 --- a/src/dotenv/__init__.py +++ b/src/dotenv/__init__.py @@ -23,9 +23,9 @@ def get_cli_string( """ command = ['dotenv'] if quote: - command.append('-q %s' % quote) + command.append(f'-q {quote}') if path: - command.append('-f %s' % path) + command.append(f'-f {path}') if action: command.append(action) if key: From 1ecb57dd7dd516d3ecedf7e58ba84520281fbd00 Mon Sep 17 00:00:00 2001 From: Ben Li-Sauerwine Date: Wed, 27 Jul 2022 03:26:26 -0400 Subject: [PATCH 134/253] Fix out of scope error when "dest" variable is undefined Fixes #413 whereby the NamedTemporaryFile "dest" was out of scope in the error handling portion of rewrite. The problem was initially fixed in #414 but it got reverted because of a linter error. This new commit works around that linter error. --- src/dotenv/main.py | 20 +++++++++----------- tests/test_main.py | 7 +++++++ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 83ea3239..43248819 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -125,19 +125,17 @@ def rewrite( path: Union[str, os.PathLike], encoding: Optional[str], ) -> Iterator[Tuple[IO[str], IO[str]]]: - try: - if not os.path.isfile(path): - with open(path, "w+", encoding=encoding) as source: - source.write("") - with tempfile.NamedTemporaryFile(mode="w+", delete=False, encoding=encoding) as dest: + if not os.path.isfile(path): + with open(path, mode="w", encoding=encoding) as source: + source.write("") + with tempfile.NamedTemporaryFile(mode="w", encoding=encoding, delete=False) as dest: + try: with open(path, encoding=encoding) as source: - yield (source, dest) # type: ignore - except BaseException: - if os.path.isfile(dest.name): + yield (source, dest) + except BaseException: os.unlink(dest.name) - raise - else: - shutil.move(dest.name, path) + raise + shutil.move(dest.name, path) def set_key( diff --git a/tests/test_main.py b/tests/test_main.py index 82c73ba1..9c895851 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -178,6 +178,13 @@ def test_unset_encoding(dotenv_file): assert f.read() == "" +def test_set_key_unauthorized_file(dotenv_file): + os.chmod(dotenv_file, 0o000) + + with pytest.raises(PermissionError): + dotenv.set_key(dotenv_file, "a", "x") + + def test_unset_non_existent_file(tmp_path): nx_file = str(tmp_path / "nx") logger = logging.getLogger("dotenv.main") From 7e199c32db62eeb03a9e5e807f34f730e90216d3 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Fri, 11 Nov 2022 17:27:11 +0100 Subject: [PATCH 135/253] Use 3.11 non-beta in CI --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b7c6d504..15875768 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,7 +10,7 @@ jobs: matrix: os: - ubuntu-latest - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11.0-beta.4 - 3.11", pypy3.9] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", pypy3.9] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} From 00cc7ae200aa2228167c689bbe8b1d3c5c7a4751 Mon Sep 17 00:00:00 2001 From: momohakarish Date: Thu, 27 Oct 2022 00:41:15 +0300 Subject: [PATCH 136/253] Modernize some code in variables.py --- src/dotenv/variables.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/dotenv/variables.py b/src/dotenv/variables.py index d77b700c..667f2f26 100644 --- a/src/dotenv/variables.py +++ b/src/dotenv/variables.py @@ -1,8 +1,8 @@ import re -from abc import ABCMeta +from abc import ABCMeta, abstractmethod from typing import Iterator, Mapping, Optional, Pattern -_posix_variable = re.compile( +_posix_variable: Pattern[str] = re.compile( r""" \$\{ (?P[^\}:]*) @@ -12,20 +12,18 @@ \} """, re.VERBOSE, -) # type: Pattern[str] +) -class Atom(): - __metaclass__ = ABCMeta - +class Atom(metaclass=ABCMeta): def __ne__(self, other: object) -> bool: result = self.__eq__(other) if result is NotImplemented: return NotImplemented return not result - def resolve(self, env: Mapping[str, Optional[str]]) -> str: - raise NotImplementedError + @abstractmethod + def resolve(self, env: Mapping[str, Optional[str]]) -> str: ... class Literal(Atom): @@ -33,7 +31,7 @@ def __init__(self, value: str) -> None: self.value = value def __repr__(self) -> str: - return "Literal(value={})".format(self.value) + return f"Literal(value={self.value})" def __eq__(self, other: object) -> bool: if not isinstance(other, self.__class__): @@ -53,7 +51,7 @@ def __init__(self, name: str, default: Optional[str]) -> None: self.default = default def __repr__(self) -> str: - return "Variable(name={}, default={})".format(self.name, self.default) + return f"Variable(name={self.name}, default={self.default})" def __eq__(self, other: object) -> bool: if not isinstance(other, self.__class__): @@ -74,8 +72,8 @@ def parse_variables(value: str) -> Iterator[Atom]: for match in _posix_variable.finditer(value): (start, end) = match.span() - name = match.groupdict()["name"] - default = match.groupdict()["default"] + name = match["name"] + default = match["default"] if start > cursor: yield Literal(value=value[cursor:start]) From cadf4fc60a0c2a6de0a743a225cd9b2c48e1426a Mon Sep 17 00:00:00 2001 From: momohakarish Date: Thu, 27 Oct 2022 22:30:58 +0300 Subject: [PATCH 137/253] Modernize main.py and parser.py code --- src/dotenv/main.py | 28 ++++++++++++++-------------- src/dotenv/parser.py | 29 +++++++++++------------------ 2 files changed, 25 insertions(+), 32 deletions(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 43248819..e7b8392a 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -25,23 +25,23 @@ def with_warn_for_invalid_lines(mappings: Iterator[Binding]) -> Iterator[Binding yield mapping -class DotEnv(): +class DotEnv: def __init__( self, dotenv_path: Optional[Union[str, os.PathLike]], stream: Optional[IO[str]] = None, verbose: bool = False, - encoding: Union[None, str] = None, + encoding: Optional[str] = None, interpolate: bool = True, override: bool = True, ) -> None: - self.dotenv_path = dotenv_path # type: Optional[Union[str, os.PathLike]] - self.stream = stream # type: Optional[IO[str]] - self._dict = None # type: Optional[Dict[str, Optional[str]]] - self.verbose = verbose # type: bool - self.encoding = encoding # type: Union[None, str] - self.interpolate = interpolate # type: bool - self.override = override # type: bool + self.dotenv_path: Optional[Union[str, os.PathLike]] = dotenv_path + self.stream: Optional[IO[str]] = stream + self._dict: Optional[Dict[str, Optional[str]]] = None + self.verbose: bool = verbose + self.encoding: Optional[str] = encoding + self.interpolate: bool = interpolate + self.override: bool = override @contextmanager def _get_stream(self) -> Iterator[IO[str]]: @@ -153,7 +153,7 @@ def set_key( an orphan .env somewhere in the filesystem """ if quote_mode not in ("always", "auto", "never"): - raise ValueError("Unknown quote_mode: {}".format(quote_mode)) + raise ValueError(f"Unknown quote_mode: {quote_mode}") quote = ( quote_mode == "always" @@ -165,9 +165,9 @@ def set_key( else: value_out = value_to_set if export: - line_out = 'export {}={}\n'.format(key_to_set, value_out) + line_out = f'export {key_to_set}={value_out}\n' else: - line_out = "{}={}\n".format(key_to_set, value_out) + line_out = f"{key_to_set}={value_out}\n" with rewrite(dotenv_path, encoding=encoding) as (source, dest): replaced = False @@ -222,14 +222,14 @@ def resolve_variables( values: Iterable[Tuple[str, Optional[str]]], override: bool, ) -> Mapping[str, Optional[str]]: - new_values = {} # type: Dict[str, Optional[str]] + new_values: Dict[str, Optional[str]] = {} for (name, value) in values: if value is None: result = None else: atoms = parse_variables(value) - env = {} # type: Dict[str, Optional[str]] + env: Dict[str, Optional[str]] = {} if override: env.update(os.environ) # type: ignore env.update(new_values) diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index 398bd49a..735f14a3 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -25,23 +25,16 @@ def make_regex(string: str, extra_flags: int = 0) -> Pattern[str]: _single_quote_escapes = make_regex(r"\\[\\']") -Original = NamedTuple( - "Original", - [ - ("string", str), - ("line", int), - ], -) - -Binding = NamedTuple( - "Binding", - [ - ("key", Optional[str]), - ("value", Optional[str]), - ("original", Original), - ("error", bool), - ], -) +class Original(NamedTuple): + string: str + line: int + + +class Binding(NamedTuple): + key: Optional[str] + value: Optional[str] + original: Original + error: bool class Position: @@ -155,7 +148,7 @@ def parse_binding(reader: Reader) -> Binding: reader.read_regex(_whitespace) if reader.peek(1) == "=": reader.read_regex(_equal_sign) - value = parse_value(reader) # type: Optional[str] + value: Optional[str] = parse_value(reader) else: value = None reader.read_regex(_comment) From c22bc509218c981b098df0e028b0a53fbbbb193b Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 12 Nov 2022 14:06:02 +0100 Subject: [PATCH 138/253] Fix IPython test warning about deprecated `magic` IPython would complain about: DeprecationWarning: `magic(...)` is deprecated since IPython 0.13 (warning added in 8.1), use run_line_magic(magic_name, parameter_s). --- tests/test_ipython.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_ipython.py b/tests/test_ipython.py index 921dfd60..f988bd9c 100644 --- a/tests/test_ipython.py +++ b/tests/test_ipython.py @@ -17,8 +17,8 @@ def test_ipython_existing_variable_no_override(tmp_path): os.environ["a"] = "c" ipshell = InteractiveShellEmbed() - ipshell.magic("load_ext dotenv") - ipshell.magic("dotenv") + ipshell.run_line_magic("load_ext", "dotenv") + ipshell.run_line_magic("dotenv", "") assert os.environ == {"a": "c"} @@ -33,8 +33,8 @@ def test_ipython_existing_variable_override(tmp_path): os.environ["a"] = "c" ipshell = InteractiveShellEmbed() - ipshell.magic("load_ext dotenv") - ipshell.magic("dotenv -o") + ipshell.run_line_magic("load_ext", "dotenv") + ipshell.run_line_magic("dotenv", "-o") assert os.environ == {"a": "b"} @@ -48,7 +48,7 @@ def test_ipython_new_variable(tmp_path): os.chdir(str(tmp_path)) ipshell = InteractiveShellEmbed() - ipshell.magic("load_ext dotenv") - ipshell.magic("dotenv") + ipshell.run_line_magic("load_ext", "dotenv") + ipshell.run_line_magic("dotenv", "") assert os.environ == {"a": "b"} From ceca48c8441f381b1edf4772288326b4be6f435c Mon Sep 17 00:00:00 2001 From: momohakarish Date: Sat, 12 Nov 2022 13:43:38 +0200 Subject: [PATCH 139/253] Use f-strings, make some code more concise and change docstrings to double quotes according to PEP257 --- src/dotenv/__init__.py | 2 +- src/dotenv/cli.py | 27 ++++++++++++--------------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/dotenv/__init__.py b/src/dotenv/__init__.py index 9dbd2543..7f4c631b 100644 --- a/src/dotenv/__init__.py +++ b/src/dotenv/__init__.py @@ -32,7 +32,7 @@ def get_cli_string( command.append(key) if value: if ' ' in value: - command.append('"%s"' % value) + command.append(f'"{value}"') else: command.append(value) diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index b845b95e..97d6a948 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -29,11 +29,8 @@ @click.version_option(version=__version__) @click.pass_context def cli(ctx: click.Context, file: Any, quote: Any, export: Any) -> None: - '''This script is used to set, get or unset values from a .env file.''' - ctx.obj = {} - ctx.obj['QUOTE'] = quote - ctx.obj['EXPORT'] = export - ctx.obj['FILE'] = file + """This script is used to set, get or unset values from a .env file.""" + ctx.obj = {'QUOTE': quote, 'EXPORT': export, 'FILE': file} @cli.command() @@ -43,11 +40,11 @@ def cli(ctx: click.Context, file: Any, quote: Any, export: Any) -> None: help="The format in which to display the list. Default format is simple, " "which displays name=value without quotes.") def list(ctx: click.Context, format: bool) -> None: - '''Display all the stored key/value.''' + """Display all the stored key/value.""" file = ctx.obj['FILE'] if not os.path.isfile(file): raise click.BadParameter( - 'Path "%s" does not exist.' % (file), + f'Path "{file}" does not exist.', ctx=ctx ) dotenv_as_dict = dotenv_values(file) @@ -60,7 +57,7 @@ def list(ctx: click.Context, format: bool) -> None: if v is not None: if format in ('export', 'shell'): v = shlex.quote(v) - click.echo('%s%s=%s' % (prefix, k, v)) + click.echo(f'{prefix}{k}={v}') @cli.command() @@ -68,13 +65,13 @@ def list(ctx: click.Context, format: bool) -> None: @click.argument('key', required=True) @click.argument('value', required=True) def set(ctx: click.Context, key: Any, value: Any) -> None: - '''Store the given key/value.''' + """Store the given key/value.""" file = ctx.obj['FILE'] quote = ctx.obj['QUOTE'] export = ctx.obj['EXPORT'] success, key, value = set_key(file, key, value, quote, export) if success: - click.echo('%s=%s' % (key, value)) + click.echo(f'{key}={value}') else: exit(1) @@ -83,11 +80,11 @@ def set(ctx: click.Context, key: Any, value: Any) -> None: @click.pass_context @click.argument('key', required=True) def get(ctx: click.Context, key: Any) -> None: - '''Retrieve the value for the given key.''' + """Retrieve the value for the given key.""" file = ctx.obj['FILE'] if not os.path.isfile(file): raise click.BadParameter( - 'Path "%s" does not exist.' % (file), + f'Path "{file}" does not exist.', ctx=ctx ) stored_value = get_key(file, key) @@ -101,12 +98,12 @@ def get(ctx: click.Context, key: Any) -> None: @click.pass_context @click.argument('key', required=True) def unset(ctx: click.Context, key: Any) -> None: - '''Removes the given key.''' + """Removes the given key.""" file = ctx.obj['FILE'] quote = ctx.obj['QUOTE'] success, key = unset_key(file, key, quote) if success: - click.echo("Successfully removed %s" % key) + click.echo(f"Successfully removed {key}") else: exit(1) @@ -124,7 +121,7 @@ def run(ctx: click.Context, override: bool, commandline: List[str]) -> None: file = ctx.obj['FILE'] if not os.path.isfile(file): raise click.BadParameter( - 'Invalid value for \'-f\' "%s" does not exist.' % (file), + f'Invalid value for \'-f\' "{file}" does not exist.', ctx=ctx ) dotenv_as_dict = { From 025f762241c9a2435cc33cd534d5d998b9d4c64c Mon Sep 17 00:00:00 2001 From: "Michael V. DePalatis" Date: Wed, 23 Nov 2022 08:41:04 -0700 Subject: [PATCH 140/253] Fix typo --- src/dotenv/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index e7b8392a..36ed94f5 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -321,7 +321,7 @@ def load_dotenv( from the `.env` file. encoding: Encoding to be used to read the file. Returns: - Bool: True if atleast one environment variable is set else False + Bool: True if at least one environment variable is set else False If both `dotenv_path` and `stream` are `None`, `find_dotenv()` is used to find the .env file. From 7dc2492805f6bfc314b4ba4380d5ee5ad499b6c1 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 12 Nov 2022 14:02:33 +0100 Subject: [PATCH 141/253] Improve error message for `get` and `list` commands The error message would previously be confusing. For example, `dotenv -f . list` would print: Error: Invalid value: Path "." does not exist. Instead, we now print: Error opening env file: [Errno 21] Is a directory: '.' I used this opportunity to slightly refactor the I/O code (e.g. fewer system calls and possible race conditions) for those two subcommands (`get` and `list`). --- src/dotenv/cli.py | 48 ++++++++++++++++++++++++++++++----------------- tests/test_cli.py | 20 +++++++++++++++++--- 2 files changed, 48 insertions(+), 20 deletions(-) diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 97d6a948..b490bfaf 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -2,8 +2,9 @@ import os import shlex import sys +from contextlib import contextmanager from subprocess import Popen -from typing import Any, Dict, List +from typing import Any, Dict, IO, Iterator, List try: import click @@ -12,7 +13,7 @@ 'Run pip install "python-dotenv[cli]" to fix this.') sys.exit(1) -from .main import dotenv_values, get_key, set_key, unset_key +from .main import dotenv_values, set_key, unset_key from .version import __version__ @@ -33,6 +34,22 @@ def cli(ctx: click.Context, file: Any, quote: Any, export: Any) -> None: ctx.obj = {'QUOTE': quote, 'EXPORT': export, 'FILE': file} +@contextmanager +def stream_file(path: os.PathLike) -> Iterator[IO[str]]: + """ + Open a file and yield the corresponding (decoded) stream. + + Exits with error code 2 if the file cannot be opened. + """ + + try: + with open(path) as stream: + yield stream + except OSError as exc: + print(f"Error opening env file: {exc}", file=sys.stderr) + exit(2) + + @cli.command() @click.pass_context @click.option('--format', default='simple', @@ -42,18 +59,16 @@ def cli(ctx: click.Context, file: Any, quote: Any, export: Any) -> None: def list(ctx: click.Context, format: bool) -> None: """Display all the stored key/value.""" file = ctx.obj['FILE'] - if not os.path.isfile(file): - raise click.BadParameter( - f'Path "{file}" does not exist.', - ctx=ctx - ) - dotenv_as_dict = dotenv_values(file) + + with stream_file(file) as stream: + values = dotenv_values(stream=stream) + if format == 'json': - click.echo(json.dumps(dotenv_as_dict, indent=2, sort_keys=True)) + click.echo(json.dumps(values, indent=2, sort_keys=True)) else: prefix = 'export ' if format == 'export' else '' - for k in sorted(dotenv_as_dict): - v = dotenv_as_dict[k] + for k in sorted(values): + v = values[k] if v is not None: if format in ('export', 'shell'): v = shlex.quote(v) @@ -82,12 +97,11 @@ def set(ctx: click.Context, key: Any, value: Any) -> None: def get(ctx: click.Context, key: Any) -> None: """Retrieve the value for the given key.""" file = ctx.obj['FILE'] - if not os.path.isfile(file): - raise click.BadParameter( - f'Path "{file}" does not exist.', - ctx=ctx - ) - stored_value = get_key(file, key) + + with stream_file(file) as stream: + values = dotenv_values(stream=stream) + + stored_value = values.get(key) if stored_value: click.echo(stored_value) else: diff --git a/tests/test_cli.py b/tests/test_cli.py index ca5ba2a1..02dc9d96 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -40,7 +40,14 @@ def test_list_non_existent_file(cli): result = cli.invoke(dotenv_cli, ['--file', 'nx_file', 'list']) assert result.exit_code == 2, result.output - assert "does not exist" in result.output + assert "Error opening env file" in result.output + + +def test_list_not_a_file(cli): + result = cli.invoke(dotenv_cli, ['--file', '.', 'list']) + + assert result.exit_code == 2, result.output + assert "Error opening env file" in result.output def test_list_no_file(cli): @@ -64,11 +71,18 @@ def test_get_non_existent_value(cli, dotenv_file): assert (result.exit_code, result.output) == (1, "") -def test_get_no_file(cli): +def test_get_non_existent_file(cli): result = cli.invoke(dotenv_cli, ['--file', 'nx_file', 'get', 'a']) assert result.exit_code == 2 - assert "does not exist" in result.output + assert "Error opening env file" in result.output + + +def test_get_not_a_file(cli): + result = cli.invoke(dotenv_cli, ['--file', '.', 'get', 'a']) + + assert result.exit_code == 2 + assert "Error opening env file" in result.output def test_unset_existing_value(cli, dotenv_file): From f75103c655f71015fbc0b90a399133149b17f58c Mon Sep 17 00:00:00 2001 From: Lukas Kahwe Smith Date: Thu, 5 Jan 2023 06:41:54 +0100 Subject: [PATCH 142/253] Updated license format to better Align with BSD OSI template (#433) Co-authored-by: Saurabh Kumar Thanks @lsmith77 --- LICENSE | 64 +-------------------------------------------------------- 1 file changed, 1 insertion(+), 63 deletions(-) diff --git a/LICENSE b/LICENSE index 39372fee..acfe8334 100644 --- a/LICENSE +++ b/LICENSE @@ -1,66 +1,4 @@ -python-dotenv -Copyright (c) 2014, Saurabh Kumar - -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - - * Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - * Neither the name of python-dotenv nor the names of its contributors - may be used to endorse or promote products derived from this software - without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR -CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, -EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, -PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - -django-dotenv-rw -Copyright (c) 2013, Ted Tieken - -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - - * Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - * Neither the name of django-dotenv nor the names of its contributors - may be used to endorse or promote products derived from this software - without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR -CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, -EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, -PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -Original django-dotenv -Copyright (c) 2013, Jacob Kaplan-Moss - -All rights reserved. +Copyright (c) 2014, Saurabh Kumar (python-dotenv), 2013, Ted Tieken (django-dotenv-rw), 2013, Jacob Kaplan-Moss (django-dotenv) Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: From 09cf4aba6279848e99874ac552660c7108b6ae50 Mon Sep 17 00:00:00 2001 From: Eddie Aftandilian Date: Tue, 10 Jan 2023 21:37:56 -0800 Subject: [PATCH 143/253] Fix type hint for dotenv_path var, add StrPath alias (#432) * Fix type hint for load_dotenv Fixes #431 * Quote type hints to avoid runtime errors in earlier Python versions * Revise type of dotenv_path parameter Based on PR feedback and typeshed's type hint for the built-in open() function: https://github.com/python/typeshed/blob/e2d67bf7034f68c07bd35150247e58e0817725d9/stdlib/builtins.pyi#L1421 * Allow only string paths, not byte paths These paths can flow into `shutil.move`, which does not accept byte paths or (int) file descriptors. See https://github.com/python/typeshed/pull/6832 * Create a type alias for the paths this library accepts And use it consistently in main.py. --- src/dotenv/main.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 36ed94f5..f40c20ea 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -12,6 +12,12 @@ from .parser import Binding, parse_stream from .variables import parse_variables +# A type alias for a string path to be used for the paths in this file. +# These paths may flow to `open()` and `shutil.move()`; `shutil.move()` +# only accepts string paths, not byte paths or file descriptors. See +# https://github.com/python/typeshed/pull/6832. +StrPath = Union[str, 'os.PathLike[str]'] + logger = logging.getLogger(__name__) @@ -28,14 +34,14 @@ def with_warn_for_invalid_lines(mappings: Iterator[Binding]) -> Iterator[Binding class DotEnv: def __init__( self, - dotenv_path: Optional[Union[str, os.PathLike]], + dotenv_path: Optional[StrPath], stream: Optional[IO[str]] = None, verbose: bool = False, encoding: Optional[str] = None, interpolate: bool = True, override: bool = True, ) -> None: - self.dotenv_path: Optional[Union[str, os.PathLike]] = dotenv_path + self.dotenv_path: Optional[StrPath] = dotenv_path self.stream: Optional[IO[str]] = stream self._dict: Optional[Dict[str, Optional[str]]] = None self.verbose: bool = verbose @@ -108,7 +114,7 @@ def get(self, key: str) -> Optional[str]: def get_key( - dotenv_path: Union[str, os.PathLike], + dotenv_path: StrPath, key_to_get: str, encoding: Optional[str] = "utf-8", ) -> Optional[str]: @@ -122,7 +128,7 @@ def get_key( @contextmanager def rewrite( - path: Union[str, os.PathLike], + path: StrPath, encoding: Optional[str], ) -> Iterator[Tuple[IO[str], IO[str]]]: if not os.path.isfile(path): @@ -139,7 +145,7 @@ def rewrite( def set_key( - dotenv_path: Union[str, os.PathLike], + dotenv_path: StrPath, key_to_set: str, value_to_set: str, quote_mode: str = "always", @@ -188,7 +194,7 @@ def set_key( def unset_key( - dotenv_path: Union[str, os.PathLike], + dotenv_path: StrPath, key_to_unset: str, quote_mode: str = "always", encoding: Optional[str] = "utf-8", @@ -303,7 +309,7 @@ def _is_interactive(): def load_dotenv( - dotenv_path: Union[str, os.PathLike, None] = None, + dotenv_path: Optional[StrPath] = None, stream: Optional[IO[str]] = None, verbose: bool = False, override: bool = False, @@ -341,7 +347,7 @@ def load_dotenv( def dotenv_values( - dotenv_path: Union[str, os.PathLike, None] = None, + dotenv_path: Optional[StrPath] = None, stream: Optional[IO[str]] = None, verbose: bool = False, interpolate: bool = True, From a18ea18dce89d5f1941710e461c2e72055f590d4 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sat, 21 Jan 2023 15:31:57 +0530 Subject: [PATCH 144/253] Fix coverage reporting (#445) --- .github/workflows/test.yml | 5 +++++ tox.ini | 24 +++++++++++++----------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 15875768..758fbb46 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,21 +5,26 @@ on: [push, pull_request] jobs: test: runs-on: ${{ matrix.os }} + strategy: max-parallel: 8 matrix: os: - ubuntu-latest python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", pypy3.9] + steps: - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + - name: Install dependencies run: python -m pip install --upgrade pip pip install tox tox-gh-actions + - name: Test with tox run: tox diff --git a/tox.ini b/tox.ini index cb8a6625..9cca82a4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,23 +1,26 @@ [tox] -envlist = lint,py{35,36,37,38,39,310,311},pypy3,manifest,coverage-report +envlist = lint,py{37,38,39,310,311},pypy3,manifest,coverage-report [gh-actions] python = - 3.7: py37, coverage-report - 3.8: py38, coverage-report - 3.9: py39, coverage-report - 3.10: py310, lint, manifest, coverage-report - 3.11: py311, coverage-report - pypy-3.9: pypy3, coverage-report + 3.7: py37 + 3.8: py38 + 3.9: py39 + 3.10: py310 + 3.11: py311, lint, manifest + pypy-3.9: pypy3 [testenv] deps = pytest - coverage + pytest-cov sh click - py{37,38,39,310,311,py3}: ipython -commands = coverage run --parallel -m pytest {posargs} + py{37,38,39,310,311,pypy3}: ipython +commands = pytest --cov --cov-report=term-missing --cov-config setup.cfg {posargs} +depends = + py{37,38,39,310,311},pypy3: coverage-clean + coverage-report: py{35,36,37,38,39,310,311},pypy3 [testenv:lint] skip_install = true @@ -46,5 +49,4 @@ commands = coverage erase deps = coverage skip_install = true commands = - coverage combine coverage report From b84bccda57fc973e7b8367380194bfae558a3d4b Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sat, 21 Jan 2023 15:45:30 +0530 Subject: [PATCH 145/253] Add changelog for 0.21.1 --- CHANGELOG.md | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e53060a5..29e38757 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.21.1] - 2022-09-03 + +### Added +* Use Python 3.11 non-beta in CI (#438 by @bbc2) +* Modernize variables code (#434 by @Nougat-Waffle) +* Modernize main.py and parser.py code (#435 by @Nougat-Waffle) +* Improve conciseness of cli.py and __init__.py (#439 by @Nougat-Waffle) +* Improve error message for `get` and `list` commands when env file can't be opened (#441 by @bbc2) +* Updated Licence to align with BSD OSI template (#433 by @lsmith77) + + +### Fixed +* Fix Out-of-scope error when "dest" variable is undefined (#413 by @theGOTOguy) +* Fix IPython test warning about deprecated `magic` (#440 by @bbc2) +* Fix type hint for dotenv_path var, add StrPath alias (#432 by @eaf) + ## [0.21.0] - 2022-09-03 ### Added @@ -16,9 +33,9 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). * Drop Python 3.5 and 3.6 and upgrade GA (#393 by @eggplants) * Use `open` instead of `io.open`. (#389 by @rabinadk1) * Improve documentation for variables without a value (#390 by @bbc2) -* Add `parse_it` to Related Projects by (#410 by @naorlivne) -* Update README.md by (#415 by @harveer07) -* Improve documentation with direct use of MkDocs by (#398 by @bbc2) +* Add `parse_it` to Related Projects (#410 by @naorlivne) +* Update README.md (#415 by @harveer07) +* Improve documentation with direct use of MkDocs (#398 by @bbc2) ## [0.20.0] - 2022-03-24 From 5317a560e0943a7311bbc5bfbd9e4b4c13bc2bb6 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sat, 21 Jan 2023 15:50:46 +0530 Subject: [PATCH 146/253] =?UTF-8?q?Bump=20version:=200.21.0=20=E2=86=92=20?= =?UTF-8?q?0.21.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- src/dotenv/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 4d49c291..89b5d30e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.21.0 +current_version = 0.21.1 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 6a726d85..76f24586 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.21.0" +__version__ = "0.21.1" From 291fa669804e0ceaef94a35e7cd6cc90b6189cd5 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sat, 21 Jan 2023 16:31:07 +0530 Subject: [PATCH 147/253] Update documtation --- CHANGELOG.md | 54 ++++++++++++++++++++++++++++++----------------- LICENSE | 18 +++++++++------- docs/license.md | 1 + docs/reference.md | 3 +-- mkdocs.yml | 8 +++++++ 5 files changed, 55 insertions(+), 29 deletions(-) create mode 120000 docs/license.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 29e38757..5845146c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,37 +5,48 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +* + +### Fixed + +* + + ## [0.21.1] - 2022-09-03 ### Added -* Use Python 3.11 non-beta in CI (#438 by @bbc2) -* Modernize variables code (#434 by @Nougat-Waffle) -* Modernize main.py and parser.py code (#435 by @Nougat-Waffle) -* Improve conciseness of cli.py and __init__.py (#439 by @Nougat-Waffle) -* Improve error message for `get` and `list` commands when env file can't be opened (#441 by @bbc2) -* Updated Licence to align with BSD OSI template (#433 by @lsmith77) +* Use Python 3.11 non-beta in CI (#438 by [@bbc2]) +* Modernize variables code (#434 by [@Nougat-Waffle]) +* Modernize main.py and parser.py code (#435 by [@Nougat-Waffle]) +* Improve conciseness of cli.py and __init__.py (#439 by [@Nougat-Waffle]) +* Improve error message for `get` and `list` commands when env file can't be opened (#441 by [@bbc2]) +* Updated License to align with BSD OSI template (#433 by [@lsmith77]) ### Fixed -* Fix Out-of-scope error when "dest" variable is undefined (#413 by @theGOTOguy) -* Fix IPython test warning about deprecated `magic` (#440 by @bbc2) -* Fix type hint for dotenv_path var, add StrPath alias (#432 by @eaf) +* Fix Out-of-scope error when "dest" variable is undefined (#413 by [@theGOTOguy]) +* Fix IPython test warning about deprecated `magic` (#440 by [@bbc2]) +* Fix type hint for dotenv_path var, add StrPath alias (#432 by [@eaf]) ## [0.21.0] - 2022-09-03 ### Added -* CLI: add support for invocations via 'python -m'. (#395 by @theskumar) -* `load_dotenv` function now returns `False`. (#388 by @larsks) -* CLI: add --format= option to list command. (#407 by @sammck) +* CLI: add support for invocations via 'python -m'. (#395 by [@theskumar]) +* `load_dotenv` function now returns `False`. (#388 by [@larsks]) +* CLI: add --format= option to list command. (#407 by [@sammck]) ### Fixed -* Drop Python 3.5 and 3.6 and upgrade GA (#393 by @eggplants) -* Use `open` instead of `io.open`. (#389 by @rabinadk1) -* Improve documentation for variables without a value (#390 by @bbc2) -* Add `parse_it` to Related Projects (#410 by @naorlivne) -* Update README.md (#415 by @harveer07) -* Improve documentation with direct use of MkDocs (#398 by @bbc2) +* Drop Python 3.5 and 3.6 and upgrade GA (#393 by [@eggplants]) +* Use `open` instead of `io.open`. (#389 by [@rabinadk1]) +* Improve documentation for variables without a value (#390 by [@bbc2]) +* Add `parse_it` to Related Projects (#410 by [@naorlivne]) +* Update README.md (#415 by [@harveer07]) +* Improve documentation with direct use of MkDocs (#398 by [@bbc2]) ## [0.20.0] - 2022-03-24 @@ -326,6 +337,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@bbc2]: https://github.com/bbc2 [@befeleme]: https://github.com/befeleme [@cjauvin]: https://github.com/cjauvin +[@eaf]: https://github.com/eaf [@earlbread]: https://github.com/earlbread [@eggplants]: https://github.com/@eggplants [@ekohl]: https://github.com/ekohl @@ -337,13 +349,16 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@harveer07]: https://github.com/@harveer07 [@jadutter]: https://github.com/jadutter [@larsks]: https://github.com/@larsks +[@lsmith77]: https://github.com/lsmith77 [@mgorny]: https://github.com/mgorny [@naorlivne]: https://github.com/@naorlivne +[@Nougat-Waffle]: https://github.com/Nougat-Waffle [@qnighy]: https://github.com/qnighy [@rabinadk1]: https://github.com/@rabinadk1 [@sammck]: https://github.com/@sammck [@snobu]: https://github.com/snobu [@techalchemy]: https://github.com/techalchemy +[@theGOTOguy]: https://github.com/theGOTOguy [@theskumar]: https://github.com/theskumar [@ulyssessouza]: https://github.com/ulyssessouza [@venthur]: https://github.com/venthur @@ -352,7 +367,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@zueve]: https://github.com/zueve -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.21.0...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.21.1...HEAD +[0.21.1]: https://github.com/theskumar/python-dotenv/compare/v0.21.0...v0.21.1 [0.21.0]: https://github.com/theskumar/python-dotenv/compare/v0.20.0...v0.21.0 [0.20.0]: https://github.com/theskumar/python-dotenv/compare/v0.19.2...v0.20.0 [0.19.2]: https://github.com/theskumar/python-dotenv/compare/v0.19.1...v0.19.2 diff --git a/LICENSE b/LICENSE index acfe8334..3a971190 100644 --- a/LICENSE +++ b/LICENSE @@ -3,14 +3,16 @@ Copyright (c) 2014, Saurabh Kumar (python-dotenv), 2013, Ted Tieken (django-dote Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - * Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - * Neither the name of django-dotenv nor the names of its contributors - may be used to endorse or promote products derived from this software - without specific prior written permission. +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +- Neither the name of django-dotenv nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT diff --git a/docs/license.md b/docs/license.md new file mode 120000 index 00000000..ea5b6064 --- /dev/null +++ b/docs/license.md @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/docs/reference.md b/docs/reference.md index a126448e..8a3762ad 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1,3 +1,2 @@ -# Reference +# ::: dotenv -::: dotenv diff --git a/mkdocs.yml b/mkdocs.yml index 27063ca2..65acc0fb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -5,8 +5,15 @@ theme: name: material palette: primary: green + features: + - toc.follow + - navigation.sections + markdown_extensions: - mdx_truly_sane_lists + - toc: + toc_depth: 2 + plugins: - mkdocstrings: handlers: @@ -21,3 +28,4 @@ nav: - Changelog: changelog.md - Contributing: contributing.md - Reference: reference.md + - License: license.md From b904a722c8aea0a41948ea50d0c549ccece97729 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sat, 21 Jan 2023 19:17:24 +0530 Subject: [PATCH 148/253] update docs - better toc for changelog --- CHANGELOG.md | 66 ++++++++++++++++++++++++++++------------------------ mkdocs.yml | 2 -- 2 files changed, 35 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5845146c..d07533b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,19 +7,20 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -### Added +**Added** * -### Fixed +**Fixed** * -## [0.21.1] - 2022-09-03 +## [0.21.1] - 2022-01-21 + +**Added** -### Added * Use Python 3.11 non-beta in CI (#438 by [@bbc2]) * Modernize variables code (#434 by [@Nougat-Waffle]) * Modernize main.py and parser.py code (#435 by [@Nougat-Waffle]) @@ -28,19 +29,22 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). * Updated License to align with BSD OSI template (#433 by [@lsmith77]) -### Fixed +**Fixed** + * Fix Out-of-scope error when "dest" variable is undefined (#413 by [@theGOTOguy]) * Fix IPython test warning about deprecated `magic` (#440 by [@bbc2]) * Fix type hint for dotenv_path var, add StrPath alias (#432 by [@eaf]) ## [0.21.0] - 2022-09-03 -### Added +**Added** + * CLI: add support for invocations via 'python -m'. (#395 by [@theskumar]) * `load_dotenv` function now returns `False`. (#388 by [@larsks]) * CLI: add --format= option to list command. (#407 by [@sammck]) -### Fixed +**Fixed** + * Drop Python 3.5 and 3.6 and upgrade GA (#393 by [@eggplants]) * Use `open` instead of `io.open`. (#389 by [@rabinadk1]) * Improve documentation for variables without a value (#390 by [@bbc2]) @@ -50,12 +54,12 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.20.0] - 2022-03-24 -### Added +**Added** - Add `encoding` (`Optional[str]`) parameter to `get_key`, `set_key` and `unset_key`. (#379 by [@bbc2]) -### Fixed +**Fixed** - Use dict to specify the `entry_points` parameter of `setuptools.setup` (#376 by [@mgorny]). @@ -63,25 +67,25 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.19.2] - 2021-11-11 -### Fixed +**Fixed** - In `set_key`, add missing newline character before new entry if necessary. (#361 by [@bbc2]) ## [0.19.1] - 2021-08-09 -### Added +**Added** - Add support for Python 3.10. (#359 by [@theskumar]) ## [0.19.0] - 2021-07-24 -### Changed +**Changed** - Require Python 3.5 or a later version. Python 2 and 3.4 are no longer supported. (#341 by [@bbc2]). -### Added +**Added** - The `dotenv_path` argument of `set_key` and `unset_key` now has a type of `Union[str, os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). @@ -91,7 +95,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.18.0] - 2021-06-20 -### Changed +**Changed** - Raise `ValueError` if `quote_mode` isn't one of `always`, `auto` or `never` in `set_key` (#330 by [@bbc2]). @@ -104,23 +108,23 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.17.1] - 2021-04-29 -### Fixed +**Fixed** - Fixed tests for build environments relying on `PYTHONPATH` (#318 by [@befeleme]). ## [0.17.0] - 2021-04-02 -### Changed +**Changed** - Make `dotenv get ` only show the value, not `key=value` (#313 by [@bbc2]). -### Added +**Added** - Add `--override`/`--no-override` option to `dotenv run` (#312 by [@zueve] and [@bbc2]). ## [0.16.0] - 2021-03-27 -### Changed +**Changed** - The default value of the `encoding` parameter for `load_dotenv` and `dotenv_values` is now `"utf-8"` instead of `None` (#306 by [@bbc2]). @@ -128,17 +132,17 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.15.0] - 2020-10-28 -### Added +**Added** - Add `--export` option to `set` to make it prepend the binding with `export` (#270 by [@jadutter]). -### Changed +**Changed** - Make `set` command create the `.env` file in the current directory if no `.env` file was found (#270 by [@jadutter]). -### Fixed +**Fixed** - 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]). @@ -147,30 +151,30 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.14.0] - 2020-07-03 -### Changed +**Changed** - Privilege definition in file over the environment in variable expansion (#256 by [@elbehery95]). -### Fixed +**Fixed** - Improve error message for when file isn't found (#245 by [@snobu]). - Use HTTPS URL in package meta data (#251 by [@ekohl]). ## [0.13.0] - 2020-04-16 -### Added +**Added** - Add support for a Bash-like default value in variable expansion (#248 by [@bbc2]). ## [0.12.0] - 2020-02-28 -### Changed +**Changed** - Use current working directory to find `.env` when bundled by PyInstaller (#213 by [@gergelyk]). -### Fixed +**Fixed** - Fix escaping of quoted values written by `set_key` (#236 by [@bbc2]). - Fix `dotenv run` crashing on environment variables without values (#237 by [@yannham]). @@ -178,23 +182,23 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.11.0] - 2020-02-07 -### Added +**Added** - Add `interpolate` argument to `load_dotenv` and `dotenv_values` to disable interpolation (#232 by [@ulyssessouza]). -### Changed +**Changed** - Use logging instead of warnings (#231 by [@bbc2]). -### Fixed +**Fixed** - Fix installation in non-UTF-8 environments (#225 by [@altendky]). - Fix PyPI classifiers (#228 by [@bbc2]). ## [0.10.5] - 2020-01-19 -### Fixed +**Fixed** - Fix handling of malformed lines and lines without a value (#222 by [@bbc2]): - Don't print warning when key has no value. @@ -203,7 +207,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.10.4] - 2020-01-17 -### Added +**Added** - Make typing optional (#179 by [@techalchemy]). - Print a warning on malformed line (#211 by [@bbc2]). diff --git a/mkdocs.yml b/mkdocs.yml index 65acc0fb..331965df 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -11,8 +11,6 @@ theme: markdown_extensions: - mdx_truly_sane_lists - - toc: - toc_depth: 2 plugins: - mkdocstrings: From fc19a55de9c8774880b73f19148704f311f517b2 Mon Sep 17 00:00:00 2001 From: jctanner Date: Thu, 23 Feb 2023 01:21:29 -0500 Subject: [PATCH 149/253] Handle situations where the cwd does not exist. (#446) This is seen in some situations with dynaconf where the system under test starts from a src code directory that got moved around or deleted during operation. Signed-off-by: James Tanner --- src/dotenv/cli.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index b490bfaf..65ead461 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -17,8 +17,22 @@ from .version import __version__ +def enumerate_env(): + """ + Return a path for the ${pwd}/.env file. + + If pwd does not exist, return None. + """ + try: + cwd = os.getcwd() + except FileNotFoundError: + return None + path = os.path.join(cwd, '.env') + return path + + @click.group() -@click.option('-f', '--file', default=os.path.join(os.getcwd(), '.env'), +@click.option('-f', '--file', default=enumerate_env(), type=click.Path(file_okay=True), help="Location of the .env file, defaults to .env file in current working directory.") @click.option('-q', '--quote', default='always', From 87e5527fcff35b7118818ce8c35c4fd90fe80844 Mon Sep 17 00:00:00 2001 From: "Kenneth C. Arnold" Date: Fri, 24 Feb 2023 01:23:26 -0500 Subject: [PATCH 150/253] Update readme, add python-decouple as a related project (#451) I usually use dotenv, but I learned about this alternative from libhunt. It seems to have similar goals. (I'll probably stick with dotenv.) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 983b7d15..89fc6434 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,7 @@ defined in the following list: - [environs](https://github.com/sloria/environs) - [dynaconf](https://github.com/rochacbruno/dynaconf) - [parse_it](https://github.com/naorlivne/parse_it) +- [python-decouple](https://github.com/HBNetwork/python-decouple) ## Acknowledgements From 06116434822a4d7dd5c4213ff0a54dc6e41a9be6 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Fri, 24 Feb 2023 11:54:32 +0530 Subject: [PATCH 151/253] Drop support for python 3.7, add python 3.12-dev (#449) * fixes for sh 2.* * Drop support for python 3.7 * Add python 3.12 alpha --- .github/workflows/test.yml | 2 +- requirements.txt | 2 +- setup.py | 4 +-- tests/test_cli.py | 66 +++++++++++++++++++------------------- tox.ini | 14 ++++---- 5 files changed, 44 insertions(+), 44 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 758fbb46..49e1399f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: matrix: os: - ubuntu-latest - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", pypy3.9] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12-dev", pypy3.9] steps: - uses: actions/checkout@v3 diff --git a/requirements.txt b/requirements.txt index 0206316f..af7e1bc4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ flake8>=2.2.3 ipython pytest-cov pytest>=3.9 -sh>=1.09 +sh>=2 tox twine wheel diff --git a/setup.py b/setup.py index bcf1b0dc..8ceddf92 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ def read_files(files): package_data={ 'dotenv': ['py.typed'], }, - python_requires=">=3.7", + python_requires=">=3.8", extras_require={ 'cli': ['click>=5.0', ], }, @@ -45,11 +45,11 @@ def read_files(files): 'Development Status :: 5 - Production/Stable', 'Programming Language :: Python', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: Implementation :: PyPy', 'Intended Audience :: Developers', 'Intended Audience :: System Administrators', diff --git a/tests/test_cli.py b/tests/test_cli.py index 02dc9d96..8afbe59d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -153,61 +153,61 @@ def test_set_no_file(cli): def test_get_default_path(tmp_path): - sh.cd(str(tmp_path)) - with open(str(tmp_path / ".env"), "w") as f: - f.write("a=b") + with sh.pushd(str(tmp_path)): + with open(str(tmp_path / ".env"), "w") as f: + f.write("a=b") - result = sh.dotenv("get", "a") + result = sh.dotenv("get", "a") - assert result == "b\n" + assert result == "b\n" def test_run(tmp_path): - sh.cd(str(tmp_path)) - dotenv_file = str(tmp_path / ".env") - with open(dotenv_file, "w") as f: - f.write("a=b") + with sh.pushd(str(tmp_path)): + dotenv_file = str(tmp_path / ".env") + with open(dotenv_file, "w") as f: + f.write("a=b") - result = sh.dotenv("run", "printenv", "a") + result = sh.dotenv("run", "printenv", "a") - assert result == "b\n" + assert result == "b\n" def test_run_with_existing_variable(tmp_path): - sh.cd(str(tmp_path)) - dotenv_file = str(tmp_path / ".env") - with open(dotenv_file, "w") as f: - f.write("a=b") - env = dict(os.environ) - env.update({"LANG": "en_US.UTF-8", "a": "c"}) + with sh.pushd(str(tmp_path)): + dotenv_file = str(tmp_path / ".env") + with open(dotenv_file, "w") as f: + f.write("a=b") + env = dict(os.environ) + env.update({"LANG": "en_US.UTF-8", "a": "c"}) - result = sh.dotenv("run", "printenv", "a", _env=env) + result = sh.dotenv("run", "printenv", "a", _env=env) - assert result == "b\n" + assert result == "b\n" def test_run_with_existing_variable_not_overridden(tmp_path): - sh.cd(str(tmp_path)) - dotenv_file = str(tmp_path / ".env") - with open(dotenv_file, "w") as f: - f.write("a=b") - env = dict(os.environ) - env.update({"LANG": "en_US.UTF-8", "a": "c"}) + with sh.pushd(str(tmp_path)): + dotenv_file = str(tmp_path / ".env") + with open(dotenv_file, "w") as f: + f.write("a=b") + env = dict(os.environ) + env.update({"LANG": "en_US.UTF-8", "a": "c"}) - result = sh.dotenv("run", "--no-override", "printenv", "a", _env=env) + result = sh.dotenv("run", "--no-override", "printenv", "a", _env=env) - assert result == "c\n" + assert result == "c\n" def test_run_with_none_value(tmp_path): - sh.cd(str(tmp_path)) - dotenv_file = str(tmp_path / ".env") - with open(dotenv_file, "w") as f: - f.write("a=b\nc") + with sh.pushd(str(tmp_path)): + dotenv_file = str(tmp_path / ".env") + with open(dotenv_file, "w") as f: + f.write("a=b\nc") - result = sh.dotenv("run", "printenv", "a") + result = sh.dotenv("run", "printenv", "a") - assert result == "b\n" + assert result == "b\n" def test_run_with_other_env(dotenv_file): diff --git a/tox.ini b/tox.ini index 9cca82a4..fad86f73 100644 --- a/tox.ini +++ b/tox.ini @@ -1,26 +1,26 @@ [tox] -envlist = lint,py{37,38,39,310,311},pypy3,manifest,coverage-report +envlist = lint,py{38,39,310,311,312-dev},pypy3,manifest,coverage-report [gh-actions] python = - 3.7: py37 3.8: py38 3.9: py39 3.10: py310 3.11: py311, lint, manifest + 3.12-dev: py312-dev pypy-3.9: pypy3 [testenv] deps = pytest pytest-cov - sh + sh >= 2.0.2, <3 click - py{37,38,39,310,311,pypy3}: ipython + py{38,39,310,311,py312-dev,pypy3}: ipython commands = pytest --cov --cov-report=term-missing --cov-config setup.cfg {posargs} depends = - py{37,38,39,310,311},pypy3: coverage-clean - coverage-report: py{35,36,37,38,39,310,311},pypy3 + py{38,39,310,311,312-dev},pypy3: coverage-clean + coverage-report: py{38,39,310,311,312-dev},pypy3 [testenv:lint] skip_install = true @@ -29,11 +29,11 @@ deps = mypy commands = flake8 src tests + mypy --python-version=3.12 src tests mypy --python-version=3.11 src tests mypy --python-version=3.10 src tests mypy --python-version=3.9 src tests mypy --python-version=3.8 src tests - mypy --python-version=3.7 src tests [testenv:manifest] deps = check-manifest From 6c8ddd23752b616aee384693381633187ee07107 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Fri, 24 Feb 2023 12:13:58 +0530 Subject: [PATCH 152/253] Prepare for release 1.0.0 --- CHANGELOG.md | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d07533b6..2f78c0d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,17 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] - -**Added** - -* +## [1.0.0] **Fixed** -* - - +* Drop support for python 3.7, add python 3.12-dev (#449 by [@theskumar]) +* Handle situations where the cwd does not exist. (#446 by [@jctanner]) ## [0.21.1] - 2022-01-21 @@ -352,6 +347,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@greyli]: https://github.com/greyli [@harveer07]: https://github.com/@harveer07 [@jadutter]: https://github.com/jadutter +[@jctanner]: https://github.com/jctanner [@larsks]: https://github.com/@larsks [@lsmith77]: https://github.com/lsmith77 [@mgorny]: https://github.com/mgorny @@ -371,7 +367,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@zueve]: https://github.com/zueve -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.21.1...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/theskumar/python-dotenv/compare/v0.21.0...v1.0.0 [0.21.1]: https://github.com/theskumar/python-dotenv/compare/v0.21.0...v0.21.1 [0.21.0]: https://github.com/theskumar/python-dotenv/compare/v0.20.0...v0.21.0 [0.20.0]: https://github.com/theskumar/python-dotenv/compare/v0.19.2...v0.20.0 From d0684d1c092fb6a9a208a09d43f02e4876ee8196 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Fri, 24 Feb 2023 12:14:03 +0530 Subject: [PATCH 153/253] =?UTF-8?q?Bump=20version:=200.21.1=20=E2=86=92=20?= =?UTF-8?q?1.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- src/dotenv/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 89b5d30e..3fefd1f0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.21.1 +current_version = 1.0.0 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 76f24586..5becc17c 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.21.1" +__version__ = "1.0.0" From dd1af684f2586d2c2fdd722f9c45d3212e1e4e59 Mon Sep 17 00:00:00 2001 From: Jan Kislinger Date: Tue, 14 Mar 2023 08:32:42 +0100 Subject: [PATCH 154/253] FIx year in release in changelog (#453) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f78c0d0..220d1888 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). * Drop support for python 3.7, add python 3.12-dev (#449 by [@theskumar]) * Handle situations where the cwd does not exist. (#446 by [@jctanner]) -## [0.21.1] - 2022-01-21 +## [0.21.1] - 2023-01-21 **Added** From 137bc3dc0b8cf3d417a1e800c4065c526e3fb96a Mon Sep 17 00:00:00 2001 From: Sam Wyma Date: Mon, 17 Apr 2023 16:12:57 +0100 Subject: [PATCH 155/253] Gracefully handle code which has been imported from a zipfile (#456) --- src/dotenv/main.py | 4 +- tests/test_zip_imports.py | 101 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 tests/test_zip_imports.py diff --git a/src/dotenv/main.py b/src/dotenv/main.py index f40c20ea..383b79f4 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -291,7 +291,9 @@ def _is_interactive(): frame = sys._getframe() current_file = __file__ - while frame.f_code.co_filename == current_file: + while frame.f_code.co_filename == current_file or not os.path.exists( + frame.f_code.co_filename + ): assert frame.f_back is not None frame = frame.f_back frame_filename = frame.f_code.co_filename diff --git a/tests/test_zip_imports.py b/tests/test_zip_imports.py new file mode 100644 index 00000000..46d3c02e --- /dev/null +++ b/tests/test_zip_imports.py @@ -0,0 +1,101 @@ +import os +import sys +import sh +import textwrap +from typing import List +from unittest import mock +from zipfile import ZipFile + + +def walk_to_root(path: str): + last_dir = None + current_dir = path + while last_dir != current_dir: + yield current_dir + (parent_dir, _) = os.path.split(current_dir) + last_dir, current_dir = current_dir, parent_dir + + +class FileToAdd: + def __init__(self, content: str, path: str): + self.content = content + self.path = path + + +def setup_zipfile(path, files: List[FileToAdd]): + zip_file_path = path / "test.zip" + dirs_init_py_added_to = set() + with ZipFile(zip_file_path, "w") as zip: + for f in files: + zip.writestr(data=f.content, zinfo_or_arcname=f.path) + for dir in walk_to_root(os.path.dirname(f.path)): + if dir not in dirs_init_py_added_to: + print(os.path.join(dir, "__init__.py")) + zip.writestr( + data="", zinfo_or_arcname=os.path.join(dir, "__init__.py") + ) + dirs_init_py_added_to.add(dir) + return zip_file_path + + +@mock.patch.object(sys, "path", list(sys.path)) +def test_load_dotenv_gracefully_handles_zip_imports_when_no_env_file(tmp_path): + zip_file_path = setup_zipfile( + tmp_path, + [ + FileToAdd( + content=textwrap.dedent( + """ + from dotenv import load_dotenv + + load_dotenv() + """ + ), + path="child1/child2/test.py", + ), + ], + ) + + # Should run without an error + sys.path.append(str(zip_file_path)) + import child1.child2.test # noqa + + +def test_load_dotenv_outside_zip_file_when_called_in_zipfile(tmp_path): + zip_file_path = setup_zipfile( + tmp_path, + [ + FileToAdd( + content=textwrap.dedent( + """ + from dotenv import load_dotenv + + load_dotenv() + """ + ), + path="child1/child2/test.py", + ), + ], + ) + dotenv_path = tmp_path / ".env" + dotenv_path.write_bytes(b"a=b") + code_path = tmp_path / "code.py" + code_path.write_text( + textwrap.dedent( + f""" + import os + import sys + + sys.path.append("{zip_file_path}") + + import child1.child2.test + + print(os.environ['a']) + """ + ) + ) + os.chdir(str(tmp_path)) + + result = sh.Command(sys.executable)(code_path) + + assert result == "b\n" From be96be259c7eaf687360367de53e5e099aea48df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0ediv=C3=BD?= <6774676+eumiro@users.noreply.github.com> Date: Sat, 24 Jun 2023 10:02:12 +0200 Subject: [PATCH 156/253] Use pathlib.Path in tests (#466) --- tests/conftest.py | 8 +- tests/test_cli.py | 93 +++++++++----------- tests/test_ipython.py | 6 +- tests/test_main.py | 196 ++++++++++++++++++------------------------ 4 files changed, 134 insertions(+), 169 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 24a82528..69193de0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ def cli(): @pytest.fixture -def dotenv_file(tmp_path): - file_ = tmp_path / '.env' - file_.write_bytes(b'') - yield str(file_) +def dotenv_path(tmp_path): + path = tmp_path / '.env' + path.write_bytes(b'') + yield path diff --git a/tests/test_cli.py b/tests/test_cli.py index 8afbe59d..fc309b48 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,8 +1,10 @@ import os - -import pytest import sh +from pathlib import Path from typing import Optional + +import pytest + import dotenv from dotenv.cli import cli as dotenv_cli from dotenv.version import __version__ @@ -23,11 +25,10 @@ ("export", "x='a b c'", '''export x='a b c'\n'''), ) ) -def test_list(cli, dotenv_file, format: Optional[str], content: str, expected: str): - with open(dotenv_file, "w") as f: - f.write(content + '\n') +def test_list(cli, dotenv_path, format: Optional[str], content: str, expected: str): + dotenv_path.write_text(content + '\n') - args = ['--file', dotenv_file, 'list'] + args = ['--file', dotenv_path, 'list'] if format is not None: args.extend(['--format', format]) @@ -56,17 +57,16 @@ def test_list_no_file(cli): assert (result.exit_code, result.output) == (1, "") -def test_get_existing_value(cli, dotenv_file): - with open(dotenv_file, "w") as f: - f.write("a=b") +def test_get_existing_value(cli, dotenv_path): + dotenv_path.write_text("a=b") - result = cli.invoke(dotenv_cli, ['--file', dotenv_file, 'get', 'a']) + result = cli.invoke(dotenv_cli, ['--file', dotenv_path, 'get', 'a']) assert (result.exit_code, result.output) == (0, "b\n") -def test_get_non_existent_value(cli, dotenv_file): - result = cli.invoke(dotenv_cli, ['--file', dotenv_file, 'get', 'a']) +def test_get_non_existent_value(cli, dotenv_path): + result = cli.invoke(dotenv_cli, ['--file', dotenv_path, 'get', 'a']) assert (result.exit_code, result.output) == (1, "") @@ -85,21 +85,20 @@ def test_get_not_a_file(cli): assert "Error opening env file" in result.output -def test_unset_existing_value(cli, dotenv_file): - with open(dotenv_file, "w") as f: - f.write("a=b") +def test_unset_existing_value(cli, dotenv_path): + dotenv_path.write_text("a=b") - result = cli.invoke(dotenv_cli, ['--file', dotenv_file, 'unset', 'a']) + result = cli.invoke(dotenv_cli, ['--file', dotenv_path, 'unset', 'a']) assert (result.exit_code, result.output) == (0, "Successfully removed a\n") - assert open(dotenv_file, "r").read() == "" + assert dotenv_path.read_text() == "" -def test_unset_non_existent_value(cli, dotenv_file): - result = cli.invoke(dotenv_cli, ['--file', dotenv_file, 'unset', 'a']) +def test_unset_non_existent_value(cli, dotenv_path): + result = cli.invoke(dotenv_cli, ['--file', dotenv_path, 'unset', 'a']) assert (result.exit_code, result.output) == (1, "") - assert open(dotenv_file, "r").read() == "" + assert dotenv_path.read_text() == "" @pytest.mark.parametrize( @@ -112,31 +111,31 @@ def test_unset_non_existent_value(cli, dotenv_file): ("auto", "a", "$", "a='$'\n"), ) ) -def test_set_quote_options(cli, dotenv_file, quote_mode, variable, value, expected): +def test_set_quote_options(cli, dotenv_path, quote_mode, variable, value, expected): result = cli.invoke( dotenv_cli, - ["--file", dotenv_file, "--export", "false", "--quote", quote_mode, "set", variable, value] + ["--file", dotenv_path, "--export", "false", "--quote", quote_mode, "set", variable, value] ) assert (result.exit_code, result.output) == (0, "{}={}\n".format(variable, value)) - assert open(dotenv_file, "r").read() == expected + assert dotenv_path.read_text() == expected @pytest.mark.parametrize( - "dotenv_file,export_mode,variable,value,expected", + "dotenv_path,export_mode,variable,value,expected", ( - (".nx_file", "true", "a", "x", "export a='x'\n"), - (".nx_file", "false", "a", "x", "a='x'\n"), + (Path(".nx_file"), "true", "a", "x", "export a='x'\n"), + (Path(".nx_file"), "false", "a", "x", "a='x'\n"), ) ) -def test_set_export(cli, dotenv_file, export_mode, variable, value, expected): +def test_set_export(cli, dotenv_path, export_mode, variable, value, expected): result = cli.invoke( dotenv_cli, - ["--file", dotenv_file, "--quote", "always", "--export", export_mode, "set", variable, value] + ["--file", dotenv_path, "--quote", "always", "--export", export_mode, "set", variable, value] ) assert (result.exit_code, result.output) == (0, "{}={}\n".format(variable, value)) - assert open(dotenv_file, "r").read() == expected + assert dotenv_path.read_text() == expected def test_set_non_existent_file(cli): @@ -153,9 +152,8 @@ def test_set_no_file(cli): def test_get_default_path(tmp_path): - with sh.pushd(str(tmp_path)): - with open(str(tmp_path / ".env"), "w") as f: - f.write("a=b") + with sh.pushd(tmp_path): + (tmp_path / ".env").write_text("a=b") result = sh.dotenv("get", "a") @@ -163,10 +161,8 @@ def test_get_default_path(tmp_path): def test_run(tmp_path): - with sh.pushd(str(tmp_path)): - dotenv_file = str(tmp_path / ".env") - with open(dotenv_file, "w") as f: - f.write("a=b") + with sh.pushd(tmp_path): + (tmp_path / ".env").write_text("a=b") result = sh.dotenv("run", "printenv", "a") @@ -174,10 +170,8 @@ def test_run(tmp_path): def test_run_with_existing_variable(tmp_path): - with sh.pushd(str(tmp_path)): - dotenv_file = str(tmp_path / ".env") - with open(dotenv_file, "w") as f: - f.write("a=b") + with sh.pushd(tmp_path): + (tmp_path / ".env").write_text("a=b") env = dict(os.environ) env.update({"LANG": "en_US.UTF-8", "a": "c"}) @@ -187,10 +181,8 @@ def test_run_with_existing_variable(tmp_path): def test_run_with_existing_variable_not_overridden(tmp_path): - with sh.pushd(str(tmp_path)): - dotenv_file = str(tmp_path / ".env") - with open(dotenv_file, "w") as f: - f.write("a=b") + with sh.pushd(tmp_path): + (tmp_path / ".env").write_text("a=b") env = dict(os.environ) env.update({"LANG": "en_US.UTF-8", "a": "c"}) @@ -200,21 +192,18 @@ def test_run_with_existing_variable_not_overridden(tmp_path): def test_run_with_none_value(tmp_path): - with sh.pushd(str(tmp_path)): - dotenv_file = str(tmp_path / ".env") - with open(dotenv_file, "w") as f: - f.write("a=b\nc") + with sh.pushd(tmp_path): + (tmp_path / ".env").write_text("a=b\nc") result = sh.dotenv("run", "printenv", "a") assert result == "b\n" -def test_run_with_other_env(dotenv_file): - with open(dotenv_file, "w") as f: - f.write("a=b") +def test_run_with_other_env(dotenv_path): + dotenv_path.write_text("a=b") - result = sh.dotenv("--file", dotenv_file, "run", "printenv", "a") + result = sh.dotenv("--file", dotenv_path, "run", "printenv", "a") assert result == "b\n" diff --git a/tests/test_ipython.py b/tests/test_ipython.py index f988bd9c..960479ba 100644 --- a/tests/test_ipython.py +++ b/tests/test_ipython.py @@ -13,7 +13,7 @@ def test_ipython_existing_variable_no_override(tmp_path): dotenv_file = tmp_path / ".env" dotenv_file.write_text("a=b\n") - os.chdir(str(tmp_path)) + os.chdir(tmp_path) os.environ["a"] = "c" ipshell = InteractiveShellEmbed() @@ -29,7 +29,7 @@ def test_ipython_existing_variable_override(tmp_path): dotenv_file = tmp_path / ".env" dotenv_file.write_text("a=b\n") - os.chdir(str(tmp_path)) + os.chdir(tmp_path) os.environ["a"] = "c" ipshell = InteractiveShellEmbed() @@ -45,7 +45,7 @@ def test_ipython_new_variable(tmp_path): dotenv_file = tmp_path / ".env" dotenv_file.write_text("a=b\n") - os.chdir(str(tmp_path)) + os.chdir(tmp_path) ipshell = InteractiveShellEmbed() ipshell.run_line_magic("load_ext", "dotenv") diff --git a/tests/test_main.py b/tests/test_main.py index 9c895851..fd5e3903 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -12,14 +12,14 @@ def test_set_key_no_file(tmp_path): - nx_file = str(tmp_path / "nx") + nx_path = tmp_path / "nx" logger = logging.getLogger("dotenv.main") with mock.patch.object(logger, "warning"): - result = dotenv.set_key(nx_file, "foo", "bar") + result = dotenv.set_key(nx_path, "foo", "bar") assert result == (True, "foo", "bar") - assert os.path.exists(nx_file) + assert nx_path.exists() @pytest.mark.parametrize( @@ -40,162 +40,151 @@ def test_set_key_no_file(tmp_path): ("a=b", "c", "d", (True, "c", "d"), "a=b\nc='d'\n"), ], ) -def test_set_key(dotenv_file, before, key, value, expected, after): +def test_set_key(dotenv_path, before, key, value, expected, after): logger = logging.getLogger("dotenv.main") - with open(dotenv_file, "w") as f: - f.write(before) + dotenv_path.write_text(before) with mock.patch.object(logger, "warning") as mock_warning: - result = dotenv.set_key(dotenv_file, key, value) + result = dotenv.set_key(dotenv_path, key, value) assert result == expected - assert open(dotenv_file, "r").read() == after + assert dotenv_path.read_text() == after mock_warning.assert_not_called() -def test_set_key_encoding(dotenv_file): +def test_set_key_encoding(dotenv_path): encoding = "latin-1" - result = dotenv.set_key(dotenv_file, "a", "é", encoding=encoding) + result = dotenv.set_key(dotenv_path, "a", "é", encoding=encoding) assert result == (True, "a", "é") - assert open(dotenv_file, "r", encoding=encoding).read() == "a='é'\n" + assert dotenv_path.read_text(encoding=encoding) == "a='é'\n" -def test_set_key_permission_error(dotenv_file): - os.chmod(dotenv_file, 0o000) +def test_set_key_permission_error(dotenv_path): + dotenv_path.chmod(0o000) with pytest.raises(Exception): - dotenv.set_key(dotenv_file, "a", "b") + dotenv.set_key(dotenv_path, "a", "b") - os.chmod(dotenv_file, 0o600) - with open(dotenv_file, "r") as fp: - assert fp.read() == "" + dotenv_path.chmod(0o600) + assert dotenv_path.read_text() == "" def test_get_key_no_file(tmp_path): - nx_file = str(tmp_path / "nx") + nx_path = tmp_path / "nx" logger = logging.getLogger("dotenv.main") with mock.patch.object(logger, "info") as mock_info, \ mock.patch.object(logger, "warning") as mock_warning: - result = dotenv.get_key(nx_file, "foo") + result = dotenv.get_key(nx_path, "foo") assert result is None mock_info.assert_has_calls( calls=[ - mock.call("Python-dotenv could not find configuration file %s.", nx_file) + mock.call("Python-dotenv could not find configuration file %s.", nx_path) ], ) mock_warning.assert_has_calls( calls=[ - mock.call("Key %s not found in %s.", "foo", nx_file) + mock.call("Key %s not found in %s.", "foo", nx_path) ], ) -def test_get_key_not_found(dotenv_file): +def test_get_key_not_found(dotenv_path): logger = logging.getLogger("dotenv.main") with mock.patch.object(logger, "warning") as mock_warning: - result = dotenv.get_key(dotenv_file, "foo") + result = dotenv.get_key(dotenv_path, "foo") assert result is None - mock_warning.assert_called_once_with("Key %s not found in %s.", "foo", dotenv_file) + mock_warning.assert_called_once_with("Key %s not found in %s.", "foo", dotenv_path) -def test_get_key_ok(dotenv_file): +def test_get_key_ok(dotenv_path): logger = logging.getLogger("dotenv.main") - with open(dotenv_file, "w") as f: - f.write("foo=bar") + dotenv_path.write_text("foo=bar") with mock.patch.object(logger, "warning") as mock_warning: - result = dotenv.get_key(dotenv_file, "foo") + result = dotenv.get_key(dotenv_path, "foo") assert result == "bar" mock_warning.assert_not_called() -def test_get_key_encoding(dotenv_file): +def test_get_key_encoding(dotenv_path): encoding = "latin-1" - with open(dotenv_file, "w", encoding=encoding) as f: - f.write("é=è") + dotenv_path.write_text("é=è", encoding=encoding) - result = dotenv.get_key(dotenv_file, "é", encoding=encoding) + result = dotenv.get_key(dotenv_path, "é", encoding=encoding) assert result == "è" -def test_get_key_none(dotenv_file): +def test_get_key_none(dotenv_path): logger = logging.getLogger("dotenv.main") - with open(dotenv_file, "w") as f: - f.write("foo") + dotenv_path.write_text("foo") with mock.patch.object(logger, "warning") as mock_warning: - result = dotenv.get_key(dotenv_file, "foo") + result = dotenv.get_key(dotenv_path, "foo") assert result is None mock_warning.assert_not_called() -def test_unset_with_value(dotenv_file): +def test_unset_with_value(dotenv_path): logger = logging.getLogger("dotenv.main") - with open(dotenv_file, "w") as f: - f.write("a=b\nc=d") + dotenv_path.write_text("a=b\nc=d") with mock.patch.object(logger, "warning") as mock_warning: - result = dotenv.unset_key(dotenv_file, "a") + result = dotenv.unset_key(dotenv_path, "a") assert result == (True, "a") - with open(dotenv_file, "r") as f: - assert f.read() == "c=d" + assert dotenv_path.read_text() == "c=d" mock_warning.assert_not_called() -def test_unset_no_value(dotenv_file): +def test_unset_no_value(dotenv_path): logger = logging.getLogger("dotenv.main") - with open(dotenv_file, "w") as f: - f.write("foo") + dotenv_path.write_text("foo") with mock.patch.object(logger, "warning") as mock_warning: - result = dotenv.unset_key(dotenv_file, "foo") + result = dotenv.unset_key(dotenv_path, "foo") assert result == (True, "foo") - with open(dotenv_file, "r") as f: - assert f.read() == "" + assert dotenv_path.read_text() == "" mock_warning.assert_not_called() -def test_unset_encoding(dotenv_file): +def test_unset_encoding(dotenv_path): encoding = "latin-1" - with open(dotenv_file, "w", encoding=encoding) as f: - f.write("é=x") + dotenv_path.write_text("é=x", encoding=encoding) - result = dotenv.unset_key(dotenv_file, "é", encoding=encoding) + result = dotenv.unset_key(dotenv_path, "é", encoding=encoding) assert result == (True, "é") - with open(dotenv_file, "r", encoding=encoding) as f: - assert f.read() == "" + assert dotenv_path.read_text(encoding=encoding) == "" -def test_set_key_unauthorized_file(dotenv_file): - os.chmod(dotenv_file, 0o000) +def test_set_key_unauthorized_file(dotenv_path): + dotenv_path.chmod(0o000) with pytest.raises(PermissionError): - dotenv.set_key(dotenv_file, "a", "x") + dotenv.set_key(dotenv_path, "a", "x") def test_unset_non_existent_file(tmp_path): - nx_file = str(tmp_path / "nx") + nx_path = tmp_path / "nx" logger = logging.getLogger("dotenv.main") with mock.patch.object(logger, "warning") as mock_warning: - result = dotenv.unset_key(nx_file, "foo") + result = dotenv.unset_key(nx_path, "foo") assert result == (None, "foo") mock_warning.assert_called_once_with( "Can't delete from %s - it doesn't exist.", - nx_file, + nx_path, ) @@ -213,27 +202,22 @@ def prepare_file_hierarchy(path): Then try to automatically `find_dotenv` starting in `child4` """ - curr_dir = path - dirs = [] - for f in ['child1', 'child2', 'child3', 'child4']: - curr_dir /= f - dirs.append(curr_dir) - curr_dir.mkdir() - - return (dirs[0], dirs[-1]) + leaf = path / "child1" / "child2" / "child3" / "child4" + leaf.mkdir(parents=True, exist_ok=True) + return leaf def test_find_dotenv_no_file_raise(tmp_path): - (root, leaf) = prepare_file_hierarchy(tmp_path) - os.chdir(str(leaf)) + leaf = prepare_file_hierarchy(tmp_path) + os.chdir(leaf) with pytest.raises(IOError): dotenv.find_dotenv(raise_error_if_not_found=True, usecwd=True) def test_find_dotenv_no_file_no_raise(tmp_path): - (root, leaf) = prepare_file_hierarchy(tmp_path) - os.chdir(str(leaf)) + leaf = prepare_file_hierarchy(tmp_path) + os.chdir(leaf) result = dotenv.find_dotenv(usecwd=True) @@ -241,22 +225,21 @@ def test_find_dotenv_no_file_no_raise(tmp_path): def test_find_dotenv_found(tmp_path): - (root, leaf) = prepare_file_hierarchy(tmp_path) - os.chdir(str(leaf)) - dotenv_file = root / ".env" - dotenv_file.write_bytes(b"TEST=test\n") + leaf = prepare_file_hierarchy(tmp_path) + os.chdir(leaf) + dotenv_path = tmp_path / ".env" + dotenv_path.write_bytes(b"TEST=test\n") result = dotenv.find_dotenv(usecwd=True) - assert result == str(dotenv_file) + assert result == str(dotenv_path) @mock.patch.dict(os.environ, {}, clear=True) -def test_load_dotenv_existing_file(dotenv_file): - with open(dotenv_file, "w") as f: - f.write("a=b") +def test_load_dotenv_existing_file(dotenv_path): + dotenv_path.write_text("a=b") - result = dotenv.load_dotenv(dotenv_file) + result = dotenv.load_dotenv(dotenv_path) assert result is True assert os.environ == {"a": "b"} @@ -273,44 +256,40 @@ def test_load_dotenv_no_file_verbose(): @mock.patch.dict(os.environ, {"a": "c"}, clear=True) -def test_load_dotenv_existing_variable_no_override(dotenv_file): - with open(dotenv_file, "w") as f: - f.write("a=b") +def test_load_dotenv_existing_variable_no_override(dotenv_path): + dotenv_path.write_text("a=b") - result = dotenv.load_dotenv(dotenv_file, override=False) + result = dotenv.load_dotenv(dotenv_path, override=False) assert result is True assert os.environ == {"a": "c"} @mock.patch.dict(os.environ, {"a": "c"}, clear=True) -def test_load_dotenv_existing_variable_override(dotenv_file): - with open(dotenv_file, "w") as f: - f.write("a=b") +def test_load_dotenv_existing_variable_override(dotenv_path): + dotenv_path.write_text("a=b") - result = dotenv.load_dotenv(dotenv_file, override=True) + result = dotenv.load_dotenv(dotenv_path, override=True) assert result is True assert os.environ == {"a": "b"} @mock.patch.dict(os.environ, {"a": "c"}, clear=True) -def test_load_dotenv_redefine_var_used_in_file_no_override(dotenv_file): - with open(dotenv_file, "w") as f: - f.write('a=b\nd="${a}"') +def test_load_dotenv_redefine_var_used_in_file_no_override(dotenv_path): + dotenv_path.write_text('a=b\nd="${a}"') - result = dotenv.load_dotenv(dotenv_file) + result = dotenv.load_dotenv(dotenv_path) assert result is True assert os.environ == {"a": "c", "d": "c"} @mock.patch.dict(os.environ, {"a": "c"}, clear=True) -def test_load_dotenv_redefine_var_used_in_file_with_override(dotenv_file): - with open(dotenv_file, "w") as f: - f.write('a=b\nd="${a}"') +def test_load_dotenv_redefine_var_used_in_file_with_override(dotenv_path): + dotenv_path.write_text('a=b\nd="${a}"') - result = dotenv.load_dotenv(dotenv_file, override=True) + result = dotenv.load_dotenv(dotenv_path, override=True) assert result is True assert os.environ == {"a": "b", "d": "b"} @@ -327,11 +306,10 @@ def test_load_dotenv_string_io_utf_8(): @mock.patch.dict(os.environ, {}, clear=True) -def test_load_dotenv_file_stream(dotenv_file): - with open(dotenv_file, "w") as f: - f.write("a=b") +def test_load_dotenv_file_stream(dotenv_path): + dotenv_path.write_text("a=b") - with open(dotenv_file, "r") as f: + with dotenv_path.open() as f: result = dotenv.load_dotenv(stream=f) assert result is True @@ -349,18 +327,17 @@ def test_load_dotenv_in_current_dir(tmp_path): dotenv.load_dotenv(verbose=True) print(os.environ['a']) """)) - os.chdir(str(tmp_path)) + os.chdir(tmp_path) result = sh.Command(sys.executable)(code_path) assert result == 'b\n' -def test_dotenv_values_file(dotenv_file): - with open(dotenv_file, "w") as f: - f.write("a=b") +def test_dotenv_values_file(dotenv_path): + dotenv_path.write_text("a=b") - result = dotenv.dotenv_values(dotenv_file) + result = dotenv.dotenv_values(dotenv_path) assert result == {"a": "b"} @@ -415,11 +392,10 @@ def test_dotenv_values_string_io(env, string, interpolate, expected): assert result == expected -def test_dotenv_values_file_stream(dotenv_file): - with open(dotenv_file, "w") as f: - f.write("a=b") +def test_dotenv_values_file_stream(dotenv_path): + dotenv_path.write_text("a=b") - with open(dotenv_file, "r") as f: + with dotenv_path.open() as f: result = dotenv.dotenv_values(stream=f) assert result == {"a": "b"} From 3ffcef60d10813b72ecf85d5941d51b0207cd40e Mon Sep 17 00:00:00 2001 From: Nicolas Appriou Date: Fri, 7 Jul 2023 19:27:15 +0200 Subject: [PATCH 157/253] Use https in README links (#474) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 89fc6434..ddc8ba87 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Python-dotenv reads key-value pairs from a `.env` file and can set them as environment variables. It helps in the development of applications following the -[12-factor](http://12factor.net/) principles. +[12-factor](https://12factor.net/) principles. - [Getting Started](#getting-started) - [Other Use Cases](#other-use-cases) @@ -243,5 +243,5 @@ people](https://github.com/theskumar/python-dotenv/graphs/contributors). [build_status_badge]: https://github.com/theskumar/python-dotenv/actions/workflows/test.yml/badge.svg [build_status_link]: https://github.com/theskumar/python-dotenv/actions/workflows/test.yml [pypi_badge]: https://badge.fury.io/py/python-dotenv.svg -[pypi_link]: http://badge.fury.io/py/python-dotenv +[pypi_link]: https://badge.fury.io/py/python-dotenv [python_streams]: https://docs.python.org/3/library/io.html From 0b94ac0822241eb526828cf506048fb0525d5c38 Mon Sep 17 00:00:00 2001 From: Freddy Boulton Date: Mon, 22 Jan 2024 21:45:11 -0800 Subject: [PATCH 158/253] Allow modules using load_dotenv to be reloaded when launched in a separate thread (#497) Update `is_interactive` code --- src/dotenv/main.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 383b79f4..20c7782e 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -280,7 +280,10 @@ def find_dotenv( def _is_interactive(): """ Decide whether this is running in a REPL or IPython notebook """ - main = __import__('__main__', None, None, fromlist=['__file__']) + try: + main = __import__('__main__', None, None, fromlist=['__file__']) + except ModuleNotFoundError: + return False return not hasattr(main, '__file__') if usecwd or _is_interactive() or getattr(sys, 'frozen', False): From 6ff139147559eff4d124c038ec5a4b60ffcf3033 Mon Sep 17 00:00:00 2001 From: Qwerty-133 <74311372+Qwerty-133@users.noreply.github.com> Date: Tue, 23 Jan 2024 11:21:15 +0530 Subject: [PATCH 159/253] Fix temporary file is deleted before closing, in the rewrite function (#468) Currently, if an error is raised while using files from the rewrite function, then the temporary file is deleted before closing it. This is okay on unix, but unlinking open files causes an error on Windows. --- src/dotenv/main.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 20c7782e..7bc54285 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -1,6 +1,7 @@ import io import logging import os +import pathlib import shutil import sys import tempfile @@ -131,17 +132,21 @@ def rewrite( path: StrPath, encoding: Optional[str], ) -> Iterator[Tuple[IO[str], IO[str]]]: - if not os.path.isfile(path): - with open(path, mode="w", encoding=encoding) as source: - source.write("") + pathlib.Path(path).touch() + with tempfile.NamedTemporaryFile(mode="w", encoding=encoding, delete=False) as dest: + error = None try: with open(path, encoding=encoding) as source: yield (source, dest) - except BaseException: - os.unlink(dest.name) - raise - shutil.move(dest.name, path) + except BaseException as err: + error = err + + if error is None: + shutil.move(dest.name, path) + else: + os.unlink(dest.name) + raise error from None def set_key( From b1eebbaaab2cf3e1c48fa5c7ad88cfb00e4b5e54 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Tue, 23 Jan 2024 11:34:15 +0530 Subject: [PATCH 160/253] Add python 3.12 and pypy3.10 to test runner --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 49e1399f..68503d45 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,10 +11,10 @@ jobs: matrix: os: - ubuntu-latest - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12-dev", pypy3.9] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", pypy3.9, pypy3.10] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 From 42dc08664bc7cef185a139137a39126a030f272c Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Tue, 23 Jan 2024 11:49:30 +0530 Subject: [PATCH 161/253] Update changelog for 1.0.1 --- CHANGELOG.md | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 220d1888..f63a1f93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.0.0] +## [1.0.1] - 2024-01-23 + +**Fixed** + +* Gracefully handle code which has been imported from a zipfile ([#456] by [@samwyma]) +* Allow modules using load_dotenv to be reloaded when launched in a separate thread ([#497] by [@freddyaboulton]) +* Fix file not closed after deletion, handle error in the rewrite function ([#469] by [@Qwerty-133]) + +**Misc** +* Use pathlib.Path in tests ([#466] by [@eumiro]) +* Fix year in release date in changelog.md ([#454] by [@jankislinger]) +* Use https in README links ([#474] by [@Nicals]) + +## [1.0.0] - 2023-02-24 **Fixed** @@ -328,6 +341,11 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [#176]: https://github.com/theskumar/python-dotenv/issues/176 [#183]: https://github.com/theskumar/python-dotenv/issues/183 [#359]: https://github.com/theskumar/python-dotenv/issues/359 +[#469]: https://github.com/theskumar/python-dotenv/issues/469 +[#456]: https://github.com/theskumar/python-dotenv/issues/456 +[#466]: https://github.com/theskumar/python-dotenv/issues/466 +[#454]: https://github.com/theskumar/python-dotenv/issues/454 +[#474]: https://github.com/theskumar/python-dotenv/issues/474 [@alanjds]: https://github.com/alanjds [@altendky]: https://github.com/altendky @@ -341,21 +359,27 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@eggplants]: https://github.com/@eggplants [@ekohl]: https://github.com/ekohl [@elbehery95]: https://github.com/elbehery95 +[@eumiro]: https://github.com/eumiro [@Flimm]: https://github.com/Flimm +[@freddyaboulton]: https://github.com/freddyaboulton [@gergelyk]: https://github.com/gergelyk [@gongqingkui]: https://github.com/gongqingkui [@greyli]: https://github.com/greyli [@harveer07]: https://github.com/@harveer07 [@jadutter]: https://github.com/jadutter +[@jankislinger]: https://github.com/jankislinger [@jctanner]: https://github.com/jctanner [@larsks]: https://github.com/@larsks [@lsmith77]: https://github.com/lsmith77 [@mgorny]: https://github.com/mgorny [@naorlivne]: https://github.com/@naorlivne +[@Nicals]: https://github.com/Nicals [@Nougat-Waffle]: https://github.com/Nougat-Waffle [@qnighy]: https://github.com/qnighy +[@Qwerty-133]: https://github.com/Qwerty-133 [@rabinadk1]: https://github.com/@rabinadk1 [@sammck]: https://github.com/@sammck +[@samwyma]: https://github.com/samwyma [@snobu]: https://github.com/snobu [@techalchemy]: https://github.com/techalchemy [@theGOTOguy]: https://github.com/theGOTOguy @@ -367,7 +391,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@zueve]: https://github.com/zueve -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.0.0...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.0.1...HEAD +[1.0.1]: https://github.com/theskumar/python-dotenv/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/theskumar/python-dotenv/compare/v0.21.0...v1.0.0 [0.21.1]: https://github.com/theskumar/python-dotenv/compare/v0.21.0...v0.21.1 [0.21.0]: https://github.com/theskumar/python-dotenv/compare/v0.20.0...v0.21.0 From d6c0b9638349a7dd605d60ee555ff60421c1a594 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Tue, 23 Jan 2024 12:00:33 +0530 Subject: [PATCH 162/253] Bumpversion 1.0.0 -> 1.0.1 --- src/dotenv/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 5becc17c..5c4105cd 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "1.0.0" +__version__ = "1.0.1" From 6d6070cc43cf5a774b757acb5499b16913cddf32 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Thu, 4 Apr 2024 22:55:17 +0200 Subject: [PATCH 163/253] Add a security policy This is a basic security policy, mostly to provide an email address. I took inspiration from the example provided by GitHub and the policy from the Pallets project. --- .github/SECURITY.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .github/SECURITY.md diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 00000000..dbdabeb1 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,19 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| --------- | ------------------ | +| latest | :white_check_mark: | +| 0.x | :x: | + +## Reporting a Vulnerability + +If you believe you have identified a security issue with Python-dotenv, please email +python-dotenv@saurabh-kumar.com. A maintainer will contact you acknowledging the report +and how to continue. + +Be sure to include as much detail as necessary in your report. As with reporting normal +issues, a minimal reproducible example will help the maintainers address the issue faster. +If you are able, you may also include a fix for the issue generated with `git +format-patch`. From bf20c809882c56291cde997722dcb7516e395473 Mon Sep 17 00:00:00 2001 From: Christian Clauss Date: Mon, 11 Mar 2024 15:20:51 +0100 Subject: [PATCH 164/253] Keep GitHub Actions up to date with GitHub's Dependabot Fixes warnings like at the bottom right of https://github.com/theskumar/python-dotenv/actions/runs/7980672386 * https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot * https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem --- .github/dependabot.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..be006de9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +# Keep GitHub Actions up to date with GitHub's Dependabot... +# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + groups: + github-actions: + patterns: + - "*" # Group all Actions updates into a single larger pull request + schedule: + interval: weekly From 8c9381e7ab617a4cde425e3df4684417fade72f5 Mon Sep 17 00:00:00 2001 From: Christian Clauss Date: Mon, 8 Apr 2024 18:17:29 +0200 Subject: [PATCH 165/253] ci: fix multiline string in test.yml & use fail-fast strategy (#514) * Fix multiline string in test.yml * strategy: fail-fast: false * Update test.yml --- .github/workflows/test.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 68503d45..7c73b8b8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,6 +7,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: + fail-fast: false max-parallel: 8 matrix: os: @@ -17,14 +18,15 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + - name: Upgrade pip + run: python -m pip install --upgrade pip + - name: Install dependencies - run: - python -m pip install --upgrade pip - pip install tox tox-gh-actions + run: pip install tox tox-gh-actions - name: Test with tox run: tox From 08937a1911c042ed3fc7cbeeb4d1d5a73d2674ed Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Mon, 29 Apr 2024 10:01:16 +0530 Subject: [PATCH 166/253] docs: clearify default behaviour of load_dotenv closes https://github.com/theskumar/python-dotenv/issues/457 --- README.md | 4 ++-- src/dotenv/main.py | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ddc8ba87..1eca986d 100644 --- a/README.md +++ b/README.md @@ -36,13 +36,13 @@ configurable via the environment: ```python from dotenv import load_dotenv -load_dotenv() # take environment variables from .env. +load_dotenv() # take environment variables # Code of your application, which uses environment variables (e.g. from `os.environ` or # `os.getenv`) as if they came from the actual environment. ``` -By default, `load_dotenv` doesn't override existing environment variables. +By default, `load_dotenv` doesn't override existing environment variables and looks for a `.env` file in same directory as python script or searches for it incrementally higher up. To configure the development environment, add a `.env` in the root directory of your project: diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 7bc54285..052de054 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -340,7 +340,9 @@ def load_dotenv( Bool: True if at least one environment variable is set else False If both `dotenv_path` and `stream` are `None`, `find_dotenv()` is used to find the - .env file. + .env file with it's default parameters. If you need to change the default parameters + of `find_dotenv()`, you can explicitly call `find_dotenv()` and pass the result + to this function as `dotenv_path`. """ if dotenv_path is None and stream is None: dotenv_path = find_dotenv() From 4543837fc674f82f131c6a1e0e7e897461feaffd Mon Sep 17 00:00:00 2001 From: eekstunt <51318131+eekstunt@users.noreply.github.com> Date: Thu, 18 Jul 2024 04:56:47 +0100 Subject: [PATCH 167/253] Enhance dotenv run: Switch to execvpe for better resource management and signal handling (#523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The current implementation of `dotenv run` CLI uses `subprocess.Popen`, which spawns a child process to execute the specified command. ``` p = Popen(command, universal_newlines=True, bufsize=0, shell=False, env=cmd_env) ``` After spawning the child process, it exits with the same exit code returned by the child process. ``` ret = run_command(commandline, dotenv_as_dict) exit(ret) ``` ### We can enhance `dotenv run` usage dramatically while preserving exactly the same behaviour By switching to `os.execvpe` instead of `subprocess.Popen`, we can replace the parent dotenv process with the new process specified by the user. This results in only one active process—the program the user intended to run. **Benefits:** 1. No hanging parent process `dotenv run` acts as a launcher, so after executing `dotenv run redis-server`, only the Redis server process remains. The dotenv process, along with its Python interpreter, is completely replaced. This prevents the dotenv process from consuming RAM and other resources, which would otherwise persist until the Redis server exits. 2. Proper signal handling When using `subprocess.Popen`, the parent process (e.g., `dotenv`) remains responsible for handling and forwarding signals, which can lead to issues if the command doesn’t receive them directly. For instance, in Docker, if Redis was started without `exec`, it may not get important signals like `SIGTERM` when the container stops, potentially resulting in improper shutdowns or zombie processes. Using `os.execvpe` ensures that the command receives signals directly, improving reliability and making `dotenv` more suitable for production environments and improving reliability for DevOps engineers managing containerized applications. All current logic will be preserved because dotenv run does not do anything special except propagate the child process exit code. Thanks / @eekstunt --- src/dotenv/cli.py | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 65ead461..b5a97f8b 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -3,7 +3,6 @@ import shlex import sys from contextlib import contextmanager -from subprocess import Popen from typing import Any, Dict, IO, Iterator, List try: @@ -161,14 +160,13 @@ def run(ctx: click.Context, override: bool, commandline: List[str]) -> None: if not commandline: click.echo('No command given.') exit(1) - ret = run_command(commandline, dotenv_as_dict) - exit(ret) + run_command(commandline, dotenv_as_dict) -def run_command(command: List[str], env: Dict[str, str]) -> int: - """Run command in sub process. +def run_command(command: List[str], env: Dict[str, str]) -> None: + """Replace the current process with the specified command. - Runs the command in a sub process with the variables from `env` + Replaces the current process with the specified command and the variables from `env` added in the current environment variables. Parameters @@ -180,8 +178,8 @@ def run_command(command: List[str], env: Dict[str, str]) -> int: Returns ------- - int - The return code of the command + None + This function does not return any value. It replaces the current process with the new one. """ # copy the current environment variables and add the vales from @@ -189,11 +187,4 @@ def run_command(command: List[str], env: Dict[str, str]) -> int: 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 + os.execvpe(command[0], args=command, env=cmd_env) From 4d505f2c9bc3569791e64bca0f2e4300f43df0e0 Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Wed, 24 Jul 2024 03:20:57 +0800 Subject: [PATCH 168/253] ci: add py3.13 to test.yml (#527) * ci: add py3.13 to test.yml * Improve type hints * fix typo --- .github/workflows/test.yml | 3 ++- src/dotenv/cli.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7c73b8b8..74a24ddd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ jobs: matrix: os: - ubuntu-latest - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", pypy3.9, pypy3.10] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", pypy3.9, pypy3.10] steps: - uses: actions/checkout@v4 @@ -21,6 +21,7 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Upgrade pip run: python -m pip install --upgrade pip diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index b5a97f8b..33ae1485 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -3,7 +3,7 @@ import shlex import sys from contextlib import contextmanager -from typing import Any, Dict, IO, Iterator, List +from typing import Any, Dict, IO, Iterator, List, Optional try: import click @@ -16,7 +16,7 @@ from .version import __version__ -def enumerate_env(): +def enumerate_env() -> Optional[str]: """ Return a path for the ${pwd}/.env file. From 533f8ac83c7873391053c1854e539afb7e124a2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= <16805946+edgarrmondragon@users.noreply.github.com> Date: Thu, 31 Oct 2024 23:12:30 -0600 Subject: [PATCH 169/253] Add Python 3.13 trove classifier (#535) --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 8ceddf92..b03b8568 100644 --- a/setup.py +++ b/setup.py @@ -50,6 +50,7 @@ def read_files(files): 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', 'Programming Language :: Python :: Implementation :: PyPy', 'Intended Audience :: Developers', 'Intended Audience :: System Administrators', From 2b8635b79f1aa15cade0950117d4e7d12c298766 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Nov 2024 10:42:56 +0530 Subject: [PATCH 170/253] Bump the github-actions group with 2 updates (#529) Bumps the github-actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [actions/setup-python](https://github.com/actions/setup-python). Updates `actions/checkout` from 2 to 4 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v2...v4) Updates `actions/setup-python` from 2 to 5 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v2...v5) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7666da09..67668d53 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,9 +8,9 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install dependencies From 41593889b63bba7f6af22279968e88727ebf5d62 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 9 Mar 2025 23:27:31 +0530 Subject: [PATCH 171/253] Add support for python 3.13 and drop 3.8 (#551) fixes #550 --- .github/workflows/test.yml | 35 ++++++++++--------- CHANGELOG.md | 13 +++++-- setup.cfg | 6 ++-- setup.py | 70 +++++++++++++++++++++----------------- tox.ini | 40 +++++++++++----------- 5 files changed, 91 insertions(+), 73 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 74a24ddd..fc86910d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,22 +12,23 @@ jobs: matrix: os: - ubuntu-latest - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", pypy3.9, pypy3.10] + python-version: + ["3.9", "3.10", "3.11", "3.12", "3.13", pypy3.9, pypy3.10] steps: - - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - allow-prereleases: true - - - name: Upgrade pip - run: python -m pip install --upgrade pip - - - name: Install dependencies - run: pip install tox tox-gh-actions - - - name: Test with tox - run: tox + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + + - name: Upgrade pip + run: python -m pip install --upgrade pip + + - name: Install dependencies + run: pip install tox tox-gh-actions + + - name: Test with tox + run: tox diff --git a/CHANGELOG.md b/CHANGELOG.md index f63a1f93..a198b1f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +- Drop support for Python 3.8 +- Add support for python 3.13 +- Enhance `dotenv run`, switch to `execvpe` for better resource management and signal handling ([#523]) by [@eekstunt] + ## [1.0.1] - 2024-01-23 **Fixed** * Gracefully handle code which has been imported from a zipfile ([#456] by [@samwyma]) -* Allow modules using load_dotenv to be reloaded when launched in a separate thread ([#497] by [@freddyaboulton]) +* Allow modules using `load_dotenv` to be reloaded when launched in a separate thread ([#497] by [@freddyaboulton]) * Fix file not closed after deletion, handle error in the rewrite function ([#469] by [@Qwerty-133]) **Misc** @@ -317,7 +324,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## 0.5.1 -- Fix find\_dotenv - it now start search from the file where this +- Fix `find_dotenv` - it now start search from the file where this function is called from. ## 0.5.0 @@ -346,6 +353,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [#466]: https://github.com/theskumar/python-dotenv/issues/466 [#454]: https://github.com/theskumar/python-dotenv/issues/454 [#474]: https://github.com/theskumar/python-dotenv/issues/474 +[#523]: https://github.com/theskumar/python-dotenv/issues/523 [@alanjds]: https://github.com/alanjds [@altendky]: https://github.com/altendky @@ -356,6 +364,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@cjauvin]: https://github.com/cjauvin [@eaf]: https://github.com/eaf [@earlbread]: https://github.com/earlbread +[@eekstunt]: https://github.com/eekstunt [@eggplants]: https://github.com/@eggplants [@ekohl]: https://github.com/ekohl [@elbehery95]: https://github.com/elbehery95 diff --git a/setup.cfg b/setup.cfg index 3fefd1f0..4a8f11ac 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.0.0 +current_version = 1.0.1 commit = True tag = True @@ -24,7 +24,7 @@ relative_files = True source = dotenv [coverage:paths] -source = +source = src/dotenv .tox/*/lib/python*/site-packages/dotenv .tox/pypy*/site-packages/dotenv @@ -32,6 +32,6 @@ source = [coverage:report] show_missing = True include = */site-packages/dotenv/* -exclude_lines = +exclude_lines = if IS_TYPE_CHECKING: pragma: no cover diff --git a/setup.py b/setup.py index b03b8568..f3d43ca1 100644 --- a/setup.py +++ b/setup.py @@ -4,60 +4,68 @@ def read_files(files): data = [] for file in files: - with open(file, encoding='utf-8') as f: + with open(file, encoding="utf-8") as f: data.append(f.read()) return "\n".join(data) -long_description = read_files(['README.md', 'CHANGELOG.md']) +long_description = read_files(["README.md", "CHANGELOG.md"]) meta = {} -with open('./src/dotenv/version.py', encoding='utf-8') as f: +with open("./src/dotenv/version.py", encoding="utf-8") as f: exec(f.read(), meta) setup( name="python-dotenv", description="Read key-value pairs from a .env file and set them as environment variables", long_description=long_description, - long_description_content_type='text/markdown', - version=meta['__version__'], + long_description_content_type="text/markdown", + version=meta["__version__"], author="Saurabh Kumar", author_email="me+github@saurabh-kumar.com", url="https://github.com/theskumar/python-dotenv", - keywords=['environment variables', 'deployments', 'settings', 'env', 'dotenv', - 'configurations', 'python'], - packages=['dotenv'], - package_dir={'': 'src'}, + keywords=[ + "environment variables", + "deployments", + "settings", + "env", + "dotenv", + "configurations", + "python", + ], + packages=["dotenv"], + package_dir={"": "src"}, package_data={ - 'dotenv': ['py.typed'], + "dotenv": ["py.typed"], }, - python_requires=">=3.8", + python_requires=">=3.9", extras_require={ - 'cli': ['click>=5.0', ], + "cli": [ + "click>=5.0", + ], }, entry_points={ "console_scripts": [ "dotenv=dotenv.__main__:cli", ], }, - license='BSD-3-Clause', + license="BSD-3-Clause", classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - 'Programming Language :: Python :: 3.13', - 'Programming Language :: Python :: Implementation :: PyPy', - 'Intended Audience :: Developers', - 'Intended Audience :: System Administrators', - 'License :: OSI Approved :: BSD License', - 'Operating System :: OS Independent', - 'Topic :: System :: Systems Administration', - 'Topic :: Utilities', - 'Environment :: Web Environment', - ] + "Development Status :: 5 - Production/Stable", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: PyPy", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Topic :: System :: Systems Administration", + "Topic :: Utilities", + "Environment :: Web Environment", + ], ) diff --git a/tox.ini b/tox.ini index fad86f73..057a1ae9 100644 --- a/tox.ini +++ b/tox.ini @@ -1,39 +1,39 @@ [tox] -envlist = lint,py{38,39,310,311,312-dev},pypy3,manifest,coverage-report +envlist = lint,py{39,310,311,312,313},pypy3,manifest,coverage-report [gh-actions] python = - 3.8: py38 3.9: py39 3.10: py310 - 3.11: py311, lint, manifest - 3.12-dev: py312-dev + 3.11: py311 + 3.12: py312 + 3.13: py313, lint, manifest pypy-3.9: pypy3 [testenv] deps = - pytest - pytest-cov - sh >= 2.0.2, <3 - click - py{38,39,310,311,py312-dev,pypy3}: ipython + pytest + pytest-cov + sh >= 2.0.2, <3 + click + py{39,310,311,312,313,pypy3}: ipython commands = pytest --cov --cov-report=term-missing --cov-config setup.cfg {posargs} depends = - py{38,39,310,311,312-dev},pypy3: coverage-clean - coverage-report: py{38,39,310,311,312-dev},pypy3 + py{39,310,311,312,313},pypy3: coverage-clean + coverage-report: py{39,310,311,312,313},pypy3 [testenv:lint] skip_install = true deps = - flake8 - mypy + flake8 + mypy commands = - flake8 src tests - mypy --python-version=3.12 src tests - mypy --python-version=3.11 src tests - mypy --python-version=3.10 src tests - mypy --python-version=3.9 src tests - mypy --python-version=3.8 src tests + flake8 src tests + mypy --python-version=3.13 src tests + mypy --python-version=3.12 src tests + mypy --python-version=3.11 src tests + mypy --python-version=3.10 src tests + mypy --python-version=3.9 src tests [testenv:manifest] deps = check-manifest @@ -49,4 +49,4 @@ commands = coverage erase deps = coverage skip_install = true commands = - coverage report + coverage report From 3c19c03dd41bd930d115aeb570f64e794d436c5f Mon Sep 17 00:00:00 2001 From: Rod Elias Date: Sun, 9 Mar 2025 14:59:52 -0300 Subject: [PATCH 172/253] s/Python-dotenv/python-dotenv/ (#516) This commit uses the name `python-dotenv` instead of `Python-dotenv` in the README.md file --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1eca986d..e92949ef 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Build Status][build_status_badge]][build_status_link] [![PyPI version][pypi_badge]][pypi_link] -Python-dotenv reads key-value pairs from a `.env` file and can set them as environment +python-dotenv reads key-value pairs from a `.env` file and can set them as environment variables. It helps in the development of applications following the [12-factor](https://12factor.net/) principles. @@ -29,7 +29,7 @@ If your application takes its configuration from environment variables, like a 1 application, launching it in development is not very practical because you have to set those environment variables yourself. -To help you with that, you can add Python-dotenv to your application to make it load the +To help you with that, you can add python-dotenv to your application to make it load the configuration from a `.env` file when it is present (e.g. in development) while remaining configurable via the environment: @@ -201,7 +201,7 @@ empty string. ### Variable expansion -Python-dotenv can interpolate variables using POSIX variable expansion. +python-dotenv can interpolate variables using POSIX variable expansion. With `load_dotenv(override=True)` or `dotenv_values()`, the value of a variable is the first of the values defined in the following list: From 9acba4af31757e99e2d6e6700de621ee8f9b98ae Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 9 Mar 2025 23:44:09 +0530 Subject: [PATCH 173/253] Some more s/Python-dotenv/python-dotenv/ (#552) --- .github/SECURITY.md | 5 ++--- src/dotenv/main.py | 49 +++++++++++++++++++++++---------------------- tests/test_main.py | 43 ++++++++++++++++++--------------------- 3 files changed, 47 insertions(+), 50 deletions(-) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index dbdabeb1..00d4d5e4 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -9,11 +9,10 @@ ## Reporting a Vulnerability -If you believe you have identified a security issue with Python-dotenv, please email +If you believe you have identified a security issue with python-dotenv, please email python-dotenv@saurabh-kumar.com. A maintainer will contact you acknowledging the report and how to continue. Be sure to include as much detail as necessary in your report. As with reporting normal issues, a minimal reproducible example will help the maintainers address the issue faster. -If you are able, you may also include a fix for the issue generated with `git -format-patch`. +If you are able, you may also include a fix for the issue generated with `git format-patch`. diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 052de054..0c81bba5 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -7,8 +7,7 @@ import tempfile from collections import OrderedDict from contextlib import contextmanager -from typing import (IO, Dict, Iterable, Iterator, Mapping, Optional, Tuple, - Union) +from typing import IO, Dict, Iterable, Iterator, Mapping, Optional, Tuple, Union from .parser import Binding, parse_stream from .variables import parse_variables @@ -17,7 +16,7 @@ # These paths may flow to `open()` and `shutil.move()`; `shutil.move()` # only accepts string paths, not byte paths or file descriptors. See # https://github.com/python/typeshed/pull/6832. -StrPath = Union[str, 'os.PathLike[str]'] +StrPath = Union[str, "os.PathLike[str]"] logger = logging.getLogger(__name__) @@ -26,7 +25,7 @@ def with_warn_for_invalid_lines(mappings: Iterator[Binding]) -> Iterator[Binding for mapping in mappings: if mapping.error: logger.warning( - "Python-dotenv could not parse statement starting at line %s", + "python-dotenv could not parse statement starting at line %s", mapping.original.line, ) yield mapping @@ -60,10 +59,10 @@ def _get_stream(self) -> Iterator[IO[str]]: else: if self.verbose: logger.info( - "Python-dotenv could not find configuration file %s.", - self.dotenv_path or '.env', + "python-dotenv could not find configuration file %s.", + self.dotenv_path or ".env", ) - yield io.StringIO('') + yield io.StringIO("") def dict(self) -> Dict[str, Optional[str]]: """Return dotenv as dict""" @@ -73,7 +72,9 @@ def dict(self) -> Dict[str, Optional[str]]: raw_values = self.parse() if self.interpolate: - self._dict = OrderedDict(resolve_variables(raw_values, override=self.override)) + self._dict = OrderedDict( + resolve_variables(raw_values, override=self.override) + ) else: self._dict = OrderedDict(raw_values) @@ -101,8 +102,7 @@ def set_as_environment_variables(self) -> bool: return True def get(self, key: str) -> Optional[str]: - """ - """ + """ """ data = self.dict() if key in data: @@ -166,9 +166,8 @@ def set_key( if quote_mode not in ("always", "auto", "never"): raise ValueError(f"Unknown quote_mode: {quote_mode}") - quote = ( - quote_mode == "always" - or (quote_mode == "auto" and not value_to_set.isalnum()) + quote = quote_mode == "always" or ( + quote_mode == "auto" and not value_to_set.isalnum() ) if quote: @@ -176,7 +175,7 @@ def set_key( else: value_out = value_to_set if export: - line_out = f'export {key_to_set}={value_out}\n' + line_out = f"export {key_to_set}={value_out}\n" else: line_out = f"{key_to_set}={value_out}\n" @@ -223,7 +222,9 @@ def unset_key( dest.write(mapping.original.string) if not removed: - logger.warning("Key %s not removed from %s - key doesn't exist.", key_to_unset, dotenv_path) + logger.warning( + "Key %s not removed from %s - key doesn't exist.", key_to_unset, dotenv_path + ) return None, key_to_unset return removed, key_to_unset @@ -235,7 +236,7 @@ def resolve_variables( ) -> Mapping[str, Optional[str]]: new_values: Dict[str, Optional[str]] = {} - for (name, value) in values: + for name, value in values: if value is None: result = None else: @@ -259,7 +260,7 @@ def _walk_to_root(path: str) -> Iterator[str]: Yield directories starting from the given directory up to the root """ if not os.path.exists(path): - raise IOError('Starting path not found') + raise IOError("Starting path not found") if os.path.isfile(path): path = os.path.dirname(path) @@ -273,7 +274,7 @@ def _walk_to_root(path: str) -> Iterator[str]: def find_dotenv( - filename: str = '.env', + filename: str = ".env", raise_error_if_not_found: bool = False, usecwd: bool = False, ) -> str: @@ -284,14 +285,14 @@ def find_dotenv( """ def _is_interactive(): - """ Decide whether this is running in a REPL or IPython notebook """ + """Decide whether this is running in a REPL or IPython notebook""" try: - main = __import__('__main__', None, None, fromlist=['__file__']) + main = __import__("__main__", None, None, fromlist=["__file__"]) except ModuleNotFoundError: return False - return not hasattr(main, '__file__') + return not hasattr(main, "__file__") - if usecwd or _is_interactive() or getattr(sys, 'frozen', False): + if usecwd or _is_interactive() or getattr(sys, "frozen", False): # Should work without __file__, e.g. in REPL or IPython notebook. path = os.getcwd() else: @@ -313,9 +314,9 @@ def _is_interactive(): return check_path if raise_error_if_not_found: - raise IOError('File not found') + raise IOError("File not found") - return '' + return "" def load_dotenv( diff --git a/tests/test_main.py b/tests/test_main.py index fd5e3903..2d63eec1 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -28,9 +28,9 @@ def test_set_key_no_file(tmp_path): ("", "a", "", (True, "a", ""), "a=''\n"), ("", "a", "b", (True, "a", "b"), "a='b'\n"), ("", "a", "'b'", (True, "a", "'b'"), "a='\\'b\\''\n"), - ("", "a", "\"b\"", (True, "a", '"b"'), "a='\"b\"'\n"), + ("", "a", '"b"', (True, "a", '"b"'), "a='\"b\"'\n"), ("", "a", "b'c", (True, "a", "b'c"), "a='b\\'c'\n"), - ("", "a", "b\"c", (True, "a", "b\"c"), "a='b\"c'\n"), + ("", "a", 'b"c', (True, "a", 'b"c'), "a='b\"c'\n"), ("a=b", "a", "c", (True, "a", "c"), "a='c'\n"), ("a=b\n", "a", "c", (True, "a", "c"), "a='c'\n"), ("a=b\n\n", "a", "c", (True, "a", "c"), "a='c'\n\n"), @@ -75,20 +75,20 @@ def test_get_key_no_file(tmp_path): nx_path = tmp_path / "nx" logger = logging.getLogger("dotenv.main") - with mock.patch.object(logger, "info") as mock_info, \ - mock.patch.object(logger, "warning") as mock_warning: + with ( + mock.patch.object(logger, "info") as mock_info, + mock.patch.object(logger, "warning") as mock_warning, + ): result = dotenv.get_key(nx_path, "foo") assert result is None mock_info.assert_has_calls( calls=[ - mock.call("Python-dotenv could not find configuration file %s.", nx_path) + mock.call("python-dotenv could not find configuration file %s.", nx_path) ], ) mock_warning.assert_has_calls( - calls=[ - mock.call("Key %s not found in %s.", "foo", nx_path) - ], + calls=[mock.call("Key %s not found in %s.", "foo", nx_path)], ) @@ -249,10 +249,12 @@ def test_load_dotenv_no_file_verbose(): logger = logging.getLogger("dotenv.main") with mock.patch.object(logger, "info") as mock_info: - result = dotenv.load_dotenv('.does_not_exist', verbose=True) + result = dotenv.load_dotenv(".does_not_exist", verbose=True) assert result is False - mock_info.assert_called_once_with("Python-dotenv could not find configuration file %s.", ".does_not_exist") + mock_info.assert_called_once_with( + "python-dotenv could not find configuration file %s.", ".does_not_exist" + ) @mock.patch.dict(os.environ, {"a": "c"}, clear=True) @@ -317,21 +319,23 @@ def test_load_dotenv_file_stream(dotenv_path): def test_load_dotenv_in_current_dir(tmp_path): - dotenv_path = tmp_path / '.env' - dotenv_path.write_bytes(b'a=b') - code_path = tmp_path / 'code.py' - code_path.write_text(textwrap.dedent(""" + dotenv_path = tmp_path / ".env" + dotenv_path.write_bytes(b"a=b") + code_path = tmp_path / "code.py" + code_path.write_text( + textwrap.dedent(""" import dotenv import os dotenv.load_dotenv(verbose=True) print(os.environ['a']) - """)) + """) + ) os.chdir(tmp_path) result = sh.Command(sys.executable)(code_path) - assert result == 'b\n' + assert result == "b\n" def test_dotenv_values_file(dotenv_path): @@ -352,30 +356,23 @@ def test_dotenv_values_file(dotenv_path): ({"b": "c"}, "a=${b}", True, {"a": "c"}), ({"b": "c"}, "a=${b:-d}", False, {"a": "${b:-d}"}), ({"b": "c"}, "a=${b:-d}", True, {"a": "c"}), - # Defined in file ({}, "b=c\na=${b}", True, {"a": "c", "b": "c"}), - # Undefined ({}, "a=${b}", True, {"a": ""}), ({}, "a=${b:-d}", True, {"a": "d"}), - # With quotes ({"b": "c"}, 'a="${b}"', True, {"a": "c"}), ({"b": "c"}, "a='${b}'", True, {"a": "c"}), - # With surrounding text ({"b": "c"}, "a=x${b}y", True, {"a": "xcy"}), - # Self-referential ({"a": "b"}, "a=${a}", True, {"a": "b"}), ({}, "a=${a}", True, {"a": ""}), ({"a": "b"}, "a=${a:-c}", True, {"a": "b"}), ({}, "a=${a:-c}", True, {"a": "c"}), - # Reused ({"b": "c"}, "a=${b}${b}", True, {"a": "cc"}), - # Re-defined and used in file ({"b": "c"}, "b=d\na=${b}", True, {"a": "d", "b": "d"}), ({}, "a=b\na=c\nd=${a}", True, {"a": "c", "d": "c"}), From 8dd413e84b1fb1b3368c02106aab07a533fae015 Mon Sep 17 00:00:00 2001 From: randomseed42 <50793718+randomseed42@users.noreply.github.com> Date: Tue, 25 Mar 2025 16:33:52 +0800 Subject: [PATCH 174/253] Add _is_debugger so load_dotenv will work in pdb (#553) --- src/dotenv/main.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 0c81bba5..1848d602 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -292,7 +292,10 @@ def _is_interactive(): return False return not hasattr(main, "__file__") - if usecwd or _is_interactive() or getattr(sys, "frozen", False): + def _is_debugger(): + return sys.gettrace() is not None + + if usecwd or _is_interactive() or _is_debugger() or getattr(sys, "frozen", False): # Should work without __file__, e.g. in REPL or IPython notebook. path = os.getcwd() else: From c89fb6d41c0a25f670b34ba05f392260eaa6ccd1 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Tue, 25 Mar 2025 15:38:56 +0530 Subject: [PATCH 175/253] Update changelog --- CHANGELOG.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a198b1f7..3544da86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unrelased] -- Drop support for Python 3.8 +**Feature** - Add support for python 3.13 - Enhance `dotenv run`, switch to `execvpe` for better resource management and signal handling ([#523]) by [@eekstunt] +**Fixed** +- `find_dotenv` and `load_dotenv` now correctly looks up at the current directory when running in debugger or pdb ([#553] by [@randomseed42]) + +**Misc** +- Drop support for Python 3.8 + ## [1.0.1] - 2024-01-23 **Fixed** @@ -354,6 +360,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [#454]: https://github.com/theskumar/python-dotenv/issues/454 [#474]: https://github.com/theskumar/python-dotenv/issues/474 [#523]: https://github.com/theskumar/python-dotenv/issues/523 +[#553]: https://github.com/theskumar/python-dotenv/issues/553 [@alanjds]: https://github.com/alanjds [@altendky]: https://github.com/altendky @@ -398,7 +405,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@x-yuri]: https://github.com/x-yuri [@yannham]: https://github.com/yannham [@zueve]: https://github.com/zueve - +[@randomseed42]: https://github.com/zueve [Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.0.1...HEAD [1.0.1]: https://github.com/theskumar/python-dotenv/compare/v1.0.0...v1.0.1 From 2198b698c021851201261fac27884ee8db6553d5 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Tue, 25 Mar 2025 15:41:24 +0530 Subject: [PATCH 176/253] =?UTF-8?q?Bump=20version:=201.0.1=20=E2=86=92=201?= =?UTF-8?q?.1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 6 +++--- src/dotenv/version.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/setup.cfg b/setup.cfg index 4a8f11ac..02dc0695 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.0.1 +current_version = 1.1.0 commit = True tag = True @@ -24,7 +24,7 @@ relative_files = True source = dotenv [coverage:paths] -source = +source = src/dotenv .tox/*/lib/python*/site-packages/dotenv .tox/pypy*/site-packages/dotenv @@ -32,6 +32,6 @@ source = [coverage:report] show_missing = True include = */site-packages/dotenv/* -exclude_lines = +exclude_lines = if IS_TYPE_CHECKING: pragma: no cover diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 5c4105cd..6849410a 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "1.0.1" +__version__ = "1.1.0" From 36c6270db41e1e88be4ec21d0fb876ba0c79d363 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Tue, 25 Mar 2025 15:42:28 +0530 Subject: [PATCH 177/253] Update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3544da86..ec525352 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unrelased] +## [1.1.0] - 2025-03-25 **Feature** - Add support for python 3.13 From 6a02ef5a1034d66338811757df07a113a1169af6 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Tue, 25 Mar 2025 16:23:46 +0530 Subject: [PATCH 178/253] update mkdocs -> mkdocstrings config --- mkdocs.yml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index 331965df..ba77fa7f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,13 +13,7 @@ markdown_extensions: - mdx_truly_sane_lists plugins: - - mkdocstrings: - handlers: - python: - rendering: - show_root_heading: yes - show_submodules: no - separate_signature: yes + - mkdocstrings - search nav: - Home: index.md From 01f899733de664cda0550207067eb36a1795062f Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Mon, 31 Mar 2025 13:29:19 +0530 Subject: [PATCH 179/253] docs update --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec525352..f1afd06d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,16 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [1.1.0] - 2025-03-25 **Feature** + - Add support for python 3.13 - Enhance `dotenv run`, switch to `execvpe` for better resource management and signal handling ([#523]) by [@eekstunt] **Fixed** + - `find_dotenv` and `load_dotenv` now correctly looks up at the current directory when running in debugger or pdb ([#553] by [@randomseed42]) **Misc** + - Drop support for Python 3.8 ## [1.0.1] - 2024-01-23 @@ -407,7 +410,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@zueve]: https://github.com/zueve [@randomseed42]: https://github.com/zueve -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.0.1...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.1.0...HEAD +[1.1.0]: https://github.com/theskumar/python-dotenv/compare/v1.0.1...v1.1.0 [1.0.1]: https://github.com/theskumar/python-dotenv/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/theskumar/python-dotenv/compare/v0.21.0...v1.0.0 [0.21.1]: https://github.com/theskumar/python-dotenv/compare/v0.21.0...v0.21.1 From 8411987b9301f716245074872afa30646e9b9eb7 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Fri, 30 May 2025 22:23:02 +0530 Subject: [PATCH 180/253] fix: ensure find_dotenv work reliably on python 3.13 (#563) --- src/dotenv/main.py | 2 + tests/test_is_interactive.py | 227 +++++++++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 tests/test_is_interactive.py diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 1848d602..8e6a7cf4 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -286,6 +286,8 @@ def find_dotenv( def _is_interactive(): """Decide whether this is running in a REPL or IPython notebook""" + if hasattr(sys, "ps1") or hasattr(sys, "ps2"): + return True try: main = __import__("__main__", None, None, fromlist=["__file__"]) except ModuleNotFoundError: diff --git a/tests/test_is_interactive.py b/tests/test_is_interactive.py new file mode 100644 index 00000000..f56378e9 --- /dev/null +++ b/tests/test_is_interactive.py @@ -0,0 +1,227 @@ +import sys +import builtins +from unittest import mock +from dotenv.main import find_dotenv + + +class TestIsInteractive: + """Tests for the _is_interactive helper function within find_dotenv. + + The _is_interactive function is used by find_dotenv to determine if the code + is running in an interactive environment (like a REPL, IPython notebook, etc.) + versus a normal script execution. + + Interactive environments include: + - Python REPL (has sys.ps1 or sys.ps2) + - IPython notebooks (no __file__ in __main__) + - Interactive shells + + Non-interactive environments include: + - Normal script execution (has __file__ in __main__) + - Module imports + + Examples of the behavior: + >>> import sys + >>> # In a REPL: + >>> hasattr(sys, 'ps1') # True + >>> # In a script: + >>> hasattr(sys, 'ps1') # False + """ + + def _create_dotenv_file(self, tmp_path): + """Helper to create a test .env file.""" + dotenv_path = tmp_path / ".env" + dotenv_path.write_text("TEST=value") + return dotenv_path + + def _setup_subdir_and_chdir(self, tmp_path, monkeypatch): + """Helper to create subdirectory and change to it.""" + test_dir = tmp_path / "subdir" + test_dir.mkdir() + monkeypatch.chdir(test_dir) + return test_dir + + def _remove_ps_attributes(self, monkeypatch): + """Helper to remove ps1/ps2 attributes if they exist.""" + if hasattr(sys, "ps1"): + monkeypatch.delattr(sys, "ps1") + if hasattr(sys, "ps2"): + monkeypatch.delattr(sys, "ps2") + + def _mock_main_import(self, monkeypatch, mock_main_module): + """Helper to mock __main__ module import.""" + original_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name == "__main__": + return mock_main_module + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", mock_import) + + def _mock_main_import_error(self, monkeypatch): + """Helper to mock __main__ module import that raises ModuleNotFoundError.""" + original_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name == "__main__": + raise ModuleNotFoundError("No module named '__main__'") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", mock_import) + + def test_is_interactive_with_ps1(self, tmp_path, monkeypatch): + """Test that _is_interactive returns True when sys.ps1 exists.""" + dotenv_path = self._create_dotenv_file(tmp_path) + + # Mock sys.ps1 to simulate interactive shell + monkeypatch.setattr(sys, "ps1", ">>> ", raising=False) + + self._setup_subdir_and_chdir(tmp_path, monkeypatch) + + # When _is_interactive() returns True, find_dotenv should search from cwd + result = find_dotenv() + assert result == str(dotenv_path) + + def test_is_interactive_with_ps2(self, tmp_path, monkeypatch): + """Test that _is_interactive returns True when sys.ps2 exists.""" + dotenv_path = self._create_dotenv_file(tmp_path) + + # Mock sys.ps2 to simulate multi-line interactive input + monkeypatch.setattr(sys, "ps2", "... ", raising=False) + + self._setup_subdir_and_chdir(tmp_path, monkeypatch) + + # When _is_interactive() returns True, find_dotenv should search from cwd + result = find_dotenv() + assert result == str(dotenv_path) + + def test_is_interactive_main_module_not_found(self, tmp_path, monkeypatch): + """Test that _is_interactive returns False when __main__ module import fails.""" + self._remove_ps_attributes(monkeypatch) + self._mock_main_import_error(monkeypatch) + + # Change to directory and test + monkeypatch.chdir(tmp_path) + + # Since _is_interactive() returns False, find_dotenv should not find anything + # without usecwd=True + result = find_dotenv() + assert result == "" + + def test_is_interactive_main_without_file(self, tmp_path, monkeypatch): + """Test that _is_interactive returns True when __main__ has no __file__ attribute.""" + self._remove_ps_attributes(monkeypatch) + dotenv_path = self._create_dotenv_file(tmp_path) + + # Mock __main__ module without __file__ attribute + mock_main = mock.MagicMock() + del mock_main.__file__ # Remove __file__ attribute + + self._mock_main_import(monkeypatch, mock_main) + self._setup_subdir_and_chdir(tmp_path, monkeypatch) + + # When _is_interactive() returns True, find_dotenv should search from cwd + result = find_dotenv() + assert result == str(dotenv_path) + + def test_is_interactive_main_with_file(self, tmp_path, monkeypatch): + """Test that _is_interactive returns False when __main__ has __file__ attribute.""" + self._remove_ps_attributes(monkeypatch) + + # Mock __main__ module with __file__ attribute + mock_main = mock.MagicMock() + mock_main.__file__ = "/path/to/script.py" + + self._mock_main_import(monkeypatch, mock_main) + + # Change to directory and test + monkeypatch.chdir(tmp_path) + + # Since _is_interactive() returns False, find_dotenv should not find anything + # without usecwd=True + result = find_dotenv() + assert result == "" + + def test_is_interactive_precedence_ps1_over_main(self, tmp_path, monkeypatch): + """Test that ps1/ps2 attributes take precedence over __main__ module check.""" + dotenv_path = self._create_dotenv_file(tmp_path) + + # Set ps1 attribute + monkeypatch.setattr(sys, "ps1", ">>> ", raising=False) + + # Mock __main__ module with __file__ attribute (which would normally return False) + mock_main = mock.MagicMock() + mock_main.__file__ = "/path/to/script.py" + + self._mock_main_import(monkeypatch, mock_main) + self._setup_subdir_and_chdir(tmp_path, monkeypatch) + + # ps1 should take precedence, so _is_interactive() returns True + result = find_dotenv() + assert result == str(dotenv_path) + + def test_is_interactive_ps1_and_ps2_both_exist(self, tmp_path, monkeypatch): + """Test that _is_interactive returns True when both ps1 and ps2 exist.""" + dotenv_path = self._create_dotenv_file(tmp_path) + + # Set both ps1 and ps2 attributes + monkeypatch.setattr(sys, "ps1", ">>> ", raising=False) + monkeypatch.setattr(sys, "ps2", "... ", raising=False) + + self._setup_subdir_and_chdir(tmp_path, monkeypatch) + + # Should return True with either attribute present + result = find_dotenv() + assert result == str(dotenv_path) + + def test_is_interactive_main_module_with_file_attribute_none(self, tmp_path, monkeypatch): + """Test _is_interactive when __main__ has __file__ attribute set to None.""" + self._remove_ps_attributes(monkeypatch) + + # Mock __main__ module with __file__ = None + mock_main = mock.MagicMock() + mock_main.__file__ = None + + self._mock_main_import(monkeypatch, mock_main) + + # Mock sys.gettrace to ensure debugger detection returns False + monkeypatch.setattr("sys.gettrace", lambda: None) + + monkeypatch.chdir(tmp_path) + + # __file__ = None should still be considered non-interactive + # and with no debugger, find_dotenv should not search from cwd + result = find_dotenv() + assert result == "" + + def test_is_interactive_no_ps_attributes_and_normal_execution(self, tmp_path, monkeypatch): + """Test normal script execution scenario where _is_interactive should return False.""" + self._remove_ps_attributes(monkeypatch) + + # Don't mock anything - let it use the real __main__ module + # which should have a __file__ attribute in normal execution + + # Change to directory and test + monkeypatch.chdir(tmp_path) + + # In normal execution, _is_interactive() should return False + # so find_dotenv should not find anything without usecwd=True + result = find_dotenv() + assert result == "" + + def test_is_interactive_with_usecwd_override(self, tmp_path, monkeypatch): + """Test that usecwd=True overrides _is_interactive behavior.""" + self._remove_ps_attributes(monkeypatch) + dotenv_path = self._create_dotenv_file(tmp_path) + + # Mock __main__ module with __file__ attribute (non-interactive) + mock_main = mock.MagicMock() + mock_main.__file__ = "/path/to/script.py" + + self._mock_main_import(monkeypatch, mock_main) + self._setup_subdir_and_chdir(tmp_path, monkeypatch) + + # Even though _is_interactive() returns False, usecwd=True should find the file + result = find_dotenv(usecwd=True) + assert result == str(dotenv_path) From 9d85edb3b8652de4601f9ad8a7a49ad9909f898a Mon Sep 17 00:00:00 2001 From: Jake Owen <30642941+wrongontheinternet@users.noreply.github.com> Date: Tue, 24 Jun 2025 13:24:26 +1000 Subject: [PATCH 181/253] fix(cli): issue with execvpe on Windows (#566) Fix dotenv run on Windows: execvpe is bad Co-authored-by: Jake Owen --- src/dotenv/cli.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 33ae1485..075a7af1 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -5,6 +5,9 @@ from contextlib import contextmanager from typing import Any, Dict, IO, Iterator, List, Optional +if sys.platform == 'win32': + from subprocess import Popen + try: import click except ImportError: @@ -187,4 +190,16 @@ def run_command(command: List[str], env: Dict[str, str]) -> None: cmd_env = os.environ.copy() cmd_env.update(env) - os.execvpe(command[0], args=command, env=cmd_env) + if sys.platform == 'win32': + # execvpe on Windows returns control immediately + # rather than once the command has finished. + p = Popen(command, + universal_newlines=True, + bufsize=0, + shell=False, + env=cmd_env) + _, _ = p.communicate() + + exit(p.returncode) + else: + os.execvpe(command[0], args=command, env=cmd_env) From 667e82f18d6e5306894c8746c46b1da2d031bd23 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Tue, 24 Jun 2025 09:02:34 +0530 Subject: [PATCH 182/253] update changelog --- CHANGELOG.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1afd06d..0669eaed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.1] - 2025-06-24 + +## Fixed + +* CLI: Ensure `find_dotenv` work reliably on python 3.13 by [@theskumar] in [#563](https://github.com/theskumar/python-dotenv/pull/563) +* CLI: revert the use of execvpe on Windows by [@wrongontheinternet] in [#566](https://github.com/theskumar/python-dotenv/pull/566) + ## [1.1.0] - 2025-03-25 @@ -409,8 +416,10 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@yannham]: https://github.com/yannham [@zueve]: https://github.com/zueve [@randomseed42]: https://github.com/zueve +[@wrongontheinternet]: https://github.com/wrongontheinternet -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.1.0...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.1.1...HEAD +[1.1.1]: https://github.com/theskumar/python-dotenv/compare/v1.1.0...1.1.1 [1.1.0]: https://github.com/theskumar/python-dotenv/compare/v1.0.1...v1.1.0 [1.0.1]: https://github.com/theskumar/python-dotenv/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/theskumar/python-dotenv/compare/v0.21.0...v1.0.0 From 16e660d384b942b11879b44500afbbe021650448 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Tue, 24 Jun 2025 09:47:34 +0530 Subject: [PATCH 183/253] =?UTF-8?q?Bump=20version:=201.1.0=20=E2=86=92=201?= =?UTF-8?q?.1.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- src/dotenv/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 02dc0695..60effd2b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.1.0 +current_version = 1.1.1 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 6849410a..a82b376d 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "1.1.0" +__version__ = "1.1.1" From 02b68577f37da2c4f4b9377d7a0ca2b58fdacf20 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Tue, 24 Jun 2025 11:22:38 +0530 Subject: [PATCH 184/253] style: upgrade to use ruff (#567) --- .pre-commit-config.yaml | 8 + CONTRIBUTING.md | 22 +- MANIFEST.in | 2 +- Makefile | 6 +- requirements.txt | 4 +- ruff.toml | 19 + src/dotenv/__init__.py | 32 +- src/dotenv/cli.py | 115 +++--- src/dotenv/ipython.py | 29 +- src/dotenv/parser.py | 29 +- tests/conftest.py | 4 +- tests/test_cli.py | 88 +++-- tests/test_ipython.py | 1 - tests/test_is_interactive.py | 11 +- tests/test_main.py | 2 +- tests/test_parser.py | 702 +++++++++++++++++++++++++++-------- tests/test_utils.py | 24 +- tests/test_variables.py | 2 +- tests/test_zip_imports.py | 19 +- tox.ini | 5 +- 20 files changed, 803 insertions(+), 321 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 ruff.toml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..60d0365c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,8 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.0 + hooks: + # Run the linter. + - id: ruff + # Run the formatter. + - id: ruff-format diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fac71bff..49840fa7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,23 +7,29 @@ a pull request. Executing the tests: - $ pip install -r requirements.txt - $ pip install -e . - $ flake8 - $ pytest + $ uv venv + $ uv pip install -r requirements.txt + $ uv pip install -e . + $ uv ruff check . + $ uv format . + $ uv run pytest or with [tox](https://pypi.org/project/tox/) installed: $ tox +Use of pre-commit is recommended: + + $ uv run precommit install + + Documentation is published with [mkdocs](): ```shell -$ pip install -r requirements-docs.txt -$ pip install -e . -$ mkdocs serve +$ uv pip install -r requirements-docs.txt +$ uv pip install -e . +$ uv run mkdocs serve ``` Open http://127.0.0.1:8000/ to view the documentation locally. - diff --git a/MANIFEST.in b/MANIFEST.in index 9c457e66..bf0d47e6 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ -include LICENSE *.md *.yml *.toml +include LICENSE *.md *.yml *.yaml *.toml include tox.ini recursive-include docs *.md diff --git a/Makefile b/Makefile index 5b58c4c2..e5bcb308 100644 --- a/Makefile +++ b/Makefile @@ -24,9 +24,9 @@ sdist: clean ls -l dist test: - pip install -e . - flake8 . - py.test tests/ + uv pip install -e . + ruff check . + pytest tests/ coverage: coverage run --source=dotenv --omit='*tests*' -m py.test tests/ -v --tb=native diff --git a/requirements.txt b/requirements.txt index af7e1bc4..660c5dcc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,5 @@ -black~=22.3.0 bumpversion click -flake8>=2.2.3 ipython pytest-cov pytest>=3.9 @@ -9,3 +7,5 @@ sh>=2 tox twine wheel +ruff +pre-commit diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 00000000..a2e0feca --- /dev/null +++ b/ruff.toml @@ -0,0 +1,19 @@ +[lint] +select = [ + # pycodestyle + "E4", + "E7", + "E9", + + # Pyflakes + "F", + + # flake8-bugbear + "B", + + # iSort + "I", + + # flake8-builtins + "A", +] diff --git a/src/dotenv/__init__.py b/src/dotenv/__init__.py index 7f4c631b..dde24a01 100644 --- a/src/dotenv/__init__.py +++ b/src/dotenv/__init__.py @@ -1,11 +1,11 @@ from typing import Any, Optional -from .main import (dotenv_values, find_dotenv, get_key, load_dotenv, set_key, - unset_key) +from .main import dotenv_values, find_dotenv, get_key, load_dotenv, set_key, unset_key def load_ipython_extension(ipython: Any) -> None: from .ipython import load_ipython_extension + load_ipython_extension(ipython) @@ -21,29 +21,31 @@ def get_cli_string( Useful for converting a arguments passed to a fabric task to be passed to a `local` or `run` command. """ - command = ['dotenv'] + command = ["dotenv"] if quote: - command.append(f'-q {quote}') + command.append(f"-q {quote}") if path: - command.append(f'-f {path}') + command.append(f"-f {path}") if action: command.append(action) if key: command.append(key) if value: - if ' ' in value: + if " " in value: command.append(f'"{value}"') else: command.append(value) - return ' '.join(command).strip() + return " ".join(command).strip() -__all__ = ['get_cli_string', - 'load_dotenv', - 'dotenv_values', - 'get_key', - 'set_key', - 'unset_key', - 'find_dotenv', - 'load_ipython_extension'] +__all__ = [ + "get_cli_string", + "load_dotenv", + "dotenv_values", + "get_key", + "set_key", + "unset_key", + "find_dotenv", + "load_ipython_extension", +] diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 075a7af1..c43c63b5 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -3,16 +3,18 @@ import shlex import sys from contextlib import contextmanager -from typing import Any, Dict, IO, Iterator, List, Optional +from typing import IO, Any, Dict, Iterator, List, Optional -if sys.platform == 'win32': +if sys.platform == "win32": from subprocess import Popen try: import click except ImportError: - sys.stderr.write('It seems python-dotenv is not installed with cli option. \n' - 'Run pip install "python-dotenv[cli]" to fix this.') + sys.stderr.write( + "It seems python-dotenv is not installed with cli option. \n" + 'Run pip install "python-dotenv[cli]" to fix this.' + ) sys.exit(1) from .main import dotenv_values, set_key, unset_key @@ -29,25 +31,37 @@ def enumerate_env() -> Optional[str]: cwd = os.getcwd() except FileNotFoundError: return None - path = os.path.join(cwd, '.env') + path = os.path.join(cwd, ".env") return path @click.group() -@click.option('-f', '--file', default=enumerate_env(), - type=click.Path(file_okay=True), - help="Location of the .env file, defaults to .env file in current working directory.") -@click.option('-q', '--quote', default='always', - type=click.Choice(['always', 'never', 'auto']), - help="Whether to quote or not the variable values. Default mode is always. This does not affect parsing.") -@click.option('-e', '--export', default=False, - type=click.BOOL, - help="Whether to write the dot file as an executable bash script.") +@click.option( + "-f", + "--file", + default=enumerate_env(), + type=click.Path(file_okay=True), + help="Location of the .env file, defaults to .env file in current working directory.", +) +@click.option( + "-q", + "--quote", + default="always", + type=click.Choice(["always", "never", "auto"]), + help="Whether to quote or not the variable values. Default mode is always. This does not affect parsing.", +) +@click.option( + "-e", + "--export", + default=False, + type=click.BOOL, + help="Whether to write the dot file as an executable bash script.", +) @click.version_option(version=__version__) @click.pass_context def cli(ctx: click.Context, file: Any, quote: Any, export: Any) -> None: """This script is used to set, get or unset values from a .env file.""" - ctx.obj = {'QUOTE': quote, 'EXPORT': export, 'FILE': file} + ctx.obj = {"QUOTE": quote, "EXPORT": export, "FILE": file} @contextmanager @@ -66,53 +80,57 @@ def stream_file(path: os.PathLike) -> Iterator[IO[str]]: exit(2) -@cli.command() +@cli.command(name="list") @click.pass_context -@click.option('--format', default='simple', - type=click.Choice(['simple', 'json', 'shell', 'export']), - help="The format in which to display the list. Default format is simple, " - "which displays name=value without quotes.") -def list(ctx: click.Context, format: bool) -> None: +@click.option( + "--format", + "output_format", + default="simple", + type=click.Choice(["simple", "json", "shell", "export"]), + help="The format in which to display the list. Default format is simple, " + "which displays name=value without quotes.", +) +def list_values(ctx: click.Context, output_format: str) -> None: """Display all the stored key/value.""" - file = ctx.obj['FILE'] + file = ctx.obj["FILE"] with stream_file(file) as stream: values = dotenv_values(stream=stream) - if format == 'json': + if output_format == "json": click.echo(json.dumps(values, indent=2, sort_keys=True)) else: - prefix = 'export ' if format == 'export' else '' + prefix = "export " if output_format == "export" else "" for k in sorted(values): v = values[k] if v is not None: - if format in ('export', 'shell'): + if output_format in ("export", "shell"): v = shlex.quote(v) - click.echo(f'{prefix}{k}={v}') + click.echo(f"{prefix}{k}={v}") -@cli.command() +@cli.command(name="set") @click.pass_context -@click.argument('key', required=True) -@click.argument('value', required=True) -def set(ctx: click.Context, key: Any, value: Any) -> None: +@click.argument("key", required=True) +@click.argument("value", required=True) +def set_value(ctx: click.Context, key: Any, value: Any) -> None: """Store the given key/value.""" - file = ctx.obj['FILE'] - quote = ctx.obj['QUOTE'] - export = ctx.obj['EXPORT'] + file = ctx.obj["FILE"] + quote = ctx.obj["QUOTE"] + export = ctx.obj["EXPORT"] success, key, value = set_key(file, key, value, quote, export) if success: - click.echo(f'{key}={value}') + click.echo(f"{key}={value}") else: exit(1) @cli.command() @click.pass_context -@click.argument('key', required=True) +@click.argument("key", required=True) def get(ctx: click.Context, key: Any) -> None: """Retrieve the value for the given key.""" - file = ctx.obj['FILE'] + file = ctx.obj["FILE"] with stream_file(file) as stream: values = dotenv_values(stream=stream) @@ -126,11 +144,11 @@ def get(ctx: click.Context, key: Any) -> None: @cli.command() @click.pass_context -@click.argument('key', required=True) +@click.argument("key", required=True) def unset(ctx: click.Context, key: Any) -> None: """Removes the given key.""" - file = ctx.obj['FILE'] - quote = ctx.obj['QUOTE'] + file = ctx.obj["FILE"] + quote = ctx.obj["QUOTE"] success, key = unset_key(file, key, quote) if success: click.echo(f"Successfully removed {key}") @@ -138,21 +156,20 @@ def unset(ctx: click.Context, key: Any) -> None: exit(1) -@cli.command(context_settings={'ignore_unknown_options': True}) +@cli.command(context_settings={"ignore_unknown_options": True}) @click.pass_context @click.option( "--override/--no-override", default=True, help="Override variables from the environment file with those from the .env file.", ) -@click.argument('commandline', nargs=-1, type=click.UNPROCESSED) +@click.argument("commandline", nargs=-1, type=click.UNPROCESSED) def run(ctx: click.Context, override: bool, commandline: List[str]) -> None: """Run command with environment variables present.""" - file = ctx.obj['FILE'] + file = ctx.obj["FILE"] if not os.path.isfile(file): raise click.BadParameter( - f'Invalid value for \'-f\' "{file}" does not exist.', - ctx=ctx + f"Invalid value for '-f' \"{file}\" does not exist.", ctx=ctx ) dotenv_as_dict = { k: v @@ -161,7 +178,7 @@ def run(ctx: click.Context, override: bool, commandline: List[str]) -> None: } if not commandline: - click.echo('No command given.') + click.echo("No command given.") exit(1) run_command(commandline, dotenv_as_dict) @@ -190,14 +207,10 @@ def run_command(command: List[str], env: Dict[str, str]) -> None: cmd_env = os.environ.copy() cmd_env.update(env) - if sys.platform == 'win32': + if sys.platform == "win32": # execvpe on Windows returns control immediately # rather than once the command has finished. - p = Popen(command, - universal_newlines=True, - bufsize=0, - shell=False, - env=cmd_env) + p = Popen(command, universal_newlines=True, bufsize=0, shell=False, env=cmd_env) _, _ = p.communicate() exit(p.returncode) diff --git a/src/dotenv/ipython.py b/src/dotenv/ipython.py index 7df727cd..4e7edbbf 100644 --- a/src/dotenv/ipython.py +++ b/src/dotenv/ipython.py @@ -1,24 +1,35 @@ from IPython.core.magic import Magics, line_magic, magics_class # type: ignore -from IPython.core.magic_arguments import (argument, magic_arguments, # type: ignore - parse_argstring) # type: ignore +from IPython.core.magic_arguments import ( + argument, + magic_arguments, + parse_argstring, +) # type: ignore from .main import find_dotenv, load_dotenv @magics_class class IPythonDotEnv(Magics): - @magic_arguments() @argument( - '-o', '--override', action='store_true', - help="Indicate to override existing variables" + "-o", + "--override", + action="store_true", + help="Indicate to override existing variables", + ) + @argument( + "-v", + "--verbose", + action="store_true", + help="Indicate function calls to be verbose", ) @argument( - '-v', '--verbose', action='store_true', - help="Indicate function calls to be verbose" + "dotenv_path", + nargs="?", + type=str, + default=".env", + help="Search in increasingly higher folders for the `dotenv_path`", ) - @argument('dotenv_path', nargs='?', type=str, default='.env', - help='Search in increasingly higher folders for the `dotenv_path`') @line_magic def dotenv(self, line): args = parse_argstring(self.dotenv, line) diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index 735f14a3..eb100b47 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -1,7 +1,14 @@ import codecs import re -from typing import (IO, Iterator, Match, NamedTuple, Optional, # noqa:F401 - Pattern, Sequence, Tuple) +from typing import ( + IO, + Iterator, + Match, + NamedTuple, + Optional, + Pattern, + Sequence, +) def make_regex(string: str, extra_flags: int = 0) -> Pattern[str]: @@ -73,15 +80,15 @@ def set_mark(self) -> None: def get_marked(self) -> Original: return Original( - string=self.string[self.mark.chars:self.position.chars], + string=self.string[self.mark.chars : self.position.chars], line=self.mark.line, ) def peek(self, count: int) -> str: - return self.string[self.position.chars:self.position.chars + count] + return self.string[self.position.chars : self.position.chars + count] def read(self, count: int) -> str: - result = self.string[self.position.chars:self.position.chars + count] + result = self.string[self.position.chars : self.position.chars + count] if len(result) < count: raise Error("read: End of string") self.position.advance(result) @@ -91,13 +98,13 @@ def read_regex(self, regex: Pattern[str]) -> Sequence[str]: match = regex.match(self.string, self.position.chars) if match is None: raise Error("read_regex: Pattern not found") - self.position.advance(self.string[match.start():match.end()]) + self.position.advance(self.string[match.start() : match.end()]) return match.groups() def decode_escapes(regex: Pattern[str], string: str) -> str: def decode_match(match: Match[str]) -> str: - return codecs.decode(match.group(0), 'unicode-escape') # type: ignore + return codecs.decode(match.group(0), "unicode-escape") # type: ignore return regex.sub(decode_match, string) @@ -120,14 +127,14 @@ def parse_unquoted_value(reader: Reader) -> str: def parse_value(reader: Reader) -> str: char = reader.peek(1) - if char == u"'": + if char == "'": (value,) = reader.read_regex(_single_quoted_value) return decode_escapes(_single_quote_escapes, value) - elif char == u'"': + elif char == '"': (value,) = reader.read_regex(_double_quoted_value) return decode_escapes(_double_quote_escapes, value) - elif char in (u"", u"\n", u"\r"): - return u"" + elif char in ("", "\n", "\r"): + return "" else: return parse_unquoted_value(reader) diff --git a/tests/conftest.py b/tests/conftest.py index 69193de0..cc6f0f07 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,6 @@ def cli(): @pytest.fixture def dotenv_path(tmp_path): - path = tmp_path / '.env' - path.write_bytes(b'') + path = tmp_path / ".env" + path.write_bytes(b"") yield path diff --git a/tests/test_cli.py b/tests/test_cli.py index fc309b48..343fdb23 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,9 +1,9 @@ import os -import sh from pathlib import Path from typing import Optional import pytest +import sh import dotenv from dotenv.cli import cli as dotenv_cli @@ -11,26 +11,28 @@ @pytest.mark.parametrize( - "format,content,expected", + "output_format,content,expected", ( - (None, "x='a b c'", '''x=a b c\n'''), - ("simple", "x='a b c'", '''x=a b c\n'''), - ("simple", """x='"a b c"'""", '''x="a b c"\n'''), - ("simple", '''x="'a b c'"''', '''x='a b c'\n'''), - ("json", "x='a b c'", '''{\n "x": "a b c"\n}\n'''), + (None, "x='a b c'", """x=a b c\n"""), + ("simple", "x='a b c'", """x=a b c\n"""), + ("simple", """x='"a b c"'""", """x="a b c"\n"""), + ("simple", '''x="'a b c'"''', """x='a b c'\n"""), + ("json", "x='a b c'", """{\n "x": "a b c"\n}\n"""), ("shell", "x='a b c'", "x='a b c'\n"), - ("shell", """x='"a b c"'""", '''x='"a b c"'\n'''), - ("shell", '''x="'a b c'"''', '''x=''"'"'a b c'"'"''\n'''), + ("shell", """x='"a b c"'""", """x='"a b c"'\n"""), + ("shell", '''x="'a b c'"''', """x=''"'"'a b c'"'"''\n"""), ("shell", "x='a\nb\nc'", "x='a\nb\nc'\n"), - ("export", "x='a b c'", '''export x='a b c'\n'''), - ) + ("export", "x='a b c'", """export x='a b c'\n"""), + ), ) -def test_list(cli, dotenv_path, format: Optional[str], content: str, expected: str): - dotenv_path.write_text(content + '\n') +def test_list( + cli, dotenv_path, output_format: Optional[str], content: str, expected: str +): + dotenv_path.write_text(content + "\n") - args = ['--file', dotenv_path, 'list'] + args = ["--file", dotenv_path, "list"] if format is not None: - args.extend(['--format', format]) + args.extend(["--format", output_format]) result = cli.invoke(dotenv_cli, args) @@ -38,21 +40,21 @@ def test_list(cli, dotenv_path, format: Optional[str], content: str, expected: s def test_list_non_existent_file(cli): - result = cli.invoke(dotenv_cli, ['--file', 'nx_file', 'list']) + result = cli.invoke(dotenv_cli, ["--file", "nx_file", "list"]) assert result.exit_code == 2, result.output assert "Error opening env file" in result.output def test_list_not_a_file(cli): - result = cli.invoke(dotenv_cli, ['--file', '.', 'list']) + result = cli.invoke(dotenv_cli, ["--file", ".", "list"]) assert result.exit_code == 2, result.output assert "Error opening env file" in result.output def test_list_no_file(cli): - result = cli.invoke(dotenv.cli.list, []) + result = cli.invoke(dotenv.cli.list_values, []) assert (result.exit_code, result.output) == (1, "") @@ -60,26 +62,26 @@ def test_list_no_file(cli): def test_get_existing_value(cli, dotenv_path): dotenv_path.write_text("a=b") - result = cli.invoke(dotenv_cli, ['--file', dotenv_path, 'get', 'a']) + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "get", "a"]) assert (result.exit_code, result.output) == (0, "b\n") def test_get_non_existent_value(cli, dotenv_path): - result = cli.invoke(dotenv_cli, ['--file', dotenv_path, 'get', 'a']) + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "get", "a"]) assert (result.exit_code, result.output) == (1, "") def test_get_non_existent_file(cli): - result = cli.invoke(dotenv_cli, ['--file', 'nx_file', 'get', 'a']) + result = cli.invoke(dotenv_cli, ["--file", "nx_file", "get", "a"]) assert result.exit_code == 2 assert "Error opening env file" in result.output def test_get_not_a_file(cli): - result = cli.invoke(dotenv_cli, ['--file', '.', 'get', 'a']) + result = cli.invoke(dotenv_cli, ["--file", ".", "get", "a"]) assert result.exit_code == 2 assert "Error opening env file" in result.output @@ -88,14 +90,14 @@ def test_get_not_a_file(cli): def test_unset_existing_value(cli, dotenv_path): dotenv_path.write_text("a=b") - result = cli.invoke(dotenv_cli, ['--file', dotenv_path, 'unset', 'a']) + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "unset", "a"]) assert (result.exit_code, result.output) == (0, "Successfully removed a\n") assert dotenv_path.read_text() == "" def test_unset_non_existent_value(cli, dotenv_path): - result = cli.invoke(dotenv_cli, ['--file', dotenv_path, 'unset', 'a']) + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "unset", "a"]) assert (result.exit_code, result.output) == (1, "") assert dotenv_path.read_text() == "" @@ -105,16 +107,26 @@ def test_unset_non_existent_value(cli, dotenv_path): "quote_mode,variable,value,expected", ( ("always", "a", "x", "a='x'\n"), - ("never", "a", "x", 'a=x\n'), + ("never", "a", "x", "a=x\n"), ("auto", "a", "x", "a=x\n"), ("auto", "a", "x y", "a='x y'\n"), ("auto", "a", "$", "a='$'\n"), - ) + ), ) def test_set_quote_options(cli, dotenv_path, quote_mode, variable, value, expected): result = cli.invoke( dotenv_cli, - ["--file", dotenv_path, "--export", "false", "--quote", quote_mode, "set", variable, value] + [ + "--file", + dotenv_path, + "--export", + "false", + "--quote", + quote_mode, + "set", + variable, + value, + ], ) assert (result.exit_code, result.output) == (0, "{}={}\n".format(variable, value)) @@ -126,12 +138,22 @@ def test_set_quote_options(cli, dotenv_path, quote_mode, variable, value, expect ( (Path(".nx_file"), "true", "a", "x", "export a='x'\n"), (Path(".nx_file"), "false", "a", "x", "a='x'\n"), - ) + ), ) def test_set_export(cli, dotenv_path, export_mode, variable, value, expected): result = cli.invoke( dotenv_cli, - ["--file", dotenv_path, "--quote", "always", "--export", export_mode, "set", variable, value] + [ + "--file", + dotenv_path, + "--quote", + "always", + "--export", + export_mode, + "set", + variable, + value, + ], ) assert (result.exit_code, result.output) == (0, "{}={}\n".format(variable, value)) @@ -139,7 +161,7 @@ def test_set_export(cli, dotenv_path, export_mode, variable, value, expected): def test_set_non_existent_file(cli): - result = cli.invoke(dotenv.cli.set, ["a", "b"]) + result = cli.invoke(dotenv.cli.set_value, ["a", "b"]) assert (result.exit_code, result.output) == (1, "") @@ -209,21 +231,21 @@ def test_run_with_other_env(dotenv_path): def test_run_without_cmd(cli): - result = cli.invoke(dotenv_cli, ['run']) + result = cli.invoke(dotenv_cli, ["run"]) assert result.exit_code == 2 assert "Invalid value for '-f'" in result.output def test_run_with_invalid_cmd(cli): - result = cli.invoke(dotenv_cli, ['run', 'i_do_not_exist']) + result = cli.invoke(dotenv_cli, ["run", "i_do_not_exist"]) assert result.exit_code == 2 assert "Invalid value for '-f'" in result.output def test_run_with_version(cli): - result = cli.invoke(dotenv_cli, ['--version']) + result = cli.invoke(dotenv_cli, ["--version"]) assert result.exit_code == 0 assert result.output.strip().endswith(__version__) diff --git a/tests/test_ipython.py b/tests/test_ipython.py index 960479ba..f01b3ad7 100644 --- a/tests/test_ipython.py +++ b/tests/test_ipython.py @@ -3,7 +3,6 @@ import pytest - pytest.importorskip("IPython") diff --git a/tests/test_is_interactive.py b/tests/test_is_interactive.py index f56378e9..1c073471 100644 --- a/tests/test_is_interactive.py +++ b/tests/test_is_interactive.py @@ -1,6 +1,7 @@ -import sys import builtins +import sys from unittest import mock + from dotenv.main import find_dotenv @@ -175,7 +176,9 @@ def test_is_interactive_ps1_and_ps2_both_exist(self, tmp_path, monkeypatch): result = find_dotenv() assert result == str(dotenv_path) - def test_is_interactive_main_module_with_file_attribute_none(self, tmp_path, monkeypatch): + def test_is_interactive_main_module_with_file_attribute_none( + self, tmp_path, monkeypatch + ): """Test _is_interactive when __main__ has __file__ attribute set to None.""" self._remove_ps_attributes(monkeypatch) @@ -195,7 +198,9 @@ def test_is_interactive_main_module_with_file_attribute_none(self, tmp_path, mon result = find_dotenv() assert result == "" - def test_is_interactive_no_ps_attributes_and_normal_execution(self, tmp_path, monkeypatch): + def test_is_interactive_no_ps_attributes_and_normal_execution( + self, tmp_path, monkeypatch + ): """Test normal script execution scenario where _is_interactive should return False.""" self._remove_ps_attributes(monkeypatch) diff --git a/tests/test_main.py b/tests/test_main.py index 2d63eec1..dfd19274 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -64,7 +64,7 @@ def test_set_key_encoding(dotenv_path): def test_set_key_permission_error(dotenv_path): dotenv_path.chmod(0o000) - with pytest.raises(Exception): + with pytest.raises(PermissionError): dotenv.set_key(dotenv_path, "a", "b") dotenv_path.chmod(0o600) diff --git a/tests/test_parser.py b/tests/test_parser.py index b0621173..43386e5a 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -5,166 +5,548 @@ from dotenv.parser import Binding, Original, parse_stream -@pytest.mark.parametrize("test_input,expected", [ - (u"", []), - (u"a=b", [Binding(key=u"a", value=u"b", original=Original(string=u"a=b", line=1), error=False)]), - (u"'a'=b", [Binding(key=u"a", value=u"b", original=Original(string=u"'a'=b", line=1), error=False)]), - (u"[=b", [Binding(key=u"[", value=u"b", original=Original(string=u"[=b", line=1), error=False)]), - (u" a = b ", [Binding(key=u"a", value=u"b", original=Original(string=u" a = b ", line=1), error=False)]), - (u"export a=b", [Binding(key=u"a", value=u"b", original=Original(string=u"export a=b", line=1), error=False)]), - ( - u" export 'a'=b", - [Binding(key=u"a", value=u"b", original=Original(string=u" export 'a'=b", line=1), error=False)], - ), - (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 #c', - [Binding(key=u"a", value=u"b", original=Original(string=u"a=b #c", 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 c", - [Binding(key=u"a", value=u"b c", original=Original(string=u"a=b c", 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", - [ - Binding(key=u"export_a", value=u"1", original=Original(string=u"export export_a=1", line=1), error=False) - ], - ), - ( - u"export port=8000", - [Binding(key=u"port", value=u"8000", original=Original(string=u"export port=8000", line=1), error=False)], - ), - (u'a="b\nc"', [Binding(key=u"a", value=u"b\nc", original=Original(string=u'a="b\nc"', line=1), error=False)]), - (u"a='b\nc'", [Binding(key=u"a", value=u"b\nc", original=Original(string=u"a='b\nc'", line=1), error=False)]), - (u'a="b\nc"', [Binding(key=u"a", value=u"b\nc", original=Original(string=u'a="b\nc"', line=1), error=False)]), - (u'a="b\\nc"', [Binding(key=u"a", value=u'b\nc', original=Original(string=u'a="b\\nc"', line=1), error=False)]), - (u"a='b\\nc'", [Binding(key=u"a", value=u'b\\nc', original=Original(string=u"a='b\\nc'", 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=à", [Binding(key=u"a", value=u"à", original=Original(string=u"a=à", line=1), error=False)]), - (u'a="à"', [Binding(key=u"a", value=u"à", original=Original(string=u'a="à"', line=1), error=False)]), - ( - u'no_value_var', - [Binding(key=u'no_value_var', value=None, original=Original(string=u"no_value_var", line=1), error=False)], - ), - (u'a: b', [Binding(key=None, value=None, original=Original(string=u"a: b", line=1), error=True)]), - ( - u"a=b\nc=d", - [ - Binding(key=u"a", value=u"b", original=Original(string=u"a=b\n", line=1), error=False), - Binding(key=u"c", value=u"d", original=Original(string=u"c=d", line=2), error=False), - ], - ), - ( - u"a=b\rc=d", - [ - Binding(key=u"a", value=u"b", original=Original(string=u"a=b\r", line=1), error=False), - Binding(key=u"c", value=u"d", original=Original(string=u"c=d", line=2), error=False), - ], - ), - ( - u"a=b\r\nc=d", - [ - Binding(key=u"a", value=u"b", original=Original(string=u"a=b\r\n", line=1), error=False), - Binding(key=u"c", value=u"d", original=Original(string=u"c=d", line=2), error=False), - ], - ), - ( - u'a=\nb=c', - [ - Binding(key=u"a", value=u'', original=Original(string=u'a=\n', line=1), error=False), - Binding(key=u"b", value=u'c', original=Original(string=u"b=c", line=2), error=False), - ] - ), - ( - u"\n\n", - [ - Binding(key=None, value=None, original=Original(string=u"\n\n", line=1), error=False), - ] - ), - ( - u"a=b\n\n", - [ - Binding(key=u"a", value=u"b", original=Original(string=u"a=b\n", line=1), error=False), - Binding(key=None, value=None, original=Original(string=u"\n", line=2), error=False), - ] - ), - ( - u'a=b\n\nc=d', - [ - Binding(key=u"a", value=u"b", original=Original(string=u"a=b\n", line=1), error=False), - Binding(key=u"c", value=u"d", original=Original(string=u"\nc=d", line=2), error=False), - ] - ), - ( - u'a="\nb=c', - [ - Binding(key=None, value=None, original=Original(string=u'a="\n', line=1), error=True), - Binding(key=u"b", value=u"c", original=Original(string=u"b=c", line=2), error=False), - ] - ), - ( - u'# comment\na="b\nc"\nd=e\n', - [ - Binding(key=None, value=None, original=Original(string=u"# comment\n", line=1), error=False), - Binding(key=u"a", value=u"b\nc", original=Original(string=u'a="b\nc"\n', line=2), error=False), - Binding(key=u"d", value=u"e", original=Original(string=u"d=e\n", line=4), error=False), - ], - ), - ( - u'a=b\n# comment 1', - [ - Binding(key="a", value="b", original=Original(string=u"a=b\n", line=1), error=False), - Binding(key=None, value=None, original=Original(string=u"# comment 1", line=2), error=False), - ], - ), - ( - u'# comment 1\n# comment 2', - [ - Binding(key=None, value=None, original=Original(string=u"# comment 1\n", line=1), error=False), - Binding(key=None, value=None, original=Original(string=u"# comment 2", line=2), error=False), - ], - ), - ( - u'uglyKey[%$=\"S3cr3t_P4ssw#rD\" #\na=b', - [ - Binding(key=u'uglyKey[%$', - value=u'S3cr3t_P4ssw#rD', - original=Original(string=u"uglyKey[%$=\"S3cr3t_P4ssw#rD\" #\n", line=1), error=False), - Binding(key=u"a", value=u"b", original=Original(string=u'a=b', line=2), error=False), - ], - ), -]) +@pytest.mark.parametrize( + "test_input,expected", + [ + ("", []), + ( + "a=b", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b", line=1), + error=False, + ) + ], + ), + ( + "'a'=b", + [ + Binding( + key="a", + value="b", + original=Original(string="'a'=b", line=1), + error=False, + ) + ], + ), + ( + "[=b", + [ + Binding( + key="[", + value="b", + original=Original(string="[=b", line=1), + error=False, + ) + ], + ), + ( + " a = b ", + [ + Binding( + key="a", + value="b", + original=Original(string=" a = b ", line=1), + error=False, + ) + ], + ), + ( + "export a=b", + [ + Binding( + key="a", + value="b", + original=Original(string="export a=b", line=1), + error=False, + ) + ], + ), + ( + " export 'a'=b", + [ + Binding( + key="a", + value="b", + original=Original(string=" export 'a'=b", line=1), + error=False, + ) + ], + ), + ( + "# a=b", + [ + Binding( + key=None, + value=None, + original=Original(string="# a=b", line=1), + error=False, + ) + ], + ), + ( + "a=b#c", + [ + Binding( + key="a", + value="b#c", + original=Original(string="a=b#c", line=1), + error=False, + ) + ], + ), + ( + "a=b #c", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b #c", line=1), + error=False, + ) + ], + ), + ( + "a=b\t#c", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b\t#c", line=1), + error=False, + ) + ], + ), + ( + "a=b c", + [ + Binding( + key="a", + value="b c", + original=Original(string="a=b c", line=1), + error=False, + ) + ], + ), + ( + "a=b\tc", + [ + Binding( + key="a", + value="b\tc", + original=Original(string="a=b\tc", line=1), + error=False, + ) + ], + ), + ( + "a=b c", + [ + Binding( + key="a", + value="b c", + original=Original(string="a=b c", line=1), + error=False, + ) + ], + ), + ( + "a=b\u00a0 c", + [ + Binding( + key="a", + value="b\u00a0 c", + original=Original(string="a=b\u00a0 c", line=1), + error=False, + ) + ], + ), + ( + "a=b c ", + [ + Binding( + key="a", + value="b c", + original=Original(string="a=b c ", line=1), + error=False, + ) + ], + ), + ( + "a='b c '", + [ + Binding( + key="a", + value="b c ", + original=Original(string="a='b c '", line=1), + error=False, + ) + ], + ), + ( + 'a="b c "', + [ + Binding( + key="a", + value="b c ", + original=Original(string='a="b c "', line=1), + error=False, + ) + ], + ), + ( + "export export_a=1", + [ + Binding( + key="export_a", + value="1", + original=Original(string="export export_a=1", line=1), + error=False, + ) + ], + ), + ( + "export port=8000", + [ + Binding( + key="port", + value="8000", + original=Original(string="export port=8000", line=1), + error=False, + ) + ], + ), + ( + 'a="b\nc"', + [ + Binding( + key="a", + value="b\nc", + original=Original(string='a="b\nc"', line=1), + error=False, + ) + ], + ), + ( + "a='b\nc'", + [ + Binding( + key="a", + value="b\nc", + original=Original(string="a='b\nc'", line=1), + error=False, + ) + ], + ), + ( + 'a="b\nc"', + [ + Binding( + key="a", + value="b\nc", + original=Original(string='a="b\nc"', line=1), + error=False, + ) + ], + ), + ( + 'a="b\\nc"', + [ + Binding( + key="a", + value="b\nc", + original=Original(string='a="b\\nc"', line=1), + error=False, + ) + ], + ), + ( + "a='b\\nc'", + [ + Binding( + key="a", + value="b\\nc", + original=Original(string="a='b\\nc'", line=1), + error=False, + ) + ], + ), + ( + 'a="b\\"c"', + [ + Binding( + key="a", + value='b"c', + original=Original(string='a="b\\"c"', line=1), + error=False, + ) + ], + ), + ( + "a='b\\'c'", + [ + Binding( + key="a", + value="b'c", + original=Original(string="a='b\\'c'", line=1), + error=False, + ) + ], + ), + ( + "a=à", + [ + Binding( + key="a", + value="à", + original=Original(string="a=à", line=1), + error=False, + ) + ], + ), + ( + 'a="à"', + [ + Binding( + key="a", + value="à", + original=Original(string='a="à"', line=1), + error=False, + ) + ], + ), + ( + "no_value_var", + [ + Binding( + key="no_value_var", + value=None, + original=Original(string="no_value_var", line=1), + error=False, + ) + ], + ), + ( + "a: b", + [ + Binding( + key=None, + value=None, + original=Original(string="a: b", line=1), + error=True, + ) + ], + ), + ( + "a=b\nc=d", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b\n", line=1), + error=False, + ), + Binding( + key="c", + value="d", + original=Original(string="c=d", line=2), + error=False, + ), + ], + ), + ( + "a=b\rc=d", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b\r", line=1), + error=False, + ), + Binding( + key="c", + value="d", + original=Original(string="c=d", line=2), + error=False, + ), + ], + ), + ( + "a=b\r\nc=d", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b\r\n", line=1), + error=False, + ), + Binding( + key="c", + value="d", + original=Original(string="c=d", line=2), + error=False, + ), + ], + ), + ( + "a=\nb=c", + [ + Binding( + key="a", + value="", + original=Original(string="a=\n", line=1), + error=False, + ), + Binding( + key="b", + value="c", + original=Original(string="b=c", line=2), + error=False, + ), + ], + ), + ( + "\n\n", + [ + Binding( + key=None, + value=None, + original=Original(string="\n\n", line=1), + error=False, + ), + ], + ), + ( + "a=b\n\n", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b\n", line=1), + error=False, + ), + Binding( + key=None, + value=None, + original=Original(string="\n", line=2), + error=False, + ), + ], + ), + ( + "a=b\n\nc=d", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b\n", line=1), + error=False, + ), + Binding( + key="c", + value="d", + original=Original(string="\nc=d", line=2), + error=False, + ), + ], + ), + ( + 'a="\nb=c', + [ + Binding( + key=None, + value=None, + original=Original(string='a="\n', line=1), + error=True, + ), + Binding( + key="b", + value="c", + original=Original(string="b=c", line=2), + error=False, + ), + ], + ), + ( + '# comment\na="b\nc"\nd=e\n', + [ + Binding( + key=None, + value=None, + original=Original(string="# comment\n", line=1), + error=False, + ), + Binding( + key="a", + value="b\nc", + original=Original(string='a="b\nc"\n', line=2), + error=False, + ), + Binding( + key="d", + value="e", + original=Original(string="d=e\n", line=4), + error=False, + ), + ], + ), + ( + "a=b\n# comment 1", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b\n", line=1), + error=False, + ), + Binding( + key=None, + value=None, + original=Original(string="# comment 1", line=2), + error=False, + ), + ], + ), + ( + "# comment 1\n# comment 2", + [ + Binding( + key=None, + value=None, + original=Original(string="# comment 1\n", line=1), + error=False, + ), + Binding( + key=None, + value=None, + original=Original(string="# comment 2", line=2), + error=False, + ), + ], + ), + ( + 'uglyKey[%$="S3cr3t_P4ssw#rD" #\na=b', + [ + Binding( + key="uglyKey[%$", + value="S3cr3t_P4ssw#rD", + original=Original( + string='uglyKey[%$="S3cr3t_P4ssw#rD" #\n', line=1 + ), + error=False, + ), + Binding( + key="a", + value="b", + original=Original(string="a=b", line=2), + error=False, + ), + ], + ), + ], +) def test_parse_stream(test_input, expected): result = parse_stream(io.StringIO(test_input)) diff --git a/tests/test_utils.py b/tests/test_utils.py index d691f0e7..93b8bae2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,12 +2,18 @@ def test_to_cli_string(): - assert c() == 'dotenv' - assert c(path='/etc/.env') == 'dotenv -f /etc/.env' - assert c(path='/etc/.env', action='list') == 'dotenv -f /etc/.env list' - assert c(action='list') == 'dotenv list' - assert c(action='get', key='DEBUG') == 'dotenv get DEBUG' - assert c(action='set', key='DEBUG', value='True') == 'dotenv set DEBUG True' - assert c(action='set', key='SECRET', value='=@asdfasf') == 'dotenv set SECRET =@asdfasf' - assert c(action='set', key='SECRET', value='a b') == 'dotenv set SECRET "a b"' - assert c(action='set', key='SECRET', value='a b', quote="always") == 'dotenv -q always set SECRET "a b"' + assert c() == "dotenv" + assert c(path="/etc/.env") == "dotenv -f /etc/.env" + assert c(path="/etc/.env", action="list") == "dotenv -f /etc/.env list" + assert c(action="list") == "dotenv list" + assert c(action="get", key="DEBUG") == "dotenv get DEBUG" + assert c(action="set", key="DEBUG", value="True") == "dotenv set DEBUG True" + assert ( + c(action="set", key="SECRET", value="=@asdfasf") + == "dotenv set SECRET =@asdfasf" + ) + assert c(action="set", key="SECRET", value="a b") == 'dotenv set SECRET "a b"' + assert ( + c(action="set", key="SECRET", value="a b", quote="always") + == 'dotenv -q always set SECRET "a b"' + ) diff --git a/tests/test_variables.py b/tests/test_variables.py index 86b06466..6f2b2203 100644 --- a/tests/test_variables.py +++ b/tests/test_variables.py @@ -27,7 +27,7 @@ Literal(value="e"), ], ), - ] + ], ) def test_parse_variables(value, expected): result = parse_variables(value) diff --git a/tests/test_zip_imports.py b/tests/test_zip_imports.py index 46d3c02e..5c0fb88d 100644 --- a/tests/test_zip_imports.py +++ b/tests/test_zip_imports.py @@ -1,11 +1,12 @@ import os import sys -import sh import textwrap from typing import List from unittest import mock from zipfile import ZipFile +import sh + def walk_to_root(path: str): last_dir = None @@ -25,16 +26,16 @@ def __init__(self, content: str, path: str): def setup_zipfile(path, files: List[FileToAdd]): zip_file_path = path / "test.zip" dirs_init_py_added_to = set() - with ZipFile(zip_file_path, "w") as zip: + with ZipFile(zip_file_path, "w") as zipfile: for f in files: - zip.writestr(data=f.content, zinfo_or_arcname=f.path) - for dir in walk_to_root(os.path.dirname(f.path)): - if dir not in dirs_init_py_added_to: - print(os.path.join(dir, "__init__.py")) - zip.writestr( - data="", zinfo_or_arcname=os.path.join(dir, "__init__.py") + zipfile.writestr(data=f.content, zinfo_or_arcname=f.path) + for dirname in walk_to_root(os.path.dirname(f.path)): + if dirname not in dirs_init_py_added_to: + print(os.path.join(dirname, "__init__.py")) + zipfile.writestr( + data="", zinfo_or_arcname=os.path.join(dirname, "__init__.py") ) - dirs_init_py_added_to.add(dir) + dirs_init_py_added_to.add(dirname) return zip_file_path diff --git a/tox.ini b/tox.ini index 057a1ae9..186b3046 100644 --- a/tox.ini +++ b/tox.ini @@ -25,10 +25,11 @@ depends = [testenv:lint] skip_install = true deps = - flake8 + ruff mypy commands = - flake8 src tests + ruff check src + ruff check tests mypy --python-version=3.13 src tests mypy --python-version=3.12 src tests mypy --python-version=3.11 src tests From 666984de9a730a54438362b1adedd09bb1e9f5c7 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Tue, 24 Jun 2025 12:21:50 +0530 Subject: [PATCH 185/253] Use sys.exit() instead of exit() (#568) --- src/dotenv/cli.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index c43c63b5..c548aa39 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -77,7 +77,7 @@ def stream_file(path: os.PathLike) -> Iterator[IO[str]]: yield stream except OSError as exc: print(f"Error opening env file: {exc}", file=sys.stderr) - exit(2) + sys.exit(2) @cli.command(name="list") @@ -122,7 +122,7 @@ def set_value(ctx: click.Context, key: Any, value: Any) -> None: if success: click.echo(f"{key}={value}") else: - exit(1) + sys.exit(1) @cli.command() @@ -139,7 +139,7 @@ def get(ctx: click.Context, key: Any) -> None: if stored_value: click.echo(stored_value) else: - exit(1) + sys.exit(1) @cli.command() @@ -153,7 +153,7 @@ def unset(ctx: click.Context, key: Any) -> None: if success: click.echo(f"Successfully removed {key}") else: - exit(1) + sys.exit(1) @cli.command(context_settings={"ignore_unknown_options": True}) @@ -179,7 +179,7 @@ def run(ctx: click.Context, override: bool, commandline: List[str]) -> None: if not commandline: click.echo("No command given.") - exit(1) + sys.exit(1) run_command(commandline, dotenv_as_dict) @@ -213,6 +213,6 @@ def run_command(command: List[str], env: Dict[str, str]) -> None: p = Popen(command, universal_newlines=True, bufsize=0, shell=False, env=cmd_env) _, _ = p.communicate() - exit(p.returncode) + sys.exit(p.returncode) else: os.execvpe(command[0], args=command, env=cmd_env) From c715d19fb88e81f04cf3506a3c2c2812621d1b46 Mon Sep 17 00:00:00 2001 From: matthewfranglen Date: Tue, 24 Jun 2025 11:03:25 +0100 Subject: [PATCH 186/253] feat: add `PYTHON_DOTENV_DISABLED` flag to disable load_dotenv (fixes #510) (#569) Co-authored-by: Saurabh Kumar --- README.md | 4 ++ src/dotenv/main.py | 15 ++++++ tests/test_main.py | 124 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+) diff --git a/README.md b/README.md index e92949ef..7594086b 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,10 @@ Optional flags: - `-o` to override existing variables. - `-v` for increased verbosity. +### Disable load_dotenv + +Set `PYTHON_DOTENV_DISABLED=1` to disable `load_dotenv()` from loading .env files or streams. Useful when you can't modify third-party package calls or in production. + ## Command-line Interface A CLI interface `dotenv` is also included, which helps you manipulate the `.env` file diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 8e6a7cf4..63fbbfcf 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -350,6 +350,12 @@ def load_dotenv( of `find_dotenv()`, you can explicitly call `find_dotenv()` and pass the result to this function as `dotenv_path`. """ + if _load_dotenv_disabled(): + logger.debug( + "python-dotenv: .env loading disabled by PYTHON_DOTENV_DISABLED environment variable" + ) + return False + if dotenv_path is None and stream is None: dotenv_path = find_dotenv() @@ -398,3 +404,12 @@ def dotenv_values( override=True, encoding=encoding, ).dict() + +def _load_dotenv_disabled() -> bool: + """ + Determine if dotenv loading has been disabled. + """ + if "PYTHON_DOTENV_DISABLED" not in os.environ: + return False + value = os.environ["PYTHON_DOTENV_DISABLED"].casefold() + return value in {"1", "true", "t", "yes", "y"} diff --git a/tests/test_main.py b/tests/test_main.py index dfd19274..08b41cd3 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -245,6 +245,130 @@ def test_load_dotenv_existing_file(dotenv_path): assert os.environ == {"a": "b"} +@pytest.mark.parametrize( + "flag_value", + [ + "true", + "yes", + "1", + "t", + "y", + "True", + "Yes", + "TRUE", + "YES", + "T", + "Y", + ], +) +def test_load_dotenv_disabled(dotenv_path, flag_value): + expected_environ = {"PYTHON_DOTENV_DISABLED": flag_value} + with mock.patch.dict(os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True): + dotenv_path.write_text("a=b") + + result = dotenv.load_dotenv(dotenv_path) + + assert result is False + assert os.environ == expected_environ + + +@pytest.mark.parametrize( + "flag_value", + [ + "true", + "yes", + "1", + "t", + "y", + "True", + "Yes", + "TRUE", + "YES", + "T", + "Y", + ], +) +def test_load_dotenv_disabled_notification(dotenv_path, flag_value): + with mock.patch.dict(os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True): + dotenv_path.write_text("a=b") + + logger = logging.getLogger("dotenv.main") + with mock.patch.object(logger, "debug") as mock_debug: + result = dotenv.load_dotenv(dotenv_path) + + assert result is False + mock_debug.assert_called_once_with( + "python-dotenv: .env loading disabled by PYTHON_DOTENV_DISABLED environment variable" + ) + + +@pytest.mark.parametrize( + "flag_value", + [ + "", + "false", + "no", + "0", + "f", + "n", + "False", + "No", + "FALSE", + "NO", + "F", + "N", + ], +) +def test_load_dotenv_enabled(dotenv_path, flag_value): + expected_environ = {"PYTHON_DOTENV_DISABLED": flag_value, "a": "b"} + with mock.patch.dict(os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True): + dotenv_path.write_text("a=b") + + result = dotenv.load_dotenv(dotenv_path) + + assert result is True + assert os.environ == expected_environ + + +@pytest.mark.parametrize( + "flag_value", + [ + "", + "false", + "no", + "0", + "f", + "n", + "False", + "No", + "FALSE", + "NO", + "F", + "N", + ], +) +def test_load_dotenv_enabled_no_notification(dotenv_path, flag_value): + with mock.patch.dict(os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True): + dotenv_path.write_text("a=b") + + logger = logging.getLogger("dotenv.main") + with mock.patch.object(logger, "debug") as mock_debug: + result = dotenv.load_dotenv(dotenv_path) + + assert result is True + mock_debug.assert_not_called() + + +@mock.patch.dict(os.environ, {}, clear=True) +def test_load_dotenv_doesnt_disable_itself(dotenv_path): + dotenv_path.write_text("PYTHON_DOTENV_DISABLED=true") + + result = dotenv.load_dotenv(dotenv_path) + + assert result is True + assert os.environ == {"PYTHON_DOTENV_DISABLED": "true"} + + def test_load_dotenv_no_file_verbose(): logger = logging.getLogger("dotenv.main") From 16f2bdad2ebbaae72790514cce713d2d22ab0f7c Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Tue, 24 Jun 2025 15:53:52 +0530 Subject: [PATCH 187/253] Update spacing and docs --- CHANGELOG.md | 6 +++++- src/dotenv/main.py | 26 +++++++++++++++----------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0669eaed..c83661a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] - 2025-06-24 + +- Add support for disabling of `load_dotenv()` using `PYTHON_DOTENV_DISABLED` env var. + ## [1.1.1] - 2025-06-24 -## Fixed +### Fixed * CLI: Ensure `find_dotenv` work reliably on python 3.13 by [@theskumar] in [#563](https://github.com/theskumar/python-dotenv/pull/563) * CLI: revert the use of execvpe on Windows by [@wrongontheinternet] in [#566](https://github.com/theskumar/python-dotenv/pull/566) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 63fbbfcf..b6de171c 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -21,6 +21,16 @@ logger = logging.getLogger(__name__) +def _load_dotenv_disabled() -> bool: + """ + Determine if dotenv loading has been disabled. + """ + if "PYTHON_DOTENV_DISABLED" not in os.environ: + return False + value = os.environ["PYTHON_DOTENV_DISABLED"].casefold() + return value in {"1", "true", "t", "yes", "y"} + + def with_warn_for_invalid_lines(mappings: Iterator[Binding]) -> Iterator[Binding]: for mapping in mappings: if mapping.error: @@ -349,11 +359,14 @@ def load_dotenv( .env file with it's default parameters. If you need to change the default parameters of `find_dotenv()`, you can explicitly call `find_dotenv()` and pass the result to this function as `dotenv_path`. + + If the environment variable `PYTHON_DOTENV_DISABLED` is set to a truthy value, + .env loading is disabled. """ if _load_dotenv_disabled(): logger.debug( - "python-dotenv: .env loading disabled by PYTHON_DOTENV_DISABLED environment variable" - ) + "python-dotenv: .env loading disabled by PYTHON_DOTENV_DISABLED environment variable" + ) return False if dotenv_path is None and stream is None: @@ -404,12 +417,3 @@ def dotenv_values( override=True, encoding=encoding, ).dict() - -def _load_dotenv_disabled() -> bool: - """ - Determine if dotenv loading has been disabled. - """ - if "PYTHON_DOTENV_DISABLED" not in os.environ: - return False - value = os.environ["PYTHON_DOTENV_DISABLED"].casefold() - return value in {"1", "true", "t", "yes", "y"} From a2bc2b3d3099d4fb74f8fd4782f0f6a747fd0fea Mon Sep 17 00:00:00 2001 From: Naman Aarzoo <84902335+23f3001135@users.noreply.github.com> Date: Tue, 14 Oct 2025 16:27:15 +0530 Subject: [PATCH 188/253] Added Python@3.14: Github CI & tox.ini (#579) --- .github/workflows/test.yml | 2 +- tox.ini | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fc86910d..b2df31f9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ jobs: os: - ubuntu-latest python-version: - ["3.9", "3.10", "3.11", "3.12", "3.13", pypy3.9, pypy3.10] + ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", pypy3.9, pypy3.10] steps: - uses: actions/checkout@v4 diff --git a/tox.ini b/tox.ini index 186b3046..7082d974 100644 --- a/tox.ini +++ b/tox.ini @@ -8,6 +8,7 @@ python = 3.11: py311 3.12: py312 3.13: py313, lint, manifest + 3.14: py314 pypy-3.9: pypy3 [testenv] @@ -16,11 +17,11 @@ deps = pytest-cov sh >= 2.0.2, <3 click - py{39,310,311,312,313,pypy3}: ipython + py{39,310,311,312,313,3.14,pypy3}: ipython commands = pytest --cov --cov-report=term-missing --cov-config setup.cfg {posargs} depends = - py{39,310,311,312,313},pypy3: coverage-clean - coverage-report: py{39,310,311,312,313},pypy3 + py{39,310,311,312,313,314},pypy3: coverage-clean + coverage-report: py{39,310,311,312,313,314},pypy3 [testenv:lint] skip_install = true @@ -30,6 +31,7 @@ deps = commands = ruff check src ruff check tests + mypy --python-version=3.14 src tests mypy --python-version=3.13 src tests mypy --python-version=3.12 src tests mypy --python-version=3.11 src tests From 9f722ce65c3fbb3bb789f80152dd6b36e8017e55 Mon Sep 17 00:00:00 2001 From: Tomiwa Kunle Oluwadare Date: Tue, 14 Oct 2025 11:59:18 +0100 Subject: [PATCH 189/253] docs: clarify what load_dotenv() does in README (#575) * docs: clarify what load_dotenv() does in README * docs: clarify what load_dotenv() does in README --- README.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7594086b..9582057a 100644 --- a/README.md +++ b/README.md @@ -36,13 +36,20 @@ configurable via the environment: ```python from dotenv import load_dotenv -load_dotenv() # take environment variables +load_dotenv() # reads variables from a .env file and sets them in os.environ +``` + # Code of your application, which uses environment variables (e.g. from `os.environ` or # `os.getenv`) as if they came from the actual environment. -``` -By default, `load_dotenv` doesn't override existing environment variables and looks for a `.env` file in same directory as python script or searches for it incrementally higher up. + +By default, `load_dotenv()` will: + + +- Look for a `.env` file in the same directory as the Python script (or higher up the directory tree). +- Read each key-value pair and add it to `os.environ`. +- **Not override** an environment variable that is already set, unless you explicitly pass `override=True`. To configure the development environment, add a `.env` in the root directory of your project: From f288da176ff6db0bd5c1ae249453eb7ef5afbca1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Oct 2025 16:30:13 +0530 Subject: [PATCH 190/253] Bump the github-actions group across 1 directory with 2 updates (#577) Bumps the github-actions group with 2 updates in the / directory: [actions/checkout](https://github.com/actions/checkout) and [actions/setup-python](https://github.com/actions/setup-python). Updates `actions/checkout` from 4 to 5 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v5) Updates `actions/setup-python` from 5 to 6 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/setup-python dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 4 ++-- .github/workflows/test.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 67668d53..a5689dc1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,9 +8,9 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.x' - name: Install dependencies diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b2df31f9..e3151bb9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,10 +16,10 @@ jobs: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", pypy3.9, pypy3.10] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} allow-prereleases: true From 8b4e13d0ca3619f89cec4ac744ef4be6afd3fa23 Mon Sep 17 00:00:00 2001 From: Laurie O Date: Sun, 26 Oct 2025 23:14:16 +1000 Subject: [PATCH 191/253] Move project metadata and config to pyproject.toml (#583) * Build with 'build' * Declare build backend and requirements * Move project metadata and config to pyproject.toml --- .github/workflows/release.yml | 2 +- Makefile | 2 +- pyproject.toml | 61 ++++++++++++++++++++++++++++++ setup.cfg | 3 -- setup.py | 71 ----------------------------------- 5 files changed, 63 insertions(+), 76 deletions(-) create mode 100644 pyproject.toml delete mode 100644 setup.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5689dc1..59b71797 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install setuptools wheel twine + pip install build twine - name: Build and publish env: TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} diff --git a/Makefile b/Makefile index e5bcb308..39f90d1e 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ release-test: sdist twine upload --repository-url https://test.pypi.org/legacy/ dist/* sdist: clean - python setup.py sdist bdist_wheel + python -m build -d dist . ls -l dist test: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..3ddd1360 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,61 @@ +[build-system] +requires = ["setuptools >= 77.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "python-dotenv" +description = "Read key-value pairs from a .env file and set them as environment variables" +authors = [ + {name = "Saurabh Kumar", email = "me+github@saurabh-kumar.com"}, +] +license = "BSD-3-Clause" +keywords = [ + "environment variables", + "deployments", + "settings", + "env", + "dotenv", + "configurations", + "python", +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: PyPy", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Operating System :: OS Independent", + "Topic :: System :: Systems Administration", + "Topic :: Utilities", + "Environment :: Web Environment", +] + +requires-python = ">=3.9" + +dynamic = ["version", "readme"] + +[project.urls] +Source = "https://github.com/theskumar/python-dotenv" + +[project.optional-dependencies] +cli = [ + "click>=5.0", +] + +[project.scripts] +dotenv = "dotenv.__main__:cli" + +[tool.setuptools] +packages = ["dotenv"] +package-dir = {"" = "src"} +package-data = {dotenv = ["py.typed"]} + +[tool.setuptools.dynamic] +version = {attr = "dotenv.version.__version__"} +readme = {file = ["README.md", "CHANGELOG.md"], content-type = "text/markdown"} diff --git a/setup.cfg b/setup.cfg index 60effd2b..8ab2d3af 100644 --- a/setup.cfg +++ b/setup.cfg @@ -13,9 +13,6 @@ exclude = .tox,.git,docs,venv,.venv,build check_untyped_defs = true ignore_missing_imports = true -[metadata] -description_file = README.md - [tool:pytest] testpaths = tests diff --git a/setup.py b/setup.py deleted file mode 100644 index f3d43ca1..00000000 --- a/setup.py +++ /dev/null @@ -1,71 +0,0 @@ -from setuptools import setup - - -def read_files(files): - data = [] - for file in files: - with open(file, encoding="utf-8") as f: - data.append(f.read()) - return "\n".join(data) - - -long_description = read_files(["README.md", "CHANGELOG.md"]) - -meta = {} -with open("./src/dotenv/version.py", encoding="utf-8") as f: - exec(f.read(), meta) - -setup( - name="python-dotenv", - description="Read key-value pairs from a .env file and set them as environment variables", - long_description=long_description, - long_description_content_type="text/markdown", - version=meta["__version__"], - author="Saurabh Kumar", - author_email="me+github@saurabh-kumar.com", - url="https://github.com/theskumar/python-dotenv", - keywords=[ - "environment variables", - "deployments", - "settings", - "env", - "dotenv", - "configurations", - "python", - ], - packages=["dotenv"], - package_dir={"": "src"}, - package_data={ - "dotenv": ["py.typed"], - }, - python_requires=">=3.9", - extras_require={ - "cli": [ - "click>=5.0", - ], - }, - entry_points={ - "console_scripts": [ - "dotenv=dotenv.__main__:cli", - ], - }, - license="BSD-3-Clause", - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: Implementation :: PyPy", - "Intended Audience :: Developers", - "Intended Audience :: System Administrators", - "License :: OSI Approved :: BSD License", - "Operating System :: OS Independent", - "Topic :: System :: Systems Administration", - "Topic :: Utilities", - "Environment :: Web Environment", - ], -) From 80cfe9f47d429190a7976bd47295f8c4527eec89 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 26 Oct 2025 18:55:53 +0530 Subject: [PATCH 192/253] Fix build command --- .github/workflows/release.yml | 40 +++++++++++++++++------------------ Makefile | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59b71797..227aa679 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,24 +8,24 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build twine - - name: Build and publish - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - make release + - uses: actions/checkout@v5 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.x" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + make release - - name: Publish Documentation - run: | - pip install -r requirements-docs.txt - pip install -e . - mkdocs gh-deploy --force + - name: Publish Documentation + run: | + pip install -r requirements-docs.txt + pip install -e . + mkdocs gh-deploy --force diff --git a/Makefile b/Makefile index 39f90d1e..934b07a4 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ release-test: sdist twine upload --repository-url https://test.pypi.org/legacy/ dist/* sdist: clean - python -m build -d dist . + python -m build -o dist . ls -l dist test: From 1fe11cc737ee4399e9c51d1b69b0dd858f6b4669 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 26 Oct 2025 19:05:03 +0530 Subject: [PATCH 193/253] upadate changelog --- CHANGELOG.md | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c83661a4..f61549ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] - 2025-06-24 +## [1.2.0] - 2025-10-26 -- Add support for disabling of `load_dotenv()` using `PYTHON_DOTENV_DISABLED` env var. +- Upgrade build system to use PEP 517 & PEP 518 to use `build` and `pyproject.toml` by [@EpicWink] in [#583] +- Add support for Python 3.14 by [@23f3001135] in [#579](https://github.com/theskumar/python-dotenv/pull/563) +- Add support for disabling of `load_dotenv()` using `PYTHON_DOTENV_DISABLED` env var. by [@matthewfranglen] in [#569] ## [1.1.1] - 2025-06-24 @@ -375,7 +377,15 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [#474]: https://github.com/theskumar/python-dotenv/issues/474 [#523]: https://github.com/theskumar/python-dotenv/issues/523 [#553]: https://github.com/theskumar/python-dotenv/issues/553 +[#569]: https://github.com/theskumar/python-dotenv/issues/569 +[#583]: https://github.com/theskumar/python-dotenv/issues/583 +[@23f3001135]: https://github.com/23f3001135 +[@EpicWink]: https://github.com/EpicWink +[@Flimm]: https://github.com/Flimm +[@Nicals]: https://github.com/Nicals +[@Nougat-Waffle]: https://github.com/Nougat-Waffle +[@Qwerty-133]: https://github.com/Qwerty-133 [@alanjds]: https://github.com/alanjds [@altendky]: https://github.com/altendky [@andrewsmith]: https://github.com/andrewsmith @@ -390,7 +400,6 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@ekohl]: https://github.com/ekohl [@elbehery95]: https://github.com/elbehery95 [@eumiro]: https://github.com/eumiro -[@Flimm]: https://github.com/Flimm [@freddyaboulton]: https://github.com/freddyaboulton [@gergelyk]: https://github.com/gergelyk [@gongqingkui]: https://github.com/gongqingkui @@ -401,13 +410,12 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@jctanner]: https://github.com/jctanner [@larsks]: https://github.com/@larsks [@lsmith77]: https://github.com/lsmith77 +[@matthewfranglen]: https://github.com/matthewfranglen [@mgorny]: https://github.com/mgorny [@naorlivne]: https://github.com/@naorlivne -[@Nicals]: https://github.com/Nicals -[@Nougat-Waffle]: https://github.com/Nougat-Waffle [@qnighy]: https://github.com/qnighy -[@Qwerty-133]: https://github.com/Qwerty-133 [@rabinadk1]: https://github.com/@rabinadk1 +[@randomseed42]: https://github.com/zueve [@sammck]: https://github.com/@sammck [@samwyma]: https://github.com/samwyma [@snobu]: https://github.com/snobu @@ -416,14 +424,14 @@ 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 +[@wrongontheinternet]: https://github.com/wrongontheinternet [@x-yuri]: https://github.com/x-yuri [@yannham]: https://github.com/yannham [@zueve]: https://github.com/zueve -[@randomseed42]: https://github.com/zueve -[@wrongontheinternet]: https://github.com/wrongontheinternet -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.1.1...HEAD -[1.1.1]: https://github.com/theskumar/python-dotenv/compare/v1.1.0...1.1.1 +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.2.0...HEAD +[1.2.0]: https://github.com/theskumar/python-dotenv/compare/v1.1.1...v1.2.0 +[1.1.1]: https://github.com/theskumar/python-dotenv/compare/v1.1.0...v1.1.1 [1.1.0]: https://github.com/theskumar/python-dotenv/compare/v1.0.1...v1.1.0 [1.0.1]: https://github.com/theskumar/python-dotenv/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/theskumar/python-dotenv/compare/v0.21.0...v1.0.0 From 5bf882241c607445bf02cf5b241535d62e2b99c1 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 26 Oct 2025 19:05:14 +0530 Subject: [PATCH 194/253] =?UTF-8?q?Bump=20version:=201.1.1=20=E2=86=92=201?= =?UTF-8?q?.2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- src/dotenv/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 8ab2d3af..608e1b02 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.1.1 +current_version = 1.2.0 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index a82b376d..c68196d1 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "1.1.1" +__version__ = "1.2.0" From 8ed4f79d202eba582b44bdf1f5deb726dd68783d Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 26 Oct 2025 19:11:03 +0530 Subject: [PATCH 195/253] Update docs requirements --- requirements-docs.txt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 7f8b71f3..b09a710d 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -1,5 +1,5 @@ -mdx_truly_sane_lists~=1.2 -mkdocs-include-markdown-plugin~=3.3.0 -mkdocs-material~=8.2.9 -mkdocstrings[python]~=0.18.1 -mkdocs~=1.3.0 +mdx_truly_sane_lists>=1.3 +mkdocs-include-markdown-plugin>=6.0.0 +mkdocs-material>=9.5.0 +mkdocstrings[python]>=0.24.0 +mkdocs>=1.5.0 From 222ce2cc58ebc82ba78da8781269267b9f585932 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 26 Oct 2025 19:29:30 +0530 Subject: [PATCH 196/253] Update to use trusted publisher on pypi --- .github/workflows/release.yml | 24 ++++++++++++++++-------- Makefile | 7 ------- requirements.txt | 2 +- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 227aa679..c56ca621 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,24 +5,32 @@ on: types: [created] jobs: - deploy: + publish: runs-on: ubuntu-latest + # Specifying a GitHub environment is optional, but strongly encouraged + environment: release + permissions: + # IMPORTANT: this permission is mandatory for trusted publishing + id-token: write + steps: - uses: actions/checkout@v5 + - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.x" + - name: Install dependencies run: | python -m pip install --upgrade pip - pip install build twine - - name: Build and publish - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - make release + pip install build + + - name: Build package distributions + run: make sdist + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 - name: Publish Documentation run: | diff --git a/Makefile b/Makefile index 934b07a4..1064482f 100644 --- a/Makefile +++ b/Makefile @@ -12,13 +12,6 @@ clean-pyc: find . -name '*.pyo' -exec rm -f {} + find . -name '*~' -exec rm -f {} + -release: sdist - twine check dist/* - twine upload dist/* - -release-test: sdist - twine upload --repository-url https://test.pypi.org/legacy/ dist/* - sdist: clean python -m build -o dist . ls -l dist diff --git a/requirements.txt b/requirements.txt index 660c5dcc..d3d0199f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ pytest-cov pytest>=3.9 sh>=2 tox -twine wheel ruff +build pre-commit From 76999e741d87e958ebd74e3ae9834c0514e77a59 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 26 Oct 2025 19:44:54 +0530 Subject: [PATCH 197/253] Move more config pyproject.toml --- .bumpversion.cfg | 6 ++++++ Makefile | 5 ++++- pyproject.toml | 28 ++++++++++++++++++++++++++++ setup.cfg | 34 ---------------------------------- tests/test_main.py | 18 +++++++++++++----- tox.ini | 9 +++++++-- 6 files changed, 58 insertions(+), 42 deletions(-) create mode 100644 .bumpversion.cfg delete mode 100644 setup.cfg diff --git a/.bumpversion.cfg b/.bumpversion.cfg new file mode 100644 index 00000000..2dcc3175 --- /dev/null +++ b/.bumpversion.cfg @@ -0,0 +1,6 @@ +[bumpversion] +current_version = 1.2.0 +commit = True +tag = True + +[bumpversion:file:src/dotenv/version.py] diff --git a/Makefile b/Makefile index 1064482f..718f2b2e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: clean-pyc clean-build test +.PHONY: clean-pyc clean-build test fmt clean: clean-build clean-pyc @@ -21,6 +21,9 @@ test: ruff check . pytest tests/ +fmt: + ruff format src tests + coverage: coverage run --source=dotenv --omit='*tests*' -m py.test tests/ -v --tb=native coverage report diff --git a/pyproject.toml b/pyproject.toml index 3ddd1360..f476c6f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,3 +59,31 @@ package-data = {dotenv = ["py.typed"]} [tool.setuptools.dynamic] version = {attr = "dotenv.version.__version__"} readme = {file = ["README.md", "CHANGELOG.md"], content-type = "text/markdown"} + +[tool.pytest.ini_options] +testpaths = [ + "tests", +] + +[tool.coverage.run] +relative_files = true +source = ["dotenv"] + +[tool.coverage.paths] +source = [ + "src/dotenv", + ".tox/*/lib/python*/site-packages/dotenv", + ".tox/pypy*/site-packages/dotenv", +] + +[tool.coverage.report] +show_missing = true +include = ["*/site-packages/dotenv/*"] +exclude_lines = [ + "if IS_TYPE_CHECKING:", + "pragma: no cover", +] + +[tool.mypy] +check_untyped_defs = true +ignore_missing_imports = true diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 608e1b02..00000000 --- a/setup.cfg +++ /dev/null @@ -1,34 +0,0 @@ -[bumpversion] -current_version = 1.2.0 -commit = True -tag = True - -[bumpversion:file:src/dotenv/version.py] - -[flake8] -max-line-length = 120 -exclude = .tox,.git,docs,venv,.venv,build - -[mypy] -check_untyped_defs = true -ignore_missing_imports = true - -[tool:pytest] -testpaths = tests - -[coverage:run] -relative_files = True -source = dotenv - -[coverage:paths] -source = - src/dotenv - .tox/*/lib/python*/site-packages/dotenv - .tox/pypy*/site-packages/dotenv - -[coverage:report] -show_missing = True -include = */site-packages/dotenv/* -exclude_lines = - if IS_TYPE_CHECKING: - pragma: no cover diff --git a/tests/test_main.py b/tests/test_main.py index 08b41cd3..44961117 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -263,7 +263,9 @@ def test_load_dotenv_existing_file(dotenv_path): ) def test_load_dotenv_disabled(dotenv_path, flag_value): expected_environ = {"PYTHON_DOTENV_DISABLED": flag_value} - with mock.patch.dict(os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True): + with mock.patch.dict( + os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True + ): dotenv_path.write_text("a=b") result = dotenv.load_dotenv(dotenv_path) @@ -289,7 +291,9 @@ def test_load_dotenv_disabled(dotenv_path, flag_value): ], ) def test_load_dotenv_disabled_notification(dotenv_path, flag_value): - with mock.patch.dict(os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True): + with mock.patch.dict( + os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True + ): dotenv_path.write_text("a=b") logger = logging.getLogger("dotenv.main") @@ -298,7 +302,7 @@ def test_load_dotenv_disabled_notification(dotenv_path, flag_value): assert result is False mock_debug.assert_called_once_with( - "python-dotenv: .env loading disabled by PYTHON_DOTENV_DISABLED environment variable" + "python-dotenv: .env loading disabled by PYTHON_DOTENV_DISABLED environment variable" ) @@ -321,7 +325,9 @@ def test_load_dotenv_disabled_notification(dotenv_path, flag_value): ) def test_load_dotenv_enabled(dotenv_path, flag_value): expected_environ = {"PYTHON_DOTENV_DISABLED": flag_value, "a": "b"} - with mock.patch.dict(os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True): + with mock.patch.dict( + os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True + ): dotenv_path.write_text("a=b") result = dotenv.load_dotenv(dotenv_path) @@ -348,7 +354,9 @@ def test_load_dotenv_enabled(dotenv_path, flag_value): ], ) def test_load_dotenv_enabled_no_notification(dotenv_path, flag_value): - with mock.patch.dict(os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True): + with mock.patch.dict( + os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True + ): dotenv_path.write_text("a=b") logger = logging.getLogger("dotenv.main") diff --git a/tox.ini b/tox.ini index 7082d974..38b51258 100644 --- a/tox.ini +++ b/tox.ini @@ -29,8 +29,8 @@ deps = ruff mypy commands = - ruff check src - ruff check tests + ruff check src tests + ruff format --check src tests mypy --python-version=3.14 src tests mypy --python-version=3.13 src tests mypy --python-version=3.12 src tests @@ -38,6 +38,11 @@ commands = mypy --python-version=3.10 src tests mypy --python-version=3.9 src tests +[testenv:format] +skip_install = true +deps = ruff +commands = ruff format src tests + [testenv:manifest] deps = check-manifest skip_install = true From 467ee22fccb2fb7ccda71a0d9e37c6ea3cb8d993 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 26 Oct 2025 19:52:04 +0530 Subject: [PATCH 198/253] Fix test failures after moving config to pyproject.toml - Remove --cov-config setup.cfg reference in tox.ini since setup.cfg was deleted - Fix coverage report configuration by replacing 'include' with 'omit' to exclude tests - Coverage now reads configuration from pyproject.toml automatically --- pyproject.toml | 2 +- tox.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f476c6f1..f8baeac1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,7 +78,7 @@ source = [ [tool.coverage.report] show_missing = true -include = ["*/site-packages/dotenv/*"] +omit = ["*/tests/*"] exclude_lines = [ "if IS_TYPE_CHECKING:", "pragma: no cover", diff --git a/tox.ini b/tox.ini index 38b51258..d5959e1e 100644 --- a/tox.ini +++ b/tox.ini @@ -18,7 +18,7 @@ deps = sh >= 2.0.2, <3 click py{39,310,311,312,313,3.14,pypy3}: ipython -commands = pytest --cov --cov-report=term-missing --cov-config setup.cfg {posargs} +commands = pytest --cov --cov-report=term-missing {posargs} depends = py{39,310,311,312,313,314},pypy3: coverage-clean coverage-report: py{39,310,311,312,313,314},pypy3 From 3af77d3029eb717aeec0a3c25f751b6a614a6d3c Mon Sep 17 00:00:00 2001 From: Sidharth Sudhir <59572198+sidharth-sudhir@users.noreply.github.com> Date: Sun, 26 Oct 2025 10:27:02 -0400 Subject: [PATCH 199/253] Support reading .env from FIFOs (Unix) (#586) * Support reading .env from FIFOs (Unix) * handle FileNotFoundError in FIFO checks and add FIFO load test --------- Co-authored-by: Sidharth Sudhir --- src/dotenv/main.py | 20 ++++++++++++++++++-- tests/test_fifo_dotenv.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 tests/test_fifo_dotenv.py diff --git a/src/dotenv/main.py b/src/dotenv/main.py index b6de171c..1d6bf0b0 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -3,6 +3,7 @@ import os import pathlib import shutil +import stat import sys import tempfile from collections import OrderedDict @@ -61,7 +62,7 @@ def __init__( @contextmanager def _get_stream(self) -> Iterator[IO[str]]: - if self.dotenv_path and os.path.isfile(self.dotenv_path): + if self.dotenv_path and _is_file_or_fifo(self.dotenv_path): with open(self.dotenv_path, encoding=self.encoding) as stream: yield stream elif self.stream is not None: @@ -325,7 +326,7 @@ def _is_debugger(): for dirname in _walk_to_root(path): check_path = os.path.join(dirname, filename) - if os.path.isfile(check_path): + if _is_file_or_fifo(check_path): return check_path if raise_error_if_not_found: @@ -417,3 +418,18 @@ def dotenv_values( override=True, encoding=encoding, ).dict() + + +def _is_file_or_fifo(path: StrPath) -> bool: + """ + Return True if `path` exists and is either a regular file or a FIFO. + """ + if os.path.isfile(path): + return True + + try: + st = os.stat(path) + except (FileNotFoundError, OSError): + return False + + return stat.S_ISFIFO(st.st_mode) diff --git a/tests/test_fifo_dotenv.py b/tests/test_fifo_dotenv.py new file mode 100644 index 00000000..4961adce --- /dev/null +++ b/tests/test_fifo_dotenv.py @@ -0,0 +1,33 @@ +import os +import pathlib +import sys +import threading + +import pytest + +from dotenv import load_dotenv + +pytestmark = pytest.mark.skipif( + sys.platform.startswith("win"), reason="FIFOs are Unix-only" +) + + +def test_load_dotenv_from_fifo(tmp_path: pathlib.Path, monkeypatch): + fifo = tmp_path / ".env" + os.mkfifo(fifo) # create named pipe + + def writer(): + with open(fifo, "w", encoding="utf-8") as w: + w.write("MY_PASSWORD=pipe-secret\n") + + t = threading.Thread(target=writer) + t.start() + + # Ensure env is clean + monkeypatch.delenv("MY_PASSWORD", raising=False) + + ok = load_dotenv(dotenv_path=str(fifo), override=True) + t.join(timeout=2) + + assert ok is True + assert os.getenv("MY_PASSWORD") == "pipe-secret" From b87807fcad6e74332c3c63a75c92ce5814fa7a55 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 26 Oct 2025 20:02:48 +0530 Subject: [PATCH 200/253] Update changelog --- CHANGELOG.md | 9 +++++++++ Makefile | 4 ++++ README.md | 5 +---- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f61549ff..1b362fdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.1] - 2025-10-26 + +- Move more config to `pyproject.toml`, removed `setup.cfg` +- Add support for reading `.env` from FIFOs (Unix) by [@sidharth-sudhir] in [#586] + ## [1.2.0] - 2025-10-26 - Upgrade build system to use PEP 517 & PEP 518 to use `build` and `pyproject.toml` by [@EpicWink] in [#583] @@ -361,6 +366,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@hugochinchilla](https://github.com/hugochinchilla)). - Improved test coverage. + [#78]: https://github.com/theskumar/python-dotenv/issues/78 [#121]: https://github.com/theskumar/python-dotenv/issues/121 [#148]: https://github.com/theskumar/python-dotenv/issues/148 @@ -379,7 +385,9 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [#553]: https://github.com/theskumar/python-dotenv/issues/553 [#569]: https://github.com/theskumar/python-dotenv/issues/569 [#583]: https://github.com/theskumar/python-dotenv/issues/583 +[#586]: https://github.com/theskumar/python-dotenv/issues/586 + [@23f3001135]: https://github.com/23f3001135 [@EpicWink]: https://github.com/EpicWink [@Flimm]: https://github.com/Flimm @@ -418,6 +426,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@randomseed42]: https://github.com/zueve [@sammck]: https://github.com/@sammck [@samwyma]: https://github.com/samwyma +[@sidharth-sudhir]: https://github.com/sidharth-sudhir [@snobu]: https://github.com/snobu [@techalchemy]: https://github.com/techalchemy [@theGOTOguy]: https://github.com/theGOTOguy diff --git a/Makefile b/Makefile index 718f2b2e..7433322f 100644 --- a/Makefile +++ b/Makefile @@ -4,8 +4,12 @@ clean: clean-build clean-pyc clean-build: rm -fr build/ + rm -rf .mypy_cache/ + rm -rf .tox/ + rm -rf site/ rm -fr dist/ rm -fr src/*.egg-info + rm .coverage clean-pyc: find . -name '*.pyc' -exec rm -f {} + diff --git a/README.md b/README.md index 9582057a..6df13fab 100644 --- a/README.md +++ b/README.md @@ -37,16 +37,13 @@ configurable via the environment: from dotenv import load_dotenv load_dotenv() # reads variables from a .env file and sets them in os.environ -``` - # Code of your application, which uses environment variables (e.g. from `os.environ` or # `os.getenv`) as if they came from the actual environment. - +``` By default, `load_dotenv()` will: - - Look for a `.env` file in the same directory as the Python script (or higher up the directory tree). - Read each key-value pair and add it to `os.environ`. - **Not override** an environment variable that is already set, unless you explicitly pass `override=True`. From 8716196891532eeb67d24a513e8d975437f5e8b7 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 26 Oct 2025 20:35:18 +0530 Subject: [PATCH 201/253] =?UTF-8?q?Bump=20version:=201.2.0=20=E2=86=92=201?= =?UTF-8?q?.2.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- src/dotenv/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 2dcc3175..a72da630 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.2.0 +current_version = 1.2.1 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index c68196d1..a955fdae 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "1.2.0" +__version__ = "1.2.1" From eaf2a9129ccec6febda0f741eb3bb852c3f947bd Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 26 Oct 2025 20:37:34 +0530 Subject: [PATCH 202/253] Do not remove .coverage file --- Makefile | 1 - 1 file changed, 1 deletion(-) diff --git a/Makefile b/Makefile index 7433322f..78866a60 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,6 @@ clean-build: rm -rf site/ rm -fr dist/ rm -fr src/*.egg-info - rm .coverage clean-pyc: find . -name '*.pyc' -exec rm -f {} + From 85f43295ccb2d15d13da370954e5b85079f4a56c Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 26 Oct 2025 20:47:08 +0530 Subject: [PATCH 203/253] Fix gh-pages deployment permission issue - Add contents: write permission for pushing to gh-pages branch - Replace mkdocs gh-deploy with peaceiris/actions-gh-pages action - Split documentation build and deploy into separate steps for better reliability --- .github/workflows/release.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c56ca621..59e3e9b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,8 @@ jobs: permissions: # IMPORTANT: this permission is mandatory for trusted publishing id-token: write + # Required for pushing to gh-pages branch + contents: write steps: - uses: actions/checkout@v5 @@ -32,8 +34,14 @@ jobs: - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - - name: Publish Documentation + - name: Build Documentation run: | pip install -r requirements-docs.txt pip install -e . - mkdocs gh-deploy --force + mkdocs build + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./site From d10ea618b4c87c14590709e7ca2c1fa4e28333f8 Mon Sep 17 00:00:00 2001 From: James Ouyang Date: Wed, 12 Nov 2025 13:59:42 -0800 Subject: [PATCH 204/253] fix: Add missing 3.14 PyPI classifier. --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index f8baeac1..0aeb9819 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: PyPy", "Intended Audience :: Developers", "Intended Audience :: System Administrators", From 0525fc94d766875e0ee4089a4c6f44e6b6aefc0a Mon Sep 17 00:00:00 2001 From: Balaje Suri Date: Fri, 23 May 2025 11:02:55 +0200 Subject: [PATCH 205/253] skip 000 permission tests for root user --- tests/test_main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_main.py b/tests/test_main.py index 44961117..17b488c4 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -61,6 +61,7 @@ def test_set_key_encoding(dotenv_path): assert dotenv_path.read_text(encoding=encoding) == "a='é'\n" +@pytest.mark.skipif(os.geteuid() == 0, reason="Root user can access files even with 000 permissions.") def test_set_key_permission_error(dotenv_path): dotenv_path.chmod(0o000) @@ -167,6 +168,7 @@ def test_unset_encoding(dotenv_path): assert dotenv_path.read_text(encoding=encoding) == "" +@pytest.mark.skipif(os.geteuid() == 0, reason="Root user can access files even with 000 permissions.") def test_set_key_unauthorized_file(dotenv_path): dotenv_path.chmod(0o000) From cd48b58b2c0f907f61e023415a12dfaeb762cb7a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 13:37:41 +0000 Subject: [PATCH 206/253] Bump actions/checkout from 5 to 6 in the github-actions group Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 5 to 6 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59e3e9b1..dc91d369 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: contents: write steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e3151bb9..3f68669d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", pypy3.9, pypy3.10] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 From f54d29f3af5a38078f9db21b1c9b545ece4f1c08 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 10 Jan 2026 18:28:51 +0100 Subject: [PATCH 207/253] Fix formatting I somehow merged a malformatted PR earlier today. Let's fix the formatting first. --- tests/test_main.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index 17b488c4..76c1f70e 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -61,7 +61,9 @@ def test_set_key_encoding(dotenv_path): assert dotenv_path.read_text(encoding=encoding) == "a='é'\n" -@pytest.mark.skipif(os.geteuid() == 0, reason="Root user can access files even with 000 permissions.") +@pytest.mark.skipif( + os.geteuid() == 0, reason="Root user can access files even with 000 permissions." +) def test_set_key_permission_error(dotenv_path): dotenv_path.chmod(0o000) @@ -168,7 +170,9 @@ def test_unset_encoding(dotenv_path): assert dotenv_path.read_text(encoding=encoding) == "" -@pytest.mark.skipif(os.geteuid() == 0, reason="Root user can access files even with 000 permissions.") +@pytest.mark.skipif( + os.geteuid() == 0, reason="Root user can access files even with 000 permissions." +) def test_set_key_unauthorized_file(dotenv_path): dotenv_path.chmod(0o000) From 9f3b8b50e4850d84d102640883560769dcb5553e Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Mon, 12 Jan 2026 09:43:58 +0100 Subject: [PATCH 208/253] Make `dotenv run` forward flags to given command (#607) Changes for users: - (BREAKING) Forward flags passed after `dotenv run` to the given command instead of interpreting them. - This means that an invocation such as `dotenv run ls --help` will show the help page of `ls` instead of that of `dotenv run`. - To pass flags to `dotenv run` itself, pass them right after `run`: `dotenv run --help` or `dotenv run --override ls`. - As usual, generic options should be passed right after `dotenv`: `dotenv --file path/to/env run ls` --- src/dotenv/cli.py | 13 ++++++++++--- tests/test_cli.py | 44 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index c548aa39..7a4c7adc 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -156,7 +156,13 @@ def unset(ctx: click.Context, key: Any) -> None: sys.exit(1) -@cli.command(context_settings={"ignore_unknown_options": True}) +@cli.command( + context_settings={ + "allow_extra_args": True, + "allow_interspersed_args": False, + "ignore_unknown_options": True, + } +) @click.pass_context @click.option( "--override/--no-override", @@ -164,7 +170,7 @@ def unset(ctx: click.Context, key: Any) -> None: help="Override variables from the environment file with those from the .env file.", ) @click.argument("commandline", nargs=-1, type=click.UNPROCESSED) -def run(ctx: click.Context, override: bool, commandline: List[str]) -> None: +def run(ctx: click.Context, override: bool, commandline: tuple[str, ...]) -> None: """Run command with environment variables present.""" file = ctx.obj["FILE"] if not os.path.isfile(file): @@ -180,7 +186,8 @@ def run(ctx: click.Context, override: bool, commandline: List[str]) -> None: if not commandline: click.echo("No command given.") sys.exit(1) - run_command(commandline, dotenv_as_dict) + + run_command([*commandline, *ctx.args], dotenv_as_dict) def run_command(command: List[str], env: Dict[str, str]) -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index 343fdb23..7cc4533d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ import os +import subprocess from pathlib import Path -from typing import Optional +from typing import Optional, Sequence import pytest import sh @@ -10,6 +11,21 @@ from dotenv.version import __version__ +def invoke_sub(args: Sequence[str]) -> subprocess.CompletedProcess: + """ + Invoke the `dotenv` CLI in a subprocess. + + This is necessary to test subcommands like `dotenv run` that replace the + current process. + """ + + return subprocess.run( + ["dotenv", *args], + capture_output=True, + text=True, + ) + + @pytest.mark.parametrize( "output_format,content,expected", ( @@ -249,3 +265,29 @@ def test_run_with_version(cli): assert result.exit_code == 0 assert result.output.strip().endswith(__version__) + + +def test_run_with_command_flags(dotenv_path): + """ + Check that command flags passed after `dotenv run` are not interpreted. + + Here, we want to run `printenv --version`, not `dotenv --version`. + """ + + result = invoke_sub(["--file", dotenv_path, "run", "printenv", "--version"]) + + assert result.returncode == 0 + assert result.stdout.strip().startswith("printenv ") + + +def test_run_with_dotenv_and_command_flags(cli, dotenv_path): + """ + Check that dotenv flags supersede command flags. + """ + + result = invoke_sub( + ["--version", "--file", dotenv_path, "run", "printenv", "--version"] + ) + + assert result.returncode == 0 + assert result.stdout.strip().startswith("dotenv, version") From 6d28eee2b3857b4d0cd5a542a7492358817ebd9e Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Mon, 12 Jan 2026 10:25:44 +0100 Subject: [PATCH 209/253] Docs: Improve readability of reference page (#605) This uses some (but not all) of the settings recommended at https://mkdocstrings.github.io/python/usage/#recommended-settings. Changes to the "Reference" page: - Replace section heading with function name (instead of function declaration): This should be more readable. - The function declaration is now shown in a code block instead of the heading. - Added "Table of contents" to the right, to quickly navigate to a particular method. - Removed "Source code": It removes clutter from the page and probably wasn't very useful (but it can easily be added back if needed). --- mkdocs.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index ba77fa7f..3d55d899 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,7 +13,14 @@ markdown_extensions: - mdx_truly_sane_lists plugins: - - mkdocstrings + - mkdocstrings: + handlers: + python: + options: + separate_signature: true + show_root_heading: true + show_symbol_type_heading: true + show_symbol_type_toc: true - search nav: - Home: index.md From 25b04ae38f3fe6c16838fc0ff8a06a6ec8b44cab Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Mon, 12 Jan 2026 10:26:31 +0100 Subject: [PATCH 210/253] Clean up "Related projects" section of the readme (#602) Changes: - Removed django-environ-2: The project was archived on GitHub. - Changed URL of dynaconf: The canonical repository changed. Those are very conservative choices. I haven't added projects, or removed projects that seemed less relevant. --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 6df13fab..e36791a8 100644 --- a/README.md +++ b/README.md @@ -233,11 +233,10 @@ defined in the following list: Procfile-based applications. - [django-dotenv](https://github.com/jpadilla/django-dotenv) - [django-environ](https://github.com/joke2k/django-environ) -- [django-environ-2](https://github.com/sergeyklay/django-environ-2) - [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) +- [dynaconf](https://github.com/dynaconf/dynaconf) - [parse_it](https://github.com/naorlivne/parse_it) - [python-decouple](https://github.com/HBNetwork/python-decouple) From e2e8e776b42e382ae38b44d3982dd649e7507dd4 Mon Sep 17 00:00:00 2001 From: cpackham-atlnz <85916201+cpackham-atlnz@users.noreply.github.com> Date: Mon, 12 Jan 2026 22:31:26 +1300 Subject: [PATCH 211/253] Fix license specifier (#597) Building with pip complains about the license property. Update to use the correct table format. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0aeb9819..577e497a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ description = "Read key-value pairs from a .env file and set them as environment authors = [ {name = "Saurabh Kumar", email = "me+github@saurabh-kumar.com"}, ] -license = "BSD-3-Clause" +license = { text = "BSD-3-Clause" } keywords = [ "environment variables", "deployments", From 4a22cf8993804aeede0c20b75bb1a29d3a99e9dc Mon Sep 17 00:00:00 2001 From: Naman Aarzoo <84902335+23f3001135@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:13:24 +0530 Subject: [PATCH 212/253] ci: enable testing on Python 3.14t (free-threaded) (#588) --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3f68669d..bd5d1ffc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ jobs: os: - ubuntu-latest python-version: - ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", pypy3.9, pypy3.10] + ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", pypy3.9, pypy3.10] steps: - uses: actions/checkout@v6 From 1baaf04f336072e0ee324d5df9563ec767f14f81 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Mon, 12 Jan 2026 17:47:09 +0530 Subject: [PATCH 213/253] Drop Python 3.9 support and update to PyPy 3.11 (#608) Python 3.9 reached end-of-life on October 5, 2025. This commit removes support for Python 3.9 and updates the minimum required version to Python 3.10. Additionally, PyPy has been updated from 3.10 to 3.11, and proper support for Python 3.14 free-threading builds (3.14t) has been added with separate tox environments. Changes: - Update requires-python from >=3.9 to >=3.10 in pyproject.toml - Remove Python 3.9 classifier from package metadata - Remove Python 3.9 and PyPy 3.9 from CI test matrix - Update PyPy from 3.10 to 3.11 (latest stable version) - Remove py39 from tox envlist and gh-actions mapping - Remove mypy type checking for Python 3.9 - Add separate tox environments for py314 and py314t to properly support both regular and free-threading Python 3.14 builds - Update all tox environment references to include py314 and py314t The project now officially supports: - CPython: 3.10, 3.11, 3.12, 3.13, 3.14 (including free-threading) - PyPy: 3.11 --- .github/workflows/test.yml | 2 +- pyproject.toml | 3 +-- tox.ini | 14 +++++++------- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bd5d1ffc..8d8ab246 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ jobs: os: - ubuntu-latest python-version: - ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", pypy3.9, pypy3.10] + ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", pypy3.11] steps: - uses: actions/checkout@v6 diff --git a/pyproject.toml b/pyproject.toml index 577e497a..1753fd89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Development Status :: 5 - Production/Stable", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -37,7 +36,7 @@ classifiers = [ "Environment :: Web Environment", ] -requires-python = ">=3.9" +requires-python = ">=3.10" dynamic = ["version", "readme"] diff --git a/tox.ini b/tox.ini index d5959e1e..6a25a3d4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,15 +1,15 @@ [tox] -envlist = lint,py{39,310,311,312,313},pypy3,manifest,coverage-report +envlist = lint,py{310,311,312,313,314,314t},pypy3,manifest,coverage-report [gh-actions] python = - 3.9: py39 3.10: py310 3.11: py311 3.12: py312 3.13: py313, lint, manifest 3.14: py314 - pypy-3.9: pypy3 + 3.14t: py314t + pypy-3.11: pypy3 [testenv] deps = @@ -17,11 +17,11 @@ deps = pytest-cov sh >= 2.0.2, <3 click - py{39,310,311,312,313,3.14,pypy3}: ipython + py{310,311,312,313,314,314t,pypy3}: ipython commands = pytest --cov --cov-report=term-missing {posargs} depends = - py{39,310,311,312,313,314},pypy3: coverage-clean - coverage-report: py{39,310,311,312,313,314},pypy3 + py{310,311,312,313,314,314t},pypy3: coverage-clean + coverage-report: py{310,311,312,313,314,314t},pypy3 [testenv:lint] skip_install = true @@ -36,7 +36,7 @@ commands = mypy --python-version=3.12 src tests mypy --python-version=3.11 src tests mypy --python-version=3.10 src tests - mypy --python-version=3.9 src tests + [testenv:format] skip_install = true From 7bd9e3dbfedc0983ad7d56d5570013035242bdf4 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Mon, 12 Jan 2026 13:22:58 +0100 Subject: [PATCH 214/253] Add Windows testing to CI (#604) Changes for users: none. Notes: - This adds CI testing with lowest and highest Python versions we support. - The main motivation for this is that we have Windows-specific code I'm worried I might break with improvements, like improvements in `dotenv run` error handling (coming soon). - I went for the least intrusive changes for now, and disabled tests which would fail unless they were trivial to adjust. - We have tests using `sh` (Unix-only module) which should be possible to fix later. Those tests are disabled on Windows. - Also tests relying on the fact that environment variables are case sensitive, which isn't the case on Windows. This is going to be more tricky to fix. Those tests are also disabled on Windows. - To check for the platform, I used `sys.platform == "win32"` everywhere, which seems to be the best practice. Co-authored-by: Saurabh Kumar --- .github/workflows/test.yml | 6 ++++ tests/test_cli.py | 11 ++++++- tests/test_fifo_dotenv.py | 4 +-- tests/test_ipython.py | 10 +++++++ tests/test_main.py | 59 +++++++++++++++++++++++++++++--------- tests/test_zip_imports.py | 6 +++- 6 files changed, 77 insertions(+), 19 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8d8ab246..a20a689f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,6 +14,12 @@ jobs: - ubuntu-latest python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", pypy3.11] + include: + # Windows: Test lowest and highest supported Python versions + - os: windows-latest + python-version: "3.10" + - os: windows-latest + python-version: "3.14" steps: - uses: actions/checkout@v6 diff --git a/tests/test_cli.py b/tests/test_cli.py index 7cc4533d..ebc4fdd9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,15 +1,18 @@ import os import subprocess +import sys from pathlib import Path from typing import Optional, Sequence import pytest -import sh import dotenv from dotenv.cli import cli as dotenv_cli from dotenv.version import __version__ +if sys.platform != "win32": + import sh + def invoke_sub(args: Sequence[str]) -> subprocess.CompletedProcess: """ @@ -189,6 +192,7 @@ def test_set_no_file(cli): assert "Missing argument" in result.output +@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_get_default_path(tmp_path): with sh.pushd(tmp_path): (tmp_path / ".env").write_text("a=b") @@ -198,6 +202,7 @@ def test_get_default_path(tmp_path): assert result == "b\n" +@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run(tmp_path): with sh.pushd(tmp_path): (tmp_path / ".env").write_text("a=b") @@ -207,6 +212,7 @@ def test_run(tmp_path): assert result == "b\n" +@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run_with_existing_variable(tmp_path): with sh.pushd(tmp_path): (tmp_path / ".env").write_text("a=b") @@ -218,6 +224,7 @@ def test_run_with_existing_variable(tmp_path): assert result == "b\n" +@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run_with_existing_variable_not_overridden(tmp_path): with sh.pushd(tmp_path): (tmp_path / ".env").write_text("a=b") @@ -229,6 +236,7 @@ def test_run_with_existing_variable_not_overridden(tmp_path): assert result == "c\n" +@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run_with_none_value(tmp_path): with sh.pushd(tmp_path): (tmp_path / ".env").write_text("a=b\nc") @@ -238,6 +246,7 @@ def test_run_with_none_value(tmp_path): assert result == "b\n" +@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run_with_other_env(dotenv_path): dotenv_path.write_text("a=b") diff --git a/tests/test_fifo_dotenv.py b/tests/test_fifo_dotenv.py index 4961adce..2aa31779 100644 --- a/tests/test_fifo_dotenv.py +++ b/tests/test_fifo_dotenv.py @@ -7,9 +7,7 @@ from dotenv import load_dotenv -pytestmark = pytest.mark.skipif( - sys.platform.startswith("win"), reason="FIFOs are Unix-only" -) +pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="FIFOs are Unix-only") def test_load_dotenv_from_fifo(tmp_path: pathlib.Path, monkeypatch): diff --git a/tests/test_ipython.py b/tests/test_ipython.py index f01b3ad7..6eda086b 100644 --- a/tests/test_ipython.py +++ b/tests/test_ipython.py @@ -1,4 +1,5 @@ import os +import sys from unittest import mock import pytest @@ -6,6 +7,9 @@ pytest.importorskip("IPython") +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {}, clear=True) def test_ipython_existing_variable_no_override(tmp_path): from IPython.terminal.embed import InteractiveShellEmbed @@ -22,6 +26,9 @@ def test_ipython_existing_variable_no_override(tmp_path): assert os.environ == {"a": "c"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {}, clear=True) def test_ipython_existing_variable_override(tmp_path): from IPython.terminal.embed import InteractiveShellEmbed @@ -38,6 +45,9 @@ def test_ipython_existing_variable_override(tmp_path): assert os.environ == {"a": "b"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {}, clear=True) def test_ipython_new_variable(tmp_path): from IPython.terminal.embed import InteractiveShellEmbed diff --git a/tests/test_main.py b/tests/test_main.py index 76c1f70e..761bdad3 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,15 +1,18 @@ import io import logging import os +import stat import sys import textwrap from unittest import mock import pytest -import sh import dotenv +if sys.platform != "win32": + import sh + def test_set_key_no_file(tmp_path): nx_path = tmp_path / "nx" @@ -62,15 +65,25 @@ def test_set_key_encoding(dotenv_path): @pytest.mark.skipif( - os.geteuid() == 0, reason="Root user can access files even with 000 permissions." + sys.platform != "win32" and os.geteuid() == 0, + reason="Root user can access files even with 000 permissions.", ) def test_set_key_permission_error(dotenv_path): - dotenv_path.chmod(0o000) + if sys.platform == "win32": + # On Windows, make file read-only + dotenv_path.chmod(stat.S_IREAD) + else: + # On Unix, remove all permissions + dotenv_path.chmod(0o000) with pytest.raises(PermissionError): dotenv.set_key(dotenv_path, "a", "b") - dotenv_path.chmod(0o600) + # Restore permissions + if sys.platform == "win32": + dotenv_path.chmod(stat.S_IWRITE | stat.S_IREAD) + else: + dotenv_path.chmod(0o600) assert dotenv_path.read_text() == "" @@ -170,16 +183,6 @@ def test_unset_encoding(dotenv_path): assert dotenv_path.read_text(encoding=encoding) == "" -@pytest.mark.skipif( - os.geteuid() == 0, reason="Root user can access files even with 000 permissions." -) -def test_set_key_unauthorized_file(dotenv_path): - dotenv_path.chmod(0o000) - - with pytest.raises(PermissionError): - dotenv.set_key(dotenv_path, "a", "x") - - def test_unset_non_existent_file(tmp_path): nx_path = tmp_path / "nx" logger = logging.getLogger("dotenv.main") @@ -241,6 +244,9 @@ def test_find_dotenv_found(tmp_path): assert result == str(dotenv_path) +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {}, clear=True) def test_load_dotenv_existing_file(dotenv_path): dotenv_path.write_text("a=b") @@ -312,6 +318,9 @@ def test_load_dotenv_disabled_notification(dotenv_path, flag_value): ) +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @pytest.mark.parametrize( "flag_value", [ @@ -395,6 +404,9 @@ def test_load_dotenv_no_file_verbose(): ) +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {"a": "c"}, clear=True) def test_load_dotenv_existing_variable_no_override(dotenv_path): dotenv_path.write_text("a=b") @@ -405,6 +417,9 @@ def test_load_dotenv_existing_variable_no_override(dotenv_path): assert os.environ == {"a": "c"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {"a": "c"}, clear=True) def test_load_dotenv_existing_variable_override(dotenv_path): dotenv_path.write_text("a=b") @@ -415,6 +430,9 @@ def test_load_dotenv_existing_variable_override(dotenv_path): assert os.environ == {"a": "b"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {"a": "c"}, clear=True) def test_load_dotenv_redefine_var_used_in_file_no_override(dotenv_path): dotenv_path.write_text('a=b\nd="${a}"') @@ -425,6 +443,9 @@ def test_load_dotenv_redefine_var_used_in_file_no_override(dotenv_path): assert os.environ == {"a": "c", "d": "c"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {"a": "c"}, clear=True) def test_load_dotenv_redefine_var_used_in_file_with_override(dotenv_path): dotenv_path.write_text('a=b\nd="${a}"') @@ -435,6 +456,9 @@ def test_load_dotenv_redefine_var_used_in_file_with_override(dotenv_path): assert os.environ == {"a": "b", "d": "b"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {}, clear=True) def test_load_dotenv_string_io_utf_8(): stream = io.StringIO("a=à") @@ -445,6 +469,9 @@ def test_load_dotenv_string_io_utf_8(): assert os.environ == {"a": "à"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {}, clear=True) def test_load_dotenv_file_stream(dotenv_path): dotenv_path.write_text("a=b") @@ -456,6 +483,7 @@ def test_load_dotenv_file_stream(dotenv_path): assert os.environ == {"a": "b"} +@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_load_dotenv_in_current_dir(tmp_path): dotenv_path = tmp_path / ".env" dotenv_path.write_bytes(b"a=b") @@ -484,6 +512,9 @@ def test_dotenv_values_file(dotenv_path): assert result == {"a": "b"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @pytest.mark.parametrize( "env,string,interpolate,expected", [ diff --git a/tests/test_zip_imports.py b/tests/test_zip_imports.py index 5c0fb88d..0b57a1c5 100644 --- a/tests/test_zip_imports.py +++ b/tests/test_zip_imports.py @@ -5,7 +5,10 @@ from unittest import mock from zipfile import ZipFile -import sh +import pytest + +if sys.platform != "win32": + import sh def walk_to_root(path: str): @@ -62,6 +65,7 @@ def test_load_dotenv_gracefully_handles_zip_imports_when_no_env_file(tmp_path): import child1.child2.test # noqa +@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_load_dotenv_outside_zip_file_when_called_in_zipfile(tmp_path): zip_file_path = setup_zipfile( tmp_path, From c8de2887c00198c22842c5ae5e92d1747467363c Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Mon, 12 Jan 2026 18:05:22 +0530 Subject: [PATCH 215/253] ci: improve workflow efficiency with best practices (#609) - Limit workflow triggers to main branch for pushes - Run tests on PRs targeting any branch - Add concurrency control to cancel outdated workflow runs - Set 15-minute timeout to prevent hung jobs - Follows GitHub Actions best practices for open-source projects --- .github/workflows/test.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a20a689f..66056f6c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,10 +1,19 @@ name: Run Tests -on: [push, pull_request] +on: + push: + branches: + - main + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: test: runs-on: ${{ matrix.os }} + timeout-minutes: 15 strategy: fail-fast: false From 09d7cee32459e7abdcb5c9d8122a552589c06a9c Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Mon, 12 Jan 2026 18:33:32 +0530 Subject: [PATCH 216/253] docs: clarify override behavior and document FIFO support (#610) - Explicitly state that override=False is the default behavior in the Getting Started section for better clarity - Add note about FIFO (named pipes) support on Unix systems in the File format section - Improve formatting consistency: - Standardize TOC list markers (use '-' instead of '*') - Fix line wrapping and spacing issues throughout --- README.md | 137 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 71 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index e36791a8..a08d6141 100644 --- a/README.md +++ b/README.md @@ -3,19 +3,19 @@ [![Build Status][build_status_badge]][build_status_link] [![PyPI version][pypi_badge]][pypi_link] -python-dotenv reads key-value pairs from a `.env` file and can set them as environment -variables. It helps in the development of applications following the +python-dotenv reads key-value pairs from a `.env` file and can set them as +environment variables. It helps in the development of applications following the [12-factor](https://12factor.net/) principles. - [Getting Started](#getting-started) - [Other Use Cases](#other-use-cases) - * [Load configuration without altering the environment](#load-configuration-without-altering-the-environment) - * [Parse configuration as a stream](#parse-configuration-as-a-stream) - * [Load .env files in IPython](#load-env-files-in-ipython) + - [Load configuration without altering the environment](#load-configuration-without-altering-the-environment) + - [Parse configuration as a stream](#parse-configuration-as-a-stream) + - [Load .env files in IPython](#load-env-files-in-ipython) - [Command-line Interface](#command-line-interface) - [File format](#file-format) - * [Multiline values](#multiline-values) - * [Variable expansion](#variable-expansion) + - [Multiline values](#multiline-values) + - [Variable expansion](#variable-expansion) - [Related Projects](#related-projects) - [Acknowledgements](#acknowledgements) @@ -25,13 +25,13 @@ variables. It helps in the development of applications following the pip install python-dotenv ``` -If your application takes its configuration from environment variables, like a 12-factor -application, launching it in development is not very practical because you have to set -those environment variables yourself. +If your application takes its configuration from environment variables, like a +12-factor application, launching it in development is not very practical because +you have to set those environment variables yourself. -To help you with that, you can add python-dotenv to your application to make it load the -configuration from a `.env` file when it is present (e.g. in development) while remaining -configurable via the environment: +To help you with that, you can add python-dotenv to your application to make it +load the configuration from a `.env` file when it is present (e.g. in +development) while remaining configurable via the environment: ```python from dotenv import load_dotenv @@ -46,10 +46,10 @@ By default, `load_dotenv()` will: - Look for a `.env` file in the same directory as the Python script (or higher up the directory tree). - Read each key-value pair and add it to `os.environ`. -- **Not override** an environment variable that is already set, unless you explicitly pass `override=True`. +- **Not override** existing environment variables (`override=False`). Pass `override=True` to override existing variables. -To configure the development environment, add a `.env` in the root directory of your -project: +To configure the development environment, add a `.env` in the root directory of +your project: ``` . @@ -57,7 +57,8 @@ project: └── foo.py ``` -The syntax of `.env` files supported by python-dotenv is similar to that of Bash: +The syntax of `.env` files supported by python-dotenv is similar to that of +Bash: ```bash # Development settings @@ -66,22 +67,21 @@ ADMIN_EMAIL=admin@${DOMAIN} ROOT_URL=${DOMAIN}/app ``` -If you use variables in values, ensure they are surrounded with `{` and `}`, like -`${DOMAIN}`, as bare variables such as `$DOMAIN` are not expanded. +If you use variables in values, ensure they are surrounded with `{` and `}`, +like `${DOMAIN}`, as bare variables such as `$DOMAIN` are not expanded. -You will probably want to add `.env` to your `.gitignore`, especially if it contains -secrets like a password. +You will probably want to add `.env` to your `.gitignore`, especially if it +contains secrets like a password. -See the section "File format" below for more information about what you can write in a -`.env` file. +See the section "[File format](#file-format)" below for more information about what you can write in a `.env` file. ## Other Use Cases ### Load configuration without altering the environment -The function `dotenv_values` works more or less the same way as `load_dotenv`, except it -doesn't touch the environment, it just returns a `dict` with the values parsed from the -`.env` file. +The function `dotenv_values` works more or less the same way as `load_dotenv`, +except it doesn't touch the environment, it just returns a `dict` with the +values parsed from the `.env` file. ```python from dotenv import dotenv_values @@ -104,9 +104,9 @@ config = { ### Parse configuration as a stream -`load_dotenv` and `dotenv_values` accept [streams][python_streams] via their `stream` -argument. It is thus possible to load the variables from sources other than the -filesystem (e.g. the network). +`load_dotenv` and `dotenv_values` accept [streams][python_streams] via their +`stream` argument. It is thus possible to load the variables from sources other +than the filesystem (e.g. the network). ```python from io import StringIO @@ -119,7 +119,7 @@ load_dotenv(stream=config) ### Load .env files in IPython -You can use dotenv in IPython. By default, it will use `find_dotenv` to search for a +You can use dotenv in IPython. By default, it will use `find_dotenv` to search for a `.env` file: ```python @@ -140,12 +140,14 @@ Optional flags: ### Disable load_dotenv -Set `PYTHON_DOTENV_DISABLED=1` to disable `load_dotenv()` from loading .env files or streams. Useful when you can't modify third-party package calls or in production. +Set `PYTHON_DOTENV_DISABLED=1` to disable `load_dotenv()` from loading .env +files or streams. Useful when you can't modify third-party package calls or in +production. ## Command-line Interface -A CLI interface `dotenv` is also included, which helps you manipulate the `.env` file -without manually opening it. +A CLI interface `dotenv` is also included, which helps you manipulate the `.env` +file without manually opening it. ```shell $ pip install "python-dotenv[cli]" @@ -166,13 +168,14 @@ Run `dotenv --help` for more information about the options and subcommands. ## File format -The format is not formally specified and still improves over time. That being said, -`.env` files should mostly look like Bash files. +The format is not formally specified and still improves over time. That being +said, `.env` files should mostly look like Bash files. Reading from FIFOs (named +pipes) on Unix systems is also supported. -Keys can be unquoted or single-quoted. Values can be unquoted, single- or double-quoted. -Spaces before and after keys, equal signs, and values are ignored. Values can be followed -by a comment. Lines can start with the `export` directive, which does not affect their -interpretation. +Keys can be unquoted or single-quoted. Values can be unquoted, single- or +double-quoted. Spaces before and after keys, equal signs, and values are +ignored. Values can be followed by a comment. Lines can start with the `export` +directive, which does not affect their interpretation. Allowed escape sequences: @@ -181,8 +184,8 @@ Allowed escape sequences: ### Multiline values -It is possible for single- or double-quoted values to span multiple lines. The following -examples are equivalent: +It is possible for single- or double-quoted values to span multiple lines. The +following examples are equivalent: ```bash FOO="first line @@ -201,26 +204,27 @@ A variable can have no value: FOO ``` -It results in `dotenv_values` associating that variable name with the value `None` (e.g. -`{"FOO": None}`. `load_dotenv`, on the other hand, simply ignores such variables. +It results in `dotenv_values` associating that variable name with the value +`None` (e.g. `{"FOO": None}`. `load_dotenv`, on the other hand, simply ignores +such variables. -This shouldn't be confused with `FOO=`, in which case the variable is associated with the -empty string. +This shouldn't be confused with `FOO=`, in which case the variable is associated +with the empty string. ### Variable expansion python-dotenv can interpolate variables using POSIX variable expansion. -With `load_dotenv(override=True)` or `dotenv_values()`, the value of a variable is the -first of the values defined in the following list: +With `load_dotenv(override=True)` or `dotenv_values()`, the value of a variable +is the first of the values defined in the following list: - Value of that variable in the `.env` file. - Value of that variable in the environment. - Default value, if provided. - Empty string. -With `load_dotenv(override=False)`, the value of a variable is the first of the values -defined in the following list: +With `load_dotenv(override=False)`, the value of a variable is the first of the +values defined in the following list: - Value of that variable in the environment. - Value of that variable in the `.env` file. @@ -229,26 +233,27 @@ defined in the following list: ## Related Projects -- [Honcho](https://github.com/nickstenning/honcho) - For managing - Procfile-based applications. -- [django-dotenv](https://github.com/jpadilla/django-dotenv) -- [django-environ](https://github.com/joke2k/django-environ) -- [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/dynaconf/dynaconf) -- [parse_it](https://github.com/naorlivne/parse_it) -- [python-decouple](https://github.com/HBNetwork/python-decouple) +- [environs](https://github.com/sloria/environs) +- [Honcho](https://github.com/nickstenning/honcho) +- [dump-env](https://github.com/sobolevn/dump-env) +- [dynaconf](https://github.com/dynaconf/dynaconf) +- [parse_it](https://github.com/naorlivne/parse_it) +- [django-dotenv](https://github.com/jpadilla/django-dotenv) +- [django-environ](https://github.com/joke2k/django-environ) +- [python-decouple](https://github.com/HBNetwork/python-decouple) +- [django-configuration](https://github.com/jezdez/django-configurations) ## Acknowledgements -This project is currently maintained by [Saurabh Kumar](https://saurabh-kumar.com) and -[Bertrand Bonnefoy-Claudet](https://github.com/bbc2) and would not have been possible -without the support of these [awesome -people](https://github.com/theskumar/python-dotenv/graphs/contributors). +This project is currently maintained by [Saurabh Kumar][saurabh-homepage] and +[Bertrand Bonnefoy-Claudet][gh-bbc2] and would not have been possible without +the support of these [awesome people][contributors]. -[build_status_badge]: https://github.com/theskumar/python-dotenv/actions/workflows/test.yml/badge.svg -[build_status_link]: https://github.com/theskumar/python-dotenv/actions/workflows/test.yml -[pypi_badge]: https://badge.fury.io/py/python-dotenv.svg +[gh-bbc2]: https://github.com/bbc2 +[saurabh-homepage]: https://saurabh-kumar.com [pypi_link]: https://badge.fury.io/py/python-dotenv +[pypi_badge]: https://badge.fury.io/py/python-dotenv.svg [python_streams]: https://docs.python.org/3/library/io.html +[contributors]: https://github.com/theskumar/python-dotenv/graphs/contributors +[build_status_link]: https://github.com/theskumar/python-dotenv/actions/workflows/test.yml +[build_status_badge]: https://github.com/theskumar/python-dotenv/actions/workflows/test.yml/badge.svg From 43340da220fb4ca4f95357bbe21a3c7f8f1278b1 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 28 Feb 2026 17:54:53 +0100 Subject: [PATCH 217/253] Remove the use of `sh` in tests (#612) This commit has the following benefits: - Remove `sh` as a development dependency. - Increase test coverage on Windows. - Improve the robustness of some tests against leftover `.env` files in the repository. - This is not perfect yet: If you have a `.env` file in your repository, it still disrupts some tests (for `find_dotenv`). - Improve the readability of error messages for some tests. --- requirements.txt | 1 - tests/test_cli.py | 127 ++++++++++++++++---------------------- tests/test_lib.py | 46 ++++++++++++++ tests/test_main.py | 14 +++-- tests/test_zip_imports.py | 47 +++++++------- tox.ini | 1 - 6 files changed, 132 insertions(+), 104 deletions(-) create mode 100644 tests/test_lib.py diff --git a/requirements.txt b/requirements.txt index d3d0199f..4a9f28a1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,6 @@ click ipython pytest-cov pytest>=3.9 -sh>=2 tox wheel ruff diff --git a/tests/test_cli.py b/tests/test_cli.py index ebc4fdd9..02bdb764 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,32 +1,13 @@ import os -import subprocess -import sys from pathlib import Path -from typing import Optional, Sequence +from typing import Optional import pytest import dotenv from dotenv.cli import cli as dotenv_cli from dotenv.version import __version__ - -if sys.platform != "win32": - import sh - - -def invoke_sub(args: Sequence[str]) -> subprocess.CompletedProcess: - """ - Invoke the `dotenv` CLI in a subprocess. - - This is necessary to test subcommands like `dotenv run` that replace the - current process. - """ - - return subprocess.run( - ["dotenv", *args], - capture_output=True, - text=True, - ) +from tests.test_lib import check_process, run_dotenv @pytest.mark.parametrize( @@ -192,111 +173,109 @@ def test_set_no_file(cli): assert "Missing argument" in result.output -@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_get_default_path(tmp_path): - with sh.pushd(tmp_path): - (tmp_path / ".env").write_text("a=b") + (tmp_path / ".env").write_text("A=x") - result = sh.dotenv("get", "a") + result = run_dotenv(["get", "A"], cwd=tmp_path) - assert result == "b\n" + check_process(result, exit_code=0, stdout="x\n") -@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run(tmp_path): - with sh.pushd(tmp_path): - (tmp_path / ".env").write_text("a=b") + (tmp_path / ".env").write_text("A=x") - result = sh.dotenv("run", "printenv", "a") + result = run_dotenv(["run", "printenv", "A"], cwd=tmp_path) - assert result == "b\n" + check_process(result, exit_code=0, stdout="x\n") -@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run_with_existing_variable(tmp_path): - with sh.pushd(tmp_path): - (tmp_path / ".env").write_text("a=b") - env = dict(os.environ) - env.update({"LANG": "en_US.UTF-8", "a": "c"}) + (tmp_path / ".env").write_text("A=x") + env = dict(os.environ) + env.update({"LANG": "en_US.UTF-8", "A": "y"}) - result = sh.dotenv("run", "printenv", "a", _env=env) + result = run_dotenv(["run", "printenv", "A"], cwd=tmp_path, env=env) - assert result == "b\n" + check_process(result, exit_code=0, stdout="x\n") -@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run_with_existing_variable_not_overridden(tmp_path): - with sh.pushd(tmp_path): - (tmp_path / ".env").write_text("a=b") - env = dict(os.environ) - env.update({"LANG": "en_US.UTF-8", "a": "c"}) + (tmp_path / ".env").write_text("A=x") + env = dict(os.environ) + env.update({"LANG": "en_US.UTF-8", "A": "C"}) - result = sh.dotenv("run", "--no-override", "printenv", "a", _env=env) + result = run_dotenv( + ["run", "--no-override", "printenv", "A"], cwd=tmp_path, env=env + ) - assert result == "c\n" + check_process(result, exit_code=0, stdout="C\n") -@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run_with_none_value(tmp_path): - with sh.pushd(tmp_path): - (tmp_path / ".env").write_text("a=b\nc") + (tmp_path / ".env").write_text("A=x\nc") - result = sh.dotenv("run", "printenv", "a") + result = run_dotenv(["run", "printenv", "A"], cwd=tmp_path) - assert result == "b\n" + check_process(result, exit_code=0, stdout="x\n") -@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") -def test_run_with_other_env(dotenv_path): - dotenv_path.write_text("a=b") +def test_run_with_other_env(dotenv_path, tmp_path): + dotenv_path.write_text("A=x") - result = sh.dotenv("--file", dotenv_path, "run", "printenv", "a") + result = run_dotenv( + ["--file", str(dotenv_path), "run", "printenv", "A"], + cwd=tmp_path, + ) - assert result == "b\n" + check_process(result, exit_code=0, stdout="x\n") -def test_run_without_cmd(cli): - result = cli.invoke(dotenv_cli, ["run"]) +def test_run_without_cmd(tmp_path): + result = run_dotenv(["run"], cwd=tmp_path) - assert result.exit_code == 2 - assert "Invalid value for '-f'" in result.output + check_process(result, exit_code=2) + assert "Invalid value for '-f'" in result.stderr -def test_run_with_invalid_cmd(cli): - result = cli.invoke(dotenv_cli, ["run", "i_do_not_exist"]) +def test_run_with_invalid_cmd(tmp_path): + result = run_dotenv(["run", "i_do_not_exist"], cwd=tmp_path) - assert result.exit_code == 2 - assert "Invalid value for '-f'" in result.output + check_process(result, exit_code=2) + assert "Invalid value for '-f'" in result.stderr -def test_run_with_version(cli): - result = cli.invoke(dotenv_cli, ["--version"]) +def test_run_with_version(tmp_path): + result = run_dotenv(["--version"], cwd=tmp_path) - assert result.exit_code == 0 - assert result.output.strip().endswith(__version__) + check_process(result, exit_code=0) + assert result.stdout.strip().endswith(__version__) -def test_run_with_command_flags(dotenv_path): +def test_run_with_command_flags(dotenv_path, tmp_path): """ Check that command flags passed after `dotenv run` are not interpreted. Here, we want to run `printenv --version`, not `dotenv --version`. """ - result = invoke_sub(["--file", dotenv_path, "run", "printenv", "--version"]) + result = run_dotenv( + ["--file", str(dotenv_path), "run", "printenv", "--version"], + cwd=tmp_path, + ) - assert result.returncode == 0 + check_process(result, exit_code=0) assert result.stdout.strip().startswith("printenv ") -def test_run_with_dotenv_and_command_flags(cli, dotenv_path): +def test_run_with_dotenv_and_command_flags(dotenv_path, tmp_path): """ Check that dotenv flags supersede command flags. """ - result = invoke_sub( - ["--version", "--file", dotenv_path, "run", "printenv", "--version"] + result = run_dotenv( + ["--version", "--file", str(dotenv_path), "run", "printenv", "--version"], + cwd=tmp_path, ) - assert result.returncode == 0 + check_process(result, exit_code=0) assert result.stdout.strip().startswith("dotenv, version") diff --git a/tests/test_lib.py b/tests/test_lib.py new file mode 100644 index 00000000..eb9d5204 --- /dev/null +++ b/tests/test_lib.py @@ -0,0 +1,46 @@ +import subprocess +from pathlib import Path +from typing import Sequence + + +def run_dotenv( + args: Sequence[str], + cwd: str | Path | None = None, + env: dict | None = None, +) -> subprocess.CompletedProcess: + """ + Run the `dotenv` CLI in a subprocess with the given arguments. + """ + + process = subprocess.run( + ["dotenv", *args], + capture_output=True, + text=True, + cwd=cwd, + env=env, + ) + + return process + + +def check_process( + process: subprocess.CompletedProcess, + exit_code: int, + stdout: str | None = None, +): + """ + Check that the process completed with the expected exit code and output. + + This provides better error messages than directly checking the attributes. + """ + + assert process.returncode == exit_code, ( + f"Unexpected exit code {process.returncode} (expected {exit_code})\n" + f"stdout:\n{process.stdout}\n" + f"stderr:\n{process.stderr}" + ) + + if stdout is not None: + assert process.stdout == stdout, ( + f"Unexpected output: {process.stdout.strip()!r} (expected {stdout!r})" + ) diff --git a/tests/test_main.py b/tests/test_main.py index 761bdad3..616c3b0c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -2,6 +2,7 @@ import logging import os import stat +import subprocess import sys import textwrap from unittest import mock @@ -10,9 +11,6 @@ import dotenv -if sys.platform != "win32": - import sh - def test_set_key_no_file(tmp_path): nx_path = tmp_path / "nx" @@ -483,7 +481,6 @@ def test_load_dotenv_file_stream(dotenv_path): assert os.environ == {"a": "b"} -@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_load_dotenv_in_current_dir(tmp_path): dotenv_path = tmp_path / ".env" dotenv_path.write_bytes(b"a=b") @@ -499,9 +496,14 @@ def test_load_dotenv_in_current_dir(tmp_path): ) os.chdir(tmp_path) - result = sh.Command(sys.executable)(code_path) + result = subprocess.run( + [sys.executable, str(code_path)], + capture_output=True, + text=True, + check=True, + ) - assert result == "b\n" + assert result.stdout == "b\n" def test_dotenv_values_file(dotenv_path): diff --git a/tests/test_zip_imports.py b/tests/test_zip_imports.py index 0b57a1c5..6a263502 100644 --- a/tests/test_zip_imports.py +++ b/tests/test_zip_imports.py @@ -1,22 +1,19 @@ import os +import posixpath +import subprocess import sys import textwrap from typing import List from unittest import mock from zipfile import ZipFile -import pytest - -if sys.platform != "win32": - import sh - def walk_to_root(path: str): last_dir = None current_dir = path while last_dir != current_dir: yield current_dir - (parent_dir, _) = os.path.split(current_dir) + parent_dir = posixpath.dirname(current_dir) last_dir, current_dir = current_dir, parent_dir @@ -32,12 +29,11 @@ def setup_zipfile(path, files: List[FileToAdd]): with ZipFile(zip_file_path, "w") as zipfile: for f in files: zipfile.writestr(data=f.content, zinfo_or_arcname=f.path) - for dirname in walk_to_root(os.path.dirname(f.path)): + for dirname in walk_to_root(posixpath.dirname(f.path)): if dirname not in dirs_init_py_added_to: - print(os.path.join(dirname, "__init__.py")) - zipfile.writestr( - data="", zinfo_or_arcname=os.path.join(dirname, "__init__.py") - ) + init_path = posixpath.join(dirname, "__init__.py") + print(f"setup_zipfile: {init_path}") + zipfile.writestr(data="", zinfo_or_arcname=init_path) dirs_init_py_added_to.add(dirname) return zip_file_path @@ -65,7 +61,6 @@ def test_load_dotenv_gracefully_handles_zip_imports_when_no_env_file(tmp_path): import child1.child2.test # noqa -@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_load_dotenv_outside_zip_file_when_called_in_zipfile(tmp_path): zip_file_path = setup_zipfile( tmp_path, @@ -83,24 +78,32 @@ def test_load_dotenv_outside_zip_file_when_called_in_zipfile(tmp_path): ], ) dotenv_path = tmp_path / ".env" - dotenv_path.write_bytes(b"a=b") + dotenv_path.write_bytes(b"A=x") code_path = tmp_path / "code.py" code_path.write_text( textwrap.dedent( f""" - import os - import sys + import os + import sys - sys.path.append("{zip_file_path}") + sys.path.append({str(zip_file_path)!r}) - import child1.child2.test + import child1.child2.test - print(os.environ['a']) - """ + print(os.environ['A']) + """ ) ) - os.chdir(str(tmp_path)) - result = sh.Command(sys.executable)(code_path) + result = subprocess.run( + [sys.executable, str(code_path)], + capture_output=True, + check=True, + cwd=tmp_path, + text=True, + env={ + k: v for k, v in os.environ.items() if k.upper() != "A" + }, # env without 'A' + ) - assert result == "b\n" + assert result.stdout == "x\n" diff --git a/tox.ini b/tox.ini index 6a25a3d4..6d1f25f8 100644 --- a/tox.ini +++ b/tox.ini @@ -15,7 +15,6 @@ python = deps = pytest pytest-cov - sh >= 2.0.2, <3 click py{310,311,312,313,314,314t,pypy3}: ipython commands = pytest --cov --cov-report=term-missing {posargs} From 790c5c02991100aa1bf41ee5330aca75edc51311 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 1 Mar 2026 16:16:10 +0100 Subject: [PATCH 218/253] Merge commit from fork Changes for users: - (BREAKING) `dotenv.set_key` and `dotenv.unset_key` used to follow symlinks in some situations. This is no longer the case. For that behavior to be restored in all cases, `follow_symlinks=True` should be used. - (BREAKING) In the CLI, `set` and `unset` used to follow symlinks in some situations. This is no longer the case. - (BREAKING) `dotenv.set_key`, `dotenv.unset_key` and the CLI commands `set` and `unset` used to reset the file mode of the modified .env file to `0o600` in some situations. This is no longer the case: The original mode of the file is now preserved. Is the file needed to be created or wasn't a regular file, mode `0o600` is used. --- src/dotenv/cli.py | 15 +++++- src/dotenv/main.py | 71 ++++++++++++++++++++----- tests/test_main.py | 128 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+), 15 deletions(-) diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 7a4c7adc..47eec047 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -114,7 +114,13 @@ def list_values(ctx: click.Context, output_format: str) -> None: @click.argument("key", required=True) @click.argument("value", required=True) def set_value(ctx: click.Context, key: Any, value: Any) -> None: - """Store the given key/value.""" + """ + Store the given key/value. + + This doesn't follow symlinks, to avoid accidentally modifying a file at a + potentially untrusted path. + """ + file = ctx.obj["FILE"] quote = ctx.obj["QUOTE"] export = ctx.obj["EXPORT"] @@ -146,7 +152,12 @@ def get(ctx: click.Context, key: Any) -> None: @click.pass_context @click.argument("key", required=True) def unset(ctx: click.Context, key: Any) -> None: - """Removes the given key.""" + """ + Removes the given key. + + This doesn't follow symlinks, to avoid accidentally modifying a file at a + potentially untrusted path. + """ file = ctx.obj["FILE"] quote = ctx.obj["QUOTE"] success, key = unset_key(file, key, quote) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 1d6bf0b0..48e5245a 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -2,7 +2,6 @@ import logging import os import pathlib -import shutil import stat import sys import tempfile @@ -14,9 +13,7 @@ from .variables import parse_variables # A type alias for a string path to be used for the paths in this file. -# These paths may flow to `open()` and `shutil.move()`; `shutil.move()` -# only accepts string paths, not byte paths or file descriptors. See -# https://github.com/python/typeshed/pull/6832. +# These paths may flow to `open()` and `os.replace()`. StrPath = Union[str, "os.PathLike[str]"] logger = logging.getLogger(__name__) @@ -142,21 +139,54 @@ def get_key( def rewrite( path: StrPath, encoding: Optional[str], + follow_symlinks: bool = False, ) -> Iterator[Tuple[IO[str], IO[str]]]: - pathlib.Path(path).touch() + if follow_symlinks: + path = os.path.realpath(path) - with tempfile.NamedTemporaryFile(mode="w", encoding=encoding, delete=False) as dest: + try: + source: IO[str] = open(path, encoding=encoding) + try: + path_stat = os.lstat(path) + original_mode: Optional[int] = ( + stat.S_IMODE(path_stat.st_mode) + if stat.S_ISREG(path_stat.st_mode) + else None + ) + except BaseException: + source.close() + raise + except FileNotFoundError: + source = io.StringIO("") + original_mode = None + + with tempfile.NamedTemporaryFile( + mode="w", + encoding=encoding, + delete=False, + prefix=".tmp_", + dir=os.path.dirname(os.path.abspath(path)), + ) as dest: + dest_path = pathlib.Path(dest.name) error = None + try: - with open(path, encoding=encoding) as source: + with source: yield (source, dest) except BaseException as err: error = err if error is None: - shutil.move(dest.name, path) + try: + if original_mode is not None: + os.chmod(dest_path, original_mode) + + os.replace(dest_path, path) + except BaseException: + dest_path.unlink(missing_ok=True) + raise else: - os.unlink(dest.name) + dest_path.unlink(missing_ok=True) raise error from None @@ -167,12 +197,16 @@ def set_key( quote_mode: str = "always", export: bool = False, encoding: Optional[str] = "utf-8", + follow_symlinks: bool = False, ) -> Tuple[Optional[bool], str, str]: """ Adds or Updates a key/value to the given .env - If the .env path given doesn't exist, fails instead of risking creating - an orphan .env somewhere in the filesystem + The target .env file is created if it doesn't exist. + + This function doesn't follow symlinks by default, to avoid accidentally + modifying a file at a potentially untrusted path. If you don't need this + protection and need symlinks to be followed, use `follow_symlinks`. """ if quote_mode not in ("always", "auto", "never"): raise ValueError(f"Unknown quote_mode: {quote_mode}") @@ -190,7 +224,10 @@ def set_key( else: line_out = f"{key_to_set}={value_out}\n" - with rewrite(dotenv_path, encoding=encoding) as (source, dest): + with rewrite(dotenv_path, encoding=encoding, follow_symlinks=follow_symlinks) as ( + source, + dest, + ): replaced = False missing_newline = False for mapping in with_warn_for_invalid_lines(parse_stream(source)): @@ -213,19 +250,27 @@ def unset_key( key_to_unset: str, quote_mode: str = "always", encoding: Optional[str] = "utf-8", + follow_symlinks: bool = False, ) -> Tuple[Optional[bool], str]: """ Removes a given key from the given `.env` file. If the .env path given doesn't exist, fails. If the given key doesn't exist in the .env, fails. + + This function doesn't follow symlinks by default, to avoid accidentally + modifying a file at a potentially untrusted path. If you don't need this + protection and need symlinks to be followed, use `follow_symlinks`. """ if not os.path.exists(dotenv_path): logger.warning("Can't delete from %s - it doesn't exist.", dotenv_path) return None, key_to_unset removed = False - with rewrite(dotenv_path, encoding=encoding) as (source, dest): + with rewrite(dotenv_path, encoding=encoding, follow_symlinks=follow_symlinks) as ( + source, + dest, + ): for mapping in with_warn_for_invalid_lines(parse_stream(source)): if mapping.key == key_to_unset: removed = True diff --git a/tests/test_main.py b/tests/test_main.py index 616c3b0c..50703af0 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -62,6 +62,86 @@ def test_set_key_encoding(dotenv_path): assert dotenv_path.read_text(encoding=encoding) == "a='é'\n" +@pytest.mark.skipif( + sys.platform == "win32", reason="file mode bits behave differently on Windows" +) +def test_set_key_preserves_file_mode(dotenv_path): + dotenv_path.write_text("a=x\n") + dotenv_path.chmod(0o640) + mode_before = stat.S_IMODE(dotenv_path.stat().st_mode) + + dotenv.set_key(dotenv_path, "a", "y") + + mode_after = stat.S_IMODE(dotenv_path.stat().st_mode) + assert mode_before == mode_after + + +def test_rewrite_closes_file_handle_on_lstat_failure(tmp_path): + dotenv_path = tmp_path / ".env" + dotenv_path.write_text("a=x\n") + real_open = open + opened_handles = [] + + def tracking_open(*args, **kwargs): + handle = real_open(*args, **kwargs) + opened_handles.append(handle) + return handle + + with mock.patch("dotenv.main.os.lstat", side_effect=FileNotFoundError): + with mock.patch("dotenv.main.open", side_effect=tracking_open): + dotenv.set_key(dotenv_path, "a", "x") + + assert opened_handles, "expected at least one file to be opened" + assert all(handle.closed for handle in opened_handles) + + +@pytest.mark.skipif( + sys.platform == "win32", reason="symlinks require elevated privileges on Windows" +) +def test_set_key_symlink_to_existing_file(tmp_path): + target = tmp_path / "target.env" + target.write_text("a=x\n") + symlink = tmp_path / ".env" + symlink.symlink_to(target) + + dotenv.set_key(symlink, "a", "y") + + assert target.read_text() == "a=x\n" + assert not symlink.is_symlink() + assert "a='y'" in symlink.read_text() + assert stat.S_IMODE(symlink.stat().st_mode) == 0o600 + + +@pytest.mark.skipif( + sys.platform == "win32", reason="symlinks require elevated privileges on Windows" +) +def test_set_key_symlink_to_missing_file(tmp_path): + target = tmp_path / "nx" + symlink = tmp_path / ".env" + symlink.symlink_to(target) + + dotenv.set_key(symlink, "a", "x") + + assert not target.exists() + assert not symlink.is_symlink() + assert symlink.read_text() == "a='x'\n" + + +@pytest.mark.skipif( + sys.platform == "win32", reason="symlinks require elevated privileges on Windows" +) +def test_set_key_follow_symlinks(tmp_path): + target = tmp_path / "target.env" + target.write_text("a=x\n") + symlink = tmp_path / ".env" + symlink.symlink_to(target) + + dotenv.set_key(symlink, "a", "y", follow_symlinks=True) + + assert target.read_text() == "a='y'\n" + assert symlink.is_symlink() + + @pytest.mark.skipif( sys.platform != "win32" and os.geteuid() == 0, reason="Root user can access files even with 000 permissions.", @@ -195,6 +275,54 @@ def test_unset_non_existent_file(tmp_path): ) +@pytest.mark.skipif( + sys.platform == "win32", reason="symlinks require elevated privileges on Windows" +) +def test_unset_key_symlink_to_existing_file(tmp_path): + target = tmp_path / "target.env" + target.write_text("a=x\n") + symlink = tmp_path / ".env" + symlink.symlink_to(target) + + dotenv.unset_key(symlink, "a") + + assert target.read_text() == "a=x\n" + assert not symlink.is_symlink() + assert symlink.read_text() == "" + + +@pytest.mark.skipif( + sys.platform == "win32", reason="symlinks require elevated privileges on Windows" +) +def test_unset_key_symlink_to_missing_file(tmp_path): + target = tmp_path / "nx" + symlink = tmp_path / ".env" + symlink.symlink_to(target) + logger = logging.getLogger("dotenv.main") + + with mock.patch.object(logger, "warning") as mock_warning: + result = dotenv.unset_key(symlink, "a") + + assert result == (None, "a") + assert symlink.is_symlink() + mock_warning.assert_called_once() + + +@pytest.mark.skipif( + sys.platform == "win32", reason="symlinks require elevated privileges on Windows" +) +def test_unset_key_follow_symlinks(tmp_path): + target = tmp_path / "target.env" + target.write_text("a=b\n") + symlink = tmp_path / ".env" + symlink.symlink_to(target) + + dotenv.unset_key(symlink, "a", follow_symlinks=True) + + assert target.read_text() == "" + assert symlink.is_symlink() + + def prepare_file_hierarchy(path): """ Create a temporary folder structure like the following: From eb202520e5933c9daf42501e1e42fdb0144002c8 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 1 Mar 2026 20:59:53 +0530 Subject: [PATCH 219/253] docs: update changelog for v1.2.2 --- CHANGELOG.md | 193 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 117 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b362fdd..ab35b253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v1.2.2] - 2026-03-01 + +### Added + +- Support for Python 3.14, including the free-threaded (3.14t) build. (#) + +### Changed + +- The `dotenv run` command now forwards flags directly to the specified command by [@bbc2] in [#607] +- Improved documentation clarity regarding override behavior and the reference page. +- Updated PyPy support to version 3.11. +- Documentation for FIFO file support. +- Dropped Support for Python 3.9. + +### Fixed + +- Improved `set_key` and `unset_key` behavior when interacting with symlinks by [@bbc2] in [#790c5](https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311) +- Corrected the license specifier and added missing Python 3.14 classifiers in package metadata by [@JYOuyang] in [#590] + +### Breaking Changes + +- `dotenv.set_key` and `dotenv.unset_key` used to follow symlinks in some + situations. This is no longer the case. For that behavior to be restored in + all cases, `follow_symlinks=True` should be used. + +- In the CLI, `set` and `unset` used to follow symlinks in some situations. This + is no longer the case. + +- `dotenv.set_key`, `dotenv.unset_key` and the CLI commands `set` and `unset` + used to reset the file mode of the modified .env file to `0o600` in some + situations. This is no longer the case: The original mode of the file is now + preserved. Is the file needed to be created or wasn't a regular file, mode + `0o600` is used. + ## [1.2.1] - 2025-10-26 - Move more config to `pyproject.toml`, removed `setup.cfg` @@ -20,9 +54,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed -* CLI: Ensure `find_dotenv` work reliably on python 3.13 by [@theskumar] in [#563](https://github.com/theskumar/python-dotenv/pull/563) -* CLI: revert the use of execvpe on Windows by [@wrongontheinternet] in [#566](https://github.com/theskumar/python-dotenv/pull/566) - +- CLI: Ensure `find_dotenv` work reliably on python 3.13 by [@theskumar] in [#563](https://github.com/theskumar/python-dotenv/pull/563) +- CLI: revert the use of execvpe on Windows by [@wrongontheinternet] in [#566](https://github.com/theskumar/python-dotenv/pull/566) ## [1.1.0] - 2025-03-25 @@ -43,56 +76,56 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). **Fixed** -* Gracefully handle code which has been imported from a zipfile ([#456] by [@samwyma]) -* Allow modules using `load_dotenv` to be reloaded when launched in a separate thread ([#497] by [@freddyaboulton]) -* Fix file not closed after deletion, handle error in the rewrite function ([#469] by [@Qwerty-133]) +- Gracefully handle code which has been imported from a zipfile ([#456] by [@samwyma]) +- Allow modules using `load_dotenv` to be reloaded when launched in a separate thread ([#497] by [@freddyaboulton]) +- Fix file not closed after deletion, handle error in the rewrite function ([#469] by [@Qwerty-133]) **Misc** -* Use pathlib.Path in tests ([#466] by [@eumiro]) -* Fix year in release date in changelog.md ([#454] by [@jankislinger]) -* Use https in README links ([#474] by [@Nicals]) + +- Use pathlib.Path in tests ([#466] by [@eumiro]) +- Fix year in release date in changelog.md ([#454] by [@jankislinger]) +- Use https in README links ([#474] by [@Nicals]) ## [1.0.0] - 2023-02-24 **Fixed** -* Drop support for python 3.7, add python 3.12-dev (#449 by [@theskumar]) -* Handle situations where the cwd does not exist. (#446 by [@jctanner]) +- Drop support for python 3.7, add python 3.12-dev (#449 by [@theskumar]) +- Handle situations where the cwd does not exist. (#446 by [@jctanner]) ## [0.21.1] - 2023-01-21 **Added** -* Use Python 3.11 non-beta in CI (#438 by [@bbc2]) -* Modernize variables code (#434 by [@Nougat-Waffle]) -* Modernize main.py and parser.py code (#435 by [@Nougat-Waffle]) -* Improve conciseness of cli.py and __init__.py (#439 by [@Nougat-Waffle]) -* Improve error message for `get` and `list` commands when env file can't be opened (#441 by [@bbc2]) -* Updated License to align with BSD OSI template (#433 by [@lsmith77]) - +- Use Python 3.11 non-beta in CI (#438 by [@bbc2]) +- Modernize variables code (#434 by [@Nougat-Waffle]) +- Modernize main.py and parser.py code (#435 by [@Nougat-Waffle]) +- Improve conciseness of cli.py and **init**.py (#439 by [@Nougat-Waffle]) +- Improve error message for `get` and `list` commands when env file can't be opened (#441 by [@bbc2]) +- Updated License to align with BSD OSI template (#433 by [@lsmith77]) **Fixed** -* Fix Out-of-scope error when "dest" variable is undefined (#413 by [@theGOTOguy]) -* Fix IPython test warning about deprecated `magic` (#440 by [@bbc2]) -* Fix type hint for dotenv_path var, add StrPath alias (#432 by [@eaf]) +- Fix Out-of-scope error when "dest" variable is undefined (#413 by [@theGOTOguy]) +- Fix IPython test warning about deprecated `magic` (#440 by [@bbc2]) +- Fix type hint for dotenv_path var, add StrPath alias (#432 by [@eaf]) ## [0.21.0] - 2022-09-03 **Added** -* CLI: add support for invocations via 'python -m'. (#395 by [@theskumar]) -* `load_dotenv` function now returns `False`. (#388 by [@larsks]) -* CLI: add --format= option to list command. (#407 by [@sammck]) +- CLI: add support for invocations via 'python -m'. (#395 by [@theskumar]) +- `load_dotenv` function now returns `False`. (#388 by [@larsks]) +- CLI: add --format= option to list command. (#407 by [@sammck]) **Fixed** -* Drop Python 3.5 and 3.6 and upgrade GA (#393 by [@eggplants]) -* Use `open` instead of `io.open`. (#389 by [@rabinadk1]) -* Improve documentation for variables without a value (#390 by [@bbc2]) -* Add `parse_it` to Related Projects (#410 by [@naorlivne]) -* Update README.md (#415 by [@harveer07]) -* Improve documentation with direct use of MkDocs (#398 by [@bbc2]) +- Drop Python 3.5 and 3.6 and upgrade GA (#393 by [@eggplants]) +- Use `open` instead of `io.open`. (#389 by [@rabinadk1]) +- Improve documentation for variables without a value (#390 by [@bbc2]) +- Add `parse_it` to Related Projects (#410 by [@naorlivne]) +- Update README.md (#415 by [@harveer07]) +- Improve documentation with direct use of MkDocs (#398 by [@bbc2]) ## [0.20.0] - 2022-03-24 @@ -124,16 +157,16 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). **Changed** -- Require Python 3.5 or a later version. Python 2 and 3.4 are no longer supported. (#341 +- Require Python 3.5 or a later version. Python 2 and 3.4 are no longer supported. (#341 by [@bbc2]). **Added** - The `dotenv_path` argument of `set_key` and `unset_key` now has a type of `Union[str, - os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). +os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). - The `stream` argument of `load_dotenv` and `dotenv_values` can now be a text stream (`IO[str]`), which includes values like `io.StringIO("foo")` and `open("file.env", - "r")` (#348 by [@bbc2]). +"r")` (#348 by [@bbc2]). ## [0.18.0] - 2021-06-20 @@ -271,6 +304,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Fix Unicode error in Python 2, introduced in 0.10.0. ([@bbc2])([#176]) ## 0.10.1 + - Fix parsing of variable without a value ([@asyncee])([@bbc2])([#158]) ## 0.10.0 @@ -283,7 +317,6 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Drop Python 3.3 support ([@greyli]) - Fix stderr/-out/-in redirection ([@venthur]) - ## 0.9.0 - Add `--version` parameter to cli ([@venthur]) @@ -292,81 +325,82 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## 0.8.1 -- Add tests for docs ([@Flimm]) -- Make 'cli' support optional. Use `pip install python-dotenv[cli]`. ([@theskumar]) +- Add tests for docs ([@Flimm]) +- Make 'cli' support optional. Use `pip install python-dotenv[cli]`. ([@theskumar]) ## 0.8.0 -- `set_key` and `unset_key` only modified the affected file instead of - parsing and re-writing file, this causes comments and other file - entact as it is. -- Add support for `export` prefix in the line. -- Internal refractoring ([@theskumar]) -- Allow `load_dotenv` and `dotenv_values` to work with `StringIO())` ([@alanjds])([@theskumar])([#78]) +- `set_key` and `unset_key` only modified the affected file instead of + parsing and re-writing file, this causes comments and other file + entact as it is. +- Add support for `export` prefix in the line. +- Internal refractoring ([@theskumar]) +- Allow `load_dotenv` and `dotenv_values` to work with `StringIO())` ([@alanjds])([@theskumar])([#78]) ## 0.7.1 -- Remove hard dependency on iPython ([@theskumar]) +- Remove hard dependency on iPython ([@theskumar]) ## 0.7.0 -- Add support to override system environment variable via .env. - ([@milonimrod](https://github.com/milonimrod)) - ([\#63](https://github.com/theskumar/python-dotenv/issues/63)) -- Disable ".env not found" warning by default - ([@maxkoryukov](https://github.com/maxkoryukov)) - ([\#57](https://github.com/theskumar/python-dotenv/issues/57)) +- Add support to override system environment variable via .env. + ([@milonimrod](https://github.com/milonimrod)) + ([\#63](https://github.com/theskumar/python-dotenv/issues/63)) +- Disable ".env not found" warning by default + ([@maxkoryukov](https://github.com/maxkoryukov)) + ([\#57](https://github.com/theskumar/python-dotenv/issues/57)) ## 0.6.5 -- Add support for special characters `\`. - ([@pjona](https://github.com/pjona)) - ([\#60](https://github.com/theskumar/python-dotenv/issues/60)) +- Add support for special characters `\`. + ([@pjona](https://github.com/pjona)) + ([\#60](https://github.com/theskumar/python-dotenv/issues/60)) ## 0.6.4 -- Fix issue with single quotes ([@Flimm]) - ([\#52](https://github.com/theskumar/python-dotenv/issues/52)) +- Fix issue with single quotes ([@Flimm]) + ([\#52](https://github.com/theskumar/python-dotenv/issues/52)) ## 0.6.3 -- Handle unicode exception in setup.py - ([\#46](https://github.com/theskumar/python-dotenv/issues/46)) +- Handle unicode exception in setup.py + ([\#46](https://github.com/theskumar/python-dotenv/issues/46)) ## 0.6.2 -- Fix dotenv list command ([@ticosax](https://github.com/ticosax)) -- Add iPython Support - ([@tillahoffmann](https://github.com/tillahoffmann)) +- Fix dotenv list command ([@ticosax](https://github.com/ticosax)) +- Add iPython Support + ([@tillahoffmann](https://github.com/tillahoffmann)) ## 0.6.0 -- Drop support for Python 2.6 -- Handle escaped characters and newlines in quoted values. (Thanks - [@iameugenejo](https://github.com/iameugenejo)) -- Remove any spaces around unquoted key/value. (Thanks - [@paulochf](https://github.com/paulochf)) -- Added POSIX variable expansion. (Thanks - [@hugochinchilla](https://github.com/hugochinchilla)) +- Drop support for Python 2.6 +- Handle escaped characters and newlines in quoted values. (Thanks + [@iameugenejo](https://github.com/iameugenejo)) +- Remove any spaces around unquoted key/value. (Thanks + [@paulochf](https://github.com/paulochf)) +- Added POSIX variable expansion. (Thanks + [@hugochinchilla](https://github.com/hugochinchilla)) ## 0.5.1 -- Fix `find_dotenv` - it now start search from the file where this - function is called from. +- Fix `find_dotenv` - it now start search from the file where this + function is called from. ## 0.5.0 -- Add `find_dotenv` method that will try to find a `.env` file. - (Thanks [@isms](https://github.com/isms)) +- Add `find_dotenv` method that will try to find a `.env` file. + (Thanks [@isms](https://github.com/isms)) ## 0.4.0 -- cli: Added `-q/--quote` option to control the behaviour of quotes - around values in `.env`. (Thanks - [@hugochinchilla](https://github.com/hugochinchilla)). -- Improved test coverage. +- cli: Added `-q/--quote` option to control the behaviour of quotes + around values in `.env`. (Thanks + [@hugochinchilla](https://github.com/hugochinchilla)). +- Improved test coverage. + [#78]: https://github.com/theskumar/python-dotenv/issues/78 [#121]: https://github.com/theskumar/python-dotenv/issues/121 [#148]: https://github.com/theskumar/python-dotenv/issues/148 @@ -386,8 +420,11 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [#569]: https://github.com/theskumar/python-dotenv/issues/569 [#583]: https://github.com/theskumar/python-dotenv/issues/583 [#586]: https://github.com/theskumar/python-dotenv/issues/586 +[#590]: https://github.com/theskumar/python-dotenv/issues/590 +[#607]: https://github.com/theskumar/python-dotenv/issues/607 + [@23f3001135]: https://github.com/23f3001135 [@EpicWink]: https://github.com/EpicWink [@Flimm]: https://github.com/Flimm @@ -437,8 +474,12 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@x-yuri]: https://github.com/x-yuri [@yannham]: https://github.com/yannham [@zueve]: https://github.com/zueve - -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.2.0...HEAD +[@JYOuyang]: https://github.com/JYOuyang +[@burnout-projects]: https://github.com/burnout-projects +[@cpackham-atlnz]: https://github.com/cpackham-atlnz +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.2.2...HEAD +[1.2.2]: https://github.com/theskumar/python-dotenv/compare/v1.2.1...v1.2.2 +[1.2.1]: https://github.com/theskumar/python-dotenv/compare/v1.2.0...v1.2.1 [1.2.0]: https://github.com/theskumar/python-dotenv/compare/v1.1.1...v1.2.0 [1.1.1]: https://github.com/theskumar/python-dotenv/compare/v1.1.0...v1.1.1 [1.1.0]: https://github.com/theskumar/python-dotenv/compare/v1.0.1...v1.1.0 From 36004e0e34be7665ff2b11a8a4005144f76f176d Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 1 Mar 2026 21:26:49 +0530 Subject: [PATCH 220/253] =?UTF-8?q?Bump=20version:=201.2.1=20=E2=86=92=201?= =?UTF-8?q?.2.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- src/dotenv/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index a72da630..646b9e7b 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.2.1 +current_version = 1.2.2 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index a955fdae..bc86c944 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "1.2.1" +__version__ = "1.2.2" From dbc3882c72b8adda1a00802d76c1403ed148d397 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 1 Mar 2026 21:39:40 +0530 Subject: [PATCH 221/253] fix typo --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab35b253..b99b5860 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [v1.2.2] - 2026-03-01 +## [1.2.2] - 2026-03-01 ### Added From dc71adbd8105188602907f6653d3c58d113de554 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 1 Mar 2026 21:50:43 +0530 Subject: [PATCH 222/253] update changelog and fix incosistencies --- CHANGELOG.md | 103 ++++++++++++++++++++++++++++----------------------- 1 file changed, 57 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b99b5860..ee732346 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +- ... + ## [1.2.2] - 2026-03-01 ### Added -- Support for Python 3.14, including the free-threaded (3.14t) build. (#) +- Support for Python 3.14, including the free-threaded (3.14t) build. (#588) ### Changed @@ -21,7 +25,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed -- Improved `set_key` and `unset_key` behavior when interacting with symlinks by [@bbc2] in [#790c5](https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311) +- Improved `set_key` and `unset_key` behavior when interacting with symlinks by [@bbc2] in [790c5c0] - Corrected the license specifier and added missing Python 3.14 classifiers in package metadata by [@JYOuyang] in [#590] ### Breaking Changes @@ -47,40 +51,40 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [1.2.0] - 2025-10-26 - Upgrade build system to use PEP 517 & PEP 518 to use `build` and `pyproject.toml` by [@EpicWink] in [#583] -- Add support for Python 3.14 by [@23f3001135] in [#579](https://github.com/theskumar/python-dotenv/pull/563) +- Add support for Python 3.14 by [@23f3001135] in [#579] - Add support for disabling of `load_dotenv()` using `PYTHON_DOTENV_DISABLED` env var. by [@matthewfranglen] in [#569] ## [1.1.1] - 2025-06-24 ### Fixed -- CLI: Ensure `find_dotenv` work reliably on python 3.13 by [@theskumar] in [#563](https://github.com/theskumar/python-dotenv/pull/563) -- CLI: revert the use of execvpe on Windows by [@wrongontheinternet] in [#566](https://github.com/theskumar/python-dotenv/pull/566) +- CLI: Ensure `find_dotenv` work reliably on python 3.13 by [@theskumar] in [#563] +- CLI: revert the use of execvpe on Windows by [@wrongontheinternet] in [#566] ## [1.1.0] - 2025-03-25 -**Feature** +### Added - Add support for python 3.13 - Enhance `dotenv run`, switch to `execvpe` for better resource management and signal handling ([#523]) by [@eekstunt] -**Fixed** +### Fixed - `find_dotenv` and `load_dotenv` now correctly looks up at the current directory when running in debugger or pdb ([#553] by [@randomseed42]) -**Misc** +### Misc - Drop support for Python 3.8 ## [1.0.1] - 2024-01-23 -**Fixed** +### Fixed - Gracefully handle code which has been imported from a zipfile ([#456] by [@samwyma]) - Allow modules using `load_dotenv` to be reloaded when launched in a separate thread ([#497] by [@freddyaboulton]) - Fix file not closed after deletion, handle error in the rewrite function ([#469] by [@Qwerty-133]) -**Misc** +### Misc - Use pathlib.Path in tests ([#466] by [@eumiro]) - Fix year in release date in changelog.md ([#454] by [@jankislinger]) @@ -88,14 +92,14 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [1.0.0] - 2023-02-24 -**Fixed** +### Fixed - Drop support for python 3.7, add python 3.12-dev (#449 by [@theskumar]) - Handle situations where the cwd does not exist. (#446 by [@jctanner]) ## [0.21.1] - 2023-01-21 -**Added** +### Added - Use Python 3.11 non-beta in CI (#438 by [@bbc2]) - Modernize variables code (#434 by [@Nougat-Waffle]) @@ -104,7 +108,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Improve error message for `get` and `list` commands when env file can't be opened (#441 by [@bbc2]) - Updated License to align with BSD OSI template (#433 by [@lsmith77]) -**Fixed** +### Fixed - Fix Out-of-scope error when "dest" variable is undefined (#413 by [@theGOTOguy]) - Fix IPython test warning about deprecated `magic` (#440 by [@bbc2]) @@ -112,13 +116,13 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.21.0] - 2022-09-03 -**Added** +### Added - CLI: add support for invocations via 'python -m'. (#395 by [@theskumar]) - `load_dotenv` function now returns `False`. (#388 by [@larsks]) - CLI: add --format= option to list command. (#407 by [@sammck]) -**Fixed** +### Fixed - Drop Python 3.5 and 3.6 and upgrade GA (#393 by [@eggplants]) - Use `open` instead of `io.open`. (#389 by [@rabinadk1]) @@ -129,12 +133,12 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.20.0] - 2022-03-24 -**Added** +### Added - Add `encoding` (`Optional[str]`) parameter to `get_key`, `set_key` and `unset_key`. (#379 by [@bbc2]) -**Fixed** +### Fixed - Use dict to specify the `entry_points` parameter of `setuptools.setup` (#376 by [@mgorny]). @@ -142,25 +146,25 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.19.2] - 2021-11-11 -**Fixed** +### Fixed - In `set_key`, add missing newline character before new entry if necessary. (#361 by [@bbc2]) ## [0.19.1] - 2021-08-09 -**Added** +### Added - Add support for Python 3.10. (#359 by [@theskumar]) ## [0.19.0] - 2021-07-24 -**Changed** +### Changed - Require Python 3.5 or a later version. Python 2 and 3.4 are no longer supported. (#341 by [@bbc2]). -**Added** +### Added - The `dotenv_path` argument of `set_key` and `unset_key` now has a type of `Union[str, os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). @@ -170,7 +174,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). ## [0.18.0] - 2021-06-20 -**Changed** +### Changed - Raise `ValueError` if `quote_mode` isn't one of `always`, `auto` or `never` in `set_key` (#330 by [@bbc2]). @@ -183,23 +187,23 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). ## [0.17.1] - 2021-04-29 -**Fixed** +### Fixed - Fixed tests for build environments relying on `PYTHONPATH` (#318 by [@befeleme]). ## [0.17.0] - 2021-04-02 -**Changed** +### Changed - Make `dotenv get ` only show the value, not `key=value` (#313 by [@bbc2]). -**Added** +### Added - Add `--override`/`--no-override` option to `dotenv run` (#312 by [@zueve] and [@bbc2]). ## [0.16.0] - 2021-03-27 -**Changed** +### Changed - The default value of the `encoding` parameter for `load_dotenv` and `dotenv_values` is now `"utf-8"` instead of `None` (#306 by [@bbc2]). @@ -207,17 +211,17 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). ## [0.15.0] - 2020-10-28 -**Added** +### Added - Add `--export` option to `set` to make it prepend the binding with `export` (#270 by [@jadutter]). -**Changed** +### Changed - Make `set` command create the `.env` file in the current directory if no `.env` file was found (#270 by [@jadutter]). -**Fixed** +### Fixed - 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]). @@ -226,30 +230,30 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). ## [0.14.0] - 2020-07-03 -**Changed** +### Changed - Privilege definition in file over the environment in variable expansion (#256 by [@elbehery95]). -**Fixed** +### Fixed - Improve error message for when file isn't found (#245 by [@snobu]). - Use HTTPS URL in package meta data (#251 by [@ekohl]). ## [0.13.0] - 2020-04-16 -**Added** +### Added - Add support for a Bash-like default value in variable expansion (#248 by [@bbc2]). ## [0.12.0] - 2020-02-28 -**Changed** +### Changed - Use current working directory to find `.env` when bundled by PyInstaller (#213 by [@gergelyk]). -**Fixed** +### Fixed - Fix escaping of quoted values written by `set_key` (#236 by [@bbc2]). - Fix `dotenv run` crashing on environment variables without values (#237 by [@yannham]). @@ -257,23 +261,23 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). ## [0.11.0] - 2020-02-07 -**Added** +### Added - Add `interpolate` argument to `load_dotenv` and `dotenv_values` to disable interpolation (#232 by [@ulyssessouza]). -**Changed** +### Changed - Use logging instead of warnings (#231 by [@bbc2]). -**Fixed** +### Fixed - Fix installation in non-UTF-8 environments (#225 by [@altendky]). - Fix PyPI classifiers (#228 by [@bbc2]). ## [0.10.5] - 2020-01-19 -**Fixed** +### Fixed - Fix handling of malformed lines and lines without a value (#222 by [@bbc2]): - Don't print warning when key has no value. @@ -282,7 +286,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). ## [0.10.4] - 2020-01-17 -**Added** +### Added - Make typing optional (#179 by [@techalchemy]). - Print a warning on malformed line (#211 by [@bbc2]). @@ -422,6 +426,13 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#586]: https://github.com/theskumar/python-dotenv/issues/586 [#590]: https://github.com/theskumar/python-dotenv/issues/590 [#607]: https://github.com/theskumar/python-dotenv/issues/607 +[#588]: https://github.com/theskumar/python-dotenv/issues/588 +[#579]: https://github.com/theskumar/python-dotenv/pull/579 +[#566]: https://github.com/theskumar/python-dotenv/pull/566 +[#563]: https://github.com/theskumar/python-dotenv/pull/563 +[#497]: https://github.com/theskumar/python-dotenv/pull/497 +[#161]: https://github.com/theskumar/python-dotenv/issues/161 +[790c5c0]: https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311 @@ -441,7 +452,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@eaf]: https://github.com/eaf [@earlbread]: https://github.com/earlbread [@eekstunt]: https://github.com/eekstunt -[@eggplants]: https://github.com/@eggplants +[@eggplants]: https://github.com/eggplants [@ekohl]: https://github.com/ekohl [@elbehery95]: https://github.com/elbehery95 [@eumiro]: https://github.com/eumiro @@ -449,19 +460,19 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@gergelyk]: https://github.com/gergelyk [@gongqingkui]: https://github.com/gongqingkui [@greyli]: https://github.com/greyli -[@harveer07]: https://github.com/@harveer07 +[@harveer07]: https://github.com/harveer07 [@jadutter]: https://github.com/jadutter [@jankislinger]: https://github.com/jankislinger [@jctanner]: https://github.com/jctanner -[@larsks]: https://github.com/@larsks +[@larsks]: https://github.com/larsks [@lsmith77]: https://github.com/lsmith77 [@matthewfranglen]: https://github.com/matthewfranglen [@mgorny]: https://github.com/mgorny -[@naorlivne]: https://github.com/@naorlivne +[@naorlivne]: https://github.com/naorlivne [@qnighy]: https://github.com/qnighy -[@rabinadk1]: https://github.com/@rabinadk1 -[@randomseed42]: https://github.com/zueve -[@sammck]: https://github.com/@sammck +[@rabinadk1]: https://github.com/rabinadk1 +[@randomseed42]: https://github.com/randomseed42 +[@sammck]: https://github.com/sammck [@samwyma]: https://github.com/samwyma [@sidharth-sudhir]: https://github.com/sidharth-sudhir [@snobu]: https://github.com/snobu From da0c82054f1ec1e03d57356d49b0b9ad09eb4209 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Mon, 2 Mar 2026 06:21:15 +0100 Subject: [PATCH 223/253] fix: print friendly error for missing command in `dotenv run` (#606) Changes for users: - (BREAKING) `dotenv run` exits with status code 1 instead of 2 if the command provided as argument can't be found. - `dotenv run` prints a friendly error message instead of a stack trace if the command provided as argument can't be found. Notes: The existing test case was wrongly testing for that situation: it was actually observing a "missing env file" error, not a "command not found error". I thus added an appropriate test case for full coverage. Co-authored-by: Saurabh Kumar --- src/dotenv/cli.py | 15 +++++++++++++-- tests/test_cli.py | 16 +++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 47eec047..79613e28 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -228,9 +228,20 @@ def run_command(command: List[str], env: Dict[str, str]) -> None: if sys.platform == "win32": # execvpe on Windows returns control immediately # rather than once the command has finished. - p = Popen(command, universal_newlines=True, bufsize=0, shell=False, env=cmd_env) + try: + p = Popen( + command, universal_newlines=True, bufsize=0, shell=False, env=cmd_env + ) + except FileNotFoundError: + print(f"Command not found: {command[0]}", file=sys.stderr) + sys.exit(1) + _, _ = p.communicate() sys.exit(p.returncode) else: - os.execvpe(command[0], args=command, env=cmd_env) + try: + os.execvpe(command[0], args=command, env=cmd_env) + except FileNotFoundError: + print(f"Command not found: {command[0]}", file=sys.stderr) + sys.exit(1) diff --git a/tests/test_cli.py b/tests/test_cli.py index 02bdb764..d4e3ad4d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -237,7 +237,21 @@ def test_run_without_cmd(tmp_path): assert "Invalid value for '-f'" in result.stderr -def test_run_with_invalid_cmd(tmp_path): +def test_run_with_invalid_cmd(dotenv_path, tmp_path): + result = run_dotenv( + ["--file", str(dotenv_path), "run", "i_do_not_exist"], + cwd=tmp_path, + ) + + check_process(result, exit_code=1) + assert "Command not found: i_do_not_exist" in result.stderr + + +def test_run_with_env_missing_and_invalid_cmd(tmp_path): + """ + Check that an .env file missing takes precedence over a command not found error. + """ + result = run_dotenv(["run", "i_do_not_exist"], cwd=tmp_path) check_process(result, exit_code=2) From fa4e6a90b45428212452afc6ee0d5c8103b9301d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= Date: Fri, 6 Mar 2026 16:48:37 -0600 Subject: [PATCH 224/253] Add missing interpolate parameter to the docstrings of functions `load_dotenv` and `dotenv_values` (#614) --- src/dotenv/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 48e5245a..491634d9 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -397,6 +397,7 @@ def load_dotenv( verbose: Whether to output a warning the .env file is missing. override: Whether to override the system environment variables with the variables from the `.env` file. + interpolate: Whether to interpolate variables using POSIX variable expansion. encoding: Encoding to be used to read the file. Returns: Bool: True if at least one environment variable is set else False @@ -447,6 +448,7 @@ def dotenv_values( dotenv_path: Absolute or relative path to the .env file. stream: `StringIO` object with .env content, used if `dotenv_path` is `None`. verbose: Whether to output a warning if the .env file is missing. + interpolate: Whether to interpolate variables using POSIX variable expansion. encoding: Encoding to be used to read the file. If both `dotenv_path` and `stream` are `None`, `find_dotenv()` is used to find the From 68ea2d10dd8e97e6dea67c32742085f27729e3fe Mon Sep 17 00:00:00 2001 From: Tereza Pohankova Date: Sun, 19 Apr 2026 21:23:31 +0200 Subject: [PATCH 225/253] fix: typo in comment (#621) --- src/dotenv/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 491634d9..d17b45cb 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -403,7 +403,7 @@ def load_dotenv( Bool: True if at least one environment variable is set else False If both `dotenv_path` and `stream` are `None`, `find_dotenv()` is used to find the - .env file with it's default parameters. If you need to change the default parameters + .env file with its default parameters. If you need to change the default parameters of `find_dotenv()`, you can explicitly call `find_dotenv()` and pass the result to this function as `dotenv_path`. From bca6644d9aedbe287b792b756b3ae3d650cd0d3a Mon Sep 17 00:00:00 2001 From: Henry Whelan <135996750+h1whelan@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:39:11 +0100 Subject: [PATCH 226/253] fix: strip UTF-8 BOM from .env files (#640) * Fix: strip UTF-8 BOM from .env files to prevent silent first-variable loss When a .env file is saved with a UTF-8 BOM (common with JetBrains IDEs on Windows), the BOM character (\ufeff) was prepended to the first variable name, making it silently inaccessible via its intended key. Strip the BOM in Reader.__init__ so all variables are parsed correctly regardless of whether the file contains a BOM. Fixes #637 Co-Authored-By: Claude Opus 4.6 (1M context) * docs: add changelog entry for BOM fix --------- Co-authored-by: h1whelan Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Saurabh Kumar --- CHANGELOG.md | 6 +++++- src/dotenv/parser.py | 2 +- tests/test_parser.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee732346..fa81cc6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -- ... +### Fixed + +- Strip a leading UTF-8 BOM from `.env` file contents so the first variable is no longer silently lost when the file is saved with BOM (e.g. by some JetBrains IDEs on Windows) by [@h1whelan] in [#640] ## [1.2.2] - 2026-03-01 @@ -432,6 +434,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#563]: https://github.com/theskumar/python-dotenv/pull/563 [#497]: https://github.com/theskumar/python-dotenv/pull/497 [#161]: https://github.com/theskumar/python-dotenv/issues/161 +[#640]: https://github.com/theskumar/python-dotenv/pull/640 [790c5c0]: https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311 @@ -460,6 +463,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@gergelyk]: https://github.com/gergelyk [@gongqingkui]: https://github.com/gongqingkui [@greyli]: https://github.com/greyli +[@h1whelan]: https://github.com/h1whelan [@harveer07]: https://github.com/harveer07 [@jadutter]: https://github.com/jadutter [@jankislinger]: https://github.com/jankislinger diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index eb100b47..66773604 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -68,7 +68,7 @@ class Error(Exception): class Reader: def __init__(self, stream: IO[str]) -> None: - self.string = stream.read() + self.string = stream.read().removeprefix("\ufeff") self.position = Position.start() self.mark = Position.start() diff --git a/tests/test_parser.py b/tests/test_parser.py index 43386e5a..4ec5a5af 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -545,6 +545,35 @@ ), ], ), + # UTF-8 BOM at the start of the file should be stripped + ( + "\ufeffa=b", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b", line=1), + error=False, + ) + ], + ), + ( + "\ufeffa=b\nc=d", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b\n", line=1), + error=False, + ), + Binding( + key="c", + value="d", + original=Original(string="c=d", line=2), + error=False, + ), + ], + ), ], ) def test_parse_stream(test_input, expected): From 3f0e3e0b461cf7127e2753efbdf896bdff31db2e Mon Sep 17 00:00:00 2001 From: rikka0612 <104909326+ReinerBRO@users.noreply.github.com> Date: Fri, 8 May 2026 22:27:36 +0800 Subject: [PATCH 227/253] Cache empty dotenv dicts (#638) --- src/dotenv/main.py | 2 +- tests/test_main.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index d17b45cb..3c4608d5 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -74,7 +74,7 @@ def _get_stream(self) -> Iterator[IO[str]]: def dict(self) -> Dict[str, Optional[str]]: """Return dotenv as dict""" - if self._dict: + if self._dict is not None: return self._dict raw_values = self.parse() diff --git a/tests/test_main.py b/tests/test_main.py index 50703af0..1c33c808 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -10,6 +10,7 @@ import pytest import dotenv +from dotenv.main import DotEnv def test_set_key_no_file(tmp_path): @@ -227,6 +228,18 @@ def test_get_key_none(dotenv_path): mock_warning.assert_not_called() +def test_empty_dotenv_dict_is_cached(tmp_path): + dotenv_path = tmp_path / ".env" + dotenv_path.write_text("") + dotenv_obj = DotEnv(dotenv_path) + + with mock.patch.object(dotenv_obj, "parse", wraps=dotenv_obj.parse) as mock_parse: + assert dotenv_obj.dict() == {} + assert dotenv_obj.dict() == {} + + assert mock_parse.call_count == 1 + + def test_unset_with_value(dotenv_path): logger = logging.getLogger("dotenv.main") dotenv_path.write_text("a=b\nc=d") From 078325e92558330e9addc661b789c7b6123fa73c Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sat, 30 May 2026 12:15:03 +0530 Subject: [PATCH 228/253] ci(security): harden CI/CD supply chain with SHA pinning and least-privilege permissions (#650) - Pin all GitHub Actions to full SHA with version comments - Add top-level permissions: contents: read to test.yml - Add top-level permissions: contents: read to release.yml (write only at job level) - Upgrade pypa/gh-action-pypi-publish from release/v1 tag to v1.14.0 SHA - Add pip ecosystem tracking to dependabot - Add commit-message prefixes and labels to dependabot config --- .github/dependabot.yml | 25 ++++++++++++++++++++++--- .github/workflows/release.yml | 13 ++++++++----- .github/workflows/test.yml | 8 ++++++-- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index be006de9..3137ade5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,13 +1,32 @@ -# Keep GitHub Actions up to date with GitHub's Dependabot... # https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot -# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem version: 2 updates: + # Keep GitHub Actions pinned-by-SHA references up to date. Dependabot + # rewrites the SHA and the trailing `# vX.Y.Z` version comment together. - package-ecosystem: github-actions directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: ci + include: scope groups: github-actions: patterns: - - "*" # Group all Actions updates into a single larger pull request + - "*" + labels: + - dependencies + - github-actions + + - package-ecosystem: pip + directory: / schedule: interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: chore + include: scope + labels: + - dependencies + - python diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dc91d369..243808f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,10 +4,13 @@ on: release: types: [created] +# Least-privilege default: jobs are read-only unless they opt into more below. +permissions: + contents: read + jobs: publish: runs-on: ubuntu-latest - # Specifying a GitHub environment is optional, but strongly encouraged environment: release permissions: # IMPORTANT: this permission is mandatory for trusted publishing @@ -16,10 +19,10 @@ jobs: contents: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: "3.x" @@ -32,7 +35,7 @@ jobs: run: make sdist - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 - name: Build Documentation run: | @@ -41,7 +44,7 @@ jobs: mkdocs build - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@v4 + uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./site diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 66056f6c..695d5175 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,10 @@ on: - main pull_request: +# Least-privilege default token for every job in this workflow. +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -31,10 +35,10 @@ jobs: python-version: "3.14" steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: ${{ matrix.python-version }} allow-prereleases: true From 72754a16a4743e4c92edf87ce59ba0e4ff78758d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 12:21:17 +0530 Subject: [PATCH 229/253] ci(deps): bump peaceiris/actions-gh-pages from 4.0.0 to 4.1.0 in the github-actions group (#651) Bumps the github-actions group with 1 update: [peaceiris/actions-gh-pages](https://github.com/peaceiris/actions-gh-pages). Updates `peaceiris/actions-gh-pages` from 4.0.0 to 4.1.0 - [Release notes](https://github.com/peaceiris/actions-gh-pages/releases) - [Changelog](https://github.com/peaceiris/actions-gh-pages/blob/main/CHANGELOG.md) - [Commits](https://github.com/peaceiris/actions-gh-pages/compare/4f9cc6602d3f66b9c108549d475ec49e8ef4d45e...84c30a85c19949d7eee79c4ff27748b70285e453) --- updated-dependencies: - dependency-name: peaceiris/actions-gh-pages dependency-version: 4.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 243808f5..dbfeb4ad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,7 @@ jobs: mkdocs build - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4 + uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./site From 72896e9244d2a1ff8d5e1934d3df8a35c813799a Mon Sep 17 00:00:00 2001 From: vincent Date: Sat, 30 May 2026 14:53:39 +0800 Subject: [PATCH 230/253] docs: fix broken mkdocs link in CONTRIBUTING.md (#636) Co-authored-by: liwenjun-dev --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49840fa7..dd21ae76 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ Use of pre-commit is recommended: $ uv run precommit install -Documentation is published with [mkdocs](): +Documentation is published with [mkdocs](https://www.mkdocs.org/): ```shell $ uv pip install -r requirements-docs.txt From 45b93720dd5e9e127d0be3971a04855d34e464d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 12:25:23 +0530 Subject: [PATCH 231/253] chore(deps): update pytest requirement from >=3.9 to >=9.0.3 (#653) Updates the requirements on [pytest](https://github.com/pytest-dev/pytest) to permit the latest version. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/3.9.0...9.0.3) --- updated-dependencies: - dependency-name: pytest dependency-version: 9.0.3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4a9f28a1..8b1177fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ bumpversion click ipython pytest-cov -pytest>=3.9 +pytest>=9.0.3 tox wheel ruff From f1937b68f338d7ebd7754da377ca08b2f0df8dbb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 12:26:18 +0530 Subject: [PATCH 232/253] chore(deps): update mkdocs-include-markdown-plugin requirement from >=6.0.0 to >=7.3.0 (#652) Updates the requirements on [mkdocs-include-markdown-plugin](https://github.com/mondeja/mkdocs-include-markdown-plugin) to permit the latest version. - [Release notes](https://github.com/mondeja/mkdocs-include-markdown-plugin/releases) - [Commits](https://github.com/mondeja/mkdocs-include-markdown-plugin/compare/v6.0.0...v7.3.0) --- updated-dependencies: - dependency-name: mkdocs-include-markdown-plugin dependency-version: 7.3.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index b09a710d..76fa086e 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -1,5 +1,5 @@ mdx_truly_sane_lists>=1.3 -mkdocs-include-markdown-plugin>=6.0.0 +mkdocs-include-markdown-plugin>=7.3.0 mkdocs-material>=9.5.0 mkdocstrings[python]>=0.24.0 mkdocs>=1.5.0 From 751f8c148222e58aa173c83c4e5e6cfccb2cc124 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:20:20 +0530 Subject: [PATCH 233/253] ci(deps): bump actions/checkout from 6.0.2 to 6.0.3 in the github-actions group (#658) Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 6.0.2 to 6.0.3 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dbfeb4ad..369ebc8e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: contents: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 695d5175..e8e7fa81 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,7 +35,7 @@ jobs: python-version: "3.14" steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 From f7b18d9c72d1abcc2ad4023424b84f5bee30d266 Mon Sep 17 00:00:00 2001 From: Dipak Chaudhari <111210939+dchaudhari7177@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:53:35 +0530 Subject: [PATCH 234/253] fix: round-trip backslashes through set_key (#680) `set_key` writes single-quoted values but only escaped single quotes, not backslashes. The single-quoted-value parser decodes `\` and `\'`, so any value with a backslash came back wrong: set_key(".env", "P", r"C:\Users") # writes P='C:\Users' get_key(".env", "P") # -> "C:Users" Escape backslashes before single quotes when writing. That alone is not enough for a value that ends in a backslash. The quoted-value patterns treated `\'` as an escape but not `\`, so in `P='back\'` the second backslash paired with the closing quote. `[^']` matches newlines, so the match ran on into the following lines and the binding failed to parse, taking the next entries with it. Both the single- and double-quoted patterns now treat a backslash as escaping whatever follows it. Fixes #661 Co-authored-by: Claude Opus 5 --- CHANGELOG.md | 3 ++ src/dotenv/main.py | 7 ++++- src/dotenv/parser.py | 6 ++-- tests/test_main.py | 29 +++++++++++++++++++ tests/test_parser.py | 69 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 111 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa81cc6e..98fb10ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed - Strip a leading UTF-8 BOM from `.env` file contents so the first variable is no longer silently lost when the file is saved with BOM (e.g. by some JetBrains IDEs on Windows) by [@h1whelan] in [#640] +- `set_key` now escapes backslashes, so values containing them (Windows paths, regular expressions) survive a write/read round-trip. Quoted values ending in an escaped backslash are no longer mis-parsed as an escaped quote, which used to swallow the following lines by [@dchaudhari7177] in [#661] ## [1.2.2] - 2026-03-01 @@ -435,6 +436,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#497]: https://github.com/theskumar/python-dotenv/pull/497 [#161]: https://github.com/theskumar/python-dotenv/issues/161 [#640]: https://github.com/theskumar/python-dotenv/pull/640 +[#661]: https://github.com/theskumar/python-dotenv/issues/661 [790c5c0]: https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311 @@ -452,6 +454,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@bbc2]: https://github.com/bbc2 [@befeleme]: https://github.com/befeleme [@cjauvin]: https://github.com/cjauvin +[@dchaudhari7177]: https://github.com/dchaudhari7177 [@eaf]: https://github.com/eaf [@earlbread]: https://github.com/earlbread [@eekstunt]: https://github.com/eekstunt diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 3c4608d5..3123690a 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -216,7 +216,12 @@ def set_key( ) if quote: - value_out = "'{}'".format(value_to_set.replace("'", "\\'")) + # The single-quoted-value parser decodes `\\` and `\'`, so both have to + # be escaped here for the value to survive a write/read round-trip. + # Backslashes first, otherwise the backslash added by the quote + # escaping would be escaped in turn. + escaped = value_to_set.replace("\\", "\\\\").replace("'", "\\'") + value_out = f"'{escaped}'" else: value_out = value_to_set if export: diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index 66773604..02648ab0 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -22,8 +22,10 @@ def make_regex(string: str, extra_flags: int = 0) -> Pattern[str]: _single_quoted_key = make_regex(r"'([^']+)'") _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'"((?:\\"|[^"])*)"') +# A backslash always escapes the character after it, so that an escaped +# backslash (`\\`) is not mistaken for the start of an escaped quote. +_single_quoted_value = make_regex(r"'((?:\\.|[^'\\])*)'", extra_flags=re.DOTALL) +_double_quoted_value = make_regex(r'"((?:\\.|[^"\\])*)"', extra_flags=re.DOTALL) _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|$)") diff --git a/tests/test_main.py b/tests/test_main.py index 1c33c808..48dd7bf4 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -40,6 +40,9 @@ def test_set_key_no_file(tmp_path): ("a=b\nc=d\ne=f", "c", "g", (True, "c", "g"), "a=b\nc='g'\ne=f"), ("a=b\n", "c", "d", (True, "c", "d"), "a=b\nc='d'\n"), ("a=b", "c", "d", (True, "c", "d"), "a=b\nc='d'\n"), + ("", "a", "b\\c", (True, "a", "b\\c"), "a='b\\\\c'\n"), + ("", "a", "b\\", (True, "a", "b\\"), "a='b\\\\'\n"), + ("", "a", "b\\'c", (True, "a", "b\\'c"), "a='b\\\\\\'c'\n"), ], ) def test_set_key(dotenv_path, before, key, value, expected, after): @@ -54,6 +57,32 @@ def test_set_key(dotenv_path, before, key, value, expected, after): mock_warning.assert_not_called() +@pytest.mark.parametrize( + "value", + [ + "C:\\Users", + "C:\\Users\\", + "\\d+", + "back\\", + "a\\'b", + "it's", + 'say "hi"', + "a\\nb", + "plain", + "", + ], +) +def test_set_key_round_trips(dotenv_path, value): + dotenv_path.write_text("") + + dotenv.set_key(dotenv_path, "a", value) + dotenv.set_key(dotenv_path, "b", "sentinel") + + assert dotenv.get_key(dotenv_path, "a") == value + # A value that is mis-tokenized can swallow the lines that follow it. + assert dotenv.get_key(dotenv_path, "b") == "sentinel" + + def test_set_key_encoding(dotenv_path): encoding = "latin-1" diff --git a/tests/test_parser.py b/tests/test_parser.py index 4ec5a5af..dd638d9b 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -295,6 +295,75 @@ ) ], ), + ( + "a='b\\\\c'", + [ + Binding( + key="a", + value="b\\c", + original=Original(string="a='b\\\\c'", line=1), + error=False, + ) + ], + ), + ( + 'a="b\\\\c"', + [ + Binding( + key="a", + value="b\\c", + original=Original(string='a="b\\\\c"', line=1), + error=False, + ) + ], + ), + # An escaped backslash at the end of the value must not be read as the + # start of an escaped quote, which would swallow the following lines. + ( + "a='b\\\\'\nc='d'", + [ + Binding( + key="a", + value="b\\", + original=Original(string="a='b\\\\'\n", line=1), + error=False, + ), + Binding( + key="c", + value="d", + original=Original(string="c='d'", line=2), + error=False, + ), + ], + ), + ( + 'a="b\\\\"\nc="d"', + [ + Binding( + key="a", + value="b\\", + original=Original(string='a="b\\\\"\n', line=1), + error=False, + ), + Binding( + key="c", + value="d", + original=Original(string='c="d"', line=2), + error=False, + ), + ], + ), + ( + "a='b\\\\\\'c'", + [ + Binding( + key="a", + value="b\\'c", + original=Original(string="a='b\\\\\\'c'", line=1), + error=False, + ) + ], + ), ( "a=à", [ From bb31c944fb4b40e8dea59587f525840c38326166 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 16 Aug 2026 21:20:02 +0530 Subject: [PATCH 235/253] docs: add 1.2.3 release notes (#606, #638, #680) --- CHANGELOG.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98fb10ff..69b51f0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,14 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [1.2.3] - 2026-08-16 + ### Fixed - Strip a leading UTF-8 BOM from `.env` file contents so the first variable is no longer silently lost when the file is saved with BOM (e.g. by some JetBrains IDEs on Windows) by [@h1whelan] in [#640] -- `set_key` now escapes backslashes, so values containing them (Windows paths, regular expressions) survive a write/read round-trip. Quoted values ending in an escaped backslash are no longer mis-parsed as an escaped quote, which used to swallow the following lines by [@dchaudhari7177] in [#661] +- `set_key` now escapes backslashes, so values containing them (Windows paths, regular expressions) survive a write/read round-trip. Quoted values ending in an escaped backslash are no longer mis-parsed as an escaped quote, which used to swallow the following lines by [@dchaudhari7177] in [#680] +- `dotenv run` now prints a friendly error instead of a traceback when no command is given by [@bbc2] in [#606] +- Cache the parsed result for empty `.env` files so repeated `dotenv_values`/`load_dotenv` calls no longer re-read the file by [@ReinerBRO] in [#638] ## [1.2.2] - 2026-03-01 @@ -435,8 +439,10 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#563]: https://github.com/theskumar/python-dotenv/pull/563 [#497]: https://github.com/theskumar/python-dotenv/pull/497 [#161]: https://github.com/theskumar/python-dotenv/issues/161 +[#606]: https://github.com/theskumar/python-dotenv/pull/606 +[#638]: https://github.com/theskumar/python-dotenv/pull/638 [#640]: https://github.com/theskumar/python-dotenv/pull/640 -[#661]: https://github.com/theskumar/python-dotenv/issues/661 +[#680]: https://github.com/theskumar/python-dotenv/pull/680 [790c5c0]: https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311 @@ -447,6 +453,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@Nicals]: https://github.com/Nicals [@Nougat-Waffle]: https://github.com/Nougat-Waffle [@Qwerty-133]: https://github.com/Qwerty-133 +[@ReinerBRO]: https://github.com/ReinerBRO [@alanjds]: https://github.com/alanjds [@altendky]: https://github.com/altendky [@andrewsmith]: https://github.com/andrewsmith @@ -495,7 +502,8 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@JYOuyang]: https://github.com/JYOuyang [@burnout-projects]: https://github.com/burnout-projects [@cpackham-atlnz]: https://github.com/cpackham-atlnz -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.2.2...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.2.3...HEAD +[1.2.3]: https://github.com/theskumar/python-dotenv/compare/v1.2.2...v1.2.3 [1.2.2]: https://github.com/theskumar/python-dotenv/compare/v1.2.1...v1.2.2 [1.2.1]: https://github.com/theskumar/python-dotenv/compare/v1.2.0...v1.2.1 [1.2.0]: https://github.com/theskumar/python-dotenv/compare/v1.1.1...v1.2.0 From 8ac846ff2760b65470e769d7eed33e540f0934e1 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 16 Aug 2026 21:20:56 +0530 Subject: [PATCH 236/253] chore: add release runbook (RELEASING.md) and make release target --- Makefile | 17 +++++++++++- RELEASING.md | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 RELEASING.md diff --git a/Makefile b/Makefile index 78866a60..6706fe94 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,6 @@ -.PHONY: clean-pyc clean-build test fmt +.PHONY: clean-pyc clean-build test fmt release + +part ?= patch clean: clean-build clean-pyc @@ -33,3 +35,16 @@ coverage: coverage-html: coverage coverage html + +# Cut a release: verify, bump version, commit and tag. See RELEASING.md. +# Override the bump size with `make release part=minor` (default: patch). +release: + @test "$$(git rev-parse --abbrev-ref HEAD)" = "main" || { echo "Release from main only (currently on $$(git rev-parse --abbrev-ref HEAD))"; exit 1; } + @git diff --quiet && git diff --cached --quiet || { echo "Working tree is dirty; commit the changelog first"; exit 1; } + uv run ruff check . + uv run pytest tests/ + uv run bumpversion $(part) + @echo "" + @echo "Tagged v$$(uv run python -c 'import dotenv.version as v; print(v.__version__)'). Next:" + @echo " git push origin main --follow-tags" + @echo " then create the GitHub Release (see RELEASING.md) to publish to PyPI" diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 00000000..9d88c343 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,78 @@ +# Releasing + +python-dotenv follows [Semantic Versioning](https://semver.org/). Bug fixes are +patch releases, backward-compatible features are minor releases, and breaking +changes are major releases. + +Publishing is automated: creating a GitHub **Release** triggers +[`.github/workflows/release.yml`](.github/workflows/release.yml), which builds +the package, publishes it to PyPI via Trusted Publishing (OIDC, no tokens), and +deploys the docs to GitHub Pages. You never run `twine` by hand. + +## Prerequisites + +- Push access to `main` and permission to create releases. +- A clean local checkout: `git switch main && git pull --ff-only`. +- Dev tools installed: `uv pip install -r requirements.txt && uv pip install -e .`. + +## Steps + +1. **Write the changelog.** In `CHANGELOG.md`, rename the `## [Unreleased]` + section to `## [X.Y.Z] - YYYY-MM-DD`, and add a fresh empty `## [Unreleased]` + above it. Every entry credits the author and links the PR, for example: + + ``` + - Short description of the change by [@handle] in [#123] + ``` + + Add the matching link definitions at the bottom of the file (`[#123]: ...` + and, for a first-time contributor, `[@handle]: https://github.com/handle`). + + Every version header is a reference link, so also maintain the compare links + at the bottom: point `[Unreleased]` at the new tag and add the new version: + + ``` + [Unreleased]: https://github.com/theskumar/python-dotenv/compare/vX.Y.Z...HEAD + [X.Y.Z]: https://github.com/theskumar/python-dotenv/compare/vPREV...vX.Y.Z + ``` + +2. **Commit the notes** on `main`: + + ``` + git commit -am "docs: add X.Y.Z release notes" + ``` + +3. **Bump the version.** This runs the checks, updates `src/dotenv/version.py` + and `.bumpversion.cfg`, commits `Bump version: ... → X.Y.Z`, and tags + `vX.Y.Z`: + + ``` + make release # patch (default) + make release part=minor + make release part=major + ``` + +4. **Push** the release commit and tag: + + ``` + git push origin main --follow-tags + ``` + +5. **Publish.** Create the GitHub Release from the new tag. This starts the + PyPI upload and docs deploy: + + ``` + VERSION=$(uv run python -c "import dotenv.version as v; print(v.__version__)") + gh release create "v$VERSION" --title "v$VERSION" \ + --notes "$(awk "/^## \\[$VERSION\\]/{f=1;next} /^## \\[/{f=0} f" CHANGELOG.md)" + ``` + +6. **Verify.** Watch the workflow (`gh run watch`), then confirm the new version + on [PyPI](https://pypi.org/project/python-dotenv/) and that the + [docs](https://saurabh-kumar.com/python-dotenv/) rebuilt. + +## Rollback + +The version bump is a normal commit plus tag. Before you push, undo with +`git reset --hard HEAD~1 && git tag -d vX.Y.Z`. After a bad publish, yank the +release on PyPI and ship a follow-up patch; PyPI versions cannot be reused. From 49515afee2d50c33cad9419b3800b3a0dc93fc59 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 16 Aug 2026 22:24:04 +0530 Subject: [PATCH 237/253] =?UTF-8?q?Bump=20version:=201.2.2=20=E2=86=92=201?= =?UTF-8?q?.2.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- src/dotenv/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 646b9e7b..9bb4df0b 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.2.2 +current_version = 1.2.3 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index bc86c944..10aa336c 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "1.2.2" +__version__ = "1.2.3" From f5485a61eefa5e686d6d5bdc7aa9ad6b104b1e92 Mon Sep 17 00:00:00 2001 From: Noethix55555 <277300782+Noethix55555@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:26:34 -0400 Subject: [PATCH 238/253] fix: parse empty unquoted value with inline comment as empty string An unquoted empty value followed by an inline comment (e.g. `KEY= # comment`) was parsed as the comment text because the equal-sign regex consumed the whitespace after `=`, leaving no whitespace for the comment-stripping rule to match. When the value is empty (whitespace follows `=`) and starts with `#`, treat the rest of the line as a comment and the value as empty. Values with no space before `#` (e.g. `KEY=#novalue`, `KEY=a#b`) are unchanged. Closes #600 --- src/dotenv/parser.py | 11 +++++-- tests/test_main.py | 19 ++++++++++++ tests/test_parser.py | 72 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index 02648ab0..d9d47583 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -156,8 +156,15 @@ def parse_binding(reader: Reader) -> Binding: key = parse_key(reader) reader.read_regex(_whitespace) if reader.peek(1) == "=": - reader.read_regex(_equal_sign) - value: Optional[str] = parse_value(reader) + (equal_sign,) = reader.read_regex(_equal_sign) + # If there is whitespace after `=` and the value starts with `#`, + # the value is empty and the rest of the line is an inline comment + # (e.g. `KEY= # comment`). Without whitespace (`KEY=#novalue`) the + # `#` is part of the unquoted value. + if len(equal_sign) > 1 and reader.peek(1) == "#": + value: Optional[str] = "" + else: + value = parse_value(reader) else: value = None reader.read_regex(_comment) diff --git a/tests/test_main.py b/tests/test_main.py index 48dd7bf4..6f9d4c5c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -737,3 +737,22 @@ def test_dotenv_values_file_stream(dotenv_path): result = dotenv.dotenv_values(stream=f) assert result == {"a": "b"} + + +@pytest.mark.parametrize( + "string,expected", + [ + ("KEY= # comment", {"KEY": ""}), + ("KEY= # comment", {"KEY": ""}), + ("KEY=val # comment", {"KEY": "val"}), + ("KEY=a#b", {"KEY": "a#b"}), + ("KEY=#novalue", {"KEY": "#novalue"}), + ("KEY=# # comment", {"KEY": "#"}), + ("KEY=\t# comment", {"KEY": ""}), + ("KEY= # comment\nOTHER=val", {"KEY": "", "OTHER": "val"}), + ], +) +def test_dotenv_values_empty_value_with_inline_comment(string, expected): + result = dotenv.dotenv_values(stream=io.StringIO(string)) + + assert result == expected diff --git a/tests/test_parser.py b/tests/test_parser.py index dd638d9b..667eae15 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -119,6 +119,78 @@ ) ], ), + ( + "a= #b", + [ + Binding( + key="a", + value="", + original=Original(string="a= #b", line=1), + error=False, + ) + ], + ), + ( + "a= # b", + [ + Binding( + key="a", + value="", + original=Original(string="a= # b", line=1), + error=False, + ) + ], + ), + ( + "a=#b", + [ + Binding( + key="a", + value="#b", + original=Original(string="a=#b", line=1), + error=False, + ) + ], + ), + ( + "a=# # comment", + [ + Binding( + key="a", + value="#", + original=Original(string="a=# # comment", line=1), + error=False, + ) + ], + ), + ( + "a=\t#c", + [ + Binding( + key="a", + value="", + original=Original(string="a=\t#c", line=1), + error=False, + ) + ], + ), + ( + "a= #c\nd=e", + [ + Binding( + key="a", + value="", + original=Original(string="a= #c\n", line=1), + error=False, + ), + Binding( + key="d", + value="e", + original=Original(string="d=e", line=2), + error=False, + ), + ], + ), ( "a=b c", [ From a00cb2eed0704cd6d2071b2004c37e95ccc86ee5 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Mon, 17 Aug 2026 13:12:07 +0530 Subject: [PATCH 239/253] docs: add CHANGELOG entry for #663 (fix #600) Credit @Noethix55555 for fixing inline comment on empty unquoted values. --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69b51f0f..00f08f58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed + +- An unquoted empty value followed by an inline comment (e.g. `KEY= # comment`) is now parsed as an empty string instead of the comment text by [@Noethix55555] in [#663] + ## [1.2.3] - 2026-08-16 ### Fixed @@ -442,6 +446,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#606]: https://github.com/theskumar/python-dotenv/pull/606 [#638]: https://github.com/theskumar/python-dotenv/pull/638 [#640]: https://github.com/theskumar/python-dotenv/pull/640 +[#663]: https://github.com/theskumar/python-dotenv/pull/663 [#680]: https://github.com/theskumar/python-dotenv/pull/680 [790c5c0]: https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311 @@ -483,6 +488,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@matthewfranglen]: https://github.com/matthewfranglen [@mgorny]: https://github.com/mgorny [@naorlivne]: https://github.com/naorlivne +[@Noethix55555]: https://github.com/Noethix55555 [@qnighy]: https://github.com/qnighy [@rabinadk1]: https://github.com/rabinadk1 [@randomseed42]: https://github.com/randomseed42 From e0310e5bb3f2b701b11bbc007ca0c82c1bd56f38 Mon Sep 17 00:00:00 2001 From: dickbown <119035617+ROTl24@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:05:56 +0800 Subject: [PATCH 240/253] fix: honor --no-override when expanding variables in dotenv run (#698) Fixes #697. --- CHANGELOG.md | 3 +++ src/dotenv/cli.py | 4 ++-- tests/test_cli.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00f08f58..02bad5aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed - An unquoted empty value followed by an inline comment (e.g. `KEY= # comment`) is now parsed as an empty string instead of the comment text by [@Noethix55555] in [#663] +- `dotenv run --no-override` now expands variable references with the same precedence as `load_dotenv(override=False)`, so a value like `${BASE}/suffix` uses the existing `BASE` from the environment instead of the one from the `.env` file by [@ROTl24] in [#698] ## [1.2.3] - 2026-08-16 @@ -448,6 +449,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#640]: https://github.com/theskumar/python-dotenv/pull/640 [#663]: https://github.com/theskumar/python-dotenv/pull/663 [#680]: https://github.com/theskumar/python-dotenv/pull/680 +[#698]: https://github.com/theskumar/python-dotenv/pull/698 [790c5c0]: https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311 @@ -459,6 +461,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@Nougat-Waffle]: https://github.com/Nougat-Waffle [@Qwerty-133]: https://github.com/Qwerty-133 [@ReinerBRO]: https://github.com/ReinerBRO +[@ROTl24]: https://github.com/ROTl24 [@alanjds]: https://github.com/alanjds [@altendky]: https://github.com/altendky [@andrewsmith]: https://github.com/andrewsmith diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 79613e28..72c81816 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -17,7 +17,7 @@ ) sys.exit(1) -from .main import dotenv_values, set_key, unset_key +from .main import DotEnv, dotenv_values, set_key, unset_key from .version import __version__ @@ -190,7 +190,7 @@ def run(ctx: click.Context, override: bool, commandline: tuple[str, ...]) -> Non ) dotenv_as_dict = { k: v - for (k, v) in dotenv_values(file).items() + for (k, v) in DotEnv(file, override=override, encoding="utf-8").dict().items() if v is not None and (override or k not in os.environ) } diff --git a/tests/test_cli.py b/tests/test_cli.py index d4e3ad4d..1a71db41 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,5 @@ import os +import sys from pathlib import Path from typing import Optional @@ -211,6 +212,40 @@ def test_run_with_existing_variable_not_overridden(tmp_path): check_process(result, exit_code=0, stdout="C\n") +@pytest.mark.parametrize( + "option,existing_value,expected", + [ + ("--override", "environment", "file\nfile/suffix\n"), + ("--no-override", "environment", "environment\nenvironment/suffix\n"), + ("--no-override", "", "\n/suffix\n"), + ], +) +def test_run_interpolation_respects_override( + tmp_path, option, existing_value, expected +): + (tmp_path / ".env").write_text( + "DOTENV_TEST_BASE=file\nDOTENV_TEST_DERIVED=${DOTENV_TEST_BASE}/suffix\n" + ) + env = dict(os.environ) + env.pop("DOTENV_TEST_DERIVED", None) + env["DOTENV_TEST_BASE"] = existing_value + + result = run_dotenv( + [ + "run", + option, + sys.executable, + "-c", + "import os; print(os.environ['DOTENV_TEST_BASE']); " + "print(os.environ['DOTENV_TEST_DERIVED'])", + ], + cwd=tmp_path, + env=env, + ) + + check_process(result, exit_code=0, stdout=expected) + + def test_run_with_none_value(tmp_path): (tmp_path / ".env").write_text("A=x\nc") From 58f2d7c112235b347040bb5cb579d4ebc0a0ad91 Mon Sep 17 00:00:00 2001 From: ANSHUL SINGH <72524975+ekanshul@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:04:37 +0530 Subject: [PATCH 241/253] test: make test_run_with_command_flags portable and meaningful (#709) Fixes #708 --- tests/test_cli.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 1a71db41..a6d3daf5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -304,16 +304,24 @@ def test_run_with_command_flags(dotenv_path, tmp_path): """ Check that command flags passed after `dotenv run` are not interpreted. - Here, we want to run `printenv --version`, not `dotenv --version`. + Here, we want the command to receive `--help`, not `dotenv run --help`. + `sys.executable` is used instead of a system tool for portability. """ result = run_dotenv( - ["--file", str(dotenv_path), "run", "printenv", "--version"], + [ + "--file", + str(dotenv_path), + "run", + sys.executable, + "-c", + "import sys; print(sys.argv[1:])", + "--help", + ], cwd=tmp_path, ) - check_process(result, exit_code=0) - assert result.stdout.strip().startswith("printenv ") + check_process(result, exit_code=0, stdout="['--help']\n") def test_run_with_dotenv_and_command_flags(dotenv_path, tmp_path): From f215c0274dc4c310e5ca5feb4c6bddade25ece21 Mon Sep 17 00:00:00 2001 From: Shamik Basu Date: Wed, 30 Sep 2026 22:34:45 -0700 Subject: [PATCH 242/253] fix: dotenv get exits 0 for empty string values (#700) Fixes #699 --- CHANGELOG.md | 3 +++ src/dotenv/cli.py | 7 +++---- tests/test_cli.py | 17 +++++++++++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02bad5aa..92f2a708 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- `dotenv get` no longer exits with code 1 for empty string values (`KEY=`) by [@ShamikOfficial] in [#700] - An unquoted empty value followed by an inline comment (e.g. `KEY= # comment`) is now parsed as an empty string instead of the comment text by [@Noethix55555] in [#663] - `dotenv run --no-override` now expands variable references with the same precedence as `load_dotenv(override=False)`, so a value like `${BASE}/suffix` uses the existing `BASE` from the environment instead of the one from the `.env` file by [@ROTl24] in [#698] @@ -450,6 +451,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#663]: https://github.com/theskumar/python-dotenv/pull/663 [#680]: https://github.com/theskumar/python-dotenv/pull/680 [#698]: https://github.com/theskumar/python-dotenv/pull/698 +[#700]: https://github.com/theskumar/python-dotenv/pull/700 [790c5c0]: https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311 @@ -497,6 +499,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@randomseed42]: https://github.com/randomseed42 [@sammck]: https://github.com/sammck [@samwyma]: https://github.com/samwyma +[@ShamikOfficial]: https://github.com/ShamikOfficial [@sidharth-sudhir]: https://github.com/sidharth-sudhir [@snobu]: https://github.com/snobu [@techalchemy]: https://github.com/techalchemy diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 72c81816..c78231cf 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -141,11 +141,10 @@ def get(ctx: click.Context, key: Any) -> None: with stream_file(file) as stream: values = dotenv_values(stream=stream) - stored_value = values.get(key) - if stored_value: - click.echo(stored_value) - else: + # Empty strings are valid values; only missing keys / bare keys (None) fail. + if key not in values or values[key] is None: sys.exit(1) + click.echo(values[key]) @cli.command() diff --git a/tests/test_cli.py b/tests/test_cli.py index a6d3daf5..1833b9d8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -68,6 +68,23 @@ def test_get_existing_value(cli, dotenv_path): assert (result.exit_code, result.output) == (0, "b\n") +def test_get_empty_string_value(cli, dotenv_path): + """Empty string values must not be treated as missing (truthiness trap).""" + dotenv_path.write_text("a=\n") + + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "get", "a"]) + + assert (result.exit_code, result.output) == (0, "\n") + + +def test_get_value_without_equals(cli, dotenv_path): + dotenv_path.write_text("a") + + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "get", "a"]) + + assert (result.exit_code, result.output) == (1, "") + + def test_get_non_existent_value(cli, dotenv_path): result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "get", "a"]) From 4a7abd00261e51baba958e15f443c66165042ea2 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Thu, 1 Oct 2026 11:05:28 +0530 Subject: [PATCH 243/253] docs: add 1.2.4 release notes (#663, #698, #700) --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92f2a708..ac0fe339 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [1.2.4] - 2026-10-01 + ### Fixed - `dotenv get` no longer exits with code 1 for empty string values (`KEY=`) by [@ShamikOfficial] in [#700] @@ -514,7 +516,8 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@JYOuyang]: https://github.com/JYOuyang [@burnout-projects]: https://github.com/burnout-projects [@cpackham-atlnz]: https://github.com/cpackham-atlnz -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.2.3...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.2.4...HEAD +[1.2.4]: https://github.com/theskumar/python-dotenv/compare/v1.2.3...v1.2.4 [1.2.3]: https://github.com/theskumar/python-dotenv/compare/v1.2.2...v1.2.3 [1.2.2]: https://github.com/theskumar/python-dotenv/compare/v1.2.1...v1.2.2 [1.2.1]: https://github.com/theskumar/python-dotenv/compare/v1.2.0...v1.2.1 From a565c2cc41599c48eabc6b7b7f5b826d43c5a6d7 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Thu, 1 Oct 2026 11:05:30 +0530 Subject: [PATCH 244/253] =?UTF-8?q?Bump=20version:=201.2.3=20=E2=86=92=201?= =?UTF-8?q?.2.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- src/dotenv/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 9bb4df0b..aa28bcfd 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.2.3 +current_version = 1.2.4 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 10aa336c..b3f9ac7f 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "1.2.3" +__version__ = "1.2.4" From 828bdfe5ba91b736b545561ebb53326f1f88f6e5 Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Thu, 1 Oct 2026 00:46:47 -0500 Subject: [PATCH 245/253] fix: use an SPDX string for project.license to silence the build deprecation warning (#648) --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac0fe339..aa470c6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed + +- Fix a package build deprecation warning caused by a non-string `license` value in `pyproject.toml` by [@kurtmckee] in [#648] + ## [1.2.4] - 2026-10-01 ### Fixed @@ -450,6 +454,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#606]: https://github.com/theskumar/python-dotenv/pull/606 [#638]: https://github.com/theskumar/python-dotenv/pull/638 [#640]: https://github.com/theskumar/python-dotenv/pull/640 +[#648]: https://github.com/theskumar/python-dotenv/pull/648 [#663]: https://github.com/theskumar/python-dotenv/pull/663 [#680]: https://github.com/theskumar/python-dotenv/pull/680 [#698]: https://github.com/theskumar/python-dotenv/pull/698 @@ -490,6 +495,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@jadutter]: https://github.com/jadutter [@jankislinger]: https://github.com/jankislinger [@jctanner]: https://github.com/jctanner +[@kurtmckee]: https://github.com/kurtmckee [@larsks]: https://github.com/larsks [@lsmith77]: https://github.com/lsmith77 [@matthewfranglen]: https://github.com/matthewfranglen diff --git a/pyproject.toml b/pyproject.toml index 1753fd89..1619e9d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ description = "Read key-value pairs from a .env file and set them as environment authors = [ {name = "Saurabh Kumar", email = "me+github@saurabh-kumar.com"}, ] -license = { text = "BSD-3-Clause" } +license = "BSD-3-Clause" keywords = [ "environment variables", "deployments", From 6a49d6d88fd9bd99bef8d63b9eb6f2e46ecc7072 Mon Sep 17 00:00:00 2001 From: Jamal Ali Date: Thu, 1 Oct 2026 09:50:13 +0400 Subject: [PATCH 246/253] fix: name the .env path in set_key and unset_key write errors (#711) Fixes #710 --- CHANGELOG.md | 3 +++ src/dotenv/cli.py | 12 ++++++++++-- src/dotenv/main.py | 22 +++++++++++++++------- tests/test_cli.py | 34 ++++++++++++++++++++++++++++++++++ tests/test_main.py | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 94 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa470c6a..ea81ecc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed - Fix a package build deprecation warning caused by a non-string `license` value in `pyproject.toml` by [@kurtmckee] in [#648] +- `set_key`, `unset_key` and the `dotenv set`/`unset` commands now name the `.env` path instead of an internal temporary file when its directory is missing or not writable, and the CLI prints a short error and exits with code 2 instead of a traceback by [@jamalkamaladdin] in [#711] ## [1.2.4] - 2026-10-01 @@ -459,6 +460,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#680]: https://github.com/theskumar/python-dotenv/pull/680 [#698]: https://github.com/theskumar/python-dotenv/pull/698 [#700]: https://github.com/theskumar/python-dotenv/pull/700 +[#711]: https://github.com/theskumar/python-dotenv/pull/711 [790c5c0]: https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311 @@ -493,6 +495,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@h1whelan]: https://github.com/h1whelan [@harveer07]: https://github.com/harveer07 [@jadutter]: https://github.com/jadutter +[@jamalkamaladdin]: https://github.com/jamalkamaladdin [@jankislinger]: https://github.com/jankislinger [@jctanner]: https://github.com/jctanner [@kurtmckee]: https://github.com/kurtmckee diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index c78231cf..44495e9b 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -124,7 +124,11 @@ def set_value(ctx: click.Context, key: Any, value: Any) -> None: file = ctx.obj["FILE"] quote = ctx.obj["QUOTE"] export = ctx.obj["EXPORT"] - success, key, value = set_key(file, key, value, quote, export) + try: + success, key, value = set_key(file, key, value, quote, export) + except OSError as exc: + print(f"Error writing env file: {exc}", file=sys.stderr) + sys.exit(2) if success: click.echo(f"{key}={value}") else: @@ -159,7 +163,11 @@ def unset(ctx: click.Context, key: Any) -> None: """ file = ctx.obj["FILE"] quote = ctx.obj["QUOTE"] - success, key = unset_key(file, key, quote) + try: + success, key = unset_key(file, key, quote) + except OSError as exc: + print(f"Error writing env file: {exc}", file=sys.stderr) + sys.exit(2) if success: click.echo(f"Successfully removed {key}") else: diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 3123690a..95ee9db2 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -160,13 +160,21 @@ def rewrite( source = io.StringIO("") original_mode = None - with tempfile.NamedTemporaryFile( - mode="w", - encoding=encoding, - delete=False, - prefix=".tmp_", - dir=os.path.dirname(os.path.abspath(path)), - ) as dest: + try: + temp_file = tempfile.NamedTemporaryFile( + mode="w", + encoding=encoding, + delete=False, + prefix=".tmp_", + dir=os.path.dirname(os.path.abspath(path)), + ) + except OSError as err: + source.close() + # Report the target path, not the name of the temporary file. + err.filename = os.fspath(path) + raise + + with temp_file as dest: dest_path = pathlib.Path(dest.name) error = None diff --git a/tests/test_cli.py b/tests/test_cli.py index 1833b9d8..b6d29c20 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -191,6 +191,40 @@ def test_set_no_file(cli): assert "Missing argument" in result.output +def test_set_missing_directory(cli, tmp_path): + dotenv_path = tmp_path / "nx_dir" / ".env" + + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "set", "a", "b"]) + + assert (result.exit_code, result.output) == ( + 2, + f"Error writing env file: [Errno 2] No such file or directory: {str(dotenv_path)!r}\n", + ) + + +@pytest.mark.skipif( + sys.platform == "win32" or os.geteuid() == 0, + reason="Directory permissions are not enforced on Windows or for root.", +) +def test_unset_read_only_directory(cli, tmp_path): + directory = tmp_path / "ro" + directory.mkdir() + dotenv_path = directory / ".env" + dotenv_path.write_text("a=b\n") + directory.chmod(0o555) + + try: + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "unset", "a"]) + finally: + directory.chmod(0o755) + + assert (result.exit_code, result.output) == ( + 2, + f"Error writing env file: [Errno 13] Permission denied: {str(dotenv_path)!r}\n", + ) + assert dotenv_path.read_text() == "a=b\n" + + def test_get_default_path(tmp_path): (tmp_path / ".env").write_text("A=x") diff --git a/tests/test_main.py b/tests/test_main.py index 6f9d4c5c..ded4f1f6 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -195,6 +195,38 @@ def test_set_key_permission_error(dotenv_path): assert dotenv_path.read_text() == "" +def test_set_key_missing_directory(tmp_path): + dotenv_path = tmp_path / "nx_dir" / ".env" + + with pytest.raises(FileNotFoundError) as exc_info: + dotenv.set_key(dotenv_path, "a", "b") + + assert exc_info.value.filename == str(dotenv_path) + assert not dotenv_path.parent.exists() + + +@pytest.mark.skipif( + sys.platform == "win32" or os.geteuid() == 0, + reason="Directory permissions are not enforced on Windows or for root.", +) +def test_set_key_read_only_directory(tmp_path): + directory = tmp_path / "ro" + directory.mkdir() + dotenv_path = directory / ".env" + dotenv_path.write_text("a=x\n") + directory.chmod(0o555) + + try: + with pytest.raises(PermissionError) as exc_info: + dotenv.set_key(dotenv_path, "a", "y") + finally: + directory.chmod(0o755) + + assert exc_info.value.filename == str(dotenv_path) + assert dotenv_path.read_text() == "a=x\n" + assert list(directory.iterdir()) == [dotenv_path] + + def test_get_key_no_file(tmp_path): nx_path = tmp_path / "nx" logger = logging.getLogger("dotenv.main") From 6bdf86adb2b8e3d9e3031b3a81f24851c8bfca0e Mon Sep 17 00:00:00 2001 From: Mohammed Alkindi Date: Wed, 30 Sep 2026 23:35:40 -0700 Subject: [PATCH 247/253] fix: don't leak or mask errors when rewrite cleanup fails on Windows (#686) When writing a read-only .env fails on Windows, rewrite() no longer leaves a .tmp_* file behind, and the error raised is the one from the failed write rather than from the cleanup. --- CHANGELOG.md | 3 ++ src/dotenv/main.py | 23 +++++++++++-- tests/test_main.py | 86 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea81ecc7..eb54e1cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Fix a package build deprecation warning caused by a non-string `license` value in `pyproject.toml` by [@kurtmckee] in [#648] - `set_key`, `unset_key` and the `dotenv set`/`unset` commands now name the `.env` path instead of an internal temporary file when its directory is missing or not writable, and the CLI prints a short error and exits with code 2 instead of a traceback by [@jamalkamaladdin] in [#711] +- `set_key` and `unset_key` no longer leave a `.tmp_*` file behind on Windows when writing a read-only `.env` fails, and the error raised is the one from the failed write rather than from cleaning up the temporary file by [@MohammedAlkindi] in [#686] ## [1.2.4] - 2026-10-01 @@ -458,6 +459,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#648]: https://github.com/theskumar/python-dotenv/pull/648 [#663]: https://github.com/theskumar/python-dotenv/pull/663 [#680]: https://github.com/theskumar/python-dotenv/pull/680 +[#686]: https://github.com/theskumar/python-dotenv/pull/686 [#698]: https://github.com/theskumar/python-dotenv/pull/698 [#700]: https://github.com/theskumar/python-dotenv/pull/700 [#711]: https://github.com/theskumar/python-dotenv/pull/711 @@ -468,6 +470,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@23f3001135]: https://github.com/23f3001135 [@EpicWink]: https://github.com/EpicWink [@Flimm]: https://github.com/Flimm +[@MohammedAlkindi]: https://github.com/MohammedAlkindi [@Nicals]: https://github.com/Nicals [@Nougat-Waffle]: https://github.com/Nougat-Waffle [@Qwerty-133]: https://github.com/Qwerty-133 diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 95ee9db2..13f6776c 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -135,6 +135,25 @@ def get_key( return DotEnv(dotenv_path, verbose=True, encoding=encoding).get(key_to_get) +def _discard_temp_file(path: pathlib.Path) -> None: + """ + Delete `rewrite`'s temporary file, ignoring any failure to do so. + + This runs while another exception is propagating, so it must not raise: + that error is the one worth reporting. On Windows, a file whose mode has + no owner-write bit carries the read-only attribute and can't be unlinked, + so the mode is reset before a second attempt. + """ + try: + path.unlink(missing_ok=True) + except OSError: + try: + path.chmod(stat.S_IWRITE | stat.S_IREAD) + path.unlink(missing_ok=True) + except OSError: + logger.warning("python-dotenv could not remove the temporary file %s", path) + + @contextmanager def rewrite( path: StrPath, @@ -191,10 +210,10 @@ def rewrite( os.replace(dest_path, path) except BaseException: - dest_path.unlink(missing_ok=True) + _discard_temp_file(dest_path) raise else: - dest_path.unlink(missing_ok=True) + _discard_temp_file(dest_path) raise error from None diff --git a/tests/test_main.py b/tests/test_main.py index ded4f1f6..27c5b5fd 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,6 +1,7 @@ import io import logging import os +import pathlib import stat import subprocess import sys @@ -195,6 +196,48 @@ def test_set_key_permission_error(dotenv_path): assert dotenv_path.read_text() == "" +@pytest.mark.skipif( + sys.platform != "win32" and os.geteuid() == 0, + reason="Root user can access files even with 000 permissions.", +) +def test_set_key_permission_error_leaves_no_temp_file(dotenv_path): + if sys.platform == "win32": + # On Windows, make file read-only + dotenv_path.chmod(stat.S_IREAD) + else: + # On Unix, remove all permissions + dotenv_path.chmod(0o000) + + try: + with pytest.raises(PermissionError): + dotenv.set_key(dotenv_path, "a", "b") + + assert list(dotenv_path.parent.glob(".tmp_*")) == [] + finally: + # Restore permissions + if sys.platform == "win32": + dotenv_path.chmod(stat.S_IWRITE | stat.S_IREAD) + else: + dotenv_path.chmod(0o600) + + +def test_rewrite_reports_original_error_when_cleanup_fails(dotenv_path, caplog): + replace_error = OSError("replace failed") + + with mock.patch("dotenv.main.os.replace", side_effect=replace_error): + with mock.patch.object( + pathlib.Path, "unlink", side_effect=OSError("unlink failed") + ): + with pytest.raises(OSError) as excinfo: + dotenv.set_key(dotenv_path, "a", "b") + + assert excinfo.value is replace_error + [temp_file] = dotenv_path.parent.glob(".tmp_*") + assert caplog.messages == [ + f"python-dotenv could not remove the temporary file {temp_file}" + ] + + def test_set_key_missing_directory(tmp_path): dotenv_path = tmp_path / "nx_dir" / ".env" @@ -227,6 +270,49 @@ def test_set_key_read_only_directory(tmp_path): assert list(directory.iterdir()) == [dotenv_path] +def windows_read_only_semantics(path): + # On Windows, a file without the owner-write bit can't be replaced or deleted. + return path.exists() and not path.stat().st_mode & stat.S_IWUSR + + +@pytest.mark.parametrize( + "rewrite", + [ + lambda path: dotenv.set_key(path, "a", "y"), + lambda path: dotenv.unset_key(path, "a"), + ], + ids=["set_key", "unset_key"], +) +def test_rewrite_read_only_file_leaves_no_temp_file(tmp_path, rewrite): + dotenv_path = tmp_path / ".env" + dotenv_path.write_text("a=x\n") + dotenv_path.chmod(stat.S_IREAD) + real_replace = os.replace + real_unlink = pathlib.Path.unlink + + def replace(src, dst): + if windows_read_only_semantics(pathlib.Path(dst)): + raise PermissionError( + 13, "Access is denied", os.fspath(src), None, os.fspath(dst) + ) + real_replace(src, dst) + + def unlink(self, missing_ok=False): + if windows_read_only_semantics(self): + raise PermissionError(13, "Access is denied", str(self)) + real_unlink(self, missing_ok=missing_ok) + + with mock.patch("dotenv.main.os.replace", side_effect=replace): + with mock.patch.object(pathlib.Path, "unlink", unlink): + with pytest.raises(PermissionError) as exc_info: + rewrite(dotenv_path) + + dotenv_path.chmod(stat.S_IREAD | stat.S_IWRITE) + assert exc_info.value.filename2 == str(dotenv_path) + assert dotenv_path.read_text() == "a=x\n" + assert list(tmp_path.iterdir()) == [dotenv_path] + + def test_get_key_no_file(tmp_path): nx_path = tmp_path / "nx" logger = logging.getLogger("dotenv.main") From 791414804eff08a23f0b7970968e1717e3b28e66 Mon Sep 17 00:00:00 2001 From: Varun Chawla <34209028+veeceey@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:56:42 -0700 Subject: [PATCH 248/253] fix: expand ~ in dotenv_path and the CLI --file option (#615) load_dotenv, dotenv_values, get_key, set_key, unset_key and the CLI --file option now expand a leading ~ to the user's home directory. Fixes #525 --- CHANGELOG.md | 3 +++ src/dotenv/cli.py | 2 ++ src/dotenv/main.py | 18 ++++++++++++-- tests/test_cli.py | 28 +++++++++++++++++++++ tests/test_main.py | 61 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb54e1cb..e4a5cead 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Fix a package build deprecation warning caused by a non-string `license` value in `pyproject.toml` by [@kurtmckee] in [#648] - `set_key`, `unset_key` and the `dotenv set`/`unset` commands now name the `.env` path instead of an internal temporary file when its directory is missing or not writable, and the CLI prints a short error and exits with code 2 instead of a traceback by [@jamalkamaladdin] in [#711] - `set_key` and `unset_key` no longer leave a `.tmp_*` file behind on Windows when writing a read-only `.env` fails, and the error raised is the one from the failed write rather than from cleaning up the temporary file by [@MohammedAlkindi] in [#686] +- `load_dotenv`, `dotenv_values`, `get_key`, `set_key`, `unset_key` and the CLI `--file` option now expand a leading `~` to the user's home directory by [@veeceey] in [#615] ## [1.2.4] - 2026-10-01 @@ -456,6 +457,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#606]: https://github.com/theskumar/python-dotenv/pull/606 [#638]: https://github.com/theskumar/python-dotenv/pull/638 [#640]: https://github.com/theskumar/python-dotenv/pull/640 +[#615]: https://github.com/theskumar/python-dotenv/pull/615 [#648]: https://github.com/theskumar/python-dotenv/pull/648 [#663]: https://github.com/theskumar/python-dotenv/pull/663 [#680]: https://github.com/theskumar/python-dotenv/pull/680 @@ -520,6 +522,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@theGOTOguy]: https://github.com/theGOTOguy [@theskumar]: https://github.com/theskumar [@ulyssessouza]: https://github.com/ulyssessouza +[@veeceey]: https://github.com/veeceey [@venthur]: https://github.com/venthur [@wrongontheinternet]: https://github.com/wrongontheinternet [@x-yuri]: https://github.com/x-yuri diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 44495e9b..c06c7068 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -61,6 +61,8 @@ def enumerate_env() -> Optional[str]: @click.pass_context def cli(ctx: click.Context, file: Any, quote: Any, export: Any) -> None: """This script is used to set, get or unset values from a .env file.""" + if file is not None: + file = os.path.expanduser(file) ctx.obj = {"QUOTE": quote, "EXPORT": export, "FILE": file} diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 13f6776c..7dfc3cba 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -50,6 +50,10 @@ def __init__( override: bool = True, ) -> None: self.dotenv_path: Optional[StrPath] = dotenv_path + if isinstance(dotenv_path, str): + self.dotenv_path = os.path.expanduser(dotenv_path) + elif dotenv_path is not None: + self.dotenv_path = pathlib.Path(dotenv_path).expanduser() self.stream: Optional[IO[str]] = stream self._dict: Optional[Dict[str, Optional[str]]] = None self.verbose: bool = verbose @@ -235,6 +239,10 @@ def set_key( modifying a file at a potentially untrusted path. If you don't need this protection and need symlinks to be followed, use `follow_symlinks`. """ + if isinstance(dotenv_path, str): + dotenv_path = os.path.expanduser(dotenv_path) + else: + dotenv_path = pathlib.Path(dotenv_path).expanduser() if quote_mode not in ("always", "auto", "never"): raise ValueError(f"Unknown quote_mode: {quote_mode}") @@ -294,6 +302,10 @@ def unset_key( modifying a file at a potentially untrusted path. If you don't need this protection and need symlinks to be followed, use `follow_symlinks`. """ + if isinstance(dotenv_path, str): + dotenv_path = os.path.expanduser(dotenv_path) + else: + dotenv_path = pathlib.Path(dotenv_path).expanduser() if not os.path.exists(dotenv_path): logger.warning("Can't delete from %s - it doesn't exist.", dotenv_path) return None, key_to_unset @@ -423,7 +435,8 @@ def load_dotenv( """Parse a .env file and then load all the variables found as environment variables. Parameters: - dotenv_path: Absolute or relative path to .env file. + dotenv_path: Absolute or relative path to .env file. A leading `~` is + expanded to the user's home directory. stream: Text stream (such as `io.StringIO`) with .env content, used if `dotenv_path` is `None`. verbose: Whether to output a warning the .env file is missing. @@ -477,7 +490,8 @@ def dotenv_values( `{"foo": None}` Parameters: - dotenv_path: Absolute or relative path to the .env file. + dotenv_path: Absolute or relative path to the .env file. A leading `~` is + expanded to the user's home directory. stream: `StringIO` object with .env content, used if `dotenv_path` is `None`. verbose: Whether to output a warning if the .env file is missing. interpolate: Whether to interpolate variables using POSIX variable expansion. diff --git a/tests/test_cli.py b/tests/test_cli.py index b6d29c20..8123c9dd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -316,6 +316,34 @@ def test_run_with_other_env(dotenv_path, tmp_path): check_process(result, exit_code=0, stdout="x\n") +@pytest.mark.parametrize( + "args,expected_stdout,expected_content", + [ + (["list"], "a=x\n", "a=x\n"), + (["get", "a"], "x\n", "a=x\n"), + (["set", "b", "y"], "b=y\n", "a=x\nb='y'\n"), + (["unset", "a"], "Successfully removed a\n", ""), + ( + ["run", sys.executable, "-c", "import os; print(os.environ['a'])"], + "x\n", + "a=x\n", + ), + ], + ids=["list", "get", "set", "unset", "run"], +) +def test_file_option_expands_user(tmp_path, args, expected_stdout, expected_content): + home = tmp_path / "home" + home.mkdir() + (home / ".env").write_text("a=x\n") + env = {**os.environ, "HOME": str(home), "USERPROFILE": str(home)} + + result = run_dotenv(["--file", "~/.env", *args], cwd=tmp_path, env=env) + + check_process(result, exit_code=0, stdout=expected_stdout) + assert (home / ".env").read_text() == expected_content + assert sorted(tmp_path.iterdir()) == [home] + + def test_run_without_cmd(tmp_path): result = run_dotenv(["run"], cwd=tmp_path) diff --git a/tests/test_main.py b/tests/test_main.py index 27c5b5fd..cd21d70d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -313,6 +313,67 @@ def unlink(self, missing_ok=False): assert list(tmp_path.iterdir()) == [dotenv_path] +@pytest.mark.parametrize("path_type", [str, pathlib.Path]) +@pytest.mark.parametrize( + "call,expected_result,expected_content", + [ + (lambda path: dotenv.dotenv_values(path), {"a": "x"}, "a=x\n"), + (lambda path: dotenv.get_key(path, "a"), "x", "a=x\n"), + (lambda path: dotenv.set_key(path, "b", "y"), (True, "b", "y"), "a=x\nb='y'\n"), + (lambda path: dotenv.unset_key(path, "a"), (True, "a"), ""), + ], + ids=["dotenv_values", "get_key", "set_key", "unset_key"], +) +def test_dotenv_path_expands_user( + tmp_path, monkeypatch, path_type, call, expected_result, expected_content +): + home = tmp_path / "home" + home.mkdir() + (home / ".env").write_text("a=x\n") + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + monkeypatch.chdir(tmp_path) + + result = call(path_type("~/.env")) + + assert result == expected_result + assert (home / ".env").read_text() == expected_content + assert sorted(tmp_path.iterdir()) == [home] + + +@mock.patch.dict(os.environ, {}, clear=True) +def test_load_dotenv_expands_user(tmp_path, monkeypatch): + (tmp_path / ".env").write_text("a=x\n") + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + + result = dotenv.load_dotenv("~/.env") + + assert result is True + assert os.environ["a"] == "x" + + +@pytest.mark.parametrize( + "path,expected", + [ + ("~/.env", "{home}/.env"), + (pathlib.Path("~/.env"), pathlib.Path("{home}/.env")), + (".env", ".env"), + (None, None), + ], +) +def test_dotenv_path_keeps_type(tmp_path, monkeypatch, path, expected): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + if expected is not None: + expected = type(expected)(str(expected).format(home=tmp_path)) + + result = DotEnv(path).dotenv_path + + assert result == expected + assert type(result) is type(expected) + + def test_get_key_no_file(tmp_path): nx_path = tmp_path / "nx" logger = logging.getLogger("dotenv.main") From 0b2880591780a426b0551436f47a17e7a76d954f Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Thu, 1 Oct 2026 13:04:34 +0530 Subject: [PATCH 249/253] fix: expand ~ in find_dotenv and the %dotenv magic (#714) find_dotenv and the IPython %dotenv magic now expand a leading ~ in the file name. Follows #615 (#525). --- CHANGELOG.md | 2 ++ README.md | 1 + src/dotenv/main.py | 4 ++++ tests/test_ipython.py | 15 +++++++++++++++ tests/test_main.py | 13 +++++++++++++ 5 files changed, 35 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4a5cead..e5f25414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `set_key`, `unset_key` and the `dotenv set`/`unset` commands now name the `.env` path instead of an internal temporary file when its directory is missing or not writable, and the CLI prints a short error and exits with code 2 instead of a traceback by [@jamalkamaladdin] in [#711] - `set_key` and `unset_key` no longer leave a `.tmp_*` file behind on Windows when writing a read-only `.env` fails, and the error raised is the one from the failed write rather than from cleaning up the temporary file by [@MohammedAlkindi] in [#686] - `load_dotenv`, `dotenv_values`, `get_key`, `set_key`, `unset_key` and the CLI `--file` option now expand a leading `~` to the user's home directory by [@veeceey] in [#615] +- `find_dotenv` and the IPython `%dotenv` magic now expand a leading `~` in the file name by [@theskumar] in [#714] ## [1.2.4] - 2026-10-01 @@ -465,6 +466,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#698]: https://github.com/theskumar/python-dotenv/pull/698 [#700]: https://github.com/theskumar/python-dotenv/pull/700 [#711]: https://github.com/theskumar/python-dotenv/pull/711 +[#714]: https://github.com/theskumar/python-dotenv/pull/714 [790c5c0]: https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311 diff --git a/README.md b/README.md index a08d6141..3400e67c 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,7 @@ You can also specify a path: ```python %dotenv relative/or/absolute/path/to/.env +%dotenv ~/path/to/.env ``` Optional flags: diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 7dfc3cba..5faa7f0e 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -381,8 +381,12 @@ def find_dotenv( """ Search in increasingly higher folders for the given file + A leading `~` in `filename` is expanded to the user's home directory, and + an absolute `filename` is returned as is if it exists. + Returns path to the file if found, or an empty string otherwise """ + filename = os.path.expanduser(filename) def _is_interactive(): """Decide whether this is running in a REPL or IPython notebook""" diff --git a/tests/test_ipython.py b/tests/test_ipython.py index 6eda086b..93bffd66 100644 --- a/tests/test_ipython.py +++ b/tests/test_ipython.py @@ -61,3 +61,18 @@ def test_ipython_new_variable(tmp_path): ipshell.run_line_magic("dotenv", "") assert os.environ == {"a": "b"} + + +@mock.patch.dict(os.environ, {}, clear=False) +def test_ipython_path_expands_user(tmp_path, monkeypatch): + from IPython.terminal.embed import InteractiveShellEmbed + + (tmp_path / ".env").write_text("dotenv_ipython_home=b\n") + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + + ipshell = InteractiveShellEmbed() + ipshell.run_line_magic("load_ext", "dotenv") + ipshell.run_line_magic("dotenv", "~/.env") + + assert os.environ["dotenv_ipython_home"] == "b" diff --git a/tests/test_main.py b/tests/test_main.py index cd21d70d..930ab171 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -591,6 +591,19 @@ def test_find_dotenv_found(tmp_path): assert result == str(dotenv_path) +def test_find_dotenv_expands_user(tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + (home / ".env").write_text("a=x\n") + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + monkeypatch.chdir(tmp_path) + + result = dotenv.find_dotenv("~/.env", usecwd=True) + + assert result == f"{home}/.env" + + @pytest.mark.skipif( sys.platform == "win32", reason="This test assumes case-sensitive variable names" ) From bad5771ce54fd643538f177aab1465e4375e2039 Mon Sep 17 00:00:00 2001 From: fengguodong <152643312+CodingFeng101@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:13:19 -0400 Subject: [PATCH 250/253] Handle CRLF invalid lines in parser recovery (#669) * Handle CRLF invalid lines in parser * Test line numbers after CRLF parser recovery --------- Co-authored-by: Bertrand Bonnefoy-Claudet --- src/dotenv/parser.py | 2 +- tests/test_parser.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index d9d47583..b3b55070 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -29,7 +29,7 @@ def make_regex(string: str, extra_flags: int = 0) -> Pattern[str]: _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)?") +_rest_of_line = make_regex(r"[^\r\n]*(?:\r\n|\n|\r)?") _double_quote_escapes = make_regex(r"\\[\\'\"abfnrtv]") _single_quote_escapes = make_regex(r"\\[\\']") diff --git a/tests/test_parser.py b/tests/test_parser.py index 667eae15..982305a3 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -480,6 +480,46 @@ ) ], ), + ( + "a: b\r\nc=d", + [ + Binding( + key=None, + value=None, + original=Original(string="a: b\r\n", line=1), + error=True, + ), + Binding( + key="c", + value="d", + original=Original(string="c=d", line=2), + error=False, + ), + ], + ), + ( + "a: b\r\nc=d\r\ne: f", + [ + Binding( + key=None, + value=None, + original=Original(string="a: b\r\n", line=1), + error=True, + ), + Binding( + key="c", + value="d", + original=Original(string="c=d\r\n", line=2), + error=False, + ), + Binding( + key=None, + value=None, + original=Original(string="e: f", line=3), + error=True, + ), + ], + ), ( "a=b\nc=d", [ From 6c800cc617943322536334f539ed4665207bc766 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Mon, 9 Nov 2026 14:03:37 +0100 Subject: [PATCH 251/253] Modernize CI (#743) Benefits: - Replace unofficial `tox-gh-actions` plugin with its maintained successor `tox-gh` - Switch to uv for slightly faster dependency installation and built-in caching This is heavily inspired from what is done by tox-gh: https://github.com/tox-dev/tox-gh/blob/6efb80fc24df3c6e08a3f99b55949d4fba3f248d/.github/workflows/check.yaml Around 20% less CI usage when cached. --- .github/workflows/release.yml | 4 ++-- .github/workflows/test.yml | 32 +++++++++++++++++++++----------- MANIFEST.in | 1 + requirements-ci.txt | 5 +++++ tox.ini | 21 ++++++++++++--------- 5 files changed, 41 insertions(+), 22 deletions(-) create mode 100644 requirements-ci.txt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 369ebc8e..1e455693 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,10 +19,10 @@ jobs: contents: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: "3.x" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e8e7fa81..40da9fb2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,20 +34,30 @@ jobs: - os: windows-latest python-version: "3.14" + env: + # Select the tox envs for this matrix entry (see `[gh]` in `tox.ini`). + TOX_GH_MAJOR_MINOR: ${{ matrix.python-version }} + # Use uv-managed interpreters everywhere. + UV_PYTHON_PREFERENCE: only-managed + steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + - name: Install uv + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: - python-version: ${{ matrix.python-version }} - allow-prereleases: true + version-file: requirements-ci.txt + enable-cache: true + cache-dependency-glob: | + pyproject.toml + tox.ini + requirements-ci.txt - - name: Upgrade pip - run: python -m pip install --upgrade pip + - name: Install tox + run: uv tool install --python 3.14 --with-requirements requirements-ci.txt tox - - name: Install dependencies - run: pip install tox tox-gh-actions + - name: Setup test suite + run: tox run -v --notest --skip-missing-interpreters false - - name: Test with tox - run: tox + - name: Run test suite + run: tox run --skip-pkg-install diff --git a/MANIFEST.in b/MANIFEST.in index bf0d47e6..afa35896 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -9,5 +9,6 @@ include .coveragerc include .editorconfig include Makefile include requirements.txt +include requirements-ci.txt include requirements-docs.txt include src/dotenv/py.typed diff --git a/requirements-ci.txt b/requirements-ci.txt new file mode 100644 index 00000000..002cc7aa --- /dev/null +++ b/requirements-ci.txt @@ -0,0 +1,5 @@ +# Tooling installed by `.github/workflows/test.yml`. +tox==4.67.4 +tox-gh==1.9.1 +tox-uv==1.37.2 +uv==0.13.6 diff --git a/tox.ini b/tox.ini index 6d1f25f8..7ce8f0c8 100644 --- a/tox.ini +++ b/tox.ini @@ -1,27 +1,30 @@ [tox] envlist = lint,py{310,311,312,313,314,314t},pypy3,manifest,coverage-report -[gh-actions] +[gh] python = - 3.10: py310 - 3.11: py311 - 3.12: py312 - 3.13: py313, lint, manifest - 3.14: py314 - 3.14t: py314t - pypy-3.11: pypy3 + 3.10 = py310 + 3.11 = py311 + 3.12 = py312 + 3.13 = py313, lint, manifest + 3.14 = py314 + 3.14t = py314t + pypy3.11 = pypy3 [testenv] deps = pytest pytest-cov click - py{310,311,312,313,314,314t,pypy3}: ipython + py{310,311,312,313,314,314t},pypy3: ipython commands = pytest --cov --cov-report=term-missing {posargs} depends = py{310,311,312,313,314,314t},pypy3: coverage-clean coverage-report: py{310,311,312,313,314,314t},pypy3 +[testenv:pypy3] +base_python = pypy3.11 + [testenv:lint] skip_install = true deps = From 555d78f76ebdb6621cd0774f457f64cf7c6ef0c0 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Mon, 9 Nov 2026 14:31:55 +0100 Subject: [PATCH 252/253] Add support for Python 3.15 (#744) --- .github/workflows/test.yml | 6 +++--- CHANGELOG.md | 4 ++++ pyproject.toml | 1 + tox.ini | 12 +++++++----- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 40da9fb2..7a3c535a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,13 +26,13 @@ jobs: os: - ubuntu-latest python-version: - ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", pypy3.11] + ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15", "3.15t", pypy3.11] include: # Windows: Test lowest and highest supported Python versions - os: windows-latest python-version: "3.10" - os: windows-latest - python-version: "3.14" + python-version: "3.15" env: # Select the tox envs for this matrix entry (see `[gh]` in `tox.ini`). @@ -54,7 +54,7 @@ jobs: requirements-ci.txt - name: Install tox - run: uv tool install --python 3.14 --with-requirements requirements-ci.txt tox + run: uv tool install --python 3.15 --with-requirements requirements-ci.txt tox - name: Setup test suite run: tox run -v --notest --skip-missing-interpreters false diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f25414..71c9740d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- Add support for Python 3.15. + ### Fixed - Fix a package build deprecation warning caused by a non-string `license` value in `pyproject.toml` by [@kurtmckee] in [#648] diff --git a/pyproject.toml b/pyproject.toml index 1619e9d4..7dc2a711 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Programming Language :: Python :: Implementation :: PyPy", "Intended Audience :: Developers", "Intended Audience :: System Administrators", diff --git a/tox.ini b/tox.ini index 7ce8f0c8..1f49dc26 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = lint,py{310,311,312,313,314,314t},pypy3,manifest,coverage-report +envlist = lint,py{310,311,312,313,314,315,315t},pypy3,manifest,coverage-report [gh] python = @@ -8,7 +8,8 @@ python = 3.12 = py312 3.13 = py313, lint, manifest 3.14 = py314 - 3.14t = py314t + 3.15 = py315 + 3.15t = py315t pypy3.11 = pypy3 [testenv] @@ -16,11 +17,11 @@ deps = pytest pytest-cov click - py{310,311,312,313,314,314t},pypy3: ipython + py{310,311,312,313,314,315,315t},pypy3: ipython commands = pytest --cov --cov-report=term-missing {posargs} depends = - py{310,311,312,313,314,314t},pypy3: coverage-clean - coverage-report: py{310,311,312,313,314,314t},pypy3 + py{310,311,312,313,314,315,315t},pypy3: coverage-clean + coverage-report: py{310,311,312,313,314,315,315t},pypy3 [testenv:pypy3] base_python = pypy3.11 @@ -33,6 +34,7 @@ deps = commands = ruff check src tests ruff format --check src tests + mypy --python-version=3.15 src tests mypy --python-version=3.14 src tests mypy --python-version=3.13 src tests mypy --python-version=3.12 src tests From 4fbc6ff07215367ece731006855e732d1405992c Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Mon, 9 Nov 2026 14:35:05 +0100 Subject: [PATCH 253/253] dotenv run: Use PATH on Windows to find the command to run (#742) --- src/dotenv/cli.py | 14 ++++++++--- tests/test_cli.py | 62 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index c06c7068..83849690 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -1,6 +1,7 @@ import json import os import shlex +import shutil import sys from contextlib import contextmanager from typing import IO, Any, Dict, Iterator, List, Optional @@ -235,11 +236,18 @@ def run_command(command: List[str], env: Dict[str, str]) -> None: cmd_env.update(env) if sys.platform == "win32": - # execvpe on Windows returns control immediately - # rather than once the command has finished. + # Resolve the command as suggested in Python's docs + # (https://docs.python.org/3/library/subprocess.html#popen-constructor). + executable = shutil.which(command[0], path=cmd_env.get("PATH")) try: + # `execvpe` on Windows returns control immediately. p = Popen( - command, universal_newlines=True, bufsize=0, shell=False, env=cmd_env + command, + executable=executable, + universal_newlines=True, + bufsize=0, + shell=False, + env=cmd_env, ) except FileNotFoundError: print(f"Command not found: {command[0]}", file=sys.stderr) diff --git a/tests/test_cli.py b/tests/test_cli.py index 8123c9dd..a719e195 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -344,6 +344,68 @@ def test_file_option_expands_user(tmp_path, args, expected_stdout, expected_cont assert sorted(tmp_path.iterdir()) == [home] +def make_command(directory: Path, name: str, output: str) -> None: + """ + Create a command named `name` in `directory` which prints `output`. + """ + + if sys.platform == "win32": + (directory / f"{name}.cmd").write_text(f"@echo {output}\n") + else: + path = directory / name + path.write_text(f"#!/bin/sh\necho {output}\n") + path.chmod(0o755) + + +def test_run_with_path_from_env_file(tmp_path): + """ + Check that the command is looked up in the `PATH` set by the .env file. + """ + + # We use two commands (one prints "x", the other "y"). + dotenv_bin_dir = tmp_path / "dotenv-bin" + dotenv_bin_dir.mkdir() + make_command(directory=dotenv_bin_dir, name="dotenv-test-command", output="x") + start_bin_dir = tmp_path / "start-bin" + start_bin_dir.mkdir() + make_command(directory=start_bin_dir, name="dotenv-test-command", output="y") + start_env = { + **os.environ, + "PATH": os.pathsep.join([str(start_bin_dir), os.environ["PATH"]]), + } + # Unquoted, so that a Windows `PATH` ending with `\` isn't read as an escape. + (tmp_path / ".env").write_text(f"PATH={dotenv_bin_dir}{os.pathsep}${{PATH}}") + + result = run_dotenv(["run", "dotenv-test-command"], cwd=tmp_path, env=start_env) + + check_process(result, exit_code=0, stdout="x\n") + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific lookup") +def test_run_python_uses_path_on_windows(dotenv_path, tmp_path): + """ + Check that `dotenv run python` runs the first `python` in `PATH`, as with an + activated virtual environment (#373). + + A fake `python` placed first in `PATH` prints "x", while the real interpreter + would run the `-c` code and print "y" instead. Without resolving the command + against `PATH`, Windows picks the real interpreter. + """ + + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + make_command(directory=bin_dir, name="python", output="x") # Creates `python.cmd`. + env = {**os.environ, "PATH": os.pathsep.join([str(bin_dir), os.environ["PATH"]])} + + result = run_dotenv( + ["--file", str(dotenv_path), "run", "python", "-c", "print('y')"], + cwd=tmp_path, + env=env, + ) + + check_process(result, exit_code=0, stdout="x\n") + + def test_run_without_cmd(tmp_path): result = run_dotenv(["run"], cwd=tmp_path)