Repository: sayanarijit/expandvars

Issue:
Add support for `$$` (pid) and `${!VAR}` syntax.
`$$` expands to `os.getpid()` and `${!VAR}` helps with nesting variables.

Discussion:

Patch:
diff --git a/README.md b/README.md
index cf76261..f7d6317 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,7 @@ my_secret_access_code = "${ACCESS_CODE:-default_access_code}"
 my_important_variable = "${IMPORTANT_VARIABLE:?}"
 my_updated_path = "$PATH:$HOME/.bin"
 my_process_id = "$$"
+my_nested_variable = "${!NESTED}
 ```
 
 > NOTE: Although this module copies most of the common behaviours of bash,
diff --git a/expandvars.py b/expandvars.py
index adae76c..e8b3ee8 100644
--- a/expandvars.py
+++ b/expandvars.py
@@ -96,16 +96,26 @@ def _isint(val):
         return False
 
 
-def getenv(var, nounset):
+def getenv(var, nounset, indirect, default=None):
     """Get value from environment variable.
 
     When nounset is True, it behaves like bash's "set -o nounset" or "set -u"
     and raises UnboundVariable exception.
+
+    When indirect is True, it will use the value of the resolved variable as
+    the name of the final variable.
     """
 
     val = environ.get(var)
+    if val is not None and indirect:
+        val = environ.get(val)
+
     if val is not None:
         return val
+
+    if default is not None:
+        return default
+
     if nounset:
         if RECOVER_NULL is not None:
             return RECOVER_NULL
@@ -154,47 +164,59 @@ def expand_var(vars_, nounset):
             buff.append(c)
         else:
             n = len(buff)
-            return getenv("".join(buff), nounset=nounset) + expandvars(
+            return getenv("".join(buff), nounset=nounset, indirect=False) + expandvars(
                 vars_[n:], nounset=nounset
             )
-    return getenv("".join(buff), nounset=nounset)
+    return getenv("".join(buff), nounset=nounset, indirect=False)
 
 
 def expand_modifier_var(vars_, nounset):
     """Expand variables with modifier."""
 
-    if len(vars_) == 1:
+    if len(vars_) <= 1:
         raise BadSubstitution(vars_)
 
+    if vars_[0] == "!":
+        indirect = True
+        vars_ = vars_[1:]
+    else:
+        indirect = False
+
     buff = []
     for c in vars_:
         if _valid_char(c):
             buff.append(c)
         elif c == "}":
             n = len(buff) + 1
-            return getenv("".join(buff), nounset=nounset) + expandvars(
-                vars_[n:], nounset=nounset
-            )
-        elif c == ":":
-            n = len(buff) + 1
-            return expand_advanced("".join(buff), vars_[n:], nounset=nounset)
+            return getenv(
+                "".join(buff), nounset=nounset, indirect=indirect
+            ) + expandvars(vars_[n:], nounset=nounset)
         else:
             n = len(buff)
-            return expand_advanced("".join(buff), vars_[n:], nounset=nounset)
+            if c == ":":
+                n += 1
+            return expand_advanced(
+                "".join(buff), vars_[n:], nounset=nounset, indirect=indirect
+            )
+
     raise MissingClosingBrace("".join(buff))
 
 
-def expand_advanced(var, vars_, nounset):
+def expand_advanced(var, vars_, nounset, indirect):
     """Expand substitution."""
 
     if len(vars_) == 0:
         raise MissingClosingBrace(var)
 
     if vars_[0] == "-":
-        return expand_default(var, vars_[1:], set_=False, nounset=nounset)
+        return expand_default(
+            var, vars_[1:], set_=False, nounset=nounset, indirect=indirect
+        )
 
     if vars_[0] == "=":
-        return expand_default(var, vars_[1:], set_=True, nounset=nounset)
+        return expand_default(
+            var, vars_[1:], set_=True, nounset=nounset, indirect=indirect
+        )
 
     if vars_[0] == "+":
         return expand_substitute(var, vars_[1:], nounset=nounset)
@@ -249,7 +271,7 @@ def expand_offset(var, vars_, nounset):
                 raise OperandExpected(var, offset_str)
             else:
                 offset = int(offset_str)
-            return getenv(var, nounset=nounset)[offset:] + expandvars(
+            return getenv(var, nounset=nounset, indirect=False)[offset:] + expandvars(
                 vars_[n:], nounset=nounset
             )
         buff.append(c)
@@ -280,9 +302,9 @@ def expand_length(var, vars_, offset, nounset):
             else:
                 width = offset + length
 
-            return getenv(var, nounset=nounset)[offset:width] + expandvars(
-                vars_[n:], nounset=nounset
-            )
+            return getenv(var, nounset=nounset, indirect=False)[
+                offset:width
+            ] + expandvars(vars_[n:], nounset=nounset)
 
         buff.append(c)
     raise MissingClosingBrace("".join(buff))
@@ -302,7 +324,7 @@ def expand_substitute(var, vars_, nounset):
     raise MissingClosingBrace("".join(sub))
 
 
-def expand_default(var, vars_, set_, nounset):
+def expand_default(var, vars_, set_, nounset, indirect):
     """Expand var or return default."""
 
     default = []
@@ -312,7 +334,10 @@ def expand_default(var, vars_, set_, nounset):
             default_ = "".join(default)
             if set_ and var not in environ:
                 environ.update({var: default_})
-            return environ.get(var, default_) + expandvars(vars_[n:], nounset=nounset)
+            return getenv(
+                var, nounset=nounset, indirect=indirect, default=default_
+            ) + expandvars(vars_[n:], nounset=nounset)
+
         default.append(c)
     raise MissingClosingBrace("".join(default))
 


Test patch:
diff --git a/tests/test_expandvars.py b/tests/test_expandvars.py
index 1df755c..eaf9b1d 100644
--- a/tests/test_expandvars.py
+++ b/tests/test_expandvars.py
@@ -61,6 +61,7 @@ def test_expandvars_pid():
     assert expandvars.expandvars("$$") == str(getpid())
     assert expandvars.expandvars("PID( $$ )") == "PID( {0} )".format(getpid())
 
+
 @patch.dict(env, {})
 def test_expandvars_get_default():
     importlib.reload(expandvars)
@@ -128,6 +129,16 @@ def test_offset_length():
     assert expandvars.expandvars("${FOO:-3:1}:bar") == "damnbigfoobar:bar"
 
 
+@patch.dict(env, {"FOO": "X", "X": "foo"})
+def test_expandvars_indirection():
+    importlib.reload(expandvars)
+
+    assert expandvars.expandvars("${!FOO}:${FOO}") == "foo:X"
+    assert expandvars.expandvars("${!FOO-default}") == "foo"
+    assert expandvars.expandvars("${!BAR-default}") == "default"
+    assert expandvars.expandvars("${!X-default}") == "default"
+
+
 @patch.dict(env, {"FOO": "foo", "BAR": "bar"})
 def test_escape():
     importlib.reload(expandvars)
@@ -201,7 +212,7 @@ def test_invalid_length_err():
     importlib.reload(expandvars)
 
     with pytest.raises(
-        expandvars.ExpandvarsException, match="FOO: -3: substring expression < 0",
+        expandvars.ExpandvarsException, match="FOO: -3: substring expression < 0"
     ) as e:
         expandvars.expandvars("${FOO:1:-3}")
     assert isinstance(e.value, expandvars.NegativeSubStringExpression)