4545import yaml
4646
4747from envstack import config
48- from envstack .exceptions import CyclicalReference
4948from 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