66import logging
77import re
88from json import JSONDecodeError
9+ from pathlib import Path
910from typing import Dict , List , Optional , Union
1011
1112import click
13+ from ruamel .yaml .comments import CommentedMap , CommentedSeq
1214
15+ from samcli .lib .config .file_manager import FILE_MANAGER_MAPPER
1316from samcli .lib .package .ecr_utils import is_ecr_url
1417
1518PARAM_AND_METADATA_KEY_REGEX = """([A-Za-z0-9\\ "\' ]+)"""
@@ -61,6 +64,38 @@ def _unquote_wrapped_quotes(value):
6164 return value .replace ("\\ " , " " ).replace ('\\ "' , '"' ).replace ("\\ '" , "'" )
6265
6366
67+ def _flatten_list (data : Union [list , tuple ]) -> list :
68+ """
69+ Recursively flattens nested lists, tuples, and list-like sequences into a single flat list.
70+
71+ This function is the first step in normalizing inputs parsed from
72+ various file formats (e.g., YAML, TOML, JSON), where nested sequences can
73+ appear due to format-specific constructs such as YAML anchors/aliases, explicit
74+ nested arrays in TOML or JSON, or user-defined nested data structures.
75+
76+ By flattening these nested lists/sequences, subsequent processing can
77+ uniformly handle all values as a simple sequential list before further
78+ normalization (e.g., converting all values to strings).
79+
80+ Parameters
81+ ----------
82+ data : list or tuple
83+ List to flatten
84+
85+ Returns
86+ -------
87+ list
88+ A single flat list containing all atomic elements from the nested input, preserving order.
89+ """
90+ flat_data = []
91+ for item in data :
92+ if isinstance (item , (tuple , list , CommentedSeq )):
93+ flat_data .extend (_flatten_list (item ))
94+ else :
95+ flat_data .append (item )
96+ return flat_data
97+
98+
6499class CfnParameterOverridesType (click .ParamType ):
65100 """
66101 Custom Click options type to accept values for CloudFormation template parameters. You can pass values for
@@ -69,6 +104,7 @@ class CfnParameterOverridesType(click.ParamType):
69104
70105 __EXAMPLE_1 = "ParameterKey=KeyPairName,ParameterValue=MyKey ParameterKey=InstanceType,ParameterValue=t1.micro"
71106 __EXAMPLE_2 = "KeyPairName=MyKey InstanceType=t1.micro"
107+ __EXAMPLE_3 = "file://MyParams.yaml"
72108
73109 # Regex that parses CloudFormation parameter key-value pairs:
74110 # https://regex101.com/r/xqfSjW/2
@@ -86,45 +122,110 @@ class CfnParameterOverridesType(click.ParamType):
86122
87123 ordered_pattern_match = [_pattern_1 , _pattern_2 ]
88124
89- name = "string, list"
125+ name = "list,object,string "
90126
91- def convert (self , value , param , ctx ):
92- result = {}
127+ def convert (self , values , param , ctx , seen_files = None ):
128+ """
129+ Takes parameter overrides loaded from various supported config file formats and
130+ flattens and normalizes them into a dictionary where all keys and values are strings.
93131
94- # Empty tuple
95- if value == ( "" ,):
96- return result
132+ The input `values` can be nested lists, dictionaries, or strings representing
133+ parameter overrides, potentially including file references. This method
134+ recursively resolves file inputs, validates formats, and standardizes output.
97135
98- value = (value ,) if isinstance (value , str ) else value
99- for val in value :
100- # Add empty string to start of the string to help match `_pattern2`
101- normalized_val = " " + val .strip ()
136+ Parameters
137+ ----------
138+ values : Any
139+ Input parameter overrides from config files or CLI (could be nested lists, dicts, strings).
140+ param : click.Parameter
141+ Parameter metadata (from Click library), used for error handling.
142+ ctx : click.Context
143+ Click context for error reporting.
144+ seen_files : set
145+ List of files processed in the current execution branch, used to detect infinite recursion
102146
103- try :
104- # NOTE(TheSriram): find the first regex that matched.
105- # pylint is concerned that we are checking at the same `val` within the loop,
106- # but that is the point, so disabling it.
107- pattern = next (
108- i
109- for i in filter (
110- lambda item : re .findall (item , normalized_val ), self .ordered_pattern_match
111- ) # pylint: disable=cell-var-from-loop
112- )
113- except StopIteration :
114- return self .fail (
115- "{} is not in valid format. It must look something like '{}' or '{}'" .format (
116- val , self .__EXAMPLE_1 , self .__EXAMPLE_2
117- ),
147+ Returns
148+ -------
149+ dict
150+ Flattened and normalized parameter overrides as a dictionary of string keys and values.
151+ """
152+ # Handle empty or trivial input cases early
153+ if values in [("" ,), "" , None ] or values == {}:
154+ LOG .debug ("Empty parameter set (%s)" , values )
155+ return {}
156+
157+ if seen_files is None :
158+ seen_files = set ()
159+
160+ LOG .debug ("Input parameters: %s" , values )
161+
162+ # Flatten any nested objects to a simple list for uniform processing
163+ values = _flatten_list ([values ])
164+ LOG .debug ("Flattened parameters: %s" , values )
165+
166+ parameters = {}
167+ for value in values :
168+ if isinstance (value , str ):
169+ # If the string is a file reference (e.g., 'file://params.yaml')
170+ if value .startswith ("file://" ):
171+ file_path = Path (value [7 :])
172+ if not file_path .is_file ():
173+ self .fail (f"{ value } was not found or is a directory" , param , ctx )
174+ file_manager = FILE_MANAGER_MAPPER .get (file_path .suffix , None )
175+ if not file_manager :
176+ self .fail (f"{ value } uses an unsupported extension" , param , ctx )
177+
178+ # Recursively process the file's contents and update parameters
179+ if file_path in seen_files :
180+ self .fail (f"Infinite recursion detected in file references: { file_path } " , param , ctx )
181+ seen_files .add (file_path )
182+ try :
183+ nested_values = file_manager .read (file_path )
184+ parameters .update (self .convert (nested_values , param , ctx , seen_files ))
185+ finally :
186+ seen_files .remove (file_path )
187+ else :
188+ # Legacy parameter matching (e.g. "X=Y" and "ParameterKey=X,ParameterValue=Y")
189+ normalized_value = " " + value .strip ()
190+ for pattern in self .ordered_pattern_match :
191+ groups = re .findall (pattern , normalized_value )
192+ if groups :
193+ parameters .update (groups )
194+ break
195+ else :
196+ self .fail (
197+ f"{ value } is not a valid format. It must look something like "
198+ f"'{ self .__EXAMPLE_1 } ', '{ self .__EXAMPLE_2 } ', or '{ self .__EXAMPLE_3 } '" ,
199+ param ,
200+ ctx ,
201+ )
202+ elif isinstance (value , (dict , CommentedMap )):
203+ # Handle dictionary-like objects (e.g. YAML mappings)
204+ for k , v in value .items ():
205+ if v is None :
206+ # Unset value if previously set
207+ parameters [str (k )] = ""
208+ elif isinstance (v , (list , CommentedSeq )):
209+ # Join list elements into comma-separated string, strip whitespace and ignore empty entries
210+ parameters [str (k )] = "," .join (str (x ).strip () for x in v if x not in (None , "" ))
211+ else :
212+ # Convert other values (numbers, booleans) to strings
213+ parameters [str (k )] = str (v )
214+ elif value is None :
215+ # Ignore empty list items since it means both the key and value are empty
216+ continue
217+ else :
218+ # Fail on unexpected types to avoid silent errors
219+ self .fail (
220+ f"{ value } is invalid in a way the code doesn't expect" ,
118221 param ,
119222 ctx ,
120223 )
121224
122- groups = re .findall (pattern , normalized_val )
123-
124- # 'groups' variable is a list of tuples ex: [(key1, value1), (key2, value2)]
125- for key , param_value in groups :
126- result [_unquote_wrapped_quotes (key )] = _unquote_wrapped_quotes (param_value )
127-
225+ result = {}
226+ for key , param_value in parameters .items ():
227+ result [_unquote_wrapped_quotes (key )] = _unquote_wrapped_quotes (param_value )
228+ LOG .debug ("Converted %d parameters: %s" , len (result ), result )
128229 return result
129230
130231
0 commit comments