MauriceBrg/lambda_bundler

View on GitHub
lambda_bundler/dependencies.py

Summary

Maintainability
A
0 mins
Test Coverage
"""This module contains code to install dependencies in a target directory"""
import logging
import os
import pathlib
import shutil
import subprocess
import sys
import tempfile
import typing

import lambda_bundler.util as util

LOGGER = logging.getLogger("lambda_bundler")

def install_dependencies(path_to_requirements: str, path_to_target_directory: str) -> str:
    """
    Installs the dependencies from path_to_requirements.txt into path_to_target_directory.

    :param path_to_requirements: Path to the requirements.txt with the dependencies.
    :type path_to_requirements: str
    :param path_to_target_directory: Path to the target directory to install them in.
    :type path_to_target_directory: str
    :return: Output of the install command.
    :rtype: str
    """

    # Use the pip module to recursively (-r) install all packages from
    # path_to_requirements into (-t) path_to_target_directory while
    # ignoring already installed packages (-I)
    LOGGER.debug("Installing '%s' to '%s'", path_to_requirements, path_to_target_directory)
    call = [sys.executable, "-m", "pip", "install", "-r", path_to_requirements,
            "-t", path_to_target_directory, "-I"]
    return subprocess.check_output(call)

def merge_requirement_files(*file_contents: typing.List[str]) -> str:
    """
    Merges the content of multiple requirements.txt files into one in a reproducible
    manner and returns it. This means empty lines will be removed and the output is
    the data for a new requirements.txt file based on the input.

    :return: A merged version of the content of the requirement files.
    :rtype: str
    """

    output_list = []
    for content in file_contents:
        output_list += [line.strip() for line in content.split("\n") if line.strip() != ""]

    return "\n".join(sorted(output_list))

def collect_and_merge_requirements(*requirement_files: typing.List[str]) -> str:
    """
    Reads the content of all requirement files in requirement_files and merges it
    into a single list of requirements in the form of a string that is returned.

    :return: Merged requirements in the form of a multiline string.
    :rtype: str
    """

    file_contents = util.get_content_of_files(*requirement_files)

    return merge_requirement_files(*file_contents)

def create_zipped_dependencies(requirements_information: str,
                               output_directory_path: str,
                               prefix_in_zip: str = None) -> str:
    """
    This function creates a zip archive that holds the python dependencies
    passed to this function via the requirements_information argument. The
    output will be stored in output_directory_path with a unique name and
    returned. If prefix_in_zip is set, requirements will be installed in a
    subdirectory of the zip (useful for Lambda layers which require a
    python prefix).


    :param requirements_information: The content of the requirements.txt
    :type requirements_information: str
    :param output_directory_path: The directory to build the requirements and store the result in.
    :type output_directory_path: str
    :param prefix_in_zip: Optional prefix in the zip file, defaults to None
    :type prefix_in_zip: str, optional
    :return: Path to the finished zip archive.
    :rtype: str
    """

    # Add the prefix to the hash so we distinguish between layers and regular packages
    prefix_seed = prefix_in_zip or ""
    directory_name = util.hash_string(requirements_information + prefix_seed)

    build_directory = os.path.join(output_directory_path, directory_name)

    # Check if directory exists
    if os.path.exists(build_directory):
        LOGGER.warning("Build-directory '%s' already exists, probably from a failed" \
                       "build - deleting it!", build_directory)
        shutil.rmtree(build_directory)

    # If the prefix is set, we create the install directory with it
    install_directory = build_directory
    if prefix_in_zip is not None:
        install_directory = os.path.join(install_directory, prefix_in_zip)

    # Now we have a clean space and can create our build directory
    pathlib.Path(install_directory).mkdir(parents=True, exist_ok=True)

    # Create the requirements.txt in the install_directory
    requirements_path = os.path.join(install_directory, "requirements.txt")
    with open(requirements_path, "w") as handle:
        handle.write(requirements_information)

    # Install the dependencies to the target directory
    install_dependencies(
        path_to_requirements=requirements_path,
        path_to_target_directory=install_directory
    )

    output_file_name = build_directory if build_directory[-1] != "/" else build_directory[:-1]
    # Zip the temporary directory
    shutil.make_archive(output_file_name, "zip", build_directory)

    # Delete the build directory
    shutil.rmtree(build_directory)

    return f"{output_file_name}.zip"


