diff --git a/.github/workflows/pull-request-code.yml b/.github/workflows/pull-request-code.yml deleted file mode 100644 index de1b0e9..0000000 --- a/.github/workflows/pull-request-code.yml +++ /dev/null @@ -1,157 +0,0 @@ -name: PR Code Checks - -on: - pull_request: - branches: - - main - - master - types: - - opened - - synchronize - -env: - PYTHON_VERSION: "3.11" - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ env.PYTHON_VERSION }} - - - name: Install uv - uses: astral-sh/setup-uv@v6.4.3 - with: - version: latest - - - name: Generate cache key - id: cache-key - run: | - echo "value=build-${{ hashFiles('pyproject.toml', 'uv.lock') }}-${{ runner.os }}" >> $GITHUB_OUTPUT - - - name: Install dependencies - run: ./scripts/setup.sh - - - name: Cache dependencies - uses: actions/cache@v4 - with: - path: | - .venv - .uv - key: ${{ steps.cache-key.outputs.value }} - - - name: Run linting - run: ./scripts/lint.sh - - build: - name: Build - runs-on: ubuntu-latest - needs: lint - outputs: - cache-key: ${{ steps.cache-key.outputs.value }} - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ env.PYTHON_VERSION }} - - - name: Install uv - uses: astral-sh/setup-uv@v6.4.3 - with: - version: latest - - - name: Install dependencies - run: ./scripts/setup.sh - - - name: Build Lambda package - run: ./scripts/build.sh - - - name: Generate cache key - id: cache-key - run: | - echo "value=build-${{ hashFiles('pyproject.toml', 'uv.lock') }}-${{ runner.os }}" >> $GITHUB_OUTPUT - - - name: Cache dependencies and build artifacts - uses: actions/cache@v4 - with: - path: | - .venv - .uv - dist - key: ${{ steps.cache-key.outputs.value }} - - unit-test: - name: Unit Test - runs-on: ubuntu-latest - needs: build - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ env.PYTHON_VERSION }} - - - name: Install uv - uses: astral-sh/setup-uv@v6.4.3 - with: - version: latest - - - name: Restore dependencies cache - uses: actions/cache@v4 - with: - path: | - .venv - .uv - dist - key: ${{ needs.build.outputs.cache-key }} - - - name: Run unit tests - run: ./scripts/unit-test.sh - - integration-test: - name: Integration Test - runs-on: ubuntu-latest - needs: build - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ env.PYTHON_VERSION }} - - - name: Install uv - uses: astral-sh/setup-uv@v6.4.3 - with: - version: latest - - - name: Restore dependencies and build cache - uses: actions/cache@v4 - with: - path: | - .venv - .uv - dist - key: ${{ needs.build.outputs.cache-key }} - - - name: Start Lambda container - run: ./scripts/local-instance.sh start - - - name: Run integration tests - run: ./scripts/integration-test.sh - - - name: Stop Lambda container - if: always() - run: ./scripts/local-instance.sh stop diff --git a/.github/workflows/pull-request-format.yml b/.github/workflows/pull-request-format.yml deleted file mode 100644 index 37c24e0..0000000 --- a/.github/workflows/pull-request-format.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: PR Format Checks -on: - pull_request: - types: - - opened - - edited - - synchronize - - reopened - -jobs: - conventional-commit: - name: Conventional Commit Title - runs-on: ubuntu-latest - # permissions: - # pull-requests: read - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Validate PR title - run: ./scripts/conventional-commit.sh "${{ github.event.pull_request.title }}" diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 0000000..60dd096 --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,62 @@ +name: PR Checks + +on: + pull_request: + branches: + - main + - master + types: + - opened + - synchronize + - reopened + - edited + +jobs: + pr-code-checks: + name: Code + runs-on: ubuntu-latest + if: github.event.action == 'opened' || github.event.action == 'synchronize' || github.event.action == 'reopened' + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + ref: ${{ github.head_ref || github.ref }} + # Need full history and tags to find the last release. + fetch-depth: 0 + fetch-tags: true + + # Work around occasional fetch-tags quirks. + - run: git fetch --tags --force + + - name: Install Python + uses: actions/setup-python@v5 + + - name: Install uv + uses: astral-sh/setup-uv@v6.4.3 + with: + version: latest + + - name: Setup + run: uv run task setup + + - name: Lint + run: uv run task lint + + - name: Unit Test + run: uv run task unit-test + + - name: Build + run: uv run task build + + - name: Integration Test + run: uv run task integration-test-no-build + + pr-content-checks: + name: Content + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Validate PR title + run: ./scripts/conventional-commit.sh "${{ github.event.pull_request.title }}" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b207694 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +######################################################## +# Stage 1: Install dependencies with uv and copy to /opt/python +######################################################## +FROM python:3.12-slim AS deps + +# Avoid prompts & keep images small +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 + +ARG APP_ROOT="/app" +WORKDIR ${APP_ROOT} + +# Bring in lockfiles first for better caching +COPY pyproject.toml ${APP_ROOT}/ +COPY uv.lock ${APP_ROOT}/ + +# Install uv and create a local venv, then sync (no dev deps) +RUN pip install --no-cache-dir uv +RUN uv sync --no-group dev --frozen + +# Materialize a Lambda-style "layer" dir with only runtime packages +# (Lambda adds /opt/python to sys.path automatically) +RUN mkdir -p /opt/python +RUN cp -a ${APP_ROOT}/.venv/lib/python3.12/site-packages/. /opt/python/ + + +######################################################## +# Stage 2: Create final AWS Lambda image +######################################################## +#FROM public.ecr.aws/lambda/python:3.12-arm64 +FROM public.ecr.aws/lambda/python:3.12 + +ARG APP_NAME +ARG APP_VERSION +ARG COMMIT_SHA +ARG BRANCH +ARG BUILD_DATE + +ENV APP_NAME="${APP_NAME}" +ENV APP_VERSION="${APP_VERSION}" +ENV COMMIT_SHA="${COMMIT_SHA}" +ENV BRANCH="${BRANCH}" +ENV BUILD_DATE="${BUILD_DATE}" + +LABEL org.opencontainers.image.name="${APP_NAME}" \ + org.opencontainers.image.version="${APP_VERSION}" \ + org.opencontainers.image.revision="${COMMIT_SHA}" \ + org.opencontainers.image.ref.branch="${BRANCH}" \ + org.opencontainers.image.created="${BUILD_DATE}" + +# This image automatically adds packages in /opt/python to sys.path +COPY --from=deps /opt/python /opt/python +COPY src/ ${LAMBDA_TASK_ROOT}/ + +ENV _HANDLER="main.handler" +CMD ["main.handler"] diff --git a/README.md b/README.md index 9040964..db0d4ff 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ -# lambda-application -Lambda app code and release, decoupled from Terraformed infrastructure +# Demo Lambda Application + +Lambda app code and release, decoupled from Terraformed infrastructure. diff --git a/Taskfile.yml b/Taskfile.yml index 8639cfa..571fc89 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -2,6 +2,12 @@ version: "3" +vars: + DOCKER_IMAGE_NAME: lambda-application + DOCKER_IMAGE_TAG: + sh: ./scripts/get-version.sh + DOCKER_IMAGE: "{{.DOCKER_IMAGE_NAME}}:{{.DOCKER_IMAGE_TAG}}" + tasks: default: cmds: @@ -18,6 +24,11 @@ tasks: cmds: - ./scripts/setup.sh + get-version: + run: once + cmds: + - ./scripts/get-version.sh + format: run: once cmds: @@ -31,28 +42,37 @@ tasks: build: run: once cmds: - - ./scripts/build.sh + - ./scripts/build-image.sh unit-test: run: once cmds: - ./scripts/unit-test.sh + integration-test-no-build: + run: once + cmds: + - task: start-local-instance + - defer: { task: stop-local-instance } + - ./scripts/integration-test.sh + integration-test: run: once deps: - build cmds: - task: start-local-instance + - defer: { task: stop-local-instance } - ./scripts/integration-test.sh - - task: stop-local-instance start-local-instance: + desc: Start a local Lambda instance for testing run: once cmds: - - ./scripts/local-instance.sh start + - ./scripts/local-instance.sh start {{.DOCKER_IMAGE}} stop-local-instance: + desc: Stop the local Lambda instance run: once cmds: - - ./scripts/local-instance.sh stop + - ./scripts/local-instance.sh stop {{.DOCKER_IMAGE}} diff --git a/pyproject.toml b/pyproject.toml index c727376..265d46b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,12 +10,12 @@ dependencies = [ [dependency-groups] dev = [ + "go-task-bin>=3.44.1", "pytest>=8.3", "pytest-cov>=6.2", "python-semantic-release>=10.3.1", "requests>=2.32.4", "ruff>=0.12", - "setuptools-scm>=8.3.1", ] [tool.pytest.ini_options] @@ -53,14 +53,26 @@ exclude_lines = [ "pragma: no cover", ] -# Decide next SemVer, create the tag, create the GitHub release [tool.semantic_release] -version_source = "tag" -tag_format = "v{version}" +version_source = "tag" +tag_format = "v{version}" +commit_parser = "conventional" +version_variables = ["pyproject.toml:project.version"] allow_zero_version = true -# Handled by separate CI job. upload_to_vcs_release = false -# Use tag to set __version__/project version for build artifact -[tool.setuptools_scm] -tag_regex = "v?(?P\\d+\\.\\d+\\.\\d+.*)" +# Tune conventional commits behavior +[tool.semantic_release.commit_parser_options] +parse_squash_commits = true +ignore_merge_commits = true + +# Full releases from main +[tool.semantic_release.branches.main] +match = "^(main|master)$" +prerelease = false + +# Everything else => prerelease with token "dev" +[tool.semantic_release.branches.dev] +match = ".*" +prerelease = true +prerelease_token = "dev" diff --git a/scripts/build-image.sh b/scripts/build-image.sh new file mode 100755 index 0000000..718144c --- /dev/null +++ b/scripts/build-image.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +set -euo pipefail + +# Arguments +AWS_ACCOUNT_ID="${1:-}" +AWS_REGION="${2:-}" + +# Derived variables +APP_NAME="lambda-application" +APP_VERSION=$(uv run ./scripts/get-version.sh) +SHA=$(git rev-parse --short HEAD) +BRANCH=$(git rev-parse --abbrev-ref HEAD) +DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + +function build() { + echo "Building Lambda deployment package..." + docker build \ + --build-arg APP_NAME="$APP_NAME" \ + --build-arg APP_VERSION="$APP_VERSION" \ + --build-arg COMMIT_SHA="$SHA" \ + --build-arg BRANCH="$BRANCH" \ + --build-arg BUILD_DATE="$DATE" \ + -t "$APP_NAME:$APP_VERSION" . + echo "Build completed successfully." +} + +function tag_ecr() { + echo "Tagging image..." + docker tag "$APP_NAME:$APP_VERSION" "${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/$APP_NAME:$APP_VERSION" + echo "Tagging completed successfully." +} + +function main() { + build; + if [ -n "${AWS_ACCOUNT_ID}" ] && [ -n "${AWS_REGION}" ]; then + tag_ecr; + else + echo "AWS_ACCOUNT_ID and AWS_REGION are not both set. Skipping ECR tagging." + fi +} + +main \ No newline at end of file diff --git a/scripts/build-zip.sh b/scripts/build-zip.sh new file mode 100755 index 0000000..83a1450 --- /dev/null +++ b/scripts/build-zip.sh @@ -0,0 +1,114 @@ +#!/bin/bash + +set -e # Exit on any error + + +ENTRYPOINT_CODE_FILE="main.py" +DIST_FILE="lambda.zip" +DIST_PATH="dist/${DIST_FILE}" + + +function cleanup() { + echo "Cleaning up previous build..." + rm -rf dist + mkdir -p dist +} + + +function build() { + echo "Building Lambda deployment package..." + # Create zip with source files at root level, excluding test and cache files + pushd src > /dev/null + zip -r ../${DIST_PATH} . \ + -x "*.pyc" \ + -x "__pycache__/*" \ + -x "*.pyo" \ + -x "*.pyd" \ + -x "*.so" \ + -x "*.egg" \ + -x "*.egg-info" \ + -x "*.test.py" \ + -x "*_test.py" \ + -x "test_*.py" \ + -x "tests/*" \ + -x ".pytest_cache/*" \ + -x ".coverage" \ + -x "*.log" \ + -x ".DS_Store" \ + -x "Thumbs.db" + + popd > /dev/null + + echo "Build completed successfully!" +} + +function verify_build() { + echo "Verifying archive structure..." + + # Verify no test files are included + if unzip -l ${DIST_PATH} | grep -q "test"; then + echo "ERROR: Test files found in archive ${DIST_PATH}!" + echo "Contents:" + unzip -l ${DIST_PATH} | grep "test" + exit 1 + fi + + # Verify no cache files are included + if unzip -l ${DIST_PATH} | grep -q "__pycache__\|\.pyc\|\.pyo"; then + echo "ERROR: Cache files found in archive ${DIST_PATH}!" + echo "Contents:" + unzip -l ${DIST_PATH} | grep "__pycache__\|\.pyc\|\.pyo" + exit 1 + fi + + # Verify source files are at root level + if ! zipinfo -1 ${DIST_PATH} | grep -q "^${ENTRYPOINT_CODE_FILE}$"; then + echo "ERROR: Entrypoint file ${ENTRYPOINT_CODE_FILE} not found at root level in ${DIST_PATH}!" + echo "Contents:" + unzip -l ${DIST_PATH} | grep "^${ENTRYPOINT_CODE_FILE}$" + exit 1 + fi + + # Verify archive is not empty + ARCHIVE_SIZE=$(unzip -l ${DIST_PATH} | tail -1 | awk '{print $2}') + if [ "$ARCHIVE_SIZE" -eq 0 ]; then + echo "ERROR: Archive ${DIST_PATH} is empty!" + exit 1 + fi + + echo "Archive structure is correct." +} + +function sha256_hash() { + # shasum is available on macOS, sha256sum is available on Linux + if uname -a | grep -q "Darwin"; then + shasum -a 256 ${DIST_PATH} | awk '{print $1}' + else + sha256sum ${DIST_PATH} | awk '{print $1}' + fi +} + +function write_gha_outputs() { + echo "size=$1" >> "$GITHUB_OUTPUT" + echo "sha256=$2" >> "$GITHUB_OUTPUT" +} + +function main() { + cleanup + build + verify_build + + zip_size=$(ls -lh dist/lambda.zip | awk '{print $5}') + zip_sha256=$(sha256_hash) + echo "Lambda deployment package built" + echo "path=${DIST_PATH}" + echo "size=${zip_size}" + echo "sha256=${zip_sha256}" + + if [ -n "$GITHUB_OUTPUT" ]; then + echo "Writing Github Actions outputs" + write_gha_outputs "$zip_size" "$zip_sha256" + fi +} + +main diff --git a/scripts/build.sh b/scripts/build.sh index 7025796..83a1450 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -106,6 +106,7 @@ function main() { echo "sha256=${zip_sha256}" if [ -n "$GITHUB_OUTPUT" ]; then + echo "Writing Github Actions outputs" write_gha_outputs "$zip_size" "$zip_sha256" fi } diff --git a/scripts/get-version.sh b/scripts/get-version.sh new file mode 100755 index 0000000..c0e5e16 --- /dev/null +++ b/scripts/get-version.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +set -euo pipefail + +# A dummy token is set to avoid the missing token warning. We just need the version. +export GH_TOKEN="${GH_TOKEN:-dummy}" + +version="$(uv run semantic-release version --print)" +echo "$version" diff --git a/scripts/local-instance.sh b/scripts/local-instance.sh index d529bdc..072aab7 100755 --- a/scripts/local-instance.sh +++ b/scripts/local-instance.sh @@ -3,25 +3,24 @@ set -e operation="$1" +image="$2" -dist_path="$PWD/dist" -lambda_zip_path="$dist_path/lambda.zip" -lambda_task_path=".lambda_task" -lambda_task_handler="main.handler" - -image="public.ecr.aws/lambda/python:3.11" -container_name="lambda-integration-test" -container_port=9000 -invoke_url="http://localhost:${container_port}/2015-03-31/functions/function/invocations" - -function unzip_lambda_archive() { - rm -rf "${lambda_task_path}" && mkdir -p "${lambda_task_path}" - unzip -q "${lambda_zip_path}" -d "${lambda_task_path}" +function usage() { + echo "Usage: $0 " + echo "Example: $0 start lambda-application:3.2.1-dev.1" } -function clean_lambda_archive() { - if [ -d "${lambda_task_path}" ]; then - rm -rf "${lambda_task_path}" +function ensure_parameters() { + if [ -z "${operation}" ]; then + echo "Error: Operation is required" + usage + exit 1 + fi + + if [ -z "${image}" ]; then + echo "Error: Image is required" + usage + exit 1 fi } @@ -58,16 +57,15 @@ function wait_for_container_ready() { function start_container() { echo "Starting container ${container_name} with image: ${image}" echo "Port mapping: ${container_port}:8080" - echo "Handler: ${lambda_task_handler}" docker run --rm -d \ --name "${container_name}" \ -p "${container_port}:8080" \ - -v "$PWD/${lambda_task_path}":/var/task:ro \ - "${image}" \ - "${lambda_task_handler}" + "${image}" wait_for_container_ready + + echo "Example invocation: curl -s -X POST ${invoke_url} -d '{\"name\":\"Foo\"}' | jq" } function stop_container() { @@ -83,16 +81,20 @@ function main() { "start") echo "Starting container ${container_name}..." stop_container || true - clean_lambda_archive || true - unzip_lambda_archive start_container ;; "stop") echo -e "\n\nStopping container ${container_name}..." stop_container - clean_lambda_archive ;; esac } +ensure_parameters +image_name="$(echo ${image} | cut -d ':' -f 1)" +image_tag="$(echo ${image} | cut -d ':' -f 2)" +container_name="${image_name}-${image_tag}-integration-test" +container_port=9000 +invoke_url="http://localhost:${container_port}/2015-03-31/functions/function/invocations" + main \ No newline at end of file diff --git a/src/main.py b/src/main.py index d211d78..8ce5ca2 100644 --- a/src/main.py +++ b/src/main.py @@ -1,10 +1,87 @@ +import json +import logging +import os + +# Configure logging +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + def handler(event, context): - print("Hello from lambda-application!") + """ + Basic Lambda function handler + + Args: + event: AWS Lambda event data + context: AWS Lambda context object + + Returns: + dict: Response with status code and message + """ + + try: + APP_NAME = os.environ["APP_NAME"] + APP_VERSION = os.environ["APP_VERSION"] + COMMIT_SHA = os.environ["COMMIT_SHA"] + BRANCH = os.environ["BRANCH"] + BUILD_DATE = os.environ["BUILD_DATE"] + + logger.debug(f"APP_NAME: {APP_NAME}") + logger.debug(f"APP_VERSION: {APP_VERSION}") + logger.debug(f"COMMIT_SHA: {COMMIT_SHA}") + logger.debug(f"BRANCH: {BRANCH}") + logger.debug(f"BUILD_DATE: {BUILD_DATE}") + + except KeyError as e: + logger.error(f"Missing environment variable: {e}") + # Set default values if environment variables are missing + APP_NAME = os.environ.get("APP_NAME", "unknown") + APP_VERSION = os.environ.get("APP_VERSION", "unknown") + COMMIT_SHA = os.environ.get("COMMIT_SHA", "unknown") + BRANCH = os.environ.get("BRANCH", "unknown") + BUILD_DATE = os.environ.get("BUILD_DATE", "unknown") + + try: + # Log the incoming event + logger.info(f"Event received: {json.dumps(event)}") + + # Extract name from event if available, otherwise use default + name = event.get("name", "World") if event else "World" + + function_name = "unknown" + if context and hasattr(context, "function_name"): + function_name = context.function_name + + function_version = "unknown" + if context and hasattr(context, "function_version"): + function_version = context.function_version + + # Create response with proper structure + response = { + "statusCode": 200, + "body": { + "message": f"Hello, {name}!", + "metadata": { + "function_name": function_name, + "function_version": function_version, + "app_name": APP_NAME, + "app_version": APP_VERSION, + "commit_sha": COMMIT_SHA, + "branch": BRANCH, + "build_date": BUILD_DATE, + }, + }, + } + + logger.info(f"Response: {json.dumps(response)}") + return response - return { - "statusCode": 200, - "body": "Hello from lambda-application!", - } + except Exception as e: + logger.error(f"Error: {str(e)}") + return { + "statusCode": 500, + "body": {"error": "Internal server error", "message": str(e)}, + } if __name__ == "__main__": diff --git a/src/main_test.py b/src/main_test.py index 2362798..630d41d 100644 --- a/src/main_test.py +++ b/src/main_test.py @@ -1,51 +1,75 @@ -from unittest.mock import patch +import os from main import handler -def test_handler_prints_hello_message(): - """Test that handler() prints the expected hello message.""" - with patch("builtins.print") as mock_print: - handler(None, None) - mock_print.assert_called_once_with("Hello from lambda-application!") +class TestHandler: + def setup_class(self): + # Set environment variables expected by handler() + os.environ["APP_NAME"] = "lambda-application" + os.environ["APP_VERSION"] = "1.0.0" + os.environ["COMMIT_SHA"] = "1234567890" + os.environ["BRANCH"] = "main" + os.environ["BUILD_DATE"] = "2021-01-01" -def test_handler_returns_correct_response(): - """Test that handler() returns the expected response structure.""" - result = handler(None, None) + def teardown_class(self): + del os.environ["APP_NAME"] + del os.environ["APP_VERSION"] + del os.environ["COMMIT_SHA"] + del os.environ["BRANCH"] + del os.environ["BUILD_DATE"] - expected_response = {"statusCode": 200, "body": "Hello from lambda-application!"} + def test_handler_returns_correct_response(self): + """Test that handler() returns the expected response structure.""" + result = handler(None, None) - assert result == expected_response - assert result["statusCode"] == 200 - assert result["body"] == "Hello from lambda-application!" + # Check that the response has the expected structure + assert result["statusCode"] == 200 + assert "body" in result + for key in ["message", "metadata"]: + assert key in result["body"], f"Key {key} not found in result['body']" + # Check message + assert result["body"]["message"] == "Hello, World!" -def test_handler_calls_print(): - """Test that handler() actually calls print function.""" - with patch("builtins.print") as mock_print: - handler(None, None) - assert mock_print.called + # Check metadata structure + metadata = result["body"]["metadata"] + for key in ["app_name", "app_version", "commit_sha", "branch", "build_date", "function_name", "function_version"]: + assert key in metadata, f"Key {key} not found in result['body']['metadata']" + assert metadata["app_name"] == "lambda-application", f"App name {metadata['app_name']} != lambda-application" + assert metadata["app_version"] == "1.0.0", f"App version {metadata['app_version']} != 1.0.0" + assert metadata["commit_sha"] == "1234567890", f"Commit sha {metadata['commit_sha']} != 1234567890" + assert metadata["branch"] == "main", f"Branch {metadata['branch']} != main" + assert metadata["build_date"] == "2021-01-01", f"Build date {metadata['build_date']} != 2021-01-01" -def test_handler_with_event_and_context(): - """Test that handler() works with event and context parameters.""" - test_event = {"test": "data"} - test_context = {"function_name": "test-function"} + # Context-related fields should be None or 'unknown' when context is None + assert metadata["function_name"] in [None, "unknown"], f"Function name {metadata['function_name']} not in [None, 'unknown']" + assert metadata["function_version"] in [None, "unknown"], f"Function version {metadata['function_version']} not in [None, 'unknown']" - with patch("builtins.print") as mock_print: - result = handler(test_event, test_context) + def test_handler_with_event_and_context(self): + """Test that handler() works with event and context parameters.""" + test_event = {"name": "test-name"} + test_context = {"function_name": "test-function-name", "function_version": "1"} - # Verify print was called - mock_print.assert_called_once_with("Hello from lambda-application!") + result = handler(test_event, test_context) - # Verify response structure + # Check that the response has the expected structure assert result["statusCode"] == 200 - assert result["body"] == "Hello from lambda-application!" + assert "body" in result + for key in ["message", "metadata"]: + assert key in result["body"], f"Key {key} not found in result['body']" + + # Check message + assert result["body"]["message"] == "Hello, test-name!" + # Check metadata structure + metadata = result["body"]["metadata"] + for key in ["app_name", "app_version", "commit_sha", "branch", "build_date", "function_name", "function_version"]: + assert key in metadata, f"Key {key} not found in result['body']['metadata']" -def test_main_module_execution(): - """Test that the module can be executed directly.""" - with patch("builtins.print"): + def test_main_module_execution(self): + """Test that the module can be executed directly.""" # Import and execute the main block import main diff --git a/tests/integration_test.py b/tests/integration_test.py index 8fedd6e..5e701ac 100644 --- a/tests/integration_test.py +++ b/tests/integration_test.py @@ -22,5 +22,24 @@ def test_handler_function_returns_expected_message(self): # Verify response structure assert isinstance(result, dict), "Handler should return a dictionary" + assert "statusCode" in result, "Response should contain statusCode" assert "body" in result, "Response should contain body" - assert result["body"] == "Hello from lambda-application!", "Body should match expected message" + + # Check status code + assert result["statusCode"] == 200, f"Status code should be 200, got {result['statusCode']}" + + # Check body structure + body = result["body"] + assert isinstance(body, dict), "Body should be a dictionary" + assert "message" in body, "Body should contain message" + assert "metadata" in body, "Body should contain metadata" + + # Check message content + assert body["message"] == "Hello, World!", f"Message should be 'Hello, World!', got '{body['message']}'" + + # Check metadata structure + metadata = body["metadata"] + expected_metadata_keys = ["app_name", "app_version", "branch", "build_date"] + for key in expected_metadata_keys: + assert key in metadata, f"Metadata should contain {key}" + assert metadata[key] is not None, f"Metadata {key} should not be None" diff --git a/uv.lock b/uv.lock index e3b17c7..2f64ef6 100644 --- a/uv.lock +++ b/uv.lock @@ -230,6 +230,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/61/d4b89fec821f72385526e1b9d9a3a0385dda4a72b206d28049e2c7cd39b8/gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77", size = 208168 }, ] +[[package]] +name = "go-task-bin" +version = "3.44.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/5e/12cda017fa77ee235745d2d67f696bbafc53918a9727806eaf090523804f/go_task_bin-3.44.1.tar.gz", hash = "sha256:dbb38dde0d3ca6445a51ed1b1c79969c40b27c6df58449012972f25f3051f54a", size = 571418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/80/b06753bf02af0cbd28721295a6dea8c951c06407d0e0a202133a0215535e/go_task_bin-3.44.1-py3-none-android_33_arm64_v8a.whl", hash = "sha256:7d7cf5df2729d20ed9d4fc5aa49c18b832b837cd5357b3f11f6b65824a29ee32", size = 6462583 }, + { url = "https://files.pythonhosted.org/packages/ba/24/9fe13951b9961810edfa39bed66c9c8c4fe88ec777e89033888c5c4c1311/go_task_bin-3.44.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5050861a1144fbd2c0e773aa23d918c0a68abcc9c9df02299feaa642e7dd1685", size = 6391126 }, + { url = "https://files.pythonhosted.org/packages/f5/c1/b626c8dbc5a956ed7d6ab9ad7fadf924245748e2b85f7ec9a50c57ac96ed/go_task_bin-3.44.1-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:be0baaf29567d430ac9275802290d3b43b9c5f4af79a9faf9893e11a73ec1eb0", size = 6846631 }, + { url = "https://files.pythonhosted.org/packages/3d/f3/b50ef4286a1dc2e600cf8890104c3b8d21713a592de8287d502f7b1e90e7/go_task_bin-3.44.1-py3-none-manylinux_2_28_aarch64.musllinux_1_2_aarch64.whl", hash = "sha256:8ab0174ebaa9113134f3b60f3e538ba926afd8e429e9e117b9d9776fd8f75fe4", size = 6153057 }, + { url = "https://files.pythonhosted.org/packages/b8/71/586a050b55953e48f09d17429c3f6a3101d26b37614065f974fcf8ae5c23/go_task_bin-3.44.1-py3-none-manylinux_2_28_ppc64le.musllinux_1_2_ppc64le.whl", hash = "sha256:7d02aefefa487edfe0e684054452302d60d683e5c36cb1f21712d2a7c4051acf", size = 6127582 }, + { url = "https://files.pythonhosted.org/packages/29/8c/cd1424ef91987e595b16a910a40efec343f1929402b82bbd36dd0ab358ae/go_task_bin-3.44.1-py3-none-manylinux_2_28_riscv64.musllinux_1_2_riscv64.whl", hash = "sha256:3d7b9d04171b4a901e40bf1ddf22db65db7b650bd05725691cf63bede4f293b7", size = 6355433 }, + { url = "https://files.pythonhosted.org/packages/c8/cb/03625bb25fe924e79e474c59a17bbbdbb2fedde2368373458db4e07e858b/go_task_bin-3.44.1-py3-none-manylinux_2_28_s390x.musllinux_1_2_s390x.whl", hash = "sha256:a49f0e15b353b9a501cf7de7a46e795c96f2e5139559aec5fcedf4526a53a9bf", size = 6602738 }, + { url = "https://files.pythonhosted.org/packages/68/19/eaaadf3c81f8179e3039fcd9e996d24190affde48bc89732209a0b773251/go_task_bin-3.44.1-py3-none-manylinux_2_28_x86_64.musllinux_1_2_x86_64.whl", hash = "sha256:2040cf1cf848fa9b6c2292c4d994f6322d237c76ba2c78c25735fc5528cf3032", size = 6735464 }, + { url = "https://files.pythonhosted.org/packages/74/42/1f7b0b27d60e7978bf87e227b5c482796b36d33e212a9ae476425a03407b/go_task_bin-3.44.1-py3-none-win_amd64.whl", hash = "sha256:ebfd39ef70433ec8adaa5b4c8b705dcbdfc9ff4998887a45572c9108f226fb09", size = 6929044 }, + { url = "https://files.pythonhosted.org/packages/da/24/87e864764d148bd9b18c40a7eef824d4340e44f2aa09d2e8c3a405d1e58e/go_task_bin-3.44.1-py3-none-win_arm64.whl", hash = "sha256:abf61a43a42854a0cc2e0036460d8f0fa924eaab7d9dab5069a431b61e06bd04", size = 6257152 }, +] + [[package]] name = "idna" version = "3.10" @@ -276,24 +294,24 @@ source = { virtual = "." } [package.dev-dependencies] dev = [ + { name = "go-task-bin" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "python-semantic-release" }, { name = "requests" }, { name = "ruff" }, - { name = "setuptools-scm" }, ] [package.metadata] [package.metadata.requires-dev] dev = [ + { name = "go-task-bin", specifier = ">=3.44.1" }, { name = "pytest", specifier = ">=8.3" }, { name = "pytest-cov", specifier = ">=6.2" }, { name = "python-semantic-release", specifier = ">=10.3.1" }, { name = "requests", specifier = ">=2.32.4" }, { name = "ruff", specifier = ">=0.12" }, - { name = "setuptools-scm", specifier = ">=8.3.1" }, ] [[package]] @@ -604,28 +622,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/5c/799a1efb8b5abab56e8a9f2a0b72d12bd64bb55815e9476c7d0a2887d2f7/ruff-0.12.8-py3-none-win_arm64.whl", hash = "sha256:c90e1a334683ce41b0e7a04f41790c429bf5073b62c1ae701c9dc5b3d14f0749", size = 11884718 }, ] -[[package]] -name = "setuptools" -version = "80.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486 }, -] - -[[package]] -name = "setuptools-scm" -version = "8.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, - { name = "setuptools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b9/19/7ae64b70b2429c48c3a7a4ed36f50f94687d3bfcd0ae2f152367b6410dff/setuptools_scm-8.3.1.tar.gz", hash = "sha256:3d555e92b75dacd037d32bafdf94f97af51ea29ae8c7b234cf94b7a5bd242a63", size = 78088 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/ac/8f96ba9b4cfe3e4ea201f23f4f97165862395e9331a424ed325ae37024a8/setuptools_scm-8.3.1-py3-none-any.whl", hash = "sha256:332ca0d43791b818b841213e76b1971b7711a960761c5bea5fc5cdb5196fbce3", size = 43935 }, -] - [[package]] name = "shellingham" version = "1.5.4"