Skip to content

Commit df33fc3

Browse files
authored
Merge pull request #69 from rsgalloway/issue59/evaluate-modifiers
Evaluate modifier refactor
2 parents 47c3b31 + f72c0e1 commit df33fc3

File tree

4 files changed

+190
-109
lines changed

4 files changed

+190
-109
lines changed

lib/envstack/env.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -834,12 +834,12 @@ def resolve_environ(env: Env):
834834
env_keys.update(get_keys_from_env(os.environ))
835835

836836
# decrypt custom nodes
837-
for key, value in env_copy.items():
837+
for key, value in list(env_copy.items()):
838838
if type(value) in custom_node_types:
839839
env_copy[key] = value.resolve(env=env_keys)
840840

841841
# resolve environment variables after decrypting custom nodes
842-
for key, value in env_copy.items():
842+
for key, value in list(env_copy.items()):
843843
value = util.evaluate_modifiers(value, environ=env_copy, parent=included)
844844
resolved[key] = util.safe_eval(value)
845845

lib/envstack/util.py

Lines changed: 123 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@
4545
import yaml
4646

4747
from envstack import config
48-
from envstack.exceptions import CyclicalReference
4948
from envstack.node import AESGCMNode, Base64Node, EncryptedNode, FernetNode
5049

5150
# default memoization cache timeout in seconds
@@ -326,104 +325,155 @@ def get_stack_name(name: str = config.DEFAULT_NAMESPACE):
326325
raise ValueError("Invalid input type. Expected string, tuple, or list.")
327326

328327

329-
def evaluate_modifiers(expression: str, environ: dict = os.environ, parent: dict = {}):
328+
def evaluate_modifiers(
329+
expression: str,
330+
environ: dict = os.environ,
331+
parent: dict = {},
332+
resolving: set = None,
333+
):
330334
"""
331-
Evaluates Bash-like variable expansion modifiers in a string, resolves
332-
custom node types, and evaluates lists and dictionaries.
335+
Evaluates Bash-like variable expansion modifiers.
333336
334-
Supported modifiers:
335-
- values like "world" (no substitution)
336-
- ${VAR} for direct substitution (empty string if unset)
337-
- ${VAR:=default} to set and use a default value if unset
338-
- ${VAR:?error message} to raise an error if the variable is unset
337+
- ${VAR} → empty string if unset
338+
- ${VAR:=default} → if unset/null, set to default and use it
339+
- ${VAR:-default} → if unset/null, use default (do not set)
340+
- ${VAR:?message} → error if unset/null
339341
340-
:param expression: The bash-like string to evaluate.
341-
:param environ: The environment dictionary to use for variable substitution.
342-
:param parent: The parent environment dictionary to use for variable substitution.
343-
:return: The resulting evaluated string with all substitutions applied.
344-
:raises CyclicalReference: If a cyclical reference is detected.
345-
:raises ValueError: If a variable is undefined and has the :? syntax with an
346-
error message.
342+
:param expression: The string to evaluate.
343+
:param environ: The environment variables to use for substitution.
344+
:param parent: Parent environment variables to use for substitution.
345+
:param resolving: A set of currently resolving variables to prevent cycles.
346+
:returns: The evaluated string with variables substituted.
347+
:raises ValueError: If a variable is not set and a message is provided.
347348
"""
349+
if resolving is None:
350+
resolving = set()
348351

349352
def sanitize_value(value):
350-
"""Sanitize the value before returning it."""
351-
# HACK: remove trailing curly braces if they exist
352-
if type(value) is str and value.endswith("}") and not value.startswith("${"):
353+
# sanitize the value to ensure it is a string or a path
354+
if (
355+
isinstance(value, str)
356+
and value.endswith("}")
357+
and not value.startswith("${")
358+
):
353359
return value.rstrip("}")
354-
# sanitize and de-dupe path-like values
355-
elif type(value) is str and detect_path(value):
360+
elif isinstance(value, str) and detect_path(value):
356361
return dedupe_paths(value)
357362
return value
358363

364+
def is_template(s: str) -> bool:
365+
# Check if the string is a template variable like ${VAR}
366+
return isinstance(s, str) and bool(variable_pattern.search(s))
367+
368+
def non_empty(s: str) -> bool:
369+
# non-empty string, not just whitespace
370+
return isinstance(s, str) and s != ""
371+
372+
def is_literal(s: str) -> bool:
373+
# non-empty, does not contain ${...}
374+
return isinstance(s, str) and s != "" and not is_template(s)
375+
359376
def substitute_variable(match):
360-
"""Substitute a variable match with its value."""
377+
# extract variable name, operator, and argument from the match
361378
var_name = match.group(1)
362-
operator = match.group(2)
363-
argument = match.group(3)
364-
parent_value = parent.get(var_name, null)
379+
operator = match.group(2) # '=', '-', '?', or None
380+
argument = match.group(3) # may be None
381+
parent_value = parent.get(var_name, "")
365382
override = os.getenv(var_name, parent_value)
366-
value = str(environ.get(var_name, parent_value))
383+
current = str(
384+
environ.get(var_name, parent_value or "")
385+
) # current value we have
367386
varstr = "${%s}" % var_name
368387

