Skip to content
This repository was archived by the owner on May 4, 2023. It is now read-only.

Commit bee44b9

Browse files
committed
Add variable expansion (fix joke2k#421)
- Expand variables referenced as `$VAR` or `${VAR}`. - Detect infinite recursion in expansion (self-reference).
1 parent 69b4bc9 commit bee44b9

File tree

5 files changed

+114
-12
lines changed

5 files changed

+114
-12
lines changed

CHANGELOG.rst

+2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ Added
1111
+++++
1212
- Added support for secure Elasticsearch connections
1313
`#463 <https://github.com/joke2k/django-environ/pull/463>`_.
14+
- Added variable expansion
15+
`#468 <https://github.com/joke2k/django-environ/pull/468>`_.
1416

1517
Changed
1618
+++++++

docs/quickstart.rst

+22
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,28 @@ And use it with ``settings.py`` as follows:
2323
:start-after: -code-begin-
2424
:end-before: -overview-
2525

26+
Variables can contain references to another variables: ``$VAR`` or ``${VAR}``.
27+
Referenced variables are searched in the environment and within all definitions
28+
in the ``.env`` file. References are checked for recursion (self-reference).
29+
Exception is thrown if any reference results in infinite loop on any level
30+
of recursion. Variable values are substituted similar to shell parameter
31+
expansion. Example:
32+
33+
.. code-block:: shell
34+
35+
# shell
36+
export POSTGRES_USERNAME='user' POSTGRES_PASSWORD='SECRET'
37+
38+
.. code-block:: shell
39+
40+
# .env
41+
POSTGRES_HOSTNAME='example.com'
42+
POSTGRES_DB='database'
43+
DATABASE_URL="postgres://${POSTGRES_USERNAME}:${POSTGRES_PASSWORD}@${POSTGRES_HOSTNAME}:5432/${POSTGRES_DB}"
44+
45+
The value of ``DATABASE_URL`` variable will become
46+
``postgres://user:SECRET@example.com:5432/database``.
47+
2648
The ``.env`` file should be specific to the environment and not checked into
2749
version control, it is best practice documenting the ``.env`` file with an example.
2850
For example, you can also add ``.env.dist`` with a template of your variables to

environ/environ.py

+54-12
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
import os
1818
import re
1919
import sys
20+
import threading
2021
import warnings
22+
from os.path import expandvars
2123
from urllib.parse import (
2224
parse_qs,
2325
ParseResult,
@@ -40,6 +42,9 @@
4042
Openable = (str, os.PathLike)
4143
logger = logging.getLogger(__name__)
4244

45+
# Variables which values should not be expanded
46+
NOT_EXPANDED = 'DJANGO_SECRET_KEY', 'CACHE_URL'
47+
4348

4449
def _cast(value):
4550
# Safely evaluate an expression node or a string containing a Python
@@ -189,7 +194,11 @@ class Env:
189194
for s in ('', 's')]
190195
CLOUDSQL = 'cloudsql'
191196

197+
VAR = re.compile(r'(?<!\\)\$\{?(?P<name>[A-Z_][0-9A-Z_]*)}?',
198+
re.IGNORECASE)
199+
192200
def __init__(self, **scheme):
201+
self._local = threading.local()
193202
self.smart_cast = True
194203
self.escape_proxy = False
195204
self.prefix = ""
@@ -343,9 +352,13 @@ def path(self, var, default=NOTSET, **kwargs):
343352
"""
344353
return Path(self.get_value(var, default=default), **kwargs)
345354

346-
def get_value(self, var, cast=None, default=NOTSET, parse_default=False):
355+
def get_value(self, var, cast=None, # pylint: disable=R0913
356+
default=NOTSET, parse_default=False, add_prefix=True):
347357
"""Return value for given environment variable.
348358
359+
- Expand variables referenced as ``$VAR`` or ``${VAR}``.
360+
- Detect infinite recursion in expansion (self-reference).
361+
349362
:param str var:
350363
Name of variable.
351364
:param collections.abc.Callable or None cast:
@@ -354,15 +367,33 @@ def get_value(self, var, cast=None, default=NOTSET, parse_default=False):
354367
If var not present in environ, return this instead.
355368
:param bool parse_default:
356369
Force to parse default.
370+
:param bool add_prefix:
371+
Whether to add prefix to variable name.
357372
:returns: Value from environment or default (if set).
358373
:rtype: typing.IO[typing.Any]
359374
"""
360-
375+
var_name = f'{self.prefix}{var}' if add_prefix else var
376+
if not hasattr(self._local, 'vars'):
377+
self._local.vars = set()
378+
if var_name in self._local.vars:
379+
error_msg = f"Environment variable '{var_name}' recursively "\
380+
"references itself (eventually)"
381+
raise ImproperlyConfigured(error_msg)
382+
383+
self._local.vars.add(var_name)
384+
try:
385+
return self._get_value(
386+
var_name, cast=cast, default=default,
387+
parse_default=parse_default)
388+
finally:
389+
self._local.vars.remove(var_name)
390+
391+
def _get_value(self, var_name, cast=None, default=NOTSET,
392+
parse_default=False):
361393
logger.debug(
362394
"get '%s' casted as '%s' with default '%s'",
363-
var, cast, default)
395+
var_name, cast, default)
364396

365-
var_name = f'{self.prefix}{var}'
366397
if var_name in self.scheme:
367398
var_info = self.scheme[var_name]
368399

@@ -388,26 +419,37 @@ def get_value(self, var, cast=None, default=NOTSET, parse_default=False):
388419
value = self.ENVIRON[var_name]
389420
except KeyError as exc:
390421
if default is self.NOTSET:
391-
error_msg = f'Set the {var} environment variable'
422+
error_msg = f'Set the {var_name} environment variable'
392423
raise ImproperlyConfigured(error_msg) from exc
393424

394425
value = default
395426

427+
# Expand variables
428+
if isinstance(value, (bytes, str)) and var_name not in NOT_EXPANDED:
429+
def repl(match_):
430+
return self.get_value(
431+
match_.group('name'), cast=cast, default=default,
432+
parse_default=parse_default, add_prefix=False)
433+
434+
is_bytes = isinstance(value, bytes)
435+
if is_bytes:
436+
value = value.decode('utf-8')
437+
value = self.VAR.sub(repl, value)
438+
value = expandvars(value)
439+
if is_bytes:
440+
value = value.encode('utf-8')
441+
396442
# Resolve any proxied values
397443
prefix = b'$' if isinstance(value, bytes) else '$'
398444
escape = rb'\$' if isinstance(value, bytes) else r'\$'
399-
if hasattr(value, 'startswith') and value.startswith(prefix):
400-
value = value.lstrip(prefix)
401-
value = self.get_value(value, cast=cast, default=default)
402445

403446
if self.escape_proxy and hasattr(value, 'replace'):
404447
value = value.replace(escape, prefix)
405448

406449
# Smart casting
407-
if self.smart_cast:
408-
if cast is None and default is not None and \
409-
not isinstance(default, NoValue):
410-
cast = type(default)
450+
if self.smart_cast and cast is None and default is not None \
451+
and not isinstance(default, NoValue):
452+
cast = type(default)
411453

412454
value = None if default is None and value == '' else value
413455

tests/test_expansion.py

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import pytest
2+
3+
from environ import Env, Path
4+
from environ.compat import ImproperlyConfigured
5+
6+
7+
class TestExpansion:
8+
def setup_method(self, method):
9+
Env.ENVIRON = {}
10+
self.env = Env()
11+
self.env.read_env(Path(__file__, is_file=True)('test_expansion.txt'))
12+
13+
def test_expansion(self):
14+
assert self.env('HELLO') == 'Hello, world!'
15+
16+
def test_braces(self):
17+
assert self.env('BRACES') == 'Hello, world!'
18+
19+
def test_recursion(self):
20+
with pytest.raises(ImproperlyConfigured) as excinfo:
21+
self.env('RECURSIVE')
22+
assert str(excinfo.value) == "Environment variable 'RECURSIVE' recursively references itself (eventually)"
23+
24+
def test_transitive(self):
25+
with pytest.raises(ImproperlyConfigured) as excinfo:
26+
self.env('R4')
27+
assert str(excinfo.value) == "Environment variable 'R4' recursively references itself (eventually)"

tests/test_expansion.txt

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
VAR1='Hello'
2+
VAR2='world'
3+
HELLO="$VAR1, $VAR2!"
4+
BRACES="${VAR1}, ${VAR2}!"
5+
RECURSIVE="This variable is $RECURSIVE"
6+
R1="$R2"
7+
R2="$R3"
8+
R3="$R4"
9+
R4="$R1"

0 commit comments

Comments
 (0)