Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Additional variable expansion features, some support for Windows #493

Closed
wants to merge 13 commits into from
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
99 changes: 79 additions & 20 deletions src/dotenv/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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:
"""
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -342,6 +398,7 @@ def load_dotenv(
stream=stream,
verbose=verbose,
interpolate=interpolate,
single_quotes_expand=single_quotes_expand,
override=override,
encoding=encoding,
)
Expand All @@ -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]]:
"""
Expand All @@ -379,6 +437,7 @@ def dotenv_values(
stream=stream,
verbose=verbose,
interpolate=interpolate,
single_quotes_expand=single_quotes_expand,
override=True,
encoding=encoding,
).dict()
12 changes: 11 additions & 1 deletion src/dotenv/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class Original(NamedTuple):
class Binding(NamedTuple):
key: Optional[str]
value: Optional[str]
quote: Optional[str]
original: Original
error: bool

Expand Down Expand Up @@ -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"'":
Expand All @@ -140,6 +146,7 @@ def parse_binding(reader: Reader) -> Binding:
return Binding(
key=None,
value=None,
quote=None,
original=reader.get_marked(),
error=False,
)
Expand All @@ -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,
)
Expand All @@ -164,6 +173,7 @@ def parse_binding(reader: Reader) -> Binding:
return Binding(
key=None,
value=None,
quote=None,
original=reader.get_marked(),
error=True,
)
Expand Down
61 changes: 49 additions & 12 deletions src/dotenv/variables.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
_posix_variable: Pattern[str] = re.compile(
r"""
\$\{
(?P<name>[^\}:]*)
(?::-
(?P<default>[^\}]*)
(?P<name>[^\}:+?-]*)
(?:
(?P<action_spec>:?[+?-])(?P<argument>[^\}]*)
)?
\}
""",
Expand Down Expand Up @@ -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 ""


Expand All @@ -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)
Expand Down
Loading