Skip to content

Commit f7d50bf

Browse files
sigJoeseshubawsvicheeyroger-zhanggreedham-aws
authored
Expand parameter overrides (#7876)
* Support file:// for parameter-overrides * Flatten nested lists and normalize parameters * handle empty param files and non-string yaml keys * fix list collapse to comma delimited * handle tuples from cli parameter-overrides * Merge samconfig and cli parameter overrides * Lint * Add unit tests, apply related fixes * Replace |= with .updates() to support older Python * Remove merging samconfig and cli * Fix type hint union * re-add accidental line deletion * Fix json schema and add tests * Update comments and black linting * add protection against infinite recursion * Update tests/unit/cli/test_types.py Co-authored-by: Roger Zhang <roger.zhang.cs@gmail.com> --------- Co-authored-by: seshubaws <116689586+seshubaws@users.noreply.github.com> Co-authored-by: vicheey <181402101+vicheey@users.noreply.github.com> Co-authored-by: Roger Zhang <ruojiazh@amazon.com> Co-authored-by: Roger Zhang <roger.zhang.cs@gmail.com> Co-authored-by: Reed Hamilton <reedham@amazon.com>
1 parent 403392b commit f7d50bf

8 files changed

Lines changed: 325 additions & 41 deletions

File tree

samcli/cli/types.py

Lines changed: 132 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@
66
import logging
77
import re
88
from json import JSONDecodeError
9+
from pathlib import Path
910
from typing import Dict, List, Optional, Union
1011

1112
import click
13+
from ruamel.yaml.comments import CommentedMap, CommentedSeq
1214

15+
from samcli.lib.config.file_manager import FILE_MANAGER_MAPPER
1316
from samcli.lib.package.ecr_utils import is_ecr_url
1417

1518
PARAM_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+
6499
class 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

schema/make_schema.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ class SamCliParameterSchema:
4545
type: Union[str, List[str]]
4646
description: str = ""
4747
default: Optional[Any] = None
48-
items: Optional[str] = None
48+
items: Optional[Union[str, Dict[str, str]]] = None
4949
choices: Optional[Any] = None
5050

5151
def to_schema(self) -> Dict[str, Any]:
@@ -55,7 +55,11 @@ def to_schema(self) -> Dict[str, Any]:
5555
if self.default:
5656
param.update({"default": self.default})
5757
if self.items:
58-
param.update({"items": {"type": self.items}})
58+
if isinstance(self.items, dict):
59+
param.update({"items": self.items})
60+
else:
61+
param.update({"items": {"type": self.items}})
62+
5963
if self.choices:
6064
if isinstance(self.choices, list):
6165
self.choices.sort()
@@ -143,11 +147,18 @@ def format_param(param: click.core.Option) -> SamCliParameterSchema:
143147
formatted_param_types.append(param_name or "string")
144148
formatted_param_types = sorted(list(set(formatted_param_types))) # deduplicate
145149

150+
# Allow nested arrays of objects and strings
151+
parameter_overrides_ref = {"$ref": "#/$defs/parameter_overrides_items"}
152+
146153
formatted_param: SamCliParameterSchema = SamCliParameterSchema(
147154
param.name or "",
148155
formatted_param_types if len(formatted_param_types) > 1 else formatted_param_types[0],
149156
clean_text(param.help or ""),
150-
items="string" if "array" in formatted_param_types else None,
157+
items=(
158+
parameter_overrides_ref
159+
if param.name == "parameter_overrides"
160+
else "string" if "array" in formatted_param_types else None
161+
),
151162
)
152163

153164
if param.default and param.name not in PARAMS_TO_OMIT_DEFAULT_FIELD:
@@ -236,6 +247,26 @@ def generate_schema() -> dict:
236247
}
237248
schema["required"] = ["version"]
238249
schema["additionalProperties"] = False
250+
# Allows objects and strings beneath variably nested arrays
251+
schema["$defs"] = {
252+
"parameter_overrides_items": {
253+
"anyOf": [
254+
{"type": "array", "items": {"$ref": "#/$defs/parameter_overrides_items"}},
255+
{
256+
"type": "object",
257+
"additionalProperties": {
258+
"anyOf": [
259+
{"type": "string"},
260+
{"type": "integer"}, # Allow cooler types if it's a dict
261+
{"type": "boolean"},
262+
]
263+
},
264+
},
265+
{"type": "string"},
266+
]
267+
}
268+
}
269+
239270
# Iterate through packages for command and parameter information
240271
for package_name in _SAM_CLI_COMMAND_PACKAGES:
241272
commands.extend(retrieve_command_structure(package_name))

0 commit comments

Comments
 (0)