369-
# check for self-referential values, e.g. FOO: ${FOO}
370-
is_recursive = value and varstr in value
371-
if is_recursive and override:
372-
value = value.replace(varstr, override)
373-
else:
374-
value = value.replace(varstr, null)
375-
376-
# ${VAR:=default} or ${VAR:-default}
377-
if operator in ("=", "-"):
378-
# get value from os.environ first
379-
if override:
380-
value = override
381-
# then from the included (parent) environment
382-
elif parent_value:
383-
value = parent_value
384-
# then look for a value in this environment
385-
elif variable_pattern.search(value):
386-
value = evaluate_modifiers(argument, environ, parent)
387-
# finally, use the default value
388+
# cycle / self-reference guard
389+
if var_name in resolving:
390+
if operator in ("=", "-"):
391+
# if os/parent provides a value, that wins over the default
392+
if override:
393+
return str(override)
394+
default = evaluate_modifiers(argument or "", environ, parent, resolving)
395+
if operator == "=":
396+
environ[var_name] = default
397+
return str(default)
398+
elif operator == "?":
399+
msg = argument or f"{var_name} is not set"
400+
raise ValueError(msg)
401+
else: # plain ${VAR}
402+
return str(override or "")
403+
404+
resolving.add(var_name)
405+
try:
406+
# replace literal self-references inside current (best-effort)
407+
if current and varstr in current:
408+
current = current.replace(varstr, override or "")
409+
410+
# defaults branch (:=, :-)
411+
if operator in ("=", "-"):
412+
# 1) explicit env/parent wins if non-empty (bash: considered "set")
413+
if non_empty(override):
414+
return str(override)
415+
416+
# 2) compute the effective value of the current entry
417+
if is_template(current):
418+
eff = evaluate_modifiers(current, environ, parent, resolving)
419+
else:
420+
eff = current or ""
421+
422+
# 3) if effective value is non-empty, do NOT take default
423+
if non_empty(eff):
424+
return str(eff)
425+
426+
# 4) unset or null, use default (and assign for :=)
427+
default = evaluate_modifiers(argument or "", environ, parent, resolving)
428+
if operator == "=":
429+
environ[var_name] = default
430+
return str(default)
431+
432+
# error branch (:?)
433+
elif operator == "?":
434+
if not current:
435+
msg = argument or f"{var_name} is not set"
436+
raise ValueError(msg)
437+
# resolve if it contains vars
438+
return (
439+
evaluate_modifiers(current, environ, parent, resolving)
440+
if variable_pattern.search(current)
441+
else current
442+
)
443+
444+
elif operator is None:
445+
if is_literal(current):
446+
return current # file literal wins
447+
if is_template(current):
448+
return str(evaluate_modifiers(current, environ, parent, resolving))
449+
# current is unset/empty, use parent/os if present, else empty
450+
return str(override or "")
451+
452+
# simple ${VAR}
388453
else:
389-
value = value or argument
390-
391-
# ${VAR:?error message}
392-
elif operator == "?":
393-
if not value:
394-
error_message = argument if argument else f"{var_name} is not set"
395-
raise ValueError(error_message)
396-
397-
# handle recursive references
398-
elif variable_pattern.search(value):
399-
value = evaluate_modifiers(value, environ, parent)
400-
401-
# handle simple ${VAR} substitution
402-
elif operator is None:
403-
value = value or override
454+
value = current or override or ""
455+
if variable_pattern.search(value):
456+
value = evaluate_modifiers(value, environ, parent, resolving)
457+
return str(value)
404458

405-
return str(value)
459+
finally:
460+
resolving.discard(var_name)
406461

407462
try:
408-
# expression must be a string
409-
if type(expression) in (int, float, bool):
463+
if isinstance(expression, (int, float, bool)):
410464
expression = str(expression)
411465

412-
# substitute all matches in the expression
413466
result = variable_pattern.sub(substitute_variable, expression)
414467

415-
# evaluate any remaining modifiers, eg. ${VAR:=${FOO:=bar}}
416468
if variable_pattern.search(result):
417-
result = evaluate_modifiers(result, environ)
469+
result = evaluate_modifiers(result, environ, parent, resolving)
418470

419-
# detect recursion errors, cycles are not errors
420471
except RecursionError:
421-
result = null
422-
# TODO: remove in next version (cycles are not errors)
423-
raise CyclicalReference(f"Cyclical reference detected in {expression}")
472+
# treat cycles as empty string instead of crashing (like bash)
473+
result = ""
424474

425-
# TODO: find a better way to handle TypeErrors
426475
except TypeError:
476+
# node/list/dict handlers
427477
if isinstance(expression, AESGCMNode):
428478
result = expression.resolve(env=environ)
429479
elif isinstance(expression, Base64Node):
@@ -433,10 +483,10 @@ def substitute_variable(match):
433483
elif isinstance(expression, FernetNode):
434484
result = expression.resolve(env=environ)
435485
elif isinstance(expression, list):
436-
result = [(evaluate_modifiers(v, environ)) for v in expression]
486+
result = [evaluate_modifiers(v, environ, parent) for v in expression]
437487
elif isinstance(expression, dict):
438488
result = {
439-
k: (evaluate_modifiers(v, environ)) for k, v in expression.items()
489+
k: evaluate_modifiers(v, environ, parent) for k, v in expression.items()
440490
}
441491
else:
442492
result = expression

0 commit comments

Comments
 (0)