Skip to content

Commit

Permalink
DRY Lambda/GCF FaaS targets (pantsbuild#18964)
Browse files Browse the repository at this point in the history
This reduces duplication between the two FaaS packaging backends
(https://en.wikipedia.org/wiki/Function_as_a_service, I didn't invent
the name 😅 ): AWS Lambdas, and Google Cloud Functions.

These are almost identical, with slight variations like the syntax for
specifying the runtime version, whether they support
`include_requirements` and how exactly they set up the handler file.
Otherwise, they're both basically just "invoke PEX then Lambdex". This
PR is basically just a move-and-adjust, with, theoretically, no
functionality change, other than tweaks to error messages to avoid
having to pass a bunch of different phrasings of user-facing strings
around.

This is refactoring in preparation for pantsbuild#18879: using the new PEX
functionality to output non-Lambdex based artefacts. It felt better to
start from a point of similarity.

The commits are individually reviewable (the first is a little larger
than it exactly needed to be, sorry!).
  • Loading branch information
huonw authored May 10, 2023
1 parent 0fe2e23 commit 7be2216
Show file tree
Hide file tree
Showing 10 changed files with 713 additions and 1,120 deletions.
179 changes: 21 additions & 158 deletions src/python/pants/backend/awslambda/python/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,45 +7,18 @@
from dataclasses import dataclass

from pants.backend.awslambda.python.target_types import (
PythonAwsLambdaCompletePlatforms,
PythonAWSLambda,
PythonAwsLambdaHandlerField,
PythonAwsLambdaIncludeRequirements,
PythonAwsLambdaRuntime,
ResolvedPythonAwsHandler,
ResolvePythonAwsHandlerRequest,
)
from pants.backend.python.subsystems.lambdex import Lambdex
from pants.backend.python.util_rules import pex_from_targets
from pants.backend.python.util_rules.pex import (
CompletePlatforms,
Pex,
PexPlatforms,
PexRequest,
VenvPex,
VenvPexProcess,
)
from pants.backend.python.util_rules.pex_from_targets import PexFromTargetsRequest
from pants.core.goals.package import (
BuiltPackage,
BuiltPackageArtifact,
OutputPathField,
PackageFieldSet,
)
from pants.core.target_types import FileSourceField
from pants.backend.python.util_rules.faas import BuildLambdexRequest, PythonFaaSCompletePlatforms
from pants.core.goals.package import BuiltPackage, OutputPathField, PackageFieldSet
from pants.core.util_rules.environments import EnvironmentField
from pants.engine.addresses import UnparsedAddressInputs
from pants.engine.platform import Platform
from pants.engine.process import ProcessResult
from pants.engine.rules import Get, MultiGet, collect_rules, rule
from pants.engine.target import (
TransitiveTargets,
TransitiveTargetsRequest,
targets_with_sources_types,
)
from pants.engine.unions import UnionMembership, UnionRule
from pants.util.docutil import bin_name, doc_url
from pants.engine.rules import Get, collect_rules, rule
from pants.engine.unions import UnionRule
from pants.util.logging import LogLevel
from pants.util.strutil import softwrap

logger = logging.getLogger(__name__)

Expand All @@ -57,142 +30,32 @@ class PythonAwsLambdaFieldSet(PackageFieldSet):
handler: PythonAwsLambdaHandlerField
include_requirements: PythonAwsLambdaIncludeRequirements
runtime: PythonAwsLambdaRuntime
complete_platforms: PythonAwsLambdaCompletePlatforms
complete_platforms: PythonFaaSCompletePlatforms
output_path: OutputPathField
environment: EnvironmentField


@rule
async def digest_complete_platforms(
complete_platforms: PythonAwsLambdaCompletePlatforms,
) -> CompletePlatforms:
return await Get(
CompletePlatforms, UnparsedAddressInputs, complete_platforms.to_unparsed_address_inputs()
)


@rule(desc="Create Python AWS Lambda", level=LogLevel.DEBUG)
async def package_python_awslambda(
field_set: PythonAwsLambdaFieldSet,
lambdex: Lambdex,
platform: Platform,
union_membership: UnionMembership,
) -> BuiltPackage:
if platform.is_macos:
logger.warning(
softwrap(
f"""
AWS Lambdas built on macOS may fail to build. If your lambda uses any third-party
dependencies without binary wheels (bdist) for Linux available, it will fail to
build. If this happens, you will either need to update your dependencies to only use
dependencies with pre-built wheels, or find a Linux environment to run {bin_name()}
package. (See https://realpython.com/python-wheels/ for more about wheels.)
(If the build does not raise an exception, it's safe to use macOS.)
"""
)
)

# Lambdas typically use the .zip suffix.
output_filename = field_set.output_path.value_or_default(file_ending="zip")

# We hardcode the platform value to the appropriate one for each AWS Lambda runtime.
# (Running the "hello world" lambda in the example code will report the platform, and can be
# used to verify correctness of these platform strings.)
pex_platforms = []
interpreter_version = field_set.runtime.to_interpreter_version()
if interpreter_version:
py_major, py_minor = interpreter_version
platform_str = f"linux_x86_64-cp-{py_major}{py_minor}-cp{py_major}{py_minor}"
# set pymalloc ABI flag - this was removed in python 3.8 https://bugs.python.org/issue36707
if py_major <= 3 and py_minor < 8:
platform_str += "m"
if (py_major, py_minor) == (2, 7):
platform_str += "u"
pex_platforms.append(platform_str)

additional_pex_args = (
# Ensure we can resolve manylinux wheels in addition to any AMI-specific wheels.
"--manylinux=manylinux2014",
# When we're executing Pex on Linux, allow a local interpreter to be resolved if
# available and matching the AMI platform.
"--resolve-local-platforms",
)

complete_platforms = await Get(
CompletePlatforms, PythonAwsLambdaCompletePlatforms, field_set.complete_platforms
)

pex_filename = f"{output_filename}.pex"
pex_request = PexFromTargetsRequest(
addresses=[field_set.address],
internal_only=False,
include_requirements=field_set.include_requirements.value,
output_filename=pex_filename,
platforms=PexPlatforms(pex_platforms),
complete_platforms=complete_platforms,
additional_args=additional_pex_args,
additional_lockfile_args=additional_pex_args,
)

lambdex_pex, pex_result, handler, transitive_targets = await MultiGet(
Get(VenvPex, PexRequest, lambdex.to_pex_request()),
Get(Pex, PexFromTargetsRequest, pex_request),
Get(ResolvedPythonAwsHandler, ResolvePythonAwsHandlerRequest(field_set.handler)),
Get(TransitiveTargets, TransitiveTargetsRequest([field_set.address])),
)

# Warn if users depend on `files` targets, which won't be included in the PEX and is a common
# gotcha.
file_tgts = targets_with_sources_types(
[FileSourceField], transitive_targets.dependencies, union_membership
)
if file_tgts:
files_addresses = sorted(tgt.address.spec for tgt in file_tgts)
logger.warning(
softwrap(
f"""
The `python_awslambda` target {field_set.address} transitively depends on the below
`files` targets, but Pants will not include them in the built Lambda. Filesystem APIs
like `open()` are not able to load files within the binary itself; instead, they
read from the current working directory.
Instead, use `resources` targets. See {doc_url('resources')}.
Files targets dependencies: {files_addresses}
"""
)
)

# NB: Lambdex can modify its input pex in-place, but the REAPI doesn't support that,
# so we provide it with an explicit `-o` option to write to a new file.
result = await Get(
ProcessResult,
VenvPexProcess(
lambdex_pex,
argv=("build", "-e", handler.val, "-o", output_filename, pex_filename),
input_digest=pex_result.digest,
output_files=(output_filename,),
description=f"Setting up handler in {output_filename}",
),
)

extra_log_data: list[tuple[str, str]] = []
if field_set.runtime.value:
extra_log_data.append(("Runtime", field_set.runtime.value))
extra_log_data.extend(("Complete platform", path) for path in complete_platforms)
# The AWS-facing handler function is always lambdex_handler.handler, which is the
# wrapper injected by lambdex that manages invocation of the actual handler.
extra_log_data.append(("Handler", "lambdex_handler.handler"))
first_column_width = 4 + max(len(header) for header, _ in extra_log_data)

artifact = BuiltPackageArtifact(
output_filename,
extra_log_lines=tuple(
f"{header.rjust(first_column_width, ' ')}: {data}" for header, data in extra_log_data
return await Get(
BuiltPackage,
BuildLambdexRequest(
address=field_set.address,
target_name=PythonAWSLambda.alias,
complete_platforms=field_set.complete_platforms,
runtime=field_set.runtime,
handler=field_set.handler,
output_path=field_set.output_path,
include_requirements=field_set.include_requirements.value,
script_handler=None,
script_module=None,
# The AWS-facing handler function is always lambdex_handler.handler, which is the
# wrapper injected by lambdex that manages invocation of the actual handler.
handler_log_message="lambdex_handler.handler",
),
)
return BuiltPackage(digest=result.output_digest, artifacts=(artifact,))


def rules():
Expand Down
2 changes: 1 addition & 1 deletion src/python/pants/backend/awslambda/python/rules_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ def handler(event, context):
".deps/mureq-0.2.0-py3-none-any.whl/mureq/__init__.py" in names
), "third-party dep `mureq` must be included"
if sys.platform == "darwin":
assert "AWS Lambdas built on macOS may fail to build." in caplog.text
assert "`python_awslambda` targets built on macOS may fail to build." in caplog.text

zip_file_relpath, content = create_python_awslambda(
rule_runner,
Expand Down
Loading

0 comments on commit 7be2216

Please sign in to comment.