Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions newsfragments/nested-variable-interpolation.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Support nested variable interpolation
222 changes: 191 additions & 31 deletions podman_compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import re
import shlex
import signal
import string
import subprocess
import sys
import tempfile
Expand Down Expand Up @@ -263,22 +264,195 @@ def fix_mount_dict(
# ${VARIABLE?err} raise error if not set
# $$ means $

var_re = re.compile(
r"""
\$(?:
(?P<escaped>\$) |
(?P<named>[_a-zA-Z][_a-zA-Z0-9]*) |
(?:{
(?P<braced>[_a-zA-Z][_a-zA-Z0-9]*)
(?:(?P<empty>:)?(?:
(?:-(?P<default>[^}]*)) |
(?:\?(?P<err>[^}]*))
))?
})
)
""",
re.VERBOSE,
)

def var_interpolate(value: str, env: dict[str, Any]) -> str:
var_name_chars = string.ascii_letters + string.digits + "_"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First letter of variable name cannot be number I think.

var_name_start_chars = string.ascii_letters + "_"

class VarInterpolationOperators(Enum):
VAR_IF_NONEMPTY = ':-'
VAR_IF_SET = '-'
REQUIRED_SET = '?'
REQUIRED_NONEMPTY = ':?'
ALTERNATIVE1 = ':+'
ALTERNATIVE2 = '+'

operators = {op.value for op in VarInterpolationOperators}

@dataclass
class Token:
def resolve(self, _: dict[str, str | None]) -> str:
raise NotImplementedError()

@dataclass
class LiteralToken(Token):
value: str

def resolve(self, _: dict[str, str | None]) -> str:
return self.value

@dataclass
class VarToken(Token):
name: str
operator: str | None
operand: str | None

def resolve(self, env: dict[str, str | None]) -> str:
var_value = env.get(self.name)

# This is only the case for simple $VAR or ${VAR} without any operator,
# in which case we just return the variable value or empty string if not set
if self.operator is None or self.operand is None:
return var_value if var_value is not None else ''

if self.operator == VarInterpolationOperators.REQUIRED_NONEMPTY.value:
if var_value is None or var_value == '':
interpolated_operand = (
interpolate_str(self.operand, env) if self.operand else ''
)
raise ValueError(
f"required variable {self.name} is missing a value: {interpolated_operand}"
)
return var_value

if self.operator == VarInterpolationOperators.REQUIRED_SET.value:
if var_value is None:
interpolated_operand = (
interpolate_str(self.operand, env) if self.operand else ''
)
raise ValueError(
f"required variable {self.name} is missing a value: {interpolated_operand}"
)
return var_value

if self.operator == VarInterpolationOperators.VAR_IF_NONEMPTY.value:
condition = var_value is None or var_value == ''
alternative = var_value if var_value is not None else ''
elif self.operator == VarInterpolationOperators.VAR_IF_SET.value:
condition = var_value is None
alternative = var_value if var_value is not None else ''
elif self.operator == VarInterpolationOperators.ALTERNATIVE1.value:
condition = var_value is not None and var_value != ''
alternative = ''
elif self.operator == VarInterpolationOperators.ALTERNATIVE2.value:
condition = var_value is not None
alternative = ''
else:
raise ValueError(f"Unknown operator in variable interpolation: {self.operator}")

return interpolate_str(self.operand, env) if condition else alternative

def var_name_lookahead(start: int, chars: list[str]) -> tuple[int, str]:
"""
moves the index to the end of the variable name
returns variable name and position after variable name
"""
var_name = ''
i = start
while i < len(chars) and chars[i] in var_name_chars:
var_name += chars[i]
i += 1
return i, var_name

def advance_to_closing_brace(start: int, chars: list[str]) -> int:
i = start
brace_level = 1
while i < len(chars):
char = chars[i]
if char == '}':
brace_level -= 1
if brace_level == 0:
return i # position of the closing brace
elif char == '{':
brace_level += 1
i += 1
raise ValueError("No closing brace found for variable interpolation")

def resolve_brace_content(content: str) -> VarToken:
operator = None
operand = None

# Check that the brace content starts with a valid variable name character.
# Refuse to interpolate otherwise. This is how Docker behaves.
if len(content) == 0 or content[0] not in var_name_start_chars:
raise ValueError(
f"Invalid interpolation format: ${{{content}}}."
" You may need to escape any $ with another $"
)

i, name = var_name_lookahead(0, list(content))

rest = content[i:]
if rest:
for op in operators:
if rest.startswith(op):
operator = op
operand = rest[len(op) :]
break

if operator is None:
raise ValueError(f"Invalid variable interpolation syntax: ${{{content}}}")

return VarToken(name=name, operator=operator, operand=operand)

def tokenize(value: str) -> list[Token]:
chars = list(value)
tokens: list[Token] = []
in_brace = False

def append_text_char(char: str) -> None:
if tokens and isinstance(tokens[-1], LiteralToken):
tokens[-1].value += char
else:
tokens.append(LiteralToken(value=char))

i = 0
while i < len(chars):
char = chars[i]
if not in_brace:
if char == '$':
# There is no lookahead, treat $ as literal
if i + 1 >= len(chars):
append_text_char(char)
i += 1
continue
lookahead_char = chars[i + 1]
if lookahead_char == '{':
in_brace = True
i += 2 # skip $ and {
continue
if lookahead_char == '$':
append_text_char('$')
i += 2 # skip both $$
continue
# If the lookahead char is valid for starting a variable name, parse the name.
# Otherwise, treat $ as literal (e.g. in "price is $5", $ should be literal)
if lookahead_char in var_name_start_chars:
i, var_name = var_name_lookahead(i + 1, chars)
tokens.append(VarToken(name=var_name, operator=None, operand=None))
continue # already advanced i to a char after var name

# Regular character
append_text_char(char)
i += 1
continue

# in_brace == True
closing_index = advance_to_closing_brace(i, chars)
brace_content = ''.join(chars[i:closing_index])
tokens.append(resolve_brace_content(brace_content))
i = closing_index + 1 # move past the closing brace
in_brace = False
continue

return tokens

def interpolate_str(s: str, env: dict[str, str | None]) -> str:
tokens = tokenize(s)
resolved_parts = [token.resolve(env) for token in tokens]
return ''.join(resolved_parts)

return interpolate_str(value, env)


@overload
Expand All @@ -305,21 +479,7 @@ def rec_subs(value: dict | str | Iterable, subs_dict: dict[str, Any]) -> dict |

value = {rec_subs(k, subs_dict): rec_subs(v, subs_dict) for k, v in value.items()}
elif isinstance(value, str):

def convert(m: re.Match) -> str:
if m.group("escaped") is not None:
return "$"
name = m.group("named") or m.group("braced")
value = subs_dict.get(name)
if value == "" and m.group("empty"):
value = None
if value is not None:
return str(value)
if m.group("err") is not None:
raise RuntimeError(m.group("err"))
return m.group("default") or ""

value = var_re.sub(convert, value)
value = var_interpolate(value, subs_dict)
elif hasattr(value, "__iter__"):
value = [rec_subs(i, subs_dict) for i in value]
return value
Expand Down
Loading