def create_or_return_zipped_dependencies(requirements_information: str,
                                         output_directory_path: str,
                                         prefix_in_zip: str = None) -> str:
    """
    This function creates or returns a zip archive that holds the python
    dependencies passed to this function via the requirements_information
    argument - if it has been built previously that path is returned. The
    output will be stored in output_directory_path with a unique name and
    returned. If prefix_in_zip is set, requirements will be installed in a
    subdirectory of the zip (useful for Lambda layers which require a
    python prefix).


    :param requirements_information: The content of the requirements.txt
    :type requirements_information: str
    :param output_directory_path: The directory to build the requirements and store the result in.
    :type output_directory_path: str
    :param prefix_in_zip: Optional prefix in the zip file, defaults to None
    :type prefix_in_zip: str, optional
    :return: Path to the finished zip archive.
    :rtype: str
    """

    prefix_seed = prefix_in_zip or ""
    artifact_name = util.hash_string(requirements_information + prefix_seed)

    artifact_path = os.path.join(output_directory_path, f"{artifact_name}.zip")
    if os.path.exists(artifact_path):
        LOGGER.debug("Using cached dependencies from %s", artifact_path)
        return artifact_path

    return create_zipped_dependencies(
        requirements_information=requirements_information,
        output_directory_path=output_directory_path,
        prefix_in_zip=prefix_in_zip
    )

def build_lambda_package_without_dependencies(
        code_directories: typing.List[str],
        exclude_patterns: typing.List[str] = None) -> str:
    """
    This function builds a deployment package for lambda without dependencies.
    It bundles the code from the code_directories while excluding all files/
    directories from the exclude_patterns list.

    :param code_directories: List of paths to directories to include in the zip.
    :type code_directories: typing.List[str]
    :param exclude_patterns: List of patterns that should be excluded from the zip, defaults to None
    :type exclude_patterns: typing.List[str], optional
    :return: Path to the zipped artifact.
    :rtype: str
    """

    # Build the exclude patterns
    exclude_patterns = exclude_patterns or []
    exclude_patterns = exclude_patterns + util.DEFAULT_EXCLUDE_LIST
    ignore_during_copy = shutil.ignore_patterns(*exclude_patterns)

    # Create a working directory, copy all source directories there with the exclude list
    with tempfile.TemporaryDirectory() as working_directory:

        for directory in code_directories:

            # Get the name of the directory -> "path/to/directory" would return "directory"
            source_directory_name = os.path.basename(directory)

            # This is the directory that will ultimately be zipped
            target_directory = os.path.join(working_directory, source_directory_name)

            # Copy the source directory to the working directory
            shutil.copytree(directory, target_directory, ignore=ignore_during_copy)

        target_zip_name = util.hash_string("".join(code_directories))
        zip_path = os.path.join(util.get_build_dir(), target_zip_name)

        # Zip the directory after removing a potential .zip suffix
        shutil.make_archive(zip_path, "zip", working_directory)
        return zip_path + ".zip"

def build_lambda_package_with_dependencies(
        code_directories: typing.List[str],
        requirement_files: typing.List[str],
        exclude_patterns: typing.List[str] = None) -> str:
    """
    This function bundles the code of one or more code_directories stripped
    from all files/directories that match the exclude_patterns together with
    the dependencies in requirement_files and returns a zip archive for deployment
    in AWS lambda.

    :param code_directories: List of paths to the directories that hold the code.
    :type code_directories: typing.List[str]
    :param requirement_files: List of paths to requirement files with the dependencies.
    :type requirement_files: typing.List[str]
    :param exclude_patterns: List of patterns to exclude from code_directories, defaults to None
    :type exclude_patterns: typing.List[str], optional
    :return: Path to the zipped artifacts.
    :rtype: str
    """

    collected_dependencies = collect_and_merge_requirements(
        *requirement_files
    )

    requirements_zip = create_or_return_zipped_dependencies(
        requirements_information=collected_dependencies,
        output_directory_path=util.get_build_dir(),
    )

    # Hash the requirement files and code directories in order to get
    # a unique hash for this combination
    target_zip_name = util.hash_string(
        "".join(code_directories) + "".join(requirement_files)) + ".zip"
    zip_path = os.path.join(util.get_build_dir(), target_zip_name)

    shutil.copyfile(
        src=requirements_zip,
        dst=zip_path
    )

    util.extend_zip(
        path_to_zip=zip_path,
        code_directories=code_directories,
        exclude_patterns=exclude_patterns
    )

    return zip_path