diff --git a/README.md b/README.md index ddc8ba87..cac5ec8d 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,10 @@ defined in the following list: - Default value, if provided. - Empty string. +Note that on Windows environment variable names in `os.environ` are +[always uppercase](https://docs.python.org/3/library/os.html#os.environ)! +This may lead to some unexpected expansions if your variables are not all uppercase. + ## Related Projects - [Honcho](https://github.com/nickstenning/honcho) - For managing diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 383b79f4..0b03862d 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -39,6 +39,7 @@ def __init__( verbose: bool = False, encoding: Optional[str] = None, interpolate: bool = True, + single_quotes_expand: bool = True, override: bool = True, ) -> None: self.dotenv_path: Optional[StrPath] = dotenv_path @@ -47,6 +48,7 @@ def __init__( self.verbose: bool = verbose self.encoding: Optional[str] = encoding self.interpolate: bool = interpolate + self.single_quotes_expand: bool = single_quotes_expand self.override: bool = override @contextmanager @@ -69,20 +71,31 @@ def dict(self) -> Dict[str, Optional[str]]: if self._dict: return self._dict - raw_values = self.parse() - if self.interpolate: - self._dict = OrderedDict(resolve_variables(raw_values, override=self.override)) + bindings = self.parse_to_bindings() + self._dict = OrderedDict( + _resolve_bindings( + bindings, + override=self.override, + single_quotes_expand=self.single_quotes_expand, + ) + ) else: + raw_values = self.parse() self._dict = OrderedDict(raw_values) return self._dict - def parse(self) -> Iterator[Tuple[str, Optional[str]]]: + def parse_to_bindings(self) -> Iterator[Binding]: 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 + yield mapping + + def parse(self) -> Iterator[Tuple[str, Optional[str]]]: + for mapping in self.parse_to_bindings(): + assert mapping.key is not None + yield mapping.key, mapping.value def set_as_environment_variables(self) -> bool: """ @@ -92,7 +105,8 @@ def set_as_environment_variables(self) -> bool: return False for k, v in self.dict().items(): - if k in os.environ and not self.override: + key_present = k in os.environ or (os.name == 'nt' and k.upper() in os.environ) + if key_present and not self.override: continue if v is not None: os.environ[k] = v @@ -224,31 +238,72 @@ def unset_key( return removed, key_to_unset -def resolve_variables( - values: Iterable[Tuple[str, Optional[str]]], +def _resolve_bindings( + bindings: Iterable[Binding], override: bool, + single_quotes_expand: bool, ) -> Mapping[str, Optional[str]]: new_values: Dict[str, Optional[str]] = {} - for (name, value) in values: - if value is None: - result = None + for binding in bindings: + name = binding.key + if name is None: + continue + + value = binding.value + if not single_quotes_expand and binding.quote == "'": + result = value else: - atoms = parse_variables(value) - env: Dict[str, Optional[str]] = {} - 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) + result = resolve_variable(value, new_values, override) new_values[name] = result return new_values +def resolve_variables( + values: Iterable[Tuple[str, Optional[str]]], + override: bool, +) -> Mapping[str, Optional[str]]: + """ + Expand POSIX variables present in the provided sequence of key-value pairs. + + Resolved `values` and `os.environ` are used as defined variables. + New values take precedence over `os.environ` if `override` is True. + """ + new_values: Dict[str, Optional[str]] = {} + + for (name, value) in values: + new_values[name] = resolve_variable(value, new_values, override) + + return new_values + + +def resolve_variable( + value: Optional[str], + variables: Dict[str, Optional[str]], + override: bool +) -> Optional[str]: + """ + Expand POSIX variables present in the provided value. + + `variables` and `os.environ` are used as defined variables. + `variables` take precedence over `os.environ` if `override` is True. + """ + if value is None: + return value + + atoms = parse_variables(value) + env: Dict[str, Optional[str]] = {} + if override: + env.update(os.environ) # type: ignore + env.update(variables) + else: + env.update(variables) + env.update(os.environ) # type: ignore + return "".join(atom.resolve(env) for atom in atoms) + + def _walk_to_root(path: str) -> Iterator[str]: """ Yield directories starting from the given directory up to the root @@ -316,6 +371,7 @@ def load_dotenv( verbose: bool = False, override: bool = False, interpolate: bool = True, + single_quotes_expand: bool = True, encoding: Optional[str] = "utf-8", ) -> bool: """Parse a .env file and then load all the variables found as environment variables. @@ -342,6 +398,7 @@ def load_dotenv( stream=stream, verbose=verbose, interpolate=interpolate, + single_quotes_expand=single_quotes_expand, override=override, encoding=encoding, ) @@ -353,6 +410,7 @@ def dotenv_values( stream: Optional[IO[str]] = None, verbose: bool = False, interpolate: bool = True, + single_quotes_expand: bool = True, encoding: Optional[str] = "utf-8", ) -> Dict[str, Optional[str]]: """ @@ -379,6 +437,7 @@ def dotenv_values( stream=stream, verbose=verbose, interpolate=interpolate, + single_quotes_expand=single_quotes_expand, override=True, encoding=encoding, ).dict() diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index 735f14a3..70f3c2e3 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -33,6 +33,7 @@ class Original(NamedTuple): class Binding(NamedTuple): key: Optional[str] value: Optional[str] + quote: Optional[str] original: Original error: bool @@ -118,6 +119,11 @@ def parse_unquoted_value(reader: Reader) -> str: return re.sub(r"\s+#.*", "", part).rstrip() +def peek_quote(reader: Reader) -> Optional[str]: + char = reader.peek(1) + return char if char in [u'"', u"'"] else None + + def parse_value(reader: Reader) -> str: char = reader.peek(1) if char == u"'": @@ -140,6 +146,7 @@ def parse_binding(reader: Reader) -> Binding: return Binding( key=None, value=None, + quote=None, original=reader.get_marked(), error=False, ) @@ -148,14 +155,16 @@ def parse_binding(reader: Reader) -> Binding: reader.read_regex(_whitespace) if reader.peek(1) == "=": reader.read_regex(_equal_sign) + quote: Optional[str] = peek_quote(reader) value: Optional[str] = parse_value(reader) else: - value = None + value = quote = None reader.read_regex(_comment) reader.read_regex(_end_of_line) return Binding( key=key, value=value, + quote=quote, original=reader.get_marked(), error=False, ) @@ -164,6 +173,7 @@ def parse_binding(reader: Reader) -> Binding: return Binding( key=None, value=None, + quote=None, original=reader.get_marked(), error=True, ) diff --git a/src/dotenv/variables.py b/src/dotenv/variables.py index 667f2f26..0ecf8d28 100644 --- a/src/dotenv/variables.py +++ b/src/dotenv/variables.py @@ -5,9 +5,9 @@ _posix_variable: Pattern[str] = re.compile( r""" \$\{ - (?P[^\}:]*) - (?::- - (?P[^\}]*) + (?P[^\}:+?-]*) + (?: + (?P:?[+?-])(?P[^\}]*) )? \} """, @@ -45,25 +45,60 @@ def resolve(self, env: Mapping[str, Optional[str]]) -> str: return self.value +class Action: + def __init__(self, spec: str, argument: str = ""): + assert len(spec) > 0 + self.spec = spec + self.argument = argument + + def __repr__(self) -> str: + return f"Action(spec={self.spec}, argument={self.argument})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, self.__class__): + return NotImplemented + return (self.spec, self.argument) == (other.spec, other.argument) + + def __hash__(self) -> int: + return hash((self.__class__, self.spec, self.argument)) + + def resolves_empty(self, value: Optional[str]): + if value is None: + return True + return self.spec[0] == ":" and value == "" + + def resolve(self, name: str, value: Optional[str]): + empty = self.resolves_empty(value) + action = self.spec[-1] + if action == "-" and empty: + return self.argument + if action == "+" and not empty: + return self.argument + if action == "?" and empty: + raise LookupError(f"{name}: {self.argument}") + return value + + class Variable(Atom): - def __init__(self, name: str, default: Optional[str]) -> None: + def __init__(self, name: str, action: Optional[Action]) -> None: self.name = name - self.default = default + self.action = action def __repr__(self) -> str: - return f"Variable(name={self.name}, default={self.default})" + return f"Variable(name={self.name}, action={repr(self.action)})" def __eq__(self, other: object) -> bool: if not isinstance(other, self.__class__): return NotImplemented - return (self.name, self.default) == (other.name, other.default) + return (self.name, self.action) == (other.name, other.action) def __hash__(self) -> int: - return hash((self.__class__, self.name, self.default)) + return hash((self.__class__, self.name, self.action)) 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) + result = env.get(self.name, None) + if self.action is not None: + result = self.action.resolve(self.name, result) return result if result is not None else "" @@ -73,12 +108,14 @@ def parse_variables(value: str) -> Iterator[Atom]: for match in _posix_variable.finditer(value): (start, end) = match.span() name = match["name"] - default = match["default"] + action_spec = match["action_spec"] + argument = match["argument"] + action = Action(action_spec, argument) if action_spec else None if start > cursor: yield Literal(value=value[cursor:start]) - yield Variable(name=name, default=default) + yield Variable(name=name, action=action) cursor = end length = len(value) diff --git a/tests/test_cli.py b/tests/test_cli.py index fc309b48..3e3f79bd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,9 +1,13 @@ import os -import sh from pathlib import Path from typing import Optional import pytest +try: + import sh + with_sh = True +except ImportError: + with_sh = False import dotenv from dotenv.cli import cli as dotenv_cli @@ -151,6 +155,7 @@ def test_set_no_file(cli): assert "Missing argument" in result.output +@pytest.mark.skipif(not with_sh, reason="sh module is not available") def test_get_default_path(tmp_path): with sh.pushd(tmp_path): (tmp_path / ".env").write_text("a=b") @@ -160,6 +165,7 @@ def test_get_default_path(tmp_path): assert result == "b\n" +@pytest.mark.skipif(not with_sh, reason="sh module is not available") def test_run(tmp_path): with sh.pushd(tmp_path): (tmp_path / ".env").write_text("a=b") @@ -169,6 +175,7 @@ def test_run(tmp_path): assert result == "b\n" +@pytest.mark.skipif(not with_sh, reason="sh module is not available") def test_run_with_existing_variable(tmp_path): with sh.pushd(tmp_path): (tmp_path / ".env").write_text("a=b") @@ -180,6 +187,7 @@ def test_run_with_existing_variable(tmp_path): assert result == "b\n" +@pytest.mark.skipif(not with_sh, reason="sh module is not available") def test_run_with_existing_variable_not_overridden(tmp_path): with sh.pushd(tmp_path): (tmp_path / ".env").write_text("a=b") @@ -191,6 +199,7 @@ def test_run_with_existing_variable_not_overridden(tmp_path): assert result == "c\n" +@pytest.mark.skipif(not with_sh, reason="sh module is not available") def test_run_with_none_value(tmp_path): with sh.pushd(tmp_path): (tmp_path / ".env").write_text("a=b\nc") @@ -200,6 +209,7 @@ def test_run_with_none_value(tmp_path): assert result == "b\n" +@pytest.mark.skipif(not with_sh, reason="sh module is not available") def test_run_with_other_env(dotenv_path): dotenv_path.write_text("a=b") diff --git a/tests/test_ipython.py b/tests/test_ipython.py index 960479ba..6cb214e1 100644 --- a/tests/test_ipython.py +++ b/tests/test_ipython.py @@ -3,6 +3,8 @@ import pytest +from .utils import as_env + pytest.importorskip("IPython") @@ -20,7 +22,7 @@ def test_ipython_existing_variable_no_override(tmp_path): ipshell.run_line_magic("load_ext", "dotenv") ipshell.run_line_magic("dotenv", "") - assert os.environ == {"a": "c"} + assert os.environ == as_env({"a": "c"}) @mock.patch.dict(os.environ, {}, clear=True) @@ -36,7 +38,7 @@ def test_ipython_existing_variable_override(tmp_path): ipshell.run_line_magic("load_ext", "dotenv") ipshell.run_line_magic("dotenv", "-o") - assert os.environ == {"a": "b"} + assert os.environ == as_env({"a": "b"}) @mock.patch.dict(os.environ, {}, clear=True) @@ -51,4 +53,4 @@ def test_ipython_new_variable(tmp_path): ipshell.run_line_magic("load_ext", "dotenv") ipshell.run_line_magic("dotenv", "") - assert os.environ == {"a": "b"} + assert os.environ == as_env({"a": "b"}) diff --git a/tests/test_main.py b/tests/test_main.py index fd5e3903..aa02c218 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -6,7 +6,14 @@ from unittest import mock import pytest -import sh + +try: + import sh + with_sh = True +except ImportError: + with_sh = False + +from .utils import as_env import dotenv @@ -242,7 +249,7 @@ def test_load_dotenv_existing_file(dotenv_path): result = dotenv.load_dotenv(dotenv_path) assert result is True - assert os.environ == {"a": "b"} + assert os.environ == as_env({"a": "b"}) def test_load_dotenv_no_file_verbose(): @@ -262,7 +269,7 @@ def test_load_dotenv_existing_variable_no_override(dotenv_path): result = dotenv.load_dotenv(dotenv_path, override=False) assert result is True - assert os.environ == {"a": "c"} + assert os.environ == as_env({"a": "c"}) @mock.patch.dict(os.environ, {"a": "c"}, clear=True) @@ -272,7 +279,7 @@ def test_load_dotenv_existing_variable_override(dotenv_path): result = dotenv.load_dotenv(dotenv_path, override=True) assert result is True - assert os.environ == {"a": "b"} + assert os.environ == as_env({"a": "b"}) @mock.patch.dict(os.environ, {"a": "c"}, clear=True) @@ -282,7 +289,12 @@ def test_load_dotenv_redefine_var_used_in_file_no_override(dotenv_path): result = dotenv.load_dotenv(dotenv_path) assert result is True - assert os.environ == {"a": "c", "d": "c"} + if os.name == 'nt': + # Variable is not overwritten, but variable expansion + # uses the lowercase variable that was just defined in the file. + assert os.environ == as_env({"a": "c", "d": "b"}) + else: + assert os.environ == as_env({"a": "c", "d": "c"}) @mock.patch.dict(os.environ, {"a": "c"}, clear=True) @@ -292,7 +304,7 @@ def test_load_dotenv_redefine_var_used_in_file_with_override(dotenv_path): result = dotenv.load_dotenv(dotenv_path, override=True) assert result is True - assert os.environ == {"a": "b", "d": "b"} + assert os.environ == as_env({"a": "b", "d": "b"}) @mock.patch.dict(os.environ, {}, clear=True) @@ -302,7 +314,7 @@ def test_load_dotenv_string_io_utf_8(): result = dotenv.load_dotenv(stream=stream) assert result is True - assert os.environ == {"a": "à"} + assert os.environ == as_env({"a": "à"}) @mock.patch.dict(os.environ, {}, clear=True) @@ -313,9 +325,10 @@ def test_load_dotenv_file_stream(dotenv_path): result = dotenv.load_dotenv(stream=f) assert result is True - assert os.environ == {"a": "b"} + assert os.environ == as_env({"a": "b"}) +@pytest.mark.skipif(not with_sh, reason="sh module is not available") def test_load_dotenv_in_current_dir(tmp_path): dotenv_path = tmp_path / '.env' dotenv_path.write_bytes(b'a=b') @@ -342,16 +355,74 @@ def test_dotenv_values_file(dotenv_path): assert result == {"a": "b"} +@pytest.mark.parametrize( + "env,variables,override,expected", + [ + ({"B": "c"}, {"a": "$B"}.items(), True, {"a": "$B"}), + ({"B": "c"}, {"a": "${B}"}.items(), False, {"a": "c"}), + + ({"B": "c"}, [("B", "d"), ("a", "${B}")], False, {"a": "c", "B": "d"}), + ({"B": "c"}, [("B", "d"), ("a", "${B}")], True, {"a": "d", "B": "d"}), + + ({"B": "c"}, [("B", "${X:-d}"), ("a", "${B}")], True, {"a": "d", "B": "d"}), + + ({"B": "c"}, {"a": "x${B}y"}.items(), True, {"a": "xcy"}), + + # Unfortunate sequence + ({"B": "c"}, [("C", "${B}"), ("B", "${A}"), ("A", "1")], True, {"C": "c", "B": "", "A": "1"}), + ({"B": "c"}, [("C", "${B}"), ("B", "${A}"), ("A", "1")], False, {"C": "c", "B": "", "A": "1"}), + + ({"B": "c"}, [("B", "x"), ("B", "${B}"), ("B", "${B}")], True, {"B": "x"}), + ({"B": "c"}, [("B", "x"), ("B", "${B}"), ("B", "${B}")], False, {"B": "c"}), + ({"B": "c"}, [("B", "x"), ("B", "${B}"), ("B", "y")], False, {"B": "y"}), + ], +) +def test_resolve_variables(env, variables, override, expected): + with mock.patch.dict(os.environ, env, clear=True): + result = dotenv.main.resolve_variables(variables, override=override) + assert result == expected + + +@pytest.mark.parametrize( + "env,variables,value,override,expected", + [ + ({"B": "c"}, {"B": "d"}, "$B", True, "$B"), + ({"B": "c"}, {"B": "d"}, "${B}", True, "d"), + ({"B": "c"}, {"B": "d"}, "${B}", False, "c"), + + ({}, {"B": "d"}, "${B}", False, "d"), + ({"B": "c"}, {}, "${B}", True, "c"), + + ({"B": "c"}, {"A": "d"}, "${B}${A}", True, "cd"), + + ({"B": "c"}, {"B": "d"}, "$B$B$B", True, "$B$B$B"), + ({"B": "c"}, {"B": "d"}, "${B}${B}${B}", True, "ddd"), + ({"B": "c"}, {"B": "d"}, "${B}${B}${B}", False, "ccc"), + + ({"B": "c"}, {"B": "d"}, "${C}", False, ""), + ({"B": "c"}, {"B": "d"}, "${C}", True, ""), + ({"B": "c"}, {"B": "d"}, "${C}${C}${C}", True, ""), + ({"B": "c"}, {"B": "d"}, "${C}a${C}b${C}", True, "ab"), + ], +) +def test_resolve_variable(env, variables, value, override, expected): + with mock.patch.dict(os.environ, env, clear=True): + result = dotenv.main.resolve_variable(value, variables, override=override) + assert result == expected + + @pytest.mark.parametrize( "env,string,interpolate,expected", [ + # Use uppercase when setting up the env to be compatible with Windows + # 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"}), - ({"b": "c"}, "a=${b:-d}", False, {"a": "${b:-d}"}), - ({"b": "c"}, "a=${b:-d}", True, {"a": "c"}), + ({"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"}), + ({"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"}), @@ -361,23 +432,23 @@ def test_dotenv_values_file(dotenv_path): ({}, "a=${b:-d}", True, {"a": "d"}), # With quotes - ({"b": "c"}, 'a="${b}"', True, {"a": "c"}), - ({"b": "c"}, "a='${b}'", True, {"a": "c"}), + ({"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"}), + ({"B": "c"}, "a=x${B}y", True, {"a": "xcy"}), # Self-referential - ({"a": "b"}, "a=${a}", True, {"a": "b"}), + ({"A": "b"}, "A=${A}", True, {"A": "b"}), ({}, "a=${a}", True, {"a": ""}), - ({"a": "b"}, "a=${a:-c}", True, {"a": "b"}), + ({"A": "b"}, "A=${A:-c}", True, {"A": "b"}), ({}, "a=${a:-c}", True, {"a": "c"}), # Reused - ({"b": "c"}, "a=${b}${b}", True, {"a": "cc"}), + ({"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"}), + ({"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"}), ], @@ -392,6 +463,101 @@ def test_dotenv_values_string_io(env, string, interpolate, expected): assert result == expected +@pytest.mark.parametrize( + "string,expected_xx", + [ + ("XX=${NOT_DEFINED-ok}", "ok"), + ("XX=${NOT_DEFINED:-ok}", "ok"), + ("XX=${EMPTY-ok}", ""), + ("XX=${EMPTY:-ok}", "ok"), + ("XX=${TEST-ok}", "tt"), + ("XX=${TEST:-ok}", "tt"), + + ("XX=${NOT_DEFINED+ok}", ""), + ("XX=${NOT_DEFINED:+ok}", ""), + ("XX=${EMPTY+ok}", "ok"), + ("XX=${EMPTY:+ok}", ""), + ("XX=${TEST+ok}", "ok"), + ("XX=${TEST:+ok}", "ok"), + + ("XX=${EMPTY?no throw}", ""), + ("XX=${TEST?no throw}", "tt"), + ("XX=${TEST:?no throw}", "tt"), + ], +) +def test_variable_expansions(string, expected_xx): + test_env = {"TEST": "tt", "EMPTY": "", } + with mock.patch.dict(os.environ, test_env, clear=True): + stream = io.StringIO(string) + stream.seek(0) + + result = dotenv.dotenv_values(stream=stream, interpolate=True) + + assert result["XX"] == expected_xx + + +@pytest.mark.parametrize( + "string,message", + [ + ("XX=${EMPTY:?throw}", "EMPTY: throw"), + ("XX=${NOT_DEFINED:?throw}", "NOT_DEFINED: throw"), + ("XX=${NOT_DEFINED?throw}", "NOT_DEFINED: throw"), + ], +) +def test_required_variable_throws(string, message): + test_env = {"TEST": "tt", "EMPTY": "", } + with mock.patch.dict(os.environ, test_env, clear=True): + stream = io.StringIO(string) + stream.seek(0) + + with pytest.raises(LookupError, match=message): + dotenv.dotenv_values(stream=stream, interpolate=True) + + +@pytest.mark.parametrize( + "string,expected_xx", + [ + ("XX=TEST", "TEST"), + ("XX=$TEST", "$TEST"), + ("XX=${TEST}", "tt"), + ("XX=\"${TEST}\"", "tt"), + ("XX='${TEST}'", "tt"), + ("XX='$TEST'", "$TEST"), + ("XX='\\$\\{TEST\\}'", "\\$\\{TEST\\}"), + ("XX=\\$\\{TEST\\}", "\\$\\{TEST\\}"), + ("XX=\"\\$\\{TEST\\}\"", "\\$\\{TEST\\}"), + ], +) +def test_document_expansions(string, expected_xx): + test_env = {"TEST": "tt"} + with mock.patch.dict(os.environ, test_env, clear=True): + stream = io.StringIO(string) + stream.seek(0) + + result = dotenv.dotenv_values(stream=stream, interpolate=True) + + assert result["XX"] == expected_xx + + +@pytest.mark.parametrize( + "string,expected_xx", + [ + ("XX=${TEST}", "tt"), + ("XX=\"${TEST}\"", "tt"), + ("XX='${TEST}'", "${TEST}"), + ], +) +def test_single_quote_expansions(string, expected_xx): + test_env = {"TEST": "tt"} + with mock.patch.dict(os.environ, test_env, clear=True): + stream = io.StringIO(string) + stream.seek(0) + + result = dotenv.dotenv_values(stream=stream, interpolate=True, single_quotes_expand=False) + + assert result["XX"] == expected_xx + + def test_dotenv_values_file_stream(dotenv_path): dotenv_path.write_text("a=b") diff --git a/tests/test_parser.py b/tests/test_parser.py index b0621173..70630aa6 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -5,166 +5,602 @@ 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", + quote=None, + original=Original(string="a=b", line=1), + error=False, + ) + ], + ), + ( + "'a'=b", + [ + Binding( + key="a", + value="b", + quote=None, + original=Original(string="'a'=b", line=1), + error=False, + ) + ], + ), + ( + "[=b", + [ + Binding( + key="[", + value="b", + quote=None, + original=Original(string="[=b", line=1), + error=False, + ) + ], + ), + ( + " a = b ", + [ + Binding( + key="a", + value="b", + quote=None, + original=Original(string=" a = b ", line=1), + error=False, + ) + ], + ), + ( + "export a=b", + [ + Binding( + key="a", + value="b", + quote=None, + original=Original(string="export a=b", line=1), + error=False, + ) + ], + ), + ( + " export 'a'=b", + [ + Binding( + key="a", + value="b", + quote=None, + original=Original(string=" export 'a'=b", line=1), + error=False, + ) + ], + ), + ( + "# a=b", + [ + Binding( + key=None, + value=None, + quote=None, + original=Original(string="# a=b", line=1), + error=False, + ) + ], + ), + ( + "a=b#c", + [ + Binding( + key="a", + value="b#c", + quote=None, + original=Original(string="a=b#c", line=1), + error=False, + ) + ], + ), + ( + "a=b #c", + [ + Binding( + key="a", + value="b", + quote=None, + original=Original(string="a=b #c", line=1), + error=False, + ) + ], + ), + ( + "a=b\t#c", + [ + Binding( + key="a", + value="b", + quote=None, + original=Original(string="a=b\t#c", line=1), + error=False, + ) + ], + ), + ( + "a=b c", + [ + Binding( + key="a", + value="b c", + quote=None, + original=Original(string="a=b c", line=1), + error=False, + ) + ], + ), + ( + "a=b\tc", + [ + Binding( + key="a", + value="b\tc", + quote=None, + original=Original(string="a=b\tc", line=1), + error=False, + ) + ], + ), + ( + "a=b c", + [ + Binding( + key="a", + value="b c", + quote=None, + original=Original(string="a=b c", line=1), + error=False, + ) + ], + ), + ( + "a=b\u00a0 c", + [ + Binding( + key="a", + value="b\u00a0 c", + quote=None, + original=Original(string="a=b\u00a0 c", line=1), + error=False, + ) + ], + ), + ( + "a=b c ", + [ + Binding( + key="a", + value="b c", + quote=None, + original=Original(string="a=b c ", line=1), + error=False, + ) + ], + ), + ( + "a='b c '", + [ + Binding( + key="a", + value="b c ", + quote="'", + original=Original(string="a='b c '", line=1), + error=False, + ) + ], + ), + ( + 'a="b c "', + [ + Binding( + key="a", + value="b c ", + quote='"', + original=Original(string='a="b c "', line=1), + error=False, + ) + ], + ), + ( + "export export_a=1", + [ + Binding( + key="export_a", + value="1", + quote=None, + original=Original(string="export export_a=1", line=1), + error=False, + ) + ], + ), + ( + "export port=8000", + [ + Binding( + key="port", + value="8000", + quote=None, + original=Original(string="export port=8000", line=1), + error=False, + ) + ], + ), + ( + 'a="b\nc"', + [ + Binding( + key="a", + value="b\nc", + quote='"', + original=Original(string='a="b\nc"', line=1), + error=False, + ) + ], + ), + ( + "a='b\nc'", + [ + Binding( + key="a", + value="b\nc", + quote="'", + original=Original(string="a='b\nc'", line=1), + error=False, + ) + ], + ), + ( + 'a="b\nc"', + [ + Binding( + key="a", + value="b\nc", + quote='"', + original=Original(string='a="b\nc"', line=1), + error=False, + ) + ], + ), + ( + 'a="b\\nc"', + [ + Binding( + key="a", + value="b\nc", + quote='"', + original=Original(string='a="b\\nc"', line=1), + error=False, + ) + ], + ), + ( + "a='b\\nc'", + [ + Binding( + key="a", + value="b\\nc", + quote="'", + original=Original(string="a='b\\nc'", line=1), + error=False, + ) + ], + ), + ( + 'a="b\\"c"', + [ + Binding( + key="a", + value='b"c', + quote='"', + original=Original(string='a="b\\"c"', line=1), + error=False, + ) + ], + ), + ( + "a='b\\'c'", + [ + Binding( + key="a", + value="b'c", + quote="'", + original=Original(string="a='b\\'c'", line=1), + error=False, + ) + ], + ), + ( + "a=à", + [ + Binding( + key="a", + value="à", + quote=None, + original=Original(string="a=à", line=1), + error=False, + ) + ], + ), + ( + 'a="à"', + [ + Binding( + key="a", + value="à", + quote='"', + original=Original(string='a="à"', line=1), + error=False, + ) + ], + ), + ( + "no_value_var", + [ + Binding( + key="no_value_var", + value=None, + quote=None, + original=Original(string="no_value_var", line=1), + error=False, + ) + ], + ), + ( + "a: b", + [ + Binding( + key=None, + value=None, + quote=None, + original=Original(string="a: b", line=1), + error=True, + ) + ], + ), + ( + "a=b\nc=d", + [ + Binding( + key="a", + value="b", + quote=None, + original=Original(string="a=b\n", line=1), + error=False, + ), + Binding( + key="c", + value="d", + quote=None, + original=Original(string="c=d", line=2), + error=False, + ), + ], + ), + ( + "a=b\rc=d", + [ + Binding( + key="a", + value="b", + quote=None, + original=Original(string="a=b\r", line=1), + error=False, + ), + Binding( + key="c", + value="d", + quote=None, + original=Original(string="c=d", line=2), + error=False, + ), + ], + ), + ( + "a=b\r\nc=d", + [ + Binding( + key="a", + value="b", + quote=None, + original=Original(string="a=b\r\n", line=1), + error=False, + ), + Binding( + key="c", + value="d", + quote=None, + original=Original(string="c=d", line=2), + error=False, + ), + ], + ), + ( + "a=\nb=c", + [ + Binding( + key="a", + value="", + quote=None, + original=Original(string="a=\n", line=1), + error=False, + ), + Binding( + key="b", + value="c", + quote=None, + original=Original(string="b=c", line=2), + error=False, + ), + ], + ), + ( + "\n\n", + [ + Binding( + key=None, + value=None, + quote=None, + original=Original(string="\n\n", line=1), + error=False, + ), + ], + ), + ( + "a=b\n\n", + [ + Binding( + key="a", + value="b", + quote=None, + original=Original(string="a=b\n", line=1), + error=False, + ), + Binding( + key=None, + value=None, + quote=None, + original=Original(string="\n", line=2), + error=False, + ), + ], + ), + ( + "a=b\n\nc=d", + [ + Binding( + key="a", + value="b", + quote=None, + original=Original(string="a=b\n", line=1), + error=False, + ), + Binding( + key="c", + value="d", + quote=None, + original=Original(string="\nc=d", line=2), + error=False, + ), + ], + ), + ( + 'a="\nb=c', + [ + Binding( + key=None, + value=None, + quote=None, + original=Original(string='a="\n', line=1), + error=True, + ), + Binding( + key="b", + value="c", + quote=None, + original=Original(string="b=c", line=2), + error=False, + ), + ], + ), + ( + '# comment\na="b\nc"\nd=e\n', + [ + Binding( + key=None, + value=None, + quote=None, + original=Original(string="# comment\n", line=1), + error=False, + ), + Binding( + key="a", + value="b\nc", + quote='"', + original=Original(string='a="b\nc"\n', line=2), + error=False, + ), + Binding( + key="d", + value="e", + quote=None, + original=Original(string="d=e\n", line=4), + error=False, + ), + ], + ), + ( + "a=b\n# comment 1", + [ + Binding( + key="a", + value="b", + quote=None, + original=Original(string="a=b\n", line=1), + error=False, + ), + Binding( + key=None, + value=None, + quote=None, + original=Original(string="# comment 1", line=2), + error=False, + ), + ], + ), + ( + "# comment 1\n# comment 2", + [ + Binding( + key=None, + value=None, + quote=None, + original=Original(string="# comment 1\n", line=1), + error=False, + ), + Binding( + key=None, + value=None, + quote=None, + original=Original(string="# comment 2", line=2), + error=False, + ), + ], + ), + ( + 'uglyKey[%$="S3cr3t_P4ssw#rD" #\na=b', + [ + Binding( + key="uglyKey[%$", + value="S3cr3t_P4ssw#rD", + quote='"', + original=Original( + string='uglyKey[%$="S3cr3t_P4ssw#rD" #\n', line=1 + ), + error=False, + ), + Binding( + key="a", + value="b", + quote=None, + 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_variables.py b/tests/test_variables.py index 86b06466..9f56b47e 100644 --- a/tests/test_variables.py +++ b/tests/test_variables.py @@ -1,6 +1,6 @@ import pytest -from dotenv.variables import Literal, Variable, parse_variables +from dotenv.variables import Literal, Variable, Action, parse_variables @pytest.mark.parametrize( @@ -8,22 +8,36 @@ [ ("", []), ("a", [Literal(value="a")]), - ("${a}", [Variable(name="a", default=None)]), - ("${a:-b}", [Variable(name="a", default="b")]), + ("${a}", [Variable(name="a", action=None)]), + + ("${a:-b}", [Variable(name="a", action=Action(":-", "b"))]), + ("${a-b}", [Variable(name="a", action=Action("-", "b"))]), + + ("${a:+b}", [Variable(name="a", action=Action(":+", "b"))]), + ("${a+b}", [Variable(name="a", action=Action("+", "b"))]), + + ("${a:?b}", [Variable(name="a", action=Action(":?", "b"))]), + ("${a?b}", [Variable(name="a", action=Action("?", "b"))]), + ("${a??b}", [Variable(name="a", action=Action("?", "?b"))]), + + # Unsupported + ("${a:b}", [Literal(value="${a:b}")]), + ("${a!b}", [Variable(name="a!b", action=None)]), + ( "${a}${b}", [ - Variable(name="a", default=None), - Variable(name="b", default=None), + Variable(name="a", action=None), + Variable(name="b", action=None), ], ), ( "a${b}c${d}e", [ Literal(value="a"), - Variable(name="b", default=None), + Variable(name="b", action=None), Literal(value="c"), - Variable(name="d", default=None), + Variable(name="d", action=None), Literal(value="e"), ], ), diff --git a/tests/test_zip_imports.py b/tests/test_zip_imports.py index 46d3c02e..d2816978 100644 --- a/tests/test_zip_imports.py +++ b/tests/test_zip_imports.py @@ -1,11 +1,18 @@ import os import sys -import sh import textwrap from typing import List from unittest import mock from zipfile import ZipFile +import pytest + +try: + import sh + with_sh = True +except ImportError: + with_sh = False + def walk_to_root(path: str): last_dir = None @@ -61,6 +68,7 @@ def test_load_dotenv_gracefully_handles_zip_imports_when_no_env_file(tmp_path): import child1.child2.test # noqa +@pytest.mark.skipif(not with_sh, reason="sh module is not available") def test_load_dotenv_outside_zip_file_when_called_in_zipfile(tmp_path): zip_file_path = setup_zipfile( tmp_path, diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 00000000..60adf722 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,9 @@ +import os + + +def as_env(d: dict): + if os.name == 'nt': + # Environment variables are always uppercase for Python on Windows + return {k.upper(): v for k, v in d.items()} + else: + return d