-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat: Add .env file support for local Lambda invocations #8297
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 10 commits
f3f091a
2fc00b1
946c15f
274fb56
3d18841
9a7074d
da11247
3b941d1
8a56be1
160afa8
02b0975
3290daf
094f53f
e602f89
34739f1
6341ce6
e766997
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,8 @@ | |
| from pathlib import Path | ||
| from typing import Any, Dict, List, Optional, TextIO, Tuple, Type, cast | ||
|
|
||
| from dotenv import dotenv_values | ||
|
|
||
| from samcli.commands._utils.template import TemplateFailedParsingException, TemplateNotFoundException | ||
| from samcli.commands.exceptions import ContainersInitializationException | ||
| from samcli.commands.local.cli_common.user_exceptions import DebugContextException, InvokeContextException | ||
|
|
@@ -76,6 +78,7 @@ def __init__( | |
| template_file: str, | ||
| function_identifier: Optional[str] = None, | ||
| env_vars_file: Optional[str] = None, | ||
| dotenv_file: Optional[str] = None, | ||
| docker_volume_basedir: Optional[str] = None, | ||
| docker_network: Optional[str] = None, | ||
| log_file: Optional[str] = None, | ||
|
|
@@ -84,6 +87,7 @@ def __init__( | |
| debug_args: Optional[str] = None, | ||
| debugger_path: Optional[str] = None, | ||
| container_env_vars_file: Optional[str] = None, | ||
| container_dotenv_file: Optional[str] = None, | ||
| parameter_overrides: Optional[Dict] = None, | ||
| layer_cache_basedir: Optional[str] = None, | ||
| force_image_build: Optional[bool] = None, | ||
|
|
@@ -110,6 +114,8 @@ def __init__( | |
| Identifier of the function to invoke | ||
| env_vars_file str | ||
| Path to a file containing values for environment variables | ||
| dotenv_file str | ||
| Path to .env file containing environment variables | ||
| docker_volume_basedir str | ||
| Directory for the Docker volume | ||
| docker_network str | ||
|
|
@@ -126,6 +132,10 @@ def __init__( | |
| Additional arguments passed to the debugger | ||
| debugger_path str | ||
| Path to the directory of the debugger to mount on Docker | ||
| container_env_vars_file str | ||
| Path to a file containing values for container environment variables | ||
| container_dotenv_file str | ||
| Path to .env file containing container environment variables | ||
| parameter_overrides dict | ||
| Values for the template parameters | ||
| layer_cache_basedir str | ||
|
|
@@ -159,6 +169,7 @@ def __init__( | |
| self._template_file = template_file | ||
| self._function_identifier = function_identifier | ||
| self._env_vars_file = env_vars_file | ||
| self._dotenv_file = dotenv_file | ||
| self._docker_volume_basedir = docker_volume_basedir | ||
| self._docker_network = docker_network | ||
| self._log_file = log_file | ||
|
|
@@ -167,6 +178,7 @@ def __init__( | |
| self._debug_args = debug_args | ||
| self._debugger_path = debugger_path | ||
| self._container_env_vars_file = container_env_vars_file | ||
| self._container_dotenv_file = container_dotenv_file | ||
|
|
||
| self._parameter_overrides = parameter_overrides | ||
| # Override certain CloudFormation pseudo-parameters based on values provided by customer | ||
|
|
@@ -245,8 +257,25 @@ def __enter__(self) -> "InvokeContext": | |
| *_function_providers_args[self._containers_mode] | ||
| ) | ||
|
|
||
| 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) | ||
| # Load and merge Lambda runtime environment variables | ||
| # Dotenv values are loaded first with function-specific parsing enabled | ||
| # Then JSON env_vars can override them | ||
| # Lambda env vars support hierarchical structure with Parameters and function-specific sections | ||
| dotenv_vars = self._get_dotenv_values(self._dotenv_file, parse_function_specific=True) | ||
| env_vars = self._get_env_vars_value(self._env_vars_file) | ||
| self._env_vars_value = self._merge_env_vars(dotenv_vars, env_vars, wrap_in_parameters=True) | ||
|
|
||
| # Load and merge container environment variables (used for debugging) | ||
| # Container env vars remain flat (not wrapped in Parameters, no function-specific parsing) | ||
| container_dotenv_vars = self._get_dotenv_values(self._container_dotenv_file, parse_function_specific=False) | ||
| container_env_vars = self._get_env_vars_value(self._container_env_vars_file) | ||
| self._container_env_vars_value = self._merge_env_vars( | ||
| container_dotenv_vars, container_env_vars, wrap_in_parameters=False | ||
| ) | ||
|
|
||
| LOG.debug("Final env vars value: %s", self._env_vars_value) | ||
| LOG.debug("Final container env vars value: %s", self._container_env_vars_value) | ||
|
|
||
| self._log_file_handle = self._setup_log_file(self._log_file) | ||
|
|
||
| # in case of warm containers && debugging is enabled && if debug-function property is not provided, so | ||
|
|
@@ -623,6 +652,172 @@ def _get_stacks(self) -> List[Stack]: | |
| LOG.debug("Can't read stacks information, either template is not found or it is invalid", exc_info=ex) | ||
| raise ex | ||
|
|
||
| @staticmethod | ||
| def _merge_env_vars( | ||
| dotenv_vars: Optional[Dict], json_env_vars: Optional[Dict], wrap_in_parameters: bool | ||
| ) -> Optional[Dict]: | ||
| """ | ||
| Merge environment variables from .env file and JSON file, with JSON taking precedence. | ||
|
|
||
| When wrap_in_parameters=True (Lambda env vars): | ||
| - dotenv_vars may already have hierarchical structure: {"Parameters": {...}, "FunctionName": {...}} | ||
| - If dotenv_vars is flat, wrap it in Parameters | ||
| - json_env_vars merges with Parameters section and preserves function-specific sections | ||
|
|
||
| When wrap_in_parameters=False (container env vars): | ||
| - Both dotenv_vars and json_env_vars should be flat | ||
| - Simple key-value merge with JSON taking precedence | ||
|
|
||
| :param dict dotenv_vars: Variables loaded from .env file (may be hierarchical or flat) | ||
| :param dict json_env_vars: Variables loaded from JSON file | ||
| :param bool wrap_in_parameters: If True, ensure hierarchical structure for Lambda env vars | ||
| :return dict: Merged environment variables, or None if both inputs are None | ||
| """ | ||
| # Handle mocked test scenarios where json_env_vars might not be a dict | ||
| # This check must come before other logic to handle Mock objects properly | ||
| if json_env_vars is not None and not isinstance(json_env_vars, dict): | ||
| return json_env_vars # type: ignore[return-value, unreachable] | ||
|
|
||
| # If both inputs are empty, return None early | ||
| if not dotenv_vars and not json_env_vars: | ||
| return None | ||
|
|
||
| # Initialize result based on dotenv_vars structure | ||
| if not dotenv_vars: | ||
| result = {} | ||
| elif wrap_in_parameters: | ||
| # Check if dotenv_vars is already hierarchical (has Parameters key) | ||
| if "Parameters" in dotenv_vars: | ||
| # Already hierarchical from parse_function_specific=True | ||
| result = {k: v.copy() if isinstance(v, dict) else v for k, v in dotenv_vars.items()} | ||
| else: | ||
| # Flat structure, wrap in Parameters | ||
| result = {"Parameters": dotenv_vars.copy()} | ||
| else: | ||
| # Container mode: keep flat | ||
| result = dotenv_vars.copy() | ||
|
|
||
| # Merge JSON env vars with precedence | ||
| if json_env_vars: | ||
| if wrap_in_parameters: | ||
| # Lambda env vars mode: handle hierarchical structure | ||
| if "Parameters" in json_env_vars: | ||
| # Merge Parameters sections, with json_env_vars taking precedence | ||
| if "Parameters" not in result: | ||
| result["Parameters"] = {} | ||
| result["Parameters"] = {**result.get("Parameters", {}), **json_env_vars["Parameters"]} | ||
|
|
||
| # Merge function-specific sections and other keys | ||
| for key, value in json_env_vars.items(): | ||
| if key != "Parameters": | ||
| if key in result and isinstance(result[key], dict) and isinstance(value, dict): | ||
| # Merge function-specific dicts, JSON takes precedence | ||
| result[key] = {**result[key], **value} | ||
| else: | ||
| # Simple override | ||
| result[key] = value | ||
| else: | ||
| # Container env vars mode: simple flat merge | ||
| result.update(json_env_vars) | ||
|
|
||
| return result if result else None | ||
|
|
||
| @staticmethod | ||
| def _get_dotenv_values(filename: Optional[str], parse_function_specific: bool = False) -> Optional[Dict]: | ||
| """ | ||
| If the user provided a .env file, this method will read the file and return its values as a dictionary. | ||
| Optionally parses function-specific environment variables using FunctionName_VAR pattern. | ||
|
|
||
| :param string filename: Path to .env file containing environment variable values | ||
| :param bool parse_function_specific: If True, parse variables with FunctionName_VAR pattern into | ||
| function-specific sections. If False, returns flat dictionary. | ||
| :return dict: Value of environment variables from .env file, if provided. None otherwise | ||
| When parse_function_specific=True, returns hierarchical structure: | ||
| {"Parameters": {...global vars...}, "FunctionName": {...function vars...}} | ||
| :raises InvokeContextException: If the file was not found or could not be parsed | ||
| """ | ||
| if not filename: | ||
| return None | ||
|
|
||
| # Check if file exists before attempting to read | ||
| if not os.path.exists(filename): | ||
| raise InvalidEnvironmentVariablesFileException("Environment variables file not found: {}".format(filename)) | ||
|
|
||
| try: | ||
| # dotenv_values returns a dictionary with all variables from the .env file | ||
| # It handles comments, quotes, multiline values, etc. | ||
| env_dict = dotenv_values(filename) | ||
|
|
||
| # Log warning if file is empty or couldn't be parsed | ||
| if not env_dict: | ||
| LOG.warning("The .env file '%s' is empty or contains no valid environment variables", filename) | ||
|
|
||
| # Filter out None values and convert to strings | ||
| clean_dict = {k: str(v) if v is not None else "" for k, v in env_dict.items()} | ||
|
|
||
| # If not parsing function-specific vars, return flat structure | ||
| if not parse_function_specific: | ||
| return clean_dict | ||
|
|
||
| # Parse function-specific variables | ||
| return InvokeContext._parse_function_specific_env_vars(clean_dict) | ||
|
|
||
| except Exception as ex: | ||
| raise InvalidEnvironmentVariablesFileException( | ||
| "Could not read environment variables from .env file {}: {}".format(filename, str(ex)) | ||
| ) from ex | ||
|
|
||
| @staticmethod | ||
| def _parse_function_specific_env_vars(env_dict: Dict[str, str]) -> Dict: | ||
| """ | ||
| Parse environment variables to separate global from function-specific variables. | ||
|
|
||
| Variables are classified as function-specific if they match the pattern: FunctionName_VAR | ||
| where FunctionName starts with an uppercase letter (PascalCase). | ||
|
|
||
| Examples: | ||
| MyFunction_API_KEY -> Function-specific for MyFunction | ||
| HelloWorld_TIMEOUT -> Function-specific for HelloWorld | ||
| LAMBDA_VAR -> Global (all uppercase, not PascalCase) | ||
| API_KEY -> Global (no underscore) | ||
| database_url -> Global (starts with lowercase) | ||
|
|
||
| :param dict env_dict: Flat dictionary of environment variables | ||
| :return dict: Hierarchical structure with Parameters and function-specific sections | ||
| """ | ||
| result: Dict[str, Dict[str, str]] = {"Parameters": {}} | ||
|
|
||
| for key, value in env_dict.items(): | ||
| # Check if variable contains underscore | ||
| if "_" not in key: | ||
| # No underscore -> global variable | ||
| result["Parameters"][key] = value | ||
| continue | ||
|
|
||
| # Split by first underscore to get function name prefix and variable name | ||
| parts = key.split("_", 1) | ||
| if len(parts) < 2: # noqa: PLR2004 | ||
| # Edge case: variable has underscore but split failed somehow | ||
| result["Parameters"][key] = value | ||
| continue | ||
|
|
||
| prefix, var_name = parts | ||
|
|
||
| # Check if prefix is PascalCase (starts with uppercase, not all uppercase) | ||
| # PascalCase: First char is uppercase AND not all chars are uppercase | ||
| is_pascal_case = prefix[0].isupper() and not prefix.isupper() | ||
|
|
||
| if is_pascal_case: | ||
| # Function-specific variable: FunctionName_VAR | ||
| if prefix not in result: | ||
| result[prefix] = {} | ||
| result[prefix][var_name] = value | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Leaving a comment in case a future reviewer has a similar concern: I was wondering if we have a way of surfacing to customers that they have provided a function-specific var that doesn't actually correspond to a function. This could be in the case that they typo'd the function name or something. In the existing function specific env-vars code, there is no check for this, so I think it's fine to ignore here. Not sure how much that functionality adds anyway.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done |
||
| else: | ||
| # Global variable (ALL_CAPS, snake_case, etc.) | ||
| result["Parameters"][key] = value | ||
|
|
||
| return result | ||
|
|
||
| @staticmethod | ||
| def _get_env_vars_value(filename: Optional[str]) -> Optional[Dict]: | ||
| """ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My concern is the following cases for function name:
MyFunctionmyFunctionmy_functionFor 1, it should work fine. For 2, it would not correctly pick up that it's Pascal case because it's not capitalized. For 3, it would take
myas the function name incorrectly. I think the best way to do this might be to use a separator that can't be used in a function name (*for example). This way I think you wouldn't have to rely on the Pascal case and have the problem with the separator being possible in a function name.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done