Skip to content

Commit c89e2ec

Browse files
sigJoebnusunny
andauthored
More expanded parameter overrides (#8604)
* feat: Add relative path resolution for nested parameter override file includes * feat: Add $include key for dict-based parameter file includes * black linting * rename parent_dir->current_file for clarity * Add $include test for infinite recursion * Add type hint for CfnParameterOverridesType convert() --------- Co-authored-by: Harold Sun <sunhua@amazon.com>
1 parent 39db13e commit c89e2ec

2 files changed

Lines changed: 112 additions & 3 deletions

File tree

samcli/cli/types.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ class CfnParameterOverridesType(click.ParamType):
124124

125125
name = "list,object,string"
126126

127-
def convert(self, values, param, ctx, seen_files=None):
127+
def convert(self, values, param, ctx, seen_files=None, current_file: Optional[Path] = None):
128128
"""
129129
Takes parameter overrides loaded from various supported config file formats and
130130
flattens and normalizes them into a dictionary where all keys and values are strings.
@@ -143,6 +143,8 @@ def convert(self, values, param, ctx, seen_files=None):
143143
Click context for error reporting.
144144
seen_files : set
145145
List of files processed in the current execution branch, used to detect infinite recursion
146+
current_file : Path
147+
Path to the file currently being processed, used to resolve relative paths in nested includes
146148
147149
Returns
148150
-------
@@ -169,6 +171,11 @@ def convert(self, values, param, ctx, seen_files=None):
169171
# If the string is a file reference (e.g., 'file://params.yaml')
170172
if value.startswith("file://"):
171173
file_path = Path(value[7:])
174+
# Resolve relative paths against current file's directory
175+
if not file_path.is_absolute() and current_file:
176+
file_path = current_file.parent / file_path
177+
file_path = file_path.resolve()
178+
172179
if not file_path.is_file():
173180
self.fail(f"{value} was not found or is a directory", param, ctx)
174181
file_manager = FILE_MANAGER_MAPPER.get(file_path.suffix, None)
@@ -181,7 +188,7 @@ def convert(self, values, param, ctx, seen_files=None):
181188
seen_files.add(file_path)
182189
try:
183190
nested_values = file_manager.read(file_path)
184-
parameters.update(self.convert(nested_values, param, ctx, seen_files))
191+
parameters.update(self.convert(nested_values, param, ctx, seen_files, file_path))
185192
finally:
186193
seen_files.remove(file_path)
187194
else:
@@ -205,6 +212,15 @@ def convert(self, values, param, ctx, seen_files=None):
205212
if v is None:
206213
# Unset value if previously set
207214
parameters[str(k)] = ""
215+
elif k == "$include":
216+
# Process includes (can be string or list)
217+
if not isinstance(v, (str, list, CommentedSeq)):
218+
self.fail(
219+
f"$include must be a string or list of strings, got {type(v).__name__}",
220+
param,
221+
ctx,
222+
)
223+
parameters.update(self.convert(v, param, ctx, seen_files, current_file))
208224
elif isinstance(v, (list, CommentedSeq)):
209225
# Join list elements into comma-separated string, strip whitespace and ignore empty entries
210226
parameters[str(k)] = ",".join(str(x).strip() for x in v if x not in (None, ""))

tests/unit/cli/test_types.py

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,18 @@ def test_successful_parsing(self, input, expected):
174174
("file://nested.yaml",),
175175
{"A": "yaml", "B": "yaml", "Toml": "toml", "Yaml": "yaml"},
176176
),
177+
(
178+
("file://nested.toml",),
179+
{"A": "toml", "B": "toml", "Toml": "toml", "Env": "production"},
180+
),
181+
(
182+
("file://nested-list.toml",),
183+
{"A": "yaml", "B": "yaml", "Toml": "toml", "Yaml": "yaml", "Env": "prod"},
184+
),
185+
(
186+
("file://nested-list.yaml",),
187+
{"A": "yaml", "B": "yaml", "Toml": "toml", "Yaml": "yaml", "Env": "prod"},
188+
),
177189
]
178190
)
179191
def test_merge_file_parsing(self, inputs, expected):
@@ -182,6 +194,9 @@ def test_merge_file_parsing(self, inputs, expected):
182194
"params.yaml": "A: yaml\nB: yaml\nYaml: yaml",
183195
"list.yaml": "- - - - - - A: a\n- List:\n - 1\n - 2\n - 3\n",
184196
"nested.yaml": "- file://params.toml\n- file://params.yaml",
197+
"nested.toml": "'$include' = 'file://params.toml'\nEnv = 'production'",
198+
"nested-list.toml": "'$include' = ['file://params.toml', 'file://params.yaml']\nEnv = 'prod'",
199+
"nested-list.yaml": "$include:\n - file://params.toml\n - file://params.yaml\nEnv: prod",
185200
}
186201

187202
def mock_read_text(file_path):
@@ -196,6 +211,27 @@ def mock_is_file(file_path):
196211
print(result)
197212
self.assertEqual(result, expected, msg="Failed with Input = " + str(inputs))
198213

214+
def test_include_infinite_recursion_protection(self):
215+
mock_files = {
216+
"A.yaml": "$include: file://B.yaml",
217+
"B.yaml": "$include: file://C.yaml",
218+
"C.yaml": "$include: file://A.yaml",
219+
}
220+
221+
def mock_read_text(file_path):
222+
file_name = file_path.name
223+
return mock_files.get(file_name, "")
224+
225+
def mock_is_file(file_path):
226+
return file_path.name in mock_files
227+
228+
with self.assertRaises(BadParameter) as exception, patch("pathlib.Path.is_file", new=mock_is_file), patch(
229+
"pathlib.Path.read_text", new=mock_read_text
230+
):
231+
self.param_type.convert("file://A.yaml", None, MagicMock())
232+
233+
self.assertIn("Infinite recursion detected in file references", str(exception.exception))
234+
199235
def test_infinite_recursion_protection(self):
200236
mock_files = {
201237
"A.yaml": "- file://B.yaml",
@@ -213,10 +249,67 @@ def mock_is_file(file_path):
213249
with self.assertRaises(BadParameter) as exception, patch("pathlib.Path.is_file", new=mock_is_file), patch(
214250
"pathlib.Path.read_text", new=mock_read_text
215251
):
216-
self.param_type.convert(f"file://A.yaml", None, MagicMock())
252+
self.param_type.convert("file://A.yaml", None, MagicMock())
217253

218254
self.assertIn("Infinite recursion detected in file references", str(exception.exception))
219255

256+
@parameterized.expand(
257+
[
258+
(
259+
{
260+
"config/default.yaml": "Base: default\nEnv: dev",
261+
"config/prod.yaml": "- file://default.yaml\n- Env: production\n- Region: us-east-1",
262+
},
263+
"config/prod.yaml",
264+
{"Base": "default", "Env": "production", "Region": "us-east-1"},
265+
),
266+
(
267+
{
268+
"config/shared/common.yaml": "Common: shared",
269+
"config/prod.yaml": "- file://shared/common.yaml\n- Env: prod",
270+
},
271+
"config/prod.yaml",
272+
{"Common": "shared", "Env": "prod"},
273+
),
274+
]
275+
)
276+
def test_relative_path_nested_includes(self, file_contents, entry_file, expected):
277+
"""Test that nested files can reference other files using relative paths"""
278+
from pathlib import Path
279+
import tempfile
280+
281+
with tempfile.TemporaryDirectory() as temp:
282+
temp_path = Path(temp)
283+
284+
# Create all files
285+
for file_path, content in file_contents.items():
286+
full_path = temp_path / file_path
287+
full_path.parent.mkdir(parents=True, exist_ok=True)
288+
full_path.write_text(content)
289+
290+
entry_path = temp_path / entry_file
291+
result = self.param_type.convert(f"file://{entry_path}", None, MagicMock())
292+
self.assertEqual(result, expected)
293+
294+
def test_include_key_invalid_type(self):
295+
"""Test that $include with invalid type fails with clear error"""
296+
mock_files = {
297+
"invalid.yaml": "$include: 123\nEnv: prod",
298+
}
299+
300+
def mock_read_text(file_path):
301+
return mock_files.get(file_path.name, "")
302+
303+
def mock_is_file(file_path):
304+
return file_path.name in mock_files
305+
306+
with self.assertRaises(BadParameter) as exception, patch("pathlib.Path.is_file", new=mock_is_file), patch(
307+
"pathlib.Path.read_text", new=mock_read_text
308+
):
309+
self.param_type.convert("file://invalid.yaml", None, MagicMock())
310+
311+
self.assertIn("$include must be a string or list of strings", str(exception.exception))
312+
220313

221314
class TestCfnMetadataType(TestCase):
222315
def setUp(self):

0 commit comments

Comments
 (0)