Skip to content

Commit 17dba65

Browse files
committed
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.
1 parent 8815885 commit 17dba65

File tree

3 files changed

+162
-38
lines changed

3 files changed

+162
-38
lines changed

src/dotenv/main.py

Lines changed: 21 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import io
55
import logging
66
import os
7-
import re
87
import shutil
98
import sys
109
import tempfile
@@ -13,13 +12,13 @@
1312

1413
from .compat import IS_TYPE_CHECKING, PY2, StringIO, to_env
1514
from .parser import Binding, parse_stream
15+
from .variables import parse_variables
1616

1717
logger = logging.getLogger(__name__)
1818

1919
if IS_TYPE_CHECKING:
20-
from typing import (
21-
Dict, Iterable, Iterator, Match, Optional, Pattern, Union, Text, IO, Tuple
22-
)
20+
from typing import (IO, Dict, Iterable, Iterator, Mapping, Optional, Text,
21+
Tuple, Union)
2322
if sys.version_info >= (3, 6):
2423
_PathLike = os.PathLike
2524
else:
@@ -30,18 +29,6 @@
3029
else:
3130
_StringIO = StringIO[Text]
3231

33-
__posix_variable = re.compile(
34-
r"""
35-
\$\{
36-
(?P<name>[^\}:]*)
37-
(?::-
38-
(?P<default>[^\}]*)
39-
)?
40-
\}
41-
""",
42-
re.VERBOSE,
43-
) # type: Pattern[Text]
44-
4532

4633
def with_warn_for_invalid_lines(mappings):
4734
# type: (Iterator[Binding]) -> Iterator[Binding]
@@ -83,13 +70,14 @@ def dict(self):
8370
if self._dict:
8471
return self._dict
8572

73+
raw_values = self.parse()
74+
8675
if self.interpolate:
87-
values = resolve_nested_variables(self.parse())
76+
self._dict = OrderedDict(resolve_variables(raw_values))
8877
else:
89-
values = OrderedDict(self.parse())
78+
self._dict = OrderedDict(raw_values)
9079

91-
self._dict = values
92-
return values
80+
return self._dict
9381

9482
def parse(self):
9583
# type: () -> Iterator[Tuple[Text, Optional[Text]]]
@@ -217,27 +205,22 @@ def unset_key(dotenv_path, key_to_unset, quote_mode="always"):
217205
return removed, key_to_unset
218206

219207

220-
def resolve_nested_variables(values):
221-
# type: (Iterable[Tuple[Text, Optional[Text]]]) -> Dict[Text, Optional[Text]]
222-
def _replacement(name, default):
223-
# type: (Text, Optional[Text]) -> Text
224-
default = default if default is not None else ""
225-
ret = new_values.get(name, os.getenv(name, default))
226-
return ret # type: ignore
208+
def resolve_variables(values):
209+
# type: (Iterable[Tuple[Text, Optional[Text]]]) -> Mapping[Text, Optional[Text]]
227210

228-
def _re_sub_callback(match):
229-
# type: (Match[Text]) -> Text
230-
"""
231-
From a match object gets the variable name and returns
232-
the correct replacement
233-
"""
234-
matches = match.groupdict()
235-
return _replacement(name=matches["name"], default=matches["default"]) # type: ignore
211+
new_values = {} # type: Dict[Text, Optional[Text]]
236212

237-
new_values = {}
213+
for (name, value) in values:
214+
if value is None:
215+
result = None
216+
else:
217+
atoms = parse_variables(value)
218+
env = {} # type: Dict[Text, Optional[Text]]
219+
env.update(os.environ) # type: ignore
220+
env.update(new_values)
221+
result = "".join(atom.resolve(env) for atom in atoms)
238222

239-
for (k, v) in values:
240-
new_values[k] = __posix_variable.sub(_re_sub_callback, v) if v is not None else None
223+
new_values[name] = result
241224

242225
return new_values
243226

