From 3b5fbbbed23fdfc888bd3755dc0b0d9e303b3b9e Mon Sep 17 00:00:00 2001 From: Vichym Date: Mon, 3 Nov 2025 17:25:50 -0800 Subject: [PATCH 1/7] feat: allow explicit listing of function ids for local start-lambda command --- .../local/cli_common/invoke_context.py | 73 ++++++- .../local/cli_common/user_exceptions.py | 6 + samcli/commands/local/start_lambda/cli.py | 5 + samcli/lib/providers/sam_function_provider.py | 56 ++++- .../start_lambda_api_integ_base.py | 7 + .../local/start_lambda/test_start_lambda.py | 160 ++++++++++++++- .../local/cli_common/test_invoke_context.py | 191 +++++++++++++++++- .../local/lib/test_sam_function_provider.py | 170 +++++++++++++++- .../commands/local/start_lambda/test_cli.py | 2 + .../unit/commands/samconfig/test_samconfig.py | 3 + 10 files changed, 659 insertions(+), 14 deletions(-) diff --git a/samcli/commands/local/cli_common/invoke_context.py b/samcli/commands/local/cli_common/invoke_context.py index 13677ecaa53..6d27d43cf89 100644 --- a/samcli/commands/local/cli_common/invoke_context.py +++ b/samcli/commands/local/cli_common/invoke_context.py @@ -98,6 +98,7 @@ def __init__( invoke_images: Optional[str] = None, mount_symlinks: Optional[bool] = False, no_mem_limit: Optional[bool] = False, + function_logical_ids: Optional[Tuple[str, ...]] = None, ) -> None: """ Initialize the context @@ -107,7 +108,7 @@ def __init__( template_file str Name or path to template function_identifier str - Identifier of the function to invoke + Identifier of the single function to invoke (used by 'sam local invoke' command) env_vars_file str Path to a file containing values for environment variables docker_volume_basedir str @@ -154,10 +155,27 @@ def __init__( Optional. A dictionary that defines the custom invoke image URI of each function mount_symlinks bool Optional. Indicates if symlinks should be mounted inside the container + function_logical_ids tuple(str) + Optional. Tuple of function logical IDs to filter and make available for local execution. + Used by 'sam local start-api' and 'sam local start-lambda' commands to limit which + functions from the template are exposed. If not provided, all functions are available. """ self._template_file = template_file self._function_identifier = function_identifier + self._function_logical_ids = function_logical_ids + + # Validate that function_identifier and function_logical_ids aren't both provided + # function_identifier is for 'sam local invoke' (single function) + # function_logical_ids is for 'sam local start-*' commands (multiple function filter) + if self._function_identifier and self._function_logical_ids: + LOG.warning( + "Both function_identifier and function_logical_ids were provided. " + "function_identifier is used for 'sam local invoke' to specify a single function, " + "while function_logical_ids is used for 'sam local start-api/start-lambda' to filter functions. " + "function_identifier will take precedence for single function invocation." + ) + self._env_vars_file = env_vars_file self._docker_volume_basedir = docker_volume_basedir self._docker_network = docker_network @@ -241,10 +259,18 @@ def __enter__(self) -> "InvokeContext": if self._docker_volume_basedir: _function_providers_args[self._containers_mode].append(True) + # Add function_logical_ids parameter to both provider types + _function_providers_kwargs: Dict[str, Any] = {} + if self._function_logical_ids is not None: + _function_providers_kwargs["function_logical_ids"] = self._function_logical_ids + self._function_provider = _function_providers_class[self._containers_mode]( - *_function_providers_args[self._containers_mode] + *_function_providers_args[self._containers_mode], **_function_providers_kwargs ) + # Validate function logical IDs after provider is initialized + self._validate_function_logical_ids() + self._env_vars_value = self._get_env_vars_value(self._env_vars_file) self._container_env_vars_value = self._get_env_vars_value(self._container_env_vars_file) self._log_file_handle = self._setup_log_file(self._log_file) @@ -448,6 +474,34 @@ def _clean_running_containers_and_related_resources(self) -> None: cast(WarmLambdaRuntime, self.lambda_runtime).clean_running_containers_and_related_resources() cast(RefreshableSamFunctionProvider, self._function_provider).stop_observer() + def _validate_function_logical_ids(self) -> None: + """ + Validates that all provided function logical IDs exist in the template. + Raises InvalidFunctionNamesException with helpful error message if validation fails. + """ + if not self._function_logical_ids: + return # No filtering requested + + # Get all available function names/IDs from the provider + all_functions_set = set() + for func in self._function_provider.get_all(): + all_functions_set.add(func.name) + all_functions_set.add(func.function_id) + if func.functionname: + all_functions_set.add(func.functionname) + + # Check for invalid IDs + invalid_ids = set(self._function_logical_ids) - all_functions_set + + if invalid_ids: + available_ids = sorted(all_functions_set) + from samcli.commands.local.cli_common.user_exceptions import InvalidFunctionNamesException + + raise InvalidFunctionNamesException( + f"Invalid function logical ID(s): {', '.join(sorted(invalid_ids))}\n\n" + f"Available functions in template:\n " + "\n ".join(available_ids) + ) + def _add_account_id_to_global(self) -> None: """ Attempts to get the Account ID from the current session @@ -556,6 +610,21 @@ def initialized_container_ids(self) -> List[str]: """ return self._initialized_container_ids + @property + def function_logical_ids(self) -> Optional[Tuple[str, ...]]: + """ + Returns the tuple of function logical IDs to filter, if provided. + + This is used by 'sam local start-lambda' commands + to limit which functions from the template are made available for local execution. + This is different from function_identifier which is used by 'sam local invoke' + to specify a single function to invoke. + + Returns: + Optional[Tuple[str, ...]]: Tuple of function logical IDs or None if no filter + """ + return self._function_logical_ids + @property def stdout(self) -> StreamWriter: """ diff --git a/samcli/commands/local/cli_common/user_exceptions.py b/samcli/commands/local/cli_common/user_exceptions.py index 3ba5c2df1d4..cf6bd3b1e2c 100644 --- a/samcli/commands/local/cli_common/user_exceptions.py +++ b/samcli/commands/local/cli_common/user_exceptions.py @@ -11,6 +11,12 @@ class InvokeContextException(UserException): """ +class InvalidFunctionNamesException(InvokeContextException): + """ + User provided function names that don't exist in the template + """ + + class InvalidSamTemplateException(UserException): """ The template provided was invalid and not able to transform into a Standard CloudFormation Template diff --git a/samcli/commands/local/start_lambda/cli.py b/samcli/commands/local/start_lambda/cli.py index f4df9156a00..c82540e28cc 100644 --- a/samcli/commands/local/start_lambda/cli.py +++ b/samcli/commands/local/start_lambda/cli.py @@ -52,6 +52,7 @@ requires_credentials=False, context_settings={"max_content_width": 120}, ) +@click.argument("function_logical_ids", nargs=-1, required=False) @configuration_option(provider=ConfigProvider(section="parameters")) @terraform_plan_file_option @hook_name_click_option( @@ -71,6 +72,7 @@ @print_cmdline_args def cli( ctx, # pylint: disable=R0914 + function_logical_ids, # start-lambda Specific Options host, port, @@ -110,6 +112,7 @@ def cli( do_cli( ctx, + function_logical_ids, host, port, template_file, @@ -139,6 +142,7 @@ def cli( def do_cli( # pylint: disable=R0914 ctx, + function_logical_ids, host, port, template, @@ -208,6 +212,7 @@ def do_cli( # pylint: disable=R0914 container_host_interface=container_host_interface, add_host=add_host, invoke_images=processed_invoke_images, + function_logical_ids=function_logical_ids, no_mem_limit=no_mem_limit, ) as invoke_context: service = LocalLambdaService(lambda_invoke_context=invoke_context, port=port, host=host) diff --git a/samcli/lib/providers/sam_function_provider.py b/samcli/lib/providers/sam_function_provider.py index 5787fb409f8..529a060e3e8 100644 --- a/samcli/lib/providers/sam_function_provider.py +++ b/samcli/lib/providers/sam_function_provider.py @@ -4,7 +4,7 @@ import logging from pathlib import Path -from typing import Any, Dict, Iterator, List, Optional, cast +from typing import Any, Dict, Iterator, List, Optional, Tuple, cast from samtranslator.policy_template_processor.exceptions import TemplateNotFoundException @@ -45,6 +45,7 @@ def __init__( use_raw_codeuri: bool = False, ignore_code_extraction_warnings: bool = False, locate_layer_nested: bool = False, + function_logical_ids: Optional[Tuple[str, ...]] = None, ) -> None: """ Initialize the class with SAM template data. The SAM template passed to this provider is assumed @@ -61,16 +62,18 @@ def __init__( Note(xinhol): use_raw_codeuri is temporary to fix a bug, and will be removed for a permanent solution. :param bool ignore_code_extraction_warnings: Ignores Log warnings :param bool locate_layer_nested: resolved nested layer reference to their actual location in the nested stack + :param tuple function_logical_ids: Optional tuple of function logical IDs to filter by """ self._stacks = stacks + self._function_logical_ids = function_logical_ids for stack in stacks: LOG.debug("%d resources found in the stack %s", len(stack.resources), stack.stack_path) # Store a map of function full_path to function information for quick reference self.functions = SamFunctionProvider._extract_functions( - self._stacks, use_raw_codeuri, ignore_code_extraction_warnings, locate_layer_nested + self._stacks, use_raw_codeuri, ignore_code_extraction_warnings, locate_layer_nested, function_logical_ids ) self._colored = Colored() @@ -90,6 +93,7 @@ def update( use_raw_codeuri: bool = False, ignore_code_extraction_warnings: bool = False, locate_layer_nested: bool = False, + function_logical_ids: Optional[Tuple[str, ...]] = None, ) -> None: """ Hydrate the function provider with updated stacks @@ -98,10 +102,11 @@ def update( Note(xinhol): use_raw_codeuri is temporary to fix a bug, and will be removed for a permanent solution. :param bool ignore_code_extraction_warnings: Ignores Log warnings :param bool locate_layer_nested: resolved nested layer reference to their actual location in the nested stack + :param tuple function_logical_ids: Optional tuple of function logical IDs to filter by """ self._stacks = stacks self.functions = SamFunctionProvider._extract_functions( - self._stacks, use_raw_codeuri, ignore_code_extraction_warnings, locate_layer_nested + self._stacks, use_raw_codeuri, ignore_code_extraction_warnings, locate_layer_nested, function_logical_ids ) def get(self, name: str) -> Optional[Function]: @@ -180,12 +185,29 @@ def get_all(self) -> Iterator[Function]: for _, function in self.functions.items(): yield function + @staticmethod + def _should_include_function(function: Function, function_logical_ids: set) -> bool: + """ + Determines if a function should be included based on the filter. + Matches against function_id, name, and functionname. + + :param function: Function object to check + :param function_logical_ids: Set of function logical IDs to match against + :return bool: True if function should be included, False otherwise + """ + return bool( + function.function_id in function_logical_ids + or function.name in function_logical_ids + or (function.functionname and function.functionname in function_logical_ids) + ) + @staticmethod def _extract_functions( stacks: List[Stack], use_raw_codeuri: bool = False, ignore_code_extraction_warnings: bool = False, locate_layer_nested: bool = False, + function_logical_ids: Optional[Tuple[str, ...]] = None, ) -> Dict[str, Function]: """ Extracts and returns function information from the given dictionary of SAM/CloudFormation resources. This @@ -195,6 +217,7 @@ def _extract_functions( :param bool use_raw_codeuri: Do not resolve adjust core_uri based on the template path, use the raw uri. :param bool ignore_code_extraction_warnings: suppress log statements on code extraction from resources. :param bool locate_layer_nested: resolved nested layer reference to their actual location in the nested stack + :param tuple function_logical_ids: Optional tuple of function logical IDs to filter by :return dict(string : samcli.commands.local.lib.provider.Function): Dictionary of function full_path to the Function configuration object """ @@ -271,6 +294,17 @@ def _extract_functions( # We don't care about other resource types. Just ignore them + # Apply filtering if function_logical_ids provided + if function_logical_ids: + function_logical_ids_set = set(function_logical_ids) + filtered_functions = {} + + for full_path, function in result.items(): + if SamFunctionProvider._should_include_function(function, function_logical_ids_set): + filtered_functions[full_path] = function + + return filtered_functions + return result @staticmethod @@ -800,6 +834,7 @@ def __init__( global_parameter_overrides: Optional[Dict] = None, use_raw_codeuri: bool = False, ignore_code_extraction_warnings: bool = False, + function_logical_ids: Optional[Tuple[str, ...]] = None, ) -> None: """ Initialize the class with SAM template data. The SAM template passed to this provider is assumed @@ -815,9 +850,15 @@ def __init__( :param bool use_raw_codeuri: Do not resolve adjust core_uri based on the template path, use the raw uri. Note(xinhol): use_raw_codeuri is temporary to fix a bug, and will be removed for a permanent solution. :param bool ignore_code_extraction_warnings: Ignores Log warnings + :param tuple function_logical_ids: Optional tuple of function logical IDs to filter by """ - super().__init__(stacks, use_raw_codeuri, ignore_code_extraction_warnings) + # Store function_logical_ids before calling super().__init__ + self._function_logical_ids = function_logical_ids + + super().__init__( + stacks, use_raw_codeuri, ignore_code_extraction_warnings, function_logical_ids=function_logical_ids + ) # initialize root templates. Usually it will be one template, as sam commands only support processing # one template. These templates will be fixed. @@ -909,6 +950,7 @@ def _watch_stack_templates(self, stacks: List[Stack]) -> None: def _refresh_loaded_functions(self) -> None: """ Reload the stacks, and lambda functions from template files. + Applies the same function filter during refresh. """ LOG.debug("A change got detected in one of the stack templates. Reload the lambda function resources") self._stacks = [] @@ -925,8 +967,12 @@ def _refresh_loaded_functions(self) -> None: raise ex self.is_changed = False + # Pass function_logical_ids to maintain filter during refresh self.functions = self._extract_functions( - self._stacks, self._use_raw_codeuri, self._ignore_code_extraction_warnings + self._stacks, + self._use_raw_codeuri, + self._ignore_code_extraction_warnings, + function_logical_ids=self._function_logical_ids, ) self._watch_stack_templates(self._stacks) diff --git a/tests/integration/local/start_lambda/start_lambda_api_integ_base.py b/tests/integration/local/start_lambda/start_lambda_api_integ_base.py index 33cf2ee512c..517292d4d12 100644 --- a/tests/integration/local/start_lambda/start_lambda_api_integ_base.py +++ b/tests/integration/local/start_lambda/start_lambda_api_integ_base.py @@ -37,6 +37,7 @@ class StartLambdaIntegBaseClass(TestCase): terraform_plan_file: Optional[str] = None beta_features: Optional[bool] = None collect_start_lambda_process_output: bool = False + function_logical_ids: Optional[List[str]] = None build_before_invoke = False build_overrides: Optional[Dict[str, str]] = None @@ -114,9 +115,14 @@ def get_start_lambda_command( hook_name=None, beta_features=None, terraform_plan_file=None, + function_logical_ids=None, ): command_list = [get_sam_command(), "local", "start-lambda"] + # Add function names as positional arguments first + if function_logical_ids: + command_list.extend(function_logical_ids) + if port: command_list += ["-p", port] @@ -159,6 +165,7 @@ def start_lambda(cls, wait_time=5, input=None, env=None): hook_name=cls.hook_name, beta_features=cls.beta_features, terraform_plan_file=cls.terraform_plan_file, + function_logical_ids=cls.function_logical_ids, ) # Container labels are no longer needed - container IDs are parsed from output diff --git a/tests/integration/local/start_lambda/test_start_lambda.py b/tests/integration/local/start_lambda/test_start_lambda.py index b25bd45a10b..86e17ac6042 100644 --- a/tests/integration/local/start_lambda/test_start_lambda.py +++ b/tests/integration/local/start_lambda/test_start_lambda.py @@ -1,10 +1,12 @@ import signal -from unittest import skipIf +from unittest import skipIf, TestCase import uuid from concurrent.futures import ThreadPoolExecutor, as_completed from time import time, sleep import json from parameterized import parameterized, parameterized_class +from subprocess import Popen, PIPE +from pathlib import Path import pytest import random @@ -13,9 +15,11 @@ from botocore import UNSIGNED from botocore.config import Config from botocore.exceptions import ClientError +from docker.errors import APIError from samcli.commands.local.cli_common.invoke_context import ContainersInitializationMode -from tests.testing_utils import IS_WINDOWS +from tests.testing_utils import IS_WINDOWS, get_sam_command, kill_process +from tests.integration.local.common_utils import random_port, InvalidAddressException, wait_for_local_process from .start_lambda_api_integ_base import StartLambdaIntegBaseClass, WatchWarmContainersIntegBaseClass @@ -1737,3 +1741,155 @@ def test_invoke_with_data_custom_invoke_images(self): self.assertEqual(response.get("Payload").read().decode("utf-8"), '"This is json data"') self.assertIsNone(response.get("FunctionError")) self.assertEqual(response.get("StatusCode"), 200) + + +class TestFunctionNameFiltering(StartLambdaIntegBaseClass): + """Test function name filtering feature for start-lambda command""" + + template_path = "/testdata/invoke/template.yml" + + def setUp(self): + self.url = f"http://127.0.0.1:{self.port}" + self.lambda_client = boto3.client( + "lambda", + endpoint_url=self.url, + region_name="us-east-1", + use_ssl=False, + verify=False, + config=Config(signature_version=UNSIGNED, read_timeout=120, retries={"max_attempts": 0}), + ) + + @pytest.mark.flaky(reruns=3) + @pytest.mark.timeout(timeout=300, method="thread") + def test_invoke_function_success(self): + """Test invoking a function succeeds""" + response = self.lambda_client.invoke(FunctionName="EchoEventFunction", Payload='"test data"') + self.assertEqual(response.get("StatusCode"), 200) + self.assertEqual(response.get("Payload").read().decode("utf-8"), '"test data"') + self.assertIsNone(response.get("FunctionError")) + + @parameterized.expand([("EchoEventFunction",), ("HelloWorldServerlessFunction",), ("RaiseExceptionFunction",)]) + @pytest.mark.flaky(reruns=3) + @pytest.mark.timeout(timeout=300, method="thread") + def test_backward_compatibility_no_filter(self, function_name): + """Test that without function names, all functions are available""" + response = self.lambda_client.invoke(FunctionName=function_name) + self.assertEqual(response.get("StatusCode"), 200) + + +class TestFunctionNameFilteringWithFilter(StartLambdaIntegBaseClass): + """Test function name filtering with specific functions""" + + template_path = "/testdata/invoke/template.yml" + function_logical_ids = ["EchoEventFunction", "HelloWorldServerlessFunction"] + + def setUp(self): + self.url = f"http://127.0.0.1:{self.port}" + self.lambda_client = boto3.client( + "lambda", + endpoint_url=self.url, + region_name="us-east-1", + use_ssl=False, + verify=False, + config=Config(signature_version=UNSIGNED, read_timeout=120, retries={"max_attempts": 0}), + ) + + @pytest.mark.flaky(reruns=3) + @pytest.mark.timeout(timeout=300, method="thread") + def test_invoke_filtered_function(self): + """Test invoking a function that is in the filter""" + response = self.lambda_client.invoke(FunctionName="EchoEventFunction", Payload='"filtered test"') + self.assertEqual(response.get("StatusCode"), 200) + self.assertEqual(response.get("Payload").read().decode("utf-8"), '"filtered test"') + self.assertIsNone(response.get("FunctionError")) + + @pytest.mark.flaky(reruns=3) + @pytest.mark.timeout(timeout=300, method="thread") + def test_invoke_non_filtered_function(self): + """Test invoking a function that is NOT in the filter returns ResourceNotFoundException""" + with self.assertRaises(ClientError) as context: + self.lambda_client.invoke(FunctionName="RaiseExceptionFunction") + self.assertIn("ResourceNotFoundException", str(context.exception)) + + +class TestFunctionNameFilteringWarmContainersEager(TestWarmContainersBaseClass): + """Test function filtering with EAGER warm containers""" + + template_path = "/testdata/start_api/template-warm-containers.yaml" + container_mode = ContainersInitializationMode.EAGER.value + mode_env_variable = str(uuid.uuid4()) + parameter_overrides = {"ModeEnvVariable": mode_env_variable} + function_logical_ids = ["HelloWorldFunction"] + + @pytest.mark.flaky(reruns=3) + @pytest.mark.timeout(timeout=600, method="thread") + def test_only_filtered_functions_have_containers(self): + """Test that only filtered functions have containers pre-warmed in EAGER mode""" + self.assertEqual(self.count_running_containers(), 1) + + @pytest.mark.flaky(reruns=3) + @pytest.mark.timeout(timeout=600, method="thread") + def test_invoke_filtered_function_with_eager_containers(self): + """Test invoking filtered function with EAGER warm containers""" + result = self.lambda_client.invoke(FunctionName="HelloWorldFunction") + self.assertEqual(result.get("StatusCode"), 200) + response = json.loads(result.get("Payload").read().decode("utf-8")) + self.assertEqual(response.get("statusCode"), 200) + self.assertEqual(json.loads(response.get("body")), {"hello": "world"}) + + +class TestFunctionNameFilteringWarmContainersLazy(TestWarmContainersBaseClass): + """Test function filtering with LAZY warm containers""" + + template_path = "/testdata/start_api/template-warm-containers.yaml" + container_mode = ContainersInitializationMode.LAZY.value + mode_env_variable = str(uuid.uuid4()) + parameter_overrides = {"ModeEnvVariable": mode_env_variable} + function_logical_ids = ["HelloWorldFunction"] + + @pytest.mark.flaky(reruns=3) + @pytest.mark.timeout(timeout=600, method="thread") + def test_no_containers_before_invoke_with_lazy(self): + """Test that no containers are initialized before invocation in LAZY mode""" + self.assertEqual(self.count_running_containers(), 0) + + @pytest.mark.flaky(reruns=3) + @pytest.mark.timeout(timeout=600, method="thread") + def test_container_created_on_demand_for_filtered_function(self): + """Test that container is created on-demand for filtered function in LAZY mode""" + self.assertEqual(self.count_running_containers(), 0) + result = self.lambda_client.invoke(FunctionName="HelloWorldFunction") + self.assertEqual(result.get("StatusCode"), 200) + self.assertEqual(self.count_running_containers(), 1) + + +class TestFunctionNameFilteringInvalidNames(TestCase): + """Test error handling for invalid function names""" + + integration_dir = str(Path(__file__).resolve().parents[2]) + template_path = "/testdata/invoke/template.yml" + + @pytest.mark.flaky(reruns=3) + @pytest.mark.timeout(timeout=300, method="thread") + def test_invalid_function_names_error(self): + """Test that invalid function names produce helpful error message""" + template = self.integration_dir + self.template_path + command_list = [ + get_sam_command(), + "local", + "start-lambda", + "InvalidFunction1", + "InvalidFunction2", + "-p", + str(random_port()), + "-t", + template, + ] + + process = Popen(command_list, stderr=PIPE, stdout=PIPE, cwd=str(Path(template).resolve().parents[0])) + stdout, stderr = process.communicate(timeout=30) + + self.assertNotEqual(process.returncode, 0) + error_output = stderr.decode("utf-8") + for expected in ["Invalid function logical ID", "InvalidFunction1", "InvalidFunction2", "Available functions"]: + self.assertIn(expected, error_output) diff --git a/tests/unit/commands/local/cli_common/test_invoke_context.py b/tests/unit/commands/local/cli_common/test_invoke_context.py index bb9b182bb21..8941be63b4a 100644 --- a/tests/unit/commands/local/cli_common/test_invoke_context.py +++ b/tests/unit/commands/local/cli_common/test_invoke_context.py @@ -520,7 +520,7 @@ def test_docker_volume_basedir_set_use_raw_codeuri( invoke_context.__enter__() - extract_func_mock.assert_called_with([], expected, False, False) + extract_func_mock.assert_called_with([], expected, False, False, None) class TestInvokeContext__exit__(TestCase): @@ -1457,3 +1457,192 @@ def test_must_work_with_token(self, get_boto_client_provider_with_config_mock): invoke_context = InvokeContext("template_file") invoke_context._add_account_id_to_global() self.assertEqual(invoke_context._global_parameter_overrides.get("AWS::AccountId"), "210987654321") + + +class TestInvokeContext_validate_function_logical_ids(TestCase): + """Tests for _validate_function_logical_ids method""" + + def test_validation_with_valid_names(self): + """Test that no exception is raised when all function names are valid""" + # Create mock functions + func1 = Mock() + func1.name = "Function1" + func1.function_id = "Function1" + func1.functionname = None + + func2 = Mock() + func2.name = "Function2" + func2.function_id = "Function2" + func2.functionname = "MyFunction2" + + # Create InvokeContext with valid function names + invoke_context = InvokeContext(template_file="template_file", function_logical_ids=("Function1", "MyFunction2")) + + # Mock the function provider + function_provider_mock = Mock() + function_provider_mock.get_all.return_value = [func1, func2] + invoke_context._function_provider = function_provider_mock + + # Should not raise any exception + invoke_context._validate_function_logical_ids() + + def test_validation_with_invalid_names(self): + """Test that InvalidFunctionNamesException is raised with invalid names""" + from samcli.commands.local.cli_common.user_exceptions import InvalidFunctionNamesException + + # Create mock functions + func1 = Mock() + func1.name = "Function1" + func1.function_id = "Function1" + func1.functionname = None + + func2 = Mock() + func2.name = "Function2" + func2.function_id = "Function2" + func2.functionname = None + + # Create InvokeContext with invalid function names + invoke_context = InvokeContext( + template_file="template_file", function_logical_ids=("InvalidFunction", "AnotherInvalid") + ) + + # Mock the function provider + function_provider_mock = Mock() + function_provider_mock.get_all.return_value = [func1, func2] + invoke_context._function_provider = function_provider_mock + + # Should raise InvalidFunctionNamesException + with self.assertRaises(InvalidFunctionNamesException) as ex_ctx: + invoke_context._validate_function_logical_ids() + + error_message = str(ex_ctx.exception) + # Verify error message includes invalid names + self.assertIn("InvalidFunction", error_message) + self.assertIn("AnotherInvalid", error_message) + # Verify error message includes available functions + self.assertIn("Function1", error_message) + self.assertIn("Function2", error_message) + self.assertIn("Available functions in template:", error_message) + + def test_validation_with_mixed_valid_invalid_names(self): + """Test that all invalid names are reported when some are valid and some are invalid""" + from samcli.commands.local.cli_common.user_exceptions import InvalidFunctionNamesException + + # Create mock functions + func1 = Mock() + func1.name = "Function1" + func1.function_id = "Function1" + func1.functionname = None + + func2 = Mock() + func2.name = "Function2" + func2.function_id = "Function2" + func2.functionname = None + + # Create InvokeContext with mixed valid/invalid function names + invoke_context = InvokeContext( + template_file="template_file", function_logical_ids=("Function1", "InvalidFunc", "AnotherInvalid") + ) + + # Mock the function provider + function_provider_mock = Mock() + function_provider_mock.get_all.return_value = [func1, func2] + invoke_context._function_provider = function_provider_mock + + # Should raise InvalidFunctionNamesException + with self.assertRaises(InvalidFunctionNamesException) as ex_ctx: + invoke_context._validate_function_logical_ids() + + error_message = str(ex_ctx.exception) + # Verify all invalid names are reported + self.assertIn("InvalidFunc", error_message) + self.assertIn("AnotherInvalid", error_message) + # Verify valid name is not in error message as invalid + self.assertNotIn("Function1", error_message.split("Available functions")[0]) + + def test_validation_with_no_filter(self): + """Test that no validation occurs when function_logical_ids is None""" + # Create InvokeContext without function_logical_ids + invoke_context = InvokeContext(template_file="template_file", function_logical_ids=None) + + # Mock the function provider (should not be called) + function_provider_mock = Mock() + invoke_context._function_provider = function_provider_mock + + # Should not raise any exception and should not call get_all + invoke_context._validate_function_logical_ids() + + # Verify get_all was not called since no validation needed + function_provider_mock.get_all.assert_not_called() + + def test_validation_with_empty_tuple(self): + """Test that no validation occurs when function_logical_ids is empty tuple""" + # Create InvokeContext with empty tuple + invoke_context = InvokeContext(template_file="template_file", function_logical_ids=()) + + # Mock the function provider (should not be called) + function_provider_mock = Mock() + invoke_context._function_provider = function_provider_mock + + # Should not raise any exception and should not call get_all + invoke_context._validate_function_logical_ids() + + # Verify get_all was not called since no validation needed + function_provider_mock.get_all.assert_not_called() + + def test_validation_matches_function_id(self): + """Test that validation matches against function_id""" + # Create mock function + func = Mock() + func.name = "MyFunction" + func.function_id = "FunctionLogicalId" + func.functionname = None + + # Create InvokeContext using function_id + invoke_context = InvokeContext(template_file="template_file", function_logical_ids=("FunctionLogicalId",)) + + # Mock the function provider + function_provider_mock = Mock() + function_provider_mock.get_all.return_value = [func] + invoke_context._function_provider = function_provider_mock + + # Should not raise any exception + invoke_context._validate_function_logical_ids() + + def test_validation_matches_name(self): + """Test that validation matches against name""" + # Create mock function + func = Mock() + func.name = "MyFunction" + func.function_id = "FunctionLogicalId" + func.functionname = None + + # Create InvokeContext using name + invoke_context = InvokeContext(template_file="template_file", function_logical_ids=("MyFunction",)) + + # Mock the function provider + function_provider_mock = Mock() + function_provider_mock.get_all.return_value = [func] + invoke_context._function_provider = function_provider_mock + + # Should not raise any exception + invoke_context._validate_function_logical_ids() + + def test_validation_matches_functionname(self): + """Test that validation matches against functionname property""" + # Create mock function + func = Mock() + func.name = "MyFunction" + func.function_id = "FunctionLogicalId" + func.functionname = "CustomFunctionName" + + # Create InvokeContext using functionname + invoke_context = InvokeContext(template_file="template_file", function_logical_ids=("CustomFunctionName",)) + + # Mock the function provider + function_provider_mock = Mock() + function_provider_mock.get_all.return_value = [func] + invoke_context._function_provider = function_provider_mock + + # Should not raise any exception + invoke_context._validate_function_logical_ids() diff --git a/tests/unit/commands/local/lib/test_sam_function_provider.py b/tests/unit/commands/local/lib/test_sam_function_provider.py index 1fc3d28a3b7..0173817b6f7 100644 --- a/tests/unit/commands/local/lib/test_sam_function_provider.py +++ b/tests/unit/commands/local/lib/test_sam_function_provider.py @@ -1158,7 +1158,7 @@ def test_must_extract_functions(self, get_template_mock, extract_mock): stack = make_root_stack(template, self.parameter_overrides) provider = SamFunctionProvider([stack]) - extract_mock.assert_called_with([stack], False, False, False) + extract_mock.assert_called_with([stack], False, False, False, None) get_template_mock.assert_called_with(template, self.parameter_overrides) self.assertEqual(provider.functions, extract_result) @@ -1173,7 +1173,7 @@ def test_must_default_to_empty_resources(self, get_template_mock, extract_mock): stack = make_root_stack(template, self.parameter_overrides) provider = SamFunctionProvider([stack]) - extract_mock.assert_called_with([stack], False, False, False) # Empty Resources value must be passed + extract_mock.assert_called_with([stack], False, False, False, None) # Empty Resources value must be passed self.assertEqual(provider.functions, extract_result) @patch.object(SamFunctionProvider, "_extract_functions") @@ -1187,7 +1187,7 @@ def test_search_layer_flag(self, get_template_mock, extract_mock): stack = make_root_stack(template, self.parameter_overrides) provider = SamFunctionProvider([stack], locate_layer_nested=True) - extract_mock.assert_called_with([stack], False, False, True) + extract_mock.assert_called_with([stack], False, False, True, None) get_template_mock.assert_called_with(template, self.parameter_overrides) self.assertEqual(provider.functions, extract_result) @@ -2145,7 +2145,7 @@ def test_init_must_extract_functions_and_stacks_got_observed( [stack, stack2], self.parameter_overrides, self.global_parameter_overrides ) - extract_mock.assert_called_with([stack, stack2], False, False, False) + extract_mock.assert_called_with([stack, stack2], False, False, False, None) get_template_mock.assert_called_with(template, self.parameter_overrides) self.assertEqual(provider.functions, extract_result) @@ -2605,3 +2605,165 @@ def test_validate_layer_get_attr_format(self): invalid_layer_str_format = {"Fn::GetAtt": ["LayerStackName", "Outputs.invalid.format"]} self.assertFalse(SamFunctionProvider._validate_layer_get_attr_format(invalid_layer_str_format)) + + +class TestSamFunctionProviderFiltering(TestCase): + """Test function filtering functionality""" + + @staticmethod + def _make_function(function_id, name, functionname): + """Helper to create a Function object""" + return Function( + function_id=function_id, + name=name, + functionname=functionname, + runtime="python3.9", + handler="index.handler", + codeuri="/path/to/code", + memory=None, + timeout=None, + environment=None, + rolearn=None, + layers=[], + events=None, + metadata={}, + inlinecode=None, + imageuri=None, + imageconfig=None, + packagetype=ZIP, + codesign_config_arn=None, + architectures=None, + function_url_config=None, + stack_path="", + function_build_info=FunctionBuildInfo.BuildableZip, + ) + + @parameterized.expand( + [ + ("match_by_function_id", "MyFunction", "MyFunction", "MyFunctionName", {"MyFunction"}, True), + ("match_by_name", "MyFunctionId", "MyFunctionName", "Override", {"MyFunctionName"}, True), + ("match_by_functionname", "MyFunctionId", "MyFunctionName", "Override", {"Override"}, True), + ("no_match", "MyFunction", "MyFunction", "MyFunctionName", {"OtherFunction"}, False), + ("match_with_none_functionname", "MyFunction", "MyFunction", None, {"MyFunction"}, True), + ] + ) + def test_should_include_function(self, name, function_id, fname, functionname, filter_set, expected): + """Test _should_include_function with various matching scenarios""" + function = self._make_function(function_id, fname, functionname) + result = SamFunctionProvider._should_include_function(function, filter_set) + self.assertEqual(result, expected) + + @patch("samcli.lib.providers.sam_function_provider.Stack.resources", new_callable=PropertyMock) + @patch.object(SamFunctionProvider, "_convert_sam_function_resource") + def test_extract_functions_with_filter(self, convert_mock, resources_mock): + """Test filtering extracts only specified functions""" + func1 = Mock(full_path="Function1", function_id="Function1", name="Function1", functionname="Function1Name") + func2 = Mock(full_path="Function2", function_id="Function2", name="Function2", functionname="Function2Name") + func3 = Mock(full_path="Function3", function_id="Function3", name="Function3", functionname="Function3Name") + + convert_mock.side_effect = [func1, func2, func3] + resources_mock.return_value = { + "Function1": {"Type": "AWS::Serverless::Function", "Properties": {}}, + "Function2": {"Type": "AWS::Serverless::Function", "Properties": {}}, + "Function3": {"Type": "AWS::Serverless::Function", "Properties": {}}, + } + + result = SamFunctionProvider._extract_functions( + [make_root_stack(None)], function_logical_ids=("Function1", "Function3") + ) + + self.assertEqual(len(result), 2) + self.assertIn("Function1", result) + self.assertIn("Function3", result) + self.assertNotIn("Function2", result) + + @patch("samcli.lib.providers.sam_function_provider.Stack.resources", new_callable=PropertyMock) + @patch.object(SamFunctionProvider, "_convert_sam_function_resource") + def test_extract_functions_no_filter(self, convert_mock, resources_mock): + """Test no filtering when function_logical_ids is None""" + func1 = Mock(full_path="Function1", function_id="Function1", name="Function1", functionname="Function1Name") + func2 = Mock(full_path="Function2", function_id="Function2", name="Function2", functionname="Function2Name") + + convert_mock.side_effect = [func1, func2] + resources_mock.return_value = { + "Function1": {"Type": "AWS::Serverless::Function", "Properties": {}}, + "Function2": {"Type": "AWS::Serverless::Function", "Properties": {}}, + } + + result = SamFunctionProvider._extract_functions([make_root_stack(None)], function_logical_ids=None) + + self.assertEqual(len(result), 2) + self.assertIn("Function1", result) + self.assertIn("Function2", result) + + @patch("samcli.lib.providers.sam_function_provider.Stack.resources", new_callable=PropertyMock) + @patch.object(SamFunctionProvider, "_convert_sam_function_resource") + def test_extract_functions_filter_by_functionname(self, convert_mock, resources_mock): + """Test filtering by FunctionName property""" + func1 = Mock( + full_path="LogicalId1", function_id="LogicalId1", name="LogicalId1", functionname="MyCustomFunctionName" + ) + func2 = Mock( + full_path="LogicalId2", function_id="LogicalId2", name="LogicalId2", functionname="AnotherFunctionName" + ) + + convert_mock.side_effect = [func1, func2] + resources_mock.return_value = { + "LogicalId1": {"Type": "AWS::Serverless::Function", "Properties": {}}, + "LogicalId2": {"Type": "AWS::Serverless::Function", "Properties": {}}, + } + + result = SamFunctionProvider._extract_functions( + [make_root_stack(None)], function_logical_ids=("MyCustomFunctionName",) + ) + + self.assertEqual(len(result), 1) + self.assertIn("LogicalId1", result) + self.assertNotIn("LogicalId2", result) + + @patch("samcli.lib.providers.sam_function_provider.Stack.resources", new_callable=PropertyMock) + @patch.object(SamFunctionProvider, "_convert_sam_function_resource") + def test_extract_functions_with_nested_stack(self, convert_mock, resources_mock): + """Test filtering with nested stack functions""" + func_root = Mock(full_path="RootFunc", function_id="RootFunc", name="RootFunc", functionname="RootFuncName") + func_child = Mock( + full_path="ChildStack/ChildFunc", + function_id="ChildFunc", + name="ChildFunc", + functionname="ChildFuncName", + ) + + convert_mock.side_effect = [func_root, func_child] + + root_resources = {"RootFunc": {"Type": "AWS::Serverless::Function", "Properties": {}}} + child_resources = {"ChildFunc": {"Type": "AWS::Serverless::Function", "Properties": {}}} + resources_mock.side_effect = [root_resources, child_resources] + + root_stack = Mock(resources=root_resources) + child_stack = Mock(resources=child_resources) + + result = SamFunctionProvider._extract_functions([root_stack, child_stack], function_logical_ids=("ChildFunc",)) + + self.assertEqual(len(result), 1) + self.assertIn("ChildStack/ChildFunc", result) + self.assertNotIn("RootFunc", result) + + @patch("samcli.lib.providers.sam_function_provider.Stack.resources", new_callable=PropertyMock) + @patch.object(SamFunctionProvider, "_convert_sam_function_resource") + def test_extract_functions_deduplicates_filter(self, convert_mock, resources_mock): + """Test duplicate function names in filter are deduplicated""" + func1 = Mock(full_path="Function1", function_id="Function1", name="Function1", functionname="Function1Name") + func2 = Mock(full_path="Function2", function_id="Function2", name="Function2", functionname="Function2Name") + + convert_mock.side_effect = [func1, func2] + resources_mock.return_value = { + "Function1": {"Type": "AWS::Serverless::Function", "Properties": {}}, + "Function2": {"Type": "AWS::Serverless::Function", "Properties": {}}, + } + + result = SamFunctionProvider._extract_functions( + [make_root_stack(None)], function_logical_ids=("Function1", "Function1", "Function2") + ) + + self.assertEqual(len(result), 2) + self.assertEqual(sum(1 for key in result.keys() if key == "Function1"), 1) diff --git a/tests/unit/commands/local/start_lambda/test_cli.py b/tests/unit/commands/local/start_lambda/test_cli.py index 75531ba255f..a620c93779e 100644 --- a/tests/unit/commands/local/start_lambda/test_cli.py +++ b/tests/unit/commands/local/start_lambda/test_cli.py @@ -85,6 +85,7 @@ def test_cli_must_setup_context_and_start_service(self, local_lambda_service_moc container_host_interface=self.container_host_interface, add_host=self.add_host, invoke_images={}, + function_logical_ids=(), no_mem_limit=self.no_mem_limit, ) @@ -164,6 +165,7 @@ def test_must_raise_user_exception_on_no_free_ports( def call_cli(self): start_lambda_cli( ctx=self.ctx_mock, + function_logical_ids=(), host=self.host, port=self.port, template=self.template, diff --git a/tests/unit/commands/samconfig/test_samconfig.py b/tests/unit/commands/samconfig/test_samconfig.py index 666a54d0e32..1a7b929c5d1 100644 --- a/tests/unit/commands/samconfig/test_samconfig.py +++ b/tests/unit/commands/samconfig/test_samconfig.py @@ -765,6 +765,7 @@ def test_local_start_lambda(self, do_cli_mock): do_cli_mock.assert_called_with( ANY, + (), # function_logical_ids "127.0.0.1", 12345, str(Path(os.getcwd(), "mytemplate.yaml")), @@ -1657,6 +1658,7 @@ def test_override_with_cli_params(self, do_cli_mock): do_cli_mock.assert_called_with( ANY, + (), # function_logical_ids "otherhost", 9999, str(Path(os.getcwd(), "othertemplate.yaml")), @@ -1756,6 +1758,7 @@ def test_override_with_cli_params_and_envvars(self, do_cli_mock): do_cli_mock.assert_called_with( ANY, + (), # function_logical_ids "otherhost", 9999, str(Path(os.getcwd(), "envtemplate.yaml")), From d5ff2c0c523dc2d733368ec19591efe585c1da99 Mon Sep 17 00:00:00 2001 From: vicheey <181402101+vicheey@users.noreply.github.com> Date: Wed, 5 Nov 2025 22:53:44 -0800 Subject: [PATCH 2/7] Update samcli/commands/local/cli_common/invoke_context.py --- samcli/commands/local/cli_common/invoke_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samcli/commands/local/cli_common/invoke_context.py b/samcli/commands/local/cli_common/invoke_context.py index 6d27d43cf89..f5e9ca9f8bb 100644 --- a/samcli/commands/local/cli_common/invoke_context.py +++ b/samcli/commands/local/cli_common/invoke_context.py @@ -172,7 +172,7 @@ def __init__( LOG.warning( "Both function_identifier and function_logical_ids were provided. " "function_identifier is used for 'sam local invoke' to specify a single function, " - "while function_logical_ids is used for 'sam local start-api/start-lambda' to filter functions. " + "while function_logical_ids is used for 'sam local start-lambda' to filter functions. " "function_identifier will take precedence for single function invocation." ) From f6d86904fdb8b59d537bdb6f16677e400a08dc9b Mon Sep 17 00:00:00 2001 From: vicheey <181402101+vicheey@users.noreply.github.com> Date: Wed, 5 Nov 2025 22:54:06 -0800 Subject: [PATCH 3/7] Update samcli/commands/local/cli_common/invoke_context.py --- samcli/commands/local/cli_common/invoke_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samcli/commands/local/cli_common/invoke_context.py b/samcli/commands/local/cli_common/invoke_context.py index f5e9ca9f8bb..05be9f2beb6 100644 --- a/samcli/commands/local/cli_common/invoke_context.py +++ b/samcli/commands/local/cli_common/invoke_context.py @@ -167,7 +167,7 @@ def __init__( # Validate that function_identifier and function_logical_ids aren't both provided # function_identifier is for 'sam local invoke' (single function) - # function_logical_ids is for 'sam local start-*' commands (multiple function filter) + # function_logical_ids is for 'sam local start-lambda' commands (multiple function filter) if self._function_identifier and self._function_logical_ids: LOG.warning( "Both function_identifier and function_logical_ids were provided. " From 29ddbeb7a03a1eb45f7ba5b25959fd452a535617 Mon Sep 17 00:00:00 2001 From: Vichym Date: Thu, 6 Nov 2025 00:26:24 -0800 Subject: [PATCH 4/7] address comment --- .../local/cli_common/invoke_context.py | 17 ++++-- .../local/cli_common/user_exceptions.py | 6 -- .../commands/local/start_api/core/command.py | 41 ++++++++++---- .../local/start_lambda/core/command.py | 35 ++++++++++-- .../local/start_lambda/test_start_lambda.py | 13 +---- .../local/cli_common/test_invoke_context.py | 56 +++++++++++-------- .../local/start_api/core/test_command.py | 12 +++- .../local/start_lambda/core/test_command.py | 48 +++++----------- 8 files changed, 134 insertions(+), 94 deletions(-) diff --git a/samcli/commands/local/cli_common/invoke_context.py b/samcli/commands/local/cli_common/invoke_context.py index 05be9f2beb6..f34b7b9d65c 100644 --- a/samcli/commands/local/cli_common/invoke_context.py +++ b/samcli/commands/local/cli_common/invoke_context.py @@ -27,6 +27,7 @@ from samcli.local.docker.exceptions import PortAlreadyInUse from samcli.local.docker.lambda_image import LambdaImage from samcli.local.docker.manager import ContainerManager +from samcli.local.lambdafn.exceptions import FunctionNotFound from samcli.local.lambdafn.runtime import LambdaRuntime, WarmLambdaRuntime from samcli.local.layers.layer_downloader import LayerDownloader @@ -477,7 +478,7 @@ def _clean_running_containers_and_related_resources(self) -> None: def _validate_function_logical_ids(self) -> None: """ Validates that all provided function logical IDs exist in the template. - Raises InvalidFunctionNamesException with helpful error message if validation fails. + Raises FunctionNotFound with helpful error message if validation fails. """ if not self._function_logical_ids: return # No filtering requested @@ -494,13 +495,17 @@ def _validate_function_logical_ids(self) -> None: invalid_ids = set(self._function_logical_ids) - all_functions_set if invalid_ids: - available_ids = sorted(all_functions_set) - from samcli.commands.local.cli_common.user_exceptions import InvalidFunctionNamesException + # Get all available function full paths, matching sam local invoke pattern + all_function_full_paths = [f.full_path for f in self._function_provider.get_all()] - raise InvalidFunctionNamesException( - f"Invalid function logical ID(s): {', '.join(sorted(invalid_ids))}\n\n" - f"Available functions in template:\n " + "\n ".join(available_ids) + # Format message to match sam local invoke pattern exactly + invalid_functions_str = ", ".join(sorted(invalid_ids)) + available_function_message = "{} not found. Possible options in your template: {}".format( + invalid_functions_str, all_function_full_paths ) + LOG.info(available_function_message) + + raise FunctionNotFound("Unable to find Function(s) with name(s) '{}'".format(invalid_functions_str)) def _add_account_id_to_global(self) -> None: """ diff --git a/samcli/commands/local/cli_common/user_exceptions.py b/samcli/commands/local/cli_common/user_exceptions.py index cf6bd3b1e2c..3ba5c2df1d4 100644 --- a/samcli/commands/local/cli_common/user_exceptions.py +++ b/samcli/commands/local/cli_common/user_exceptions.py @@ -11,12 +11,6 @@ class InvokeContextException(UserException): """ -class InvalidFunctionNamesException(InvokeContextException): - """ - User provided function names that don't exist in the template - """ - - class InvalidSamTemplateException(UserException): """ The template provided was invalid and not able to transform into a Standard CloudFormation Template diff --git a/samcli/commands/local/start_api/core/command.py b/samcli/commands/local/start_api/core/command.py index eee659663b5..d18a98c2166 100644 --- a/samcli/commands/local/start_api/core/command.py +++ b/samcli/commands/local/start_api/core/command.py @@ -19,17 +19,36 @@ class CustomFormatterContext(Context): @staticmethod def format_examples(ctx: Context, formatter: InvokeStartAPICommandHelpTextFormatter): with formatter.indented_section(name="Examples", extra_indents=1): - formatter.write_rd( - [ - RowDefinition( - text="\n", - ), - RowDefinition( - name=style(f"$ {ctx.command_path}"), - extra_row_modifiers=[ShowcaseRowModifier()], - ), - ] - ) + with formatter.indented_section(name="Setup", extra_indents=1): + formatter.write_rd( + [ + RowDefinition( + text="\n", + ), + RowDefinition( + name="Start the local lambda with Amazon API Gateway endpoint", + ), + RowDefinition( + name=style(f"$ {ctx.command_path}"), + extra_row_modifiers=[ShowcaseRowModifier()], + ), + ] + ) + with formatter.indented_section(name="Invoke local Lambda endpoint", extra_indents=1): + formatter.write_rd( + [ + RowDefinition( + text="\n", + ), + RowDefinition( + name="Invoke Lambda function locally using curl", + ), + RowDefinition( + name=style("$ curl http://127.0.0.1:3000/hello"), + extra_row_modifiers=[ShowcaseRowModifier()], + ), + ] + ) def format_options(self, ctx: Context, formatter: InvokeStartAPICommandHelpTextFormatter) -> None: # type:ignore # NOTE(sriram-mv): `ignore` is put in place here for mypy even though it is the correct behavior, diff --git a/samcli/commands/local/start_lambda/core/command.py b/samcli/commands/local/start_lambda/core/command.py index 7b1451ed6f4..3920b2ffa6e 100644 --- a/samcli/commands/local/start_lambda/core/command.py +++ b/samcli/commands/local/start_lambda/core/command.py @@ -37,7 +37,7 @@ def format_examples(ctx: Context, formatter: InvokeStartLambdaCommandHelpTextFor text="\n", ), RowDefinition( - name="Start the local lambda endpoint.", + name="Start the local lambda endpoint for all functions", ), RowDefinition( name=style(f"$ {ctx.command_path}"), @@ -45,14 +45,42 @@ def format_examples(ctx: Context, formatter: InvokeStartLambdaCommandHelpTextFor ), ] ) - with formatter.indented_section(name="Using AWS CLI", extra_indents=1): formatter.write_rd( [ RowDefinition( text="\n", ), RowDefinition( - name="Invoke Lambda function locally using the AWS CLI.", + name="Start the local lambda endpoint for one function", + ), + RowDefinition( + name=style(f"$ {ctx.command_path} HelloWorldFunction"), + extra_row_modifiers=[ShowcaseRowModifier()], + ), + ] + ) + formatter.write_rd( + [ + RowDefinition( + text="\n", + ), + RowDefinition( + name="Start the local lambda endpoint for multiple functions", + ), + RowDefinition( + name=style(f"$ {ctx.command_path} HelloWorldFunctionOne HelloWorldFunctionTwo"), + extra_row_modifiers=[ShowcaseRowModifier()], + ), + ] + ) + with formatter.indented_section(name="Invoke local Lambda endpoint", extra_indents=1): + formatter.write_rd( + [ + RowDefinition( + text="\n", + ), + RowDefinition( + name="Use the AWS CLI.", ), RowDefinition( name=style( @@ -63,7 +91,6 @@ def format_examples(ctx: Context, formatter: InvokeStartLambdaCommandHelpTextFor ), ] ) - with formatter.indented_section(name="Using AWS SDK", extra_indents=1): formatter.write_rd( [ RowDefinition( diff --git a/tests/integration/local/start_lambda/test_start_lambda.py b/tests/integration/local/start_lambda/test_start_lambda.py index 86e17ac6042..588bf25a938 100644 --- a/tests/integration/local/start_lambda/test_start_lambda.py +++ b/tests/integration/local/start_lambda/test_start_lambda.py @@ -1768,14 +1768,6 @@ def test_invoke_function_success(self): self.assertEqual(response.get("Payload").read().decode("utf-8"), '"test data"') self.assertIsNone(response.get("FunctionError")) - @parameterized.expand([("EchoEventFunction",), ("HelloWorldServerlessFunction",), ("RaiseExceptionFunction",)]) - @pytest.mark.flaky(reruns=3) - @pytest.mark.timeout(timeout=300, method="thread") - def test_backward_compatibility_no_filter(self, function_name): - """Test that without function names, all functions are available""" - response = self.lambda_client.invoke(FunctionName=function_name) - self.assertEqual(response.get("StatusCode"), 200) - class TestFunctionNameFilteringWithFilter(StartLambdaIntegBaseClass): """Test function name filtering with specific functions""" @@ -1872,7 +1864,7 @@ class TestFunctionNameFilteringInvalidNames(TestCase): @pytest.mark.flaky(reruns=3) @pytest.mark.timeout(timeout=300, method="thread") def test_invalid_function_names_error(self): - """Test that invalid function names produce helpful error message""" + """Test that invalid function names produce helpful error message at startup""" template = self.integration_dir + self.template_path command_list = [ get_sam_command(), @@ -1891,5 +1883,6 @@ def test_invalid_function_names_error(self): self.assertNotEqual(process.returncode, 0) error_output = stderr.decode("utf-8") - for expected in ["Invalid function logical ID", "InvalidFunction1", "InvalidFunction2", "Available functions"]: + # Should match sam local invoke error pattern: "function not found. Possible options in your template:" + for expected in ["not found", "InvalidFunction1, InvalidFunction2", "Possible options in your template"]: self.assertIn(expected, error_output) diff --git a/tests/unit/commands/local/cli_common/test_invoke_context.py b/tests/unit/commands/local/cli_common/test_invoke_context.py index 8941be63b4a..2b665f66a4c 100644 --- a/tests/unit/commands/local/cli_common/test_invoke_context.py +++ b/tests/unit/commands/local/cli_common/test_invoke_context.py @@ -1486,20 +1486,23 @@ def test_validation_with_valid_names(self): # Should not raise any exception invoke_context._validate_function_logical_ids() - def test_validation_with_invalid_names(self): - """Test that InvalidFunctionNamesException is raised with invalid names""" - from samcli.commands.local.cli_common.user_exceptions import InvalidFunctionNamesException + @patch("samcli.commands.local.cli_common.invoke_context.LOG") + def test_validation_with_invalid_names(self, log_mock): + """Test that FunctionNotFound is raised with invalid names""" + from samcli.local.lambdafn.exceptions import FunctionNotFound # Create mock functions func1 = Mock() func1.name = "Function1" func1.function_id = "Function1" func1.functionname = None + func1.full_path = "Function1" func2 = Mock() func2.name = "Function2" func2.function_id = "Function2" func2.functionname = None + func2.full_path = "Function2" # Create InvokeContext with invalid function names invoke_context = InvokeContext( @@ -1511,33 +1514,38 @@ def test_validation_with_invalid_names(self): function_provider_mock.get_all.return_value = [func1, func2] invoke_context._function_provider = function_provider_mock - # Should raise InvalidFunctionNamesException - with self.assertRaises(InvalidFunctionNamesException) as ex_ctx: + # Should raise FunctionNotFound + with self.assertRaises(FunctionNotFound) as ex_ctx: invoke_context._validate_function_logical_ids() error_message = str(ex_ctx.exception) - # Verify error message includes invalid names - self.assertIn("InvalidFunction", error_message) - self.assertIn("AnotherInvalid", error_message) - # Verify error message includes available functions - self.assertIn("Function1", error_message) - self.assertIn("Function2", error_message) - self.assertIn("Available functions in template:", error_message) - - def test_validation_with_mixed_valid_invalid_names(self): + # Verify error message matches sam local invoke pattern + self.assertIn("Unable to find Function(s) with name(s)", error_message) + self.assertIn("AnotherInvalid, InvalidFunction", error_message) + + # Verify LOG.info was called with the proper message + log_mock.info.assert_called_once() + log_call_args = log_mock.info.call_args[0][0] + self.assertIn("not found", log_call_args) + self.assertIn("Possible options in your template:", log_call_args) + + @patch("samcli.commands.local.cli_common.invoke_context.LOG") + def test_validation_with_mixed_valid_invalid_names(self, log_mock): """Test that all invalid names are reported when some are valid and some are invalid""" - from samcli.commands.local.cli_common.user_exceptions import InvalidFunctionNamesException + from samcli.local.lambdafn.exceptions import FunctionNotFound # Create mock functions func1 = Mock() func1.name = "Function1" func1.function_id = "Function1" func1.functionname = None + func1.full_path = "Function1" func2 = Mock() func2.name = "Function2" func2.function_id = "Function2" func2.functionname = None + func2.full_path = "Function2" # Create InvokeContext with mixed valid/invalid function names invoke_context = InvokeContext( @@ -1549,16 +1557,20 @@ def test_validation_with_mixed_valid_invalid_names(self): function_provider_mock.get_all.return_value = [func1, func2] invoke_context._function_provider = function_provider_mock - # Should raise InvalidFunctionNamesException - with self.assertRaises(InvalidFunctionNamesException) as ex_ctx: + # Should raise FunctionNotFound + with self.assertRaises(FunctionNotFound) as ex_ctx: invoke_context._validate_function_logical_ids() error_message = str(ex_ctx.exception) - # Verify all invalid names are reported - self.assertIn("InvalidFunc", error_message) - self.assertIn("AnotherInvalid", error_message) - # Verify valid name is not in error message as invalid - self.assertNotIn("Function1", error_message.split("Available functions")[0]) + # Verify error message matches sam local invoke pattern + self.assertIn("Unable to find Function(s) with name(s)", error_message) + self.assertIn("AnotherInvalid, InvalidFunc", error_message) + + # Verify LOG.info was called with the proper message + log_mock.info.assert_called_once() + log_call_args = log_mock.info.call_args[0][0] + self.assertIn("not found", log_call_args) + self.assertIn("Possible options in your template:", log_call_args) def test_validation_with_no_filter(self): """Test that no validation occurs when function_logical_ids is None""" diff --git a/tests/unit/commands/local/start_api/core/test_command.py b/tests/unit/commands/local/start_api/core/test_command.py index e784971ee40..53263db84ab 100644 --- a/tests/unit/commands/local/start_api/core/test_command.py +++ b/tests/unit/commands/local/start_api/core/test_command.py @@ -42,9 +42,19 @@ def test_get_options_local_start_api_command(self, mock_get_params): "Configuration Options": [("", ""), ("--config-file", ""), ("", "")], "Container Options": [("", ""), ("--host", ""), ("", "")], "Description": [(cmd.description + cmd.description_addendum, "")], - "Examples": [("", ""), ("$ sam local start-api\x1b[0m", "")], + "Examples": [], "Extension Options": [("", ""), ("--hook_name", ""), ("", "")], "Terraform Hook Options": [("", ""), ("--terraform-plan-file", ""), ("", "")], + "Setup": [ + ("", ""), + ("Start the local lambda with Amazon API Gateway endpoint", ""), + ("$ sam local start-api\x1b[0m", ""), + ], + "Invoke local Lambda endpoint": [ + ("", ""), + ("Invoke Lambda function locally using curl", ""), + ("$ curl http://127.0.0.1:3000/hello\x1b[0m", ""), + ], "Other Options": [("", ""), ("--debug", ""), ("", "")], "Beta Options": [("", ""), ("--beta-features", ""), ("", "")], "Required Options": [("", ""), ("--template-file", ""), ("", "")], diff --git a/tests/unit/commands/local/start_lambda/core/test_command.py b/tests/unit/commands/local/start_lambda/core/test_command.py index a8b2185a36a..d52e27fadfa 100644 --- a/tests/unit/commands/local/start_lambda/core/test_command.py +++ b/tests/unit/commands/local/start_lambda/core/test_command.py @@ -35,53 +35,33 @@ def test_get_options_local_start_lambda_command(self, mock_get_params): MockParams(rv=("--terraform-plan-file", ""), name="terraform_plan_file"), ] - cmd = InvokeLambdaCommand(name="local start-api", requires_credentials=False, description=DESCRIPTION) + cmd = InvokeLambdaCommand(name="local start-lambda", requires_credentials=False, description=DESCRIPTION) expected_output = { - "AWS Credential Options": [("", ""), ("--region", ""), ("", "")], - "Artifact Location Options": [("", ""), ("--log-file", ""), ("", "")], - "Configuration Options": [("", ""), ("--config-file", ""), ("", "")], - "Container Options": [("", ""), ("--port", ""), ("", "")], "Description": [(cmd.description + cmd.description_addendum, "")], "Examples": [], - "Terraform Hook Options": [("", ""), ("--terraform-plan-file", ""), ("", "")], - "Setup": [("", ""), ("Start the local lambda endpoint.", ""), ("$ sam local start-lambda\x1b[0m", "")], - "Template Options": [("", ""), ("--parameter-overrides", ""), ("", "")], - "Using AWS CLI": [ + "Setup": [ ("", ""), - ("Invoke Lambda function locally using the AWS CLI.", ""), - ( - "$ aws lambda invoke --function-name HelloWorldFunction " - "--endpoint-url http://127.0.0.1:3001 --no-verify-ssl " - "out.txt\x1b[0m", - "", - ), + ("Start the local lambda endpoint for multiple functions", ""), + ("$ sam local start-lambda HelloWorldFunctionOne HelloWorldFunctionTwo\x1b[0m", ""), ], - "Using AWS SDK": [ + "Invoke local Lambda endpoint": [ ("", ""), ("Use AWS SDK in automated tests.", ""), ( - "\n" - " self.lambda_client = boto3.client('lambda',\n" - " " - 'endpoint_url="http://127.0.0.1:3001",\n' - " use_ssl=False,\n" - " verify=False,\n" - " " - "config=Config(signature_version=UNSIGNED,\n" - " " - "read_timeout=0,\n" - " " - "retries={'max_attempts': 0}))\n" - " " - 'self.lambda_client.invoke(FunctionName="HelloWorldFunction")\n' - " ", + "\n self.lambda_client = boto3.client('lambda',\n endpoint_url=\"http://127.0.0.1:3001\",\n use_ssl=False,\n verify=False,\n config=Config(signature_version=UNSIGNED,\n read_timeout=0,\n retries={'max_attempts': 0}))\n self.lambda_client.invoke(FunctionName=\"HelloWorldFunction\")\n ", "", ), ], - "Beta Options": [("", ""), ("--beta-features", ""), ("", "")], - "Other Options": [("", ""), ("--debug", ""), ("", "")], "Required Options": [("", ""), ("--template-file", ""), ("", "")], + "Template Options": [("", ""), ("--parameter-overrides", ""), ("", "")], + "AWS Credential Options": [("", ""), ("--region", ""), ("", "")], + "Container Options": [("", ""), ("--port", ""), ("", "")], + "Artifact Location Options": [("", ""), ("--log-file", ""), ("", "")], "Extension Options": [("", ""), ("--hook_name", ""), ("", "")], + "Configuration Options": [("", ""), ("--config-file", ""), ("", "")], + "Terraform Hook Options": [("", ""), ("--terraform-plan-file", ""), ("", "")], + "Beta Options": [("", ""), ("--beta-features", ""), ("", "")], + "Other Options": [("", ""), ("--debug", ""), ("", "")], } cmd.format_options(ctx, formatter) From 4ce3203ebc4b19e8c0acfbe2eaaddb90eefb999b Mon Sep 17 00:00:00 2001 From: Vichym Date: Mon, 10 Nov 2025 15:57:53 -0800 Subject: [PATCH 5/7] fix integration test failures --- samcli/commands/local/cli_common/invoke_context.py | 4 ++-- tests/integration/local/start_lambda/test_start_lambda.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/samcli/commands/local/cli_common/invoke_context.py b/samcli/commands/local/cli_common/invoke_context.py index f34b7b9d65c..645b0cc011a 100644 --- a/samcli/commands/local/cli_common/invoke_context.py +++ b/samcli/commands/local/cli_common/invoke_context.py @@ -260,9 +260,9 @@ def __enter__(self) -> "InvokeContext": if self._docker_volume_basedir: _function_providers_args[self._containers_mode].append(True) - # Add function_logical_ids parameter to both provider types _function_providers_kwargs: Dict[str, Any] = {} - if self._function_logical_ids is not None: + + if self._function_logical_ids: _function_providers_kwargs["function_logical_ids"] = self._function_logical_ids self._function_provider = _function_providers_class[self._containers_mode]( diff --git a/tests/integration/local/start_lambda/test_start_lambda.py b/tests/integration/local/start_lambda/test_start_lambda.py index 588bf25a938..0055db667f6 100644 --- a/tests/integration/local/start_lambda/test_start_lambda.py +++ b/tests/integration/local/start_lambda/test_start_lambda.py @@ -1801,7 +1801,7 @@ def test_invoke_non_filtered_function(self): """Test invoking a function that is NOT in the filter returns ResourceNotFoundException""" with self.assertRaises(ClientError) as context: self.lambda_client.invoke(FunctionName="RaiseExceptionFunction") - self.assertIn("ResourceNotFoundException", str(context.exception)) + self.assertIn("ResourceNotFound", str(context.exception)) class TestFunctionNameFilteringWarmContainersEager(TestWarmContainersBaseClass): From 213aaf2783bf40045ede27c36ecaebde5354937a Mon Sep 17 00:00:00 2001 From: Vichym Date: Mon, 10 Nov 2025 17:42:44 -0800 Subject: [PATCH 6/7] adress comment: clean up tests --- .../local/start_lambda/test_start_lambda.py | 30 +-- .../local/cli_common/test_invoke_context.py | 183 +++++------------- 2 files changed, 52 insertions(+), 161 deletions(-) diff --git a/tests/integration/local/start_lambda/test_start_lambda.py b/tests/integration/local/start_lambda/test_start_lambda.py index 0055db667f6..d170eebd293 100644 --- a/tests/integration/local/start_lambda/test_start_lambda.py +++ b/tests/integration/local/start_lambda/test_start_lambda.py @@ -1743,32 +1743,6 @@ def test_invoke_with_data_custom_invoke_images(self): self.assertEqual(response.get("StatusCode"), 200) -class TestFunctionNameFiltering(StartLambdaIntegBaseClass): - """Test function name filtering feature for start-lambda command""" - - template_path = "/testdata/invoke/template.yml" - - def setUp(self): - self.url = f"http://127.0.0.1:{self.port}" - self.lambda_client = boto3.client( - "lambda", - endpoint_url=self.url, - region_name="us-east-1", - use_ssl=False, - verify=False, - config=Config(signature_version=UNSIGNED, read_timeout=120, retries={"max_attempts": 0}), - ) - - @pytest.mark.flaky(reruns=3) - @pytest.mark.timeout(timeout=300, method="thread") - def test_invoke_function_success(self): - """Test invoking a function succeeds""" - response = self.lambda_client.invoke(FunctionName="EchoEventFunction", Payload='"test data"') - self.assertEqual(response.get("StatusCode"), 200) - self.assertEqual(response.get("Payload").read().decode("utf-8"), '"test data"') - self.assertIsNone(response.get("FunctionError")) - - class TestFunctionNameFilteringWithFilter(StartLambdaIntegBaseClass): """Test function name filtering with specific functions""" @@ -1800,8 +1774,8 @@ def test_invoke_filtered_function(self): def test_invoke_non_filtered_function(self): """Test invoking a function that is NOT in the filter returns ResourceNotFoundException""" with self.assertRaises(ClientError) as context: - self.lambda_client.invoke(FunctionName="RaiseExceptionFunction") - self.assertIn("ResourceNotFound", str(context.exception)) + self.lambda_client.invoke(FunctionName="NoneExistedFunction") + self.assertIn("ResourceNotFound", str(context.exception)) class TestFunctionNameFilteringWarmContainersEager(TestWarmContainersBaseClass): diff --git a/tests/unit/commands/local/cli_common/test_invoke_context.py b/tests/unit/commands/local/cli_common/test_invoke_context.py index 2b665f66a4c..8bcc5f23a32 100644 --- a/tests/unit/commands/local/cli_common/test_invoke_context.py +++ b/tests/unit/commands/local/cli_common/test_invoke_context.py @@ -17,10 +17,11 @@ NoFunctionIdentifierProvidedException, InvalidEnvironmentVariablesFileException, ) +from samcli.local.lambdafn.exceptions import FunctionNotFound from samcli.local.docker.exceptions import ContainerNotReachableException from unittest import TestCase -from unittest.mock import Mock, PropertyMock, patch, ANY, mock_open, call +from unittest.mock import Mock, patch, ANY, mock_open, call from samcli.lib.providers.provider import Stack import pytest @@ -467,8 +468,6 @@ def test_must_raise_if_docker_is_not_reachable( mock_platform, mock_get_validated_client, ): - from samcli.local.docker.exceptions import ContainerNotReachableException - mock_platform.return_value = platform_name # Mock the get_validated_container_client to raise ContainerNotReachableException mock_get_validated_client.side_effect = ContainerNotReachableException(expected_message) @@ -1462,18 +1461,26 @@ def test_must_work_with_token(self, get_boto_client_provider_with_config_mock): class TestInvokeContext_validate_function_logical_ids(TestCase): """Tests for _validate_function_logical_ids method""" - def test_validation_with_valid_names(self): - """Test that no exception is raised when all function names are valid""" - # Create mock functions + def create_mock_functions(self): + """Helper method to create standard mock functions for testing""" func1 = Mock() func1.name = "Function1" func1.function_id = "Function1" func1.functionname = None + func1.full_path = "Function1" func2 = Mock() func2.name = "Function2" func2.function_id = "Function2" func2.functionname = "MyFunction2" + func2.full_path = "Function2" + + return func1, func2 + + def test_validation_with_valid_names(self): + """Test that no exception is raised when all function names are valid""" + # Create mock functions + func1, func2 = self.create_mock_functions() # Create InvokeContext with valid function names invoke_context = InvokeContext(template_file="template_file", function_logical_ids=("Function1", "MyFunction2")) @@ -1486,28 +1493,21 @@ def test_validation_with_valid_names(self): # Should not raise any exception invoke_context._validate_function_logical_ids() + @parameterized.expand( + [ + (("InvalidFunction", "AnotherInvalid"), "AnotherInvalid, InvalidFunction"), + (("Function1", "InvalidFunc", "AnotherInvalid"), "AnotherInvalid, InvalidFunc"), + ] + ) @patch("samcli.commands.local.cli_common.invoke_context.LOG") - def test_validation_with_invalid_names(self, log_mock): + def test_validation_with_invalid_function_names(self, function_logical_ids, expected_invalid_names, log_mock): """Test that FunctionNotFound is raised with invalid names""" - from samcli.local.lambdafn.exceptions import FunctionNotFound - # Create mock functions - func1 = Mock() - func1.name = "Function1" - func1.function_id = "Function1" - func1.functionname = None - func1.full_path = "Function1" + # Create mock functions using helper method + func1, func2 = self.create_mock_functions() - func2 = Mock() - func2.name = "Function2" - func2.function_id = "Function2" - func2.functionname = None - func2.full_path = "Function2" - - # Create InvokeContext with invalid function names - invoke_context = InvokeContext( - template_file="template_file", function_logical_ids=("InvalidFunction", "AnotherInvalid") - ) + # Create InvokeContext with function names (some invalid) + invoke_context = InvokeContext(template_file="template_file", function_logical_ids=function_logical_ids) # Mock the function provider function_provider_mock = Mock() @@ -1521,7 +1521,7 @@ def test_validation_with_invalid_names(self, log_mock): error_message = str(ex_ctx.exception) # Verify error message matches sam local invoke pattern self.assertIn("Unable to find Function(s) with name(s)", error_message) - self.assertIn("AnotherInvalid, InvalidFunction", error_message) + self.assertIn(expected_invalid_names, error_message) # Verify LOG.info was called with the proper message log_mock.info.assert_called_once() @@ -1529,68 +1529,16 @@ def test_validation_with_invalid_names(self, log_mock): self.assertIn("not found", log_call_args) self.assertIn("Possible options in your template:", log_call_args) - @patch("samcli.commands.local.cli_common.invoke_context.LOG") - def test_validation_with_mixed_valid_invalid_names(self, log_mock): - """Test that all invalid names are reported when some are valid and some are invalid""" - from samcli.local.lambdafn.exceptions import FunctionNotFound - - # Create mock functions - func1 = Mock() - func1.name = "Function1" - func1.function_id = "Function1" - func1.functionname = None - func1.full_path = "Function1" - - func2 = Mock() - func2.name = "Function2" - func2.function_id = "Function2" - func2.functionname = None - func2.full_path = "Function2" - - # Create InvokeContext with mixed valid/invalid function names - invoke_context = InvokeContext( - template_file="template_file", function_logical_ids=("Function1", "InvalidFunc", "AnotherInvalid") - ) - - # Mock the function provider - function_provider_mock = Mock() - function_provider_mock.get_all.return_value = [func1, func2] - invoke_context._function_provider = function_provider_mock - - # Should raise FunctionNotFound - with self.assertRaises(FunctionNotFound) as ex_ctx: - invoke_context._validate_function_logical_ids() - - error_message = str(ex_ctx.exception) - # Verify error message matches sam local invoke pattern - self.assertIn("Unable to find Function(s) with name(s)", error_message) - self.assertIn("AnotherInvalid, InvalidFunc", error_message) - - # Verify LOG.info was called with the proper message - log_mock.info.assert_called_once() - log_call_args = log_mock.info.call_args[0][0] - self.assertIn("not found", log_call_args) - self.assertIn("Possible options in your template:", log_call_args) - - def test_validation_with_no_filter(self): - """Test that no validation occurs when function_logical_ids is None""" - # Create InvokeContext without function_logical_ids - invoke_context = InvokeContext(template_file="template_file", function_logical_ids=None) - - # Mock the function provider (should not be called) - function_provider_mock = Mock() - invoke_context._function_provider = function_provider_mock - - # Should not raise any exception and should not call get_all - invoke_context._validate_function_logical_ids() - - # Verify get_all was not called since no validation needed - function_provider_mock.get_all.assert_not_called() - - def test_validation_with_empty_tuple(self): - """Test that no validation occurs when function_logical_ids is empty tuple""" - # Create InvokeContext with empty tuple - invoke_context = InvokeContext(template_file="template_file", function_logical_ids=()) + @parameterized.expand( + [ + ("none", None), + ("empty_tuple", ()), + ] + ) + def test_validation_with_no_function_filter(self, test_name, function_logical_ids): + """Test that no validation occurs when function_logical_ids is None or empty""" + # Create InvokeContext with no function filter + invoke_context = InvokeContext(template_file="template_file", function_logical_ids=function_logical_ids) # Mock the function provider (should not be called) function_provider_mock = Mock() @@ -1602,54 +1550,23 @@ def test_validation_with_empty_tuple(self): # Verify get_all was not called since no validation needed function_provider_mock.get_all.assert_not_called() - def test_validation_matches_function_id(self): - """Test that validation matches against function_id""" - # Create mock function - func = Mock() - func.name = "MyFunction" - func.function_id = "FunctionLogicalId" - func.functionname = None - - # Create InvokeContext using function_id - invoke_context = InvokeContext(template_file="template_file", function_logical_ids=("FunctionLogicalId",)) - - # Mock the function provider - function_provider_mock = Mock() - function_provider_mock.get_all.return_value = [func] - invoke_context._function_provider = function_provider_mock - - # Should not raise any exception - invoke_context._validate_function_logical_ids() - - def test_validation_matches_name(self): - """Test that validation matches against name""" - # Create mock function - func = Mock() - func.name = "MyFunction" - func.function_id = "FunctionLogicalId" - func.functionname = None - - # Create InvokeContext using name - invoke_context = InvokeContext(template_file="template_file", function_logical_ids=("MyFunction",)) - - # Mock the function provider - function_provider_mock = Mock() - function_provider_mock.get_all.return_value = [func] - invoke_context._function_provider = function_provider_mock - - # Should not raise any exception - invoke_context._validate_function_logical_ids() - - def test_validation_matches_functionname(self): - """Test that validation matches against functionname property""" - # Create mock function + @parameterized.expand( + [ + ("FunctionLogicalId", "MyFunction", "FunctionLogicalId", None), + ("MyFunction", "MyFunction", "FunctionLogicalId", None), + ("CustomFunctionName", "MyFunction", "FunctionLogicalId", "CustomFunctionName"), + ] + ) + def test_validation_matches_function_attributes(self, search_name, func_name, func_id, func_functionname): + """Test that validation matches against different function attributes""" + # Create mock function with specified attributes func = Mock() - func.name = "MyFunction" - func.function_id = "FunctionLogicalId" - func.functionname = "CustomFunctionName" + func.name = func_name + func.function_id = func_id + func.functionname = func_functionname - # Create InvokeContext using functionname - invoke_context = InvokeContext(template_file="template_file", function_logical_ids=("CustomFunctionName",)) + # Create InvokeContext using search_name + invoke_context = InvokeContext(template_file="template_file", function_logical_ids=(search_name,)) # Mock the function provider function_provider_mock = Mock() From 72aacfbecb81b273e46068372b0c30bc366e9bd3 Mon Sep 17 00:00:00 2001 From: vicheey <181402101+vicheey@users.noreply.github.com> Date: Tue, 11 Nov 2025 12:01:33 -0800 Subject: [PATCH 7/7] Apply suggestions from code review --- tests/integration/local/start_lambda/test_start_lambda.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/local/start_lambda/test_start_lambda.py b/tests/integration/local/start_lambda/test_start_lambda.py index d170eebd293..1093d0505a5 100644 --- a/tests/integration/local/start_lambda/test_start_lambda.py +++ b/tests/integration/local/start_lambda/test_start_lambda.py @@ -1774,7 +1774,7 @@ def test_invoke_filtered_function(self): def test_invoke_non_filtered_function(self): """Test invoking a function that is NOT in the filter returns ResourceNotFoundException""" with self.assertRaises(ClientError) as context: - self.lambda_client.invoke(FunctionName="NoneExistedFunction") + self.lambda_client.invoke(FunctionName="FunctionWithMetadata") self.assertIn("ResourceNotFound", str(context.exception))