aws_lambda_builders/workflows/nodejs_npm_esbuild/esbuild.py (134 lines of code) (raw):
"""
Wrapper around calling esbuild through a subprocess.
"""
import logging
from pathlib import Path
from typing import Any, Callable, Dict, List, Union
from aws_lambda_builders.actions import ActionFailedError
from aws_lambda_builders.workflows.nodejs_npm.utils import OSUtils
from aws_lambda_builders.workflows.nodejs_npm_esbuild.exceptions import EsbuildCommandError, EsbuildExecutionError
LOG = logging.getLogger(__name__)
class SubprocessEsbuild(object):
"""
Wrapper around the Esbuild command line utility, making it
easy to consume execution results.
"""
def __init__(self, osutils, executable_search_paths, which):
"""
:type osutils: aws_lambda_builders.workflows.nodejs_npm.utils.OSUtils
:param osutils: An instance of OS Utilities for file manipulation
:type executable_search_paths: list
:param executable_search_paths: List of paths to the NPM package binary utilities. This will
be used to find embedded esbuild at runtime if present in the package
:type which: aws_lambda_builders.utils.which
:param which: Function to get paths which conform to the given mode on the PATH
with the prepended additional search paths
"""
self.osutils = osutils
self.executable_search_paths = executable_search_paths
self.which = which
def esbuild_binary(self):
"""
Finds the esbuild binary at runtime.
The utility may be present as a package dependency of the Lambda project,
or in the global path. If there is one in the Lambda project, it should
be preferred over a global utility. The check has to be executed
at runtime, since NPM dependencies will be installed by the workflow
using one of the previous actions.
"""
LOG.debug("checking for esbuild in: %s", self.executable_search_paths)
binaries = self.which("esbuild", executable_search_paths=self.executable_search_paths)
LOG.debug("potential esbuild binaries: %s", binaries)
if binaries:
return binaries[0]
else:
raise EsbuildExecutionError(
message="Cannot find esbuild. esbuild must be installed on the host machine to use this feature. "
"It is recommended to be installed on the PATH, "
"but can also be included as a project dependency."
)
def run(self, args, cwd=None):
"""
Runs the action.
:type args: list
:param args: Command line arguments to pass to Esbuild
:type cwd: str
:param cwd: Directory where to execute the command (defaults to current dir)
:rtype: str
:return: text of the standard output from the command
:raises aws_lambda_builders.workflows.nodejs_npm.npm.EsbuildExecutionError:
when the command executes with a non-zero return code. The exception will
contain the text of the standard error output from the command.
:raises ValueError: if arguments are not provided, or not a list
"""
if not isinstance(args, list):
raise ValueError("args must be a list")
if not args:
raise ValueError("requires at least one arg")
invoke_esbuild = [self.esbuild_binary()] + args
LOG.debug("executing Esbuild: %s", invoke_esbuild)
p = self.osutils.popen(invoke_esbuild, stdout=self.osutils.pipe, stderr=self.osutils.pipe, cwd=cwd)
out, err = p.communicate()
if p.returncode != 0:
raise EsbuildExecutionError(message=err.decode("utf8").strip())
return out.decode("utf8").strip()
NON_CONFIGURABLE_VALUES = {"bundle", "platform", "outdir"}
# Ignore the values below. These are options that Lambda Builders accepts for
# Node.js related workflows, but are not relevant to esbuild itself.
ESBUILD_IGNORE_VALUES = {"use_npm_ci", "entry_points"}
class EsbuildCommandBuilder:
ENTRY_POINTS = "entry_points"
def __init__(
self, scratch_dir: str, artifacts_dir: str, bundler_config: Dict[Any, Any], osutils: OSUtils, manifest: str
):
self._scratch_dir = scratch_dir
self._artifacts_dir = artifacts_dir
self._bundler_config = bundler_config
self._osutils = osutils
self._manifest = manifest
self._command: List[str] = []
def get_command(self) -> List[str]:
"""
Get all of the commands flags created by the command builder
:rtype: List[str]
:return: List of esbuild commands to be executed
"""
return self._command
def build_esbuild_args_from_config(self) -> "EsbuildCommandBuilder":
"""
Build arguments configured in the command config (e.g. template.yaml)
:rtype: EsbuildCommandBuilder
:return: An instance of the command builder
"""
args = []
for config_key, config_value in self._bundler_config.items():
if config_key in NON_CONFIGURABLE_VALUES:
LOG.debug(
"'%s=%s' was not a used configuration since AWS Lambda Builders "
"sets these values for the code to be correctly consumed by AWS Lambda",
config_key,
config_value,
)
continue
if config_key in ESBUILD_IGNORE_VALUES:
continue
configuration_type_callback = self._get_config_type_callback(config_value)
LOG.debug("Configuring the parameter '%s=%s'", config_key, config_value)
args.extend(configuration_type_callback(config_key, config_value))
LOG.debug("Found the following args in the config: %s", str(args))
self._command.extend(args)
return self
def _get_config_type_callback(
self, config_value: Union[bool, str, list]
) -> Callable[[str, Union[bool, str, list]], List[str]]:
"""
Determines the type of the command and returns the corresponding
function to build out that command line argument type
:param config_value: Union[bool, str, list]
The configuration value configured through the options. The configuration should be one
of the supported types as defined by the esbuild API (https://esbuild.github.io/api/).
:return: Callable[[str, Union[bool, str, list]], List[str]]
Returns a function that the caller can use to turn the relevant
configuration into the correctly formatted command line argument.
"""
if isinstance(config_value, bool):
return self._create_boolean_config
elif isinstance(config_value, str):
return self._create_str_config
elif isinstance(config_value, list):
return self._create_list_config
raise EsbuildCommandError("Failed to determine the type of the configuration: %s", config_value)
def _create_boolean_config(self, config_key: str, config_value: bool) -> List[str]:
"""
Given boolean-type configuration, convert it to a string representation suitable for the esbuild API
Should be created in the form ([--config-key])
:param config_key: str
The configuration key to be used
:param config_value: bool
The configuration value to be used
:return: List[str]
List of resolved command line arguments to be appended to the builder
"""
if config_value is True:
return [f"--{self._convert_snake_to_kebab_case(config_key)}"]
return []
def _create_str_config(self, config_key: str, config_value: str) -> List[str]:
"""
Given string-type configuration, convert it to a string representation suitable for the esbuild API
Should be created in the form ([--config-key=config_value])
:param config_key: str
The configuration key to be used
:param config_value: List[str]
The configuration value to be used
:return: List[str]
List of resolved command line arguments to be appended to the builder
"""
return [f"--{self._convert_snake_to_kebab_case(config_key)}={config_value}"]
def _create_list_config(self, config_key: str, config_value: List[str]) -> List[str]:
"""
Given list-type configuration, convert it to a string representation suitable for the esbuild API
Should be created in the form ([--config-key:config_value_a, --config_key:config_value_b])
:param config_key: str
The configuration key to be used
:param config_value: List[str]
The configuration value to be used
:return: List[str]
List of resolved command line arguments to be appended to the builder
"""
args = []
for config_item in config_value:
args.append(f"--{self._convert_snake_to_kebab_case(config_key)}:{config_item}")
return args
def build_entry_points(self) -> "EsbuildCommandBuilder":
"""
Build the entry points to the command
:rtype: EsbuildCommandBuilder
:return: An instance of the command builder
"""
if self.ENTRY_POINTS not in self._bundler_config:
raise EsbuildCommandError(f"{self.ENTRY_POINTS} not set ({self._bundler_config})")
entry_points = self._bundler_config[self.ENTRY_POINTS]
if not isinstance(entry_points, list):
raise EsbuildCommandError(f"{self.ENTRY_POINTS} must be a list ({self._bundler_config})")
if not entry_points:
raise EsbuildCommandError(f"{self.ENTRY_POINTS} must not be empty ({self._bundler_config})")
entry_paths = [self._osutils.joinpath(self._scratch_dir, entry_point) for entry_point in entry_points]
LOG.debug("NODEJS building %s using esbuild to %s", entry_paths, self._artifacts_dir)
for entry_path, entry_point in zip(entry_paths, entry_points):
self._command.append(self._get_explicit_file_type(entry_point, entry_path))
return self
def build_default_values(self) -> "EsbuildCommandBuilder":
"""
Build the default values that each call to esbuild should contain
:rtype: EsbuildCommandBuilder
:return: An instance of the command builder
"""
args = ["--bundle", "--platform=node", "--outdir={}".format(self._artifacts_dir)]
if "target" not in self._bundler_config:
args.append("--target=es2020")
if "format" not in self._bundler_config:
args.append("--format=cjs")
if "minify" not in self._bundler_config:
args.append("--minify")
LOG.debug("Using the following default args: %s", str(args))
self._command.extend(args)
return self
def build_with_no_dependencies(self) -> "EsbuildCommandBuilder":
"""
Set all dependencies located in the package.json to
external so as to not bundle them with the source code
:rtype: EsbuildCommandBuilder
:return: An instance of the command builder
"""
package = self._osutils.parse_json(self._manifest)
dependencies = package.get("dependencies", {}).keys()
args = ["--external:{}".format(dep) for dep in dependencies]
self._command.extend(args)
return self
def _get_explicit_file_type(self, entry_point, entry_path):
"""
Get an entry point with an explicit .ts or .js suffix.
:type entry_point: str
:param entry_point: path to entry file from code uri
:type entry_path: str
:param entry_path: full path of entry file
:rtype: str
:return: entry point with appropriate file extension
:raises lambda_builders.actions.ActionFailedError: when esbuild packaging fails
"""
if Path(entry_point).suffix:
if self._osutils.file_exists(entry_path):
return entry_point
raise ActionFailedError("entry point {} does not exist".format(entry_path))
for ext in [".ts", ".js"]:
entry_path_with_ext = entry_path + ext
if self._osutils.file_exists(entry_path_with_ext):
return entry_point + ext
raise ActionFailedError("entry point {} does not exist".format(entry_path))
@staticmethod
def _convert_snake_to_kebab_case(arg: str) -> str:
"""
The configuration properties passed down to Lambda Builders are done so using snake case
e.g. "main_fields" but esbuild expects them using kebab-case "main-fields"
:rtype: str
:return: mutated string to match the esbuild argument format
"""
return arg.replace("_", "-")