src/dotenv/variables.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import re
2+
from abc import ABCMeta
3+
4+
from .compat import IS_TYPE_CHECKING
5+
6+
if IS_TYPE_CHECKING:
7+
from typing import Iterator, Mapping, Optional, Pattern, Text
8+
9+
10+
_posix_variable = re.compile(
11+
r"""
12+
\$\{
13+
(?P<name>[^\}:]*)
14+
(?::-
15+
(?P<default>[^\}]*)
16+
)?
17+
\}
18+
""",
19+
re.VERBOSE,
20+
) # type: Pattern[Text]
21+
22+
23+
class Atom():
24+
__metaclass__ = ABCMeta
25+
26+
def __ne__(self, other):
27+
# type: (object) -> bool
28+
result = self.__eq__(other)
29+
if result is NotImplemented:
30+
return NotImplemented
31+
return not result
32+
33+
def resolve(self, env):
34+
# type: (Mapping[Text, Optional[Text]]) -> Text
35+
raise NotImplementedError
36+
37+
38+
class Literal(Atom):
39+
def __init__(self, value):
40+
# type: (Text) -> None
41+
self.value = value
42+
43+
def __repr__(self):
44+
# type: () -> str
45+
return "Literal(value={})".format(self.value)
46+
47+
def __eq__(self, other):
48+
# type: (object) -> bool
49+
if not isinstance(other, self.__class__):
50+
return NotImplemented
51+
return self.value == other.value
52+
53+
def __hash__(self):
54+
# type: () -> int
55+
return hash((self.__class__, self.value))
56+
57+
def resolve(self, env):
58+
# type: (Mapping[Text, Optional[Text]]) -> Text
59+
return self.value
60+
61+
62+
class Variable(Atom):
63+
def __init__(self, name, default):
64+
# type: (Text, Optional[Text]) -> None
65+
self.name = name
66+
self.default = default
67+
68+
def __repr__(self):
69+
# type: () -> str
70+
return "Variable(name={}, default={})".format(self.name, self.default)
71+
72+
def __eq__(self, other):
73+
# type: (object) -> bool
74+
if not isinstance(other, self.__class__):
75+
return NotImplemented
76+
return (self.name, self.default) == (other.name, other.default)
77+
78+
def __hash__(self):
79+
# type: () -> int
80+
return hash((self.__class__, self.name, self.default))
81+
82+
def resolve(self, env):
83+
# type: (Mapping[Text, Optional[Text]]) -> Text
84+
default = self.default if self.default is not None else ""
85+
result = env.get(self.name, default)
86+
return result if result is not None else ""
87+
88+
89+
def parse_variables(value):
90+
# type: (Text) -> Iterator[Atom]
91+
cursor = 0
92+
93+
for match in _posix_variable.finditer(value):
94+
(start, end) = match.span()
95+
name = match.groupdict()["name"]
96+
default = match.groupdict()["default"]
97+
98+
if start > cursor:
99+
yield Literal(value=value[cursor:start])
100+
101+
yield Variable(name=name, default=default)
102+
cursor = end
103+
104+
length = len(value)
105+
if cursor < length:
106+
yield Literal(value=value[cursor:length])

tests/test_variables.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import pytest
2+
3+
from dotenv.variables import Literal, Variable, parse_variables
4+
5+
6+
@pytest.mark.parametrize(
7+
"value,expected",
8+
[
9+
("", []),
10+
("a", [Literal(value="a")]),
11+
("${a}", [Variable(name="a", default=None)]),
12+
("${a:-b}", [Variable(name="a", default="b")]),
13+
(
14+
"${a}${b}",
15+
[
16+
Variable(name="a", default=None),
17+
Variable(name="b", default=None),
18+
],
19+
),
20+
(
21+
"a${b}c${d}e",
22+
[
23+
Literal(value="a"),
24+
Variable(name="b", default=None),
25+
Literal(value="c"),
26+
Variable(name="d", default=None),
27+
Literal(value="e"),
28+
],
29+
),
30+
]
31+
)
32+
def test_parse_variables(value, expected):
33+
result = parse_variables(value)
34+
35+
assert list(result) == expected

0 commit comments

Comments
 (0)