From 3f76ff8f926d573e16bb4adbe89293f40d4aaac9 Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Sat, 9 Aug 2025 21:01:42 -0700 Subject: [PATCH 01/19] Add build script and task --- Taskfile.yml | 4 ++++ scripts/build.sh | 4 ++++ 2 files changed, 8 insertions(+) create mode 100755 scripts/build.sh diff --git a/Taskfile.yml b/Taskfile.yml index 4489189..68d8d39 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -26,3 +26,7 @@ tasks: test: cmds: - ./scripts/test.sh + + build: + cmds: + - ./scripts/build.sh diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..99b06d6 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +mkdir -p dist +zip -r ./dist/lambda.zip ./src/ \ No newline at end of file From 475577ed889c49b69dd90a32745b6a8c04b8fbc7 Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Sat, 9 Aug 2025 21:47:24 -0700 Subject: [PATCH 02/19] Add zip archive structural verification --- scripts/build.sh | 103 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 2 deletions(-) diff --git a/scripts/build.sh b/scripts/build.sh index 99b06d6..3dc68ca 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,4 +1,103 @@ #!/bin/bash -mkdir -p dist -zip -r ./dist/lambda.zip ./src/ \ No newline at end of file +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 + cd src && 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" + + cd .. + + 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 main() { + cleanup + build + verify_build + + zip_size=$(ls -lh dist/lambda.zip | awk '{print $5}') + zip_hash=$(sha256_hash) + echo "Lambda deployment package built" + echo "path=${DIST_PATH}" + echo "size=${zip_size}" + echo "hash=${zip_hash}" +} + +main From 0bf70b151636a278b11ea825861298513e6484b5 Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Sat, 9 Aug 2025 21:48:43 -0700 Subject: [PATCH 03/19] Use pushd/popd instead of cd --- scripts/build.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/build.sh b/scripts/build.sh index 3dc68ca..0d80058 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -18,7 +18,8 @@ function cleanup() { function build() { echo "Building Lambda deployment package..." # Create zip with source files at root level, excluding test and cache files - cd src && zip -r ../${DIST_PATH} . \ + pushd src > /dev/null + zip -r ../${DIST_PATH} . \ -x "*.pyc" \ -x "__pycache__/*" \ -x "*.pyo" \ @@ -36,7 +37,7 @@ function build() { -x ".DS_Store" \ -x "Thumbs.db" - cd .. + popd > /dev/null echo "Build completed successfully!" } From 2ff4ff7241e6712801dfbbf745da17f6494d6966 Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Sat, 9 Aug 2025 22:00:57 -0700 Subject: [PATCH 04/19] Rename test to unit-test --- .github/workflows/pull-request.yml | 29 +++++++++++++++++++++++++---- Taskfile.yml | 11 ++++++++--- scripts/{test.sh => unit-test.sh} | 0 3 files changed, 33 insertions(+), 7 deletions(-) rename scripts/{test.sh => unit-test.sh} (100%) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index fe91592..0f36933 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -37,8 +37,29 @@ jobs: - name: Run linting run: ./scripts/lint.sh - test: - name: Test + build: + name: Build + runs-on: ubuntu-latest + needs: lint + 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: Build + run: ./scripts/build.sh + + unit-test: + name: Unit Test runs-on: ubuntu-latest needs: lint @@ -59,5 +80,5 @@ jobs: - name: Install dependencies run: ./scripts/setup.sh - - name: Run tests - run: ./scripts/test.sh + - name: Run unit tests + run: ./scripts/unit-test.sh diff --git a/Taskfile.yml b/Taskfile.yml index 68d8d39..50f6afd 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -8,7 +8,8 @@ tasks: - setup - format - lint - - test + - build + - unit-test silent: true setup: @@ -23,9 +24,13 @@ tasks: cmds: - ./scripts/lint.sh - test: + unit-test: cmds: - - ./scripts/test.sh + - ./scripts/unit-test.sh + + test: + deps: + - unit-test build: cmds: diff --git a/scripts/test.sh b/scripts/unit-test.sh similarity index 100% rename from scripts/test.sh rename to scripts/unit-test.sh From 8b6ae4ea468e95ed9793b457f06cc9b3e6c3bc2d Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Sat, 9 Aug 2025 22:41:35 -0700 Subject: [PATCH 05/19] Add handler function and update tests --- src/main.py | 9 ++++++-- src/main_test.py | 58 +++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/src/main.py b/src/main.py index 3ea060b..d211d78 100644 --- a/src/main.py +++ b/src/main.py @@ -1,6 +1,11 @@ -def main(): +def handler(event, context): print("Hello from lambda-application!") + return { + "statusCode": 200, + "body": "Hello from lambda-application!", + } + if __name__ == "__main__": - main() + handler(None, None) diff --git a/src/main_test.py b/src/main_test.py index 2fb670e..594d129 100644 --- a/src/main_test.py +++ b/src/main_test.py @@ -1,22 +1,58 @@ from unittest.mock import patch -from main import main +from main import handler -def test_main_prints_hello_message(): - """Test that main() prints the expected hello message.""" +def test_handler_prints_hello_message(): + """Test that handler() prints the expected hello message.""" with patch("builtins.print") as mock_print: - main() + handler(None, None) mock_print.assert_called_once_with("Hello from lambda-application!") -def test_main_returns_none(): - """Test that main() returns None (implicit return).""" - result = main() - assert result is None +def test_handler_returns_correct_response(): + """Test that handler() returns the expected response structure.""" + result = handler(None, None) + expected_response = { + "statusCode": 200, + "body": "Hello from lambda-application!" + } -def test_main_calls_print(): - """Test that main() actually calls print function.""" + assert result == expected_response + assert result["statusCode"] == 200 + assert result["body"] == "Hello from lambda-application!" + + +def test_handler_calls_print(): + """Test that handler() actually calls print function.""" with patch("builtins.print") as mock_print: - main() + handler(None, None) assert mock_print.called + + +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"} + + with patch("builtins.print") as mock_print: + result = handler(test_event, test_context) + + # Verify print was called + mock_print.assert_called_once_with("Hello from lambda-application!") + + # Verify response structure + assert result["statusCode"] == 200 + assert result["body"] == "Hello from lambda-application!" + + +def test_main_module_execution(): + """Test that the module can be executed directly.""" + with patch("builtins.print") as mock_print: + # Import and execute the main block + import main + # The if __name__ == "__main__" block should have executed + # We can't easily test this without refactoring, but we can verify + # the handler function works as expected + result = main.handler(None, None) + assert result["statusCode"] == 200 From 7cb29be81da3e0088d252f91ae4b8ff4e9de219d Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Sun, 10 Aug 2025 00:00:21 -0700 Subject: [PATCH 06/19] Add runtime interface emulator & integration testing --- .gitignore | 3 ++ Taskfile.yml | 27 ++++++++++- pyproject.toml | 1 + scripts/integration-test.sh | 3 ++ scripts/local-instance.sh | 56 +++++++++++++++++++++ src/main_test.py | 6 +-- tests/integration_test.py | 30 ++++++++++++ uv.lock | 97 +++++++++++++++++++++++++++++++++++++ 8 files changed, 218 insertions(+), 5 deletions(-) create mode 100755 scripts/integration-test.sh create mode 100755 scripts/local-instance.sh create mode 100644 tests/integration_test.py diff --git a/.gitignore b/.gitignore index 7416aff..4e3b3aa 100644 --- a/.gitignore +++ b/.gitignore @@ -206,3 +206,6 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ + +# Lambda +.lambda_task \ No newline at end of file diff --git a/Taskfile.yml b/Taskfile.yml index 50f6afd..2f7e608 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -9,29 +9,54 @@ tasks: - format - lint - build - - unit-test + - test silent: true setup: + run: once cmds: - ./scripts/setup.sh format: + run: once cmds: - ./scripts/format.sh lint: + run: once cmds: - ./scripts/lint.sh unit-test: + run: once cmds: - ./scripts/unit-test.sh + integration-test: + run: once + deps: + - build + cmds: + - task start-local-instance + - ./scripts/integration-test.sh + - task stop-local-instance + test: deps: - unit-test + - integration-test build: + run: once cmds: - ./scripts/build.sh + + start-local-instance: + run: once + cmds: + - ./scripts/local-instance.sh start + + stop-local-instance: + run: once + cmds: + - ./scripts/local-instance.sh stop diff --git a/pyproject.toml b/pyproject.toml index 8fe8493..52cc033 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [] dev = [ "pytest>=8.3", "pytest-cov>=6.2", + "requests>=2.32.4", "ruff>=0.12", ] diff --git a/scripts/integration-test.sh b/scripts/integration-test.sh new file mode 100755 index 0000000..f2810e5 --- /dev/null +++ b/scripts/integration-test.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +uv run pytest ./tests/ diff --git a/scripts/local-instance.sh b/scripts/local-instance.sh new file mode 100755 index 0000000..d4a1eda --- /dev/null +++ b/scripts/local-instance.sh @@ -0,0 +1,56 @@ +#!/bin/bash + +set -e + +operation="$1" + +dist_path="$PWD/dist" +lambda_zip_path="$dist_path/lambda.zip" +lambda_task_path=".lambda_task" + +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 clean_lambda_archive() { + rm -rf "${lambda_task_path}" +} + +function start_container() { + + docker run --rm -d \ + --name "${container_name}" \ + -p "${container_port}:8080" \ + -v "$PWD/${lambda_task_path}":/var/task:ro \ + "${image}" \ + "main.handler" +} + +function stop_container() { + docker stop "${container_name}" +} + +function main() { + case "$operation" in + "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 +} + +main \ No newline at end of file diff --git a/src/main_test.py b/src/main_test.py index 594d129..cfc9041 100644 --- a/src/main_test.py +++ b/src/main_test.py @@ -13,10 +13,7 @@ def test_handler_returns_correct_response(): """Test that handler() returns the expected response structure.""" result = handler(None, None) - expected_response = { - "statusCode": 200, - "body": "Hello from lambda-application!" - } + expected_response = {"statusCode": 200, "body": "Hello from lambda-application!"} assert result == expected_response assert result["statusCode"] == 200 @@ -51,6 +48,7 @@ def test_main_module_execution(): with patch("builtins.print") as mock_print: # Import and execute the main block import main + # The if __name__ == "__main__" block should have executed # We can't easily test this without refactoring, but we can verify # the handler function works as expected diff --git a/tests/integration_test.py b/tests/integration_test.py new file mode 100644 index 0000000..941db4a --- /dev/null +++ b/tests/integration_test.py @@ -0,0 +1,30 @@ +""" +Integration tests for the lambda-application. + +These tests verify the application works end-to-end in a local environment. +""" +import requests + +INVOKE_URL = "http://localhost:9000/2015-03-31/functions/function/invocations" + +class TestIntegration: + """Test the complete integration workflow.""" + + def test_handler_function_integration(self): + """Test the handler function works as expected in integration context.""" + + # Test with None parameters (as used in main block) + response = requests.post(INVOKE_URL, json={"foo": "bar"}) + + # Check if the request was successful + #assert response.status_code == 200, f"Request failed with status {response.status_code}: {response.text}" + + # Parse the JSON response + result = response.json() + + # 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["statusCode"] == 200, "Status code should be 200" + assert result["body"] == "Hello from lambda-application!", "Body should match expected message" diff --git a/uv.lock b/uv.lock index d1f4550..152fe07 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,68 @@ version = 1 requires-python = ">=3.11" +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483 }, + { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520 }, + { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876 }, + { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083 }, + { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295 }, + { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379 }, + { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018 }, + { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430 }, + { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600 }, + { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616 }, + { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108 }, + { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655 }, + { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223 }, + { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366 }, + { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104 }, + { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830 }, + { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854 }, + { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670 }, + { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501 }, + { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173 }, + { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822 }, + { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543 }, + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326 }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008 }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196 }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819 }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350 }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644 }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468 }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187 }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699 }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580 }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366 }, + { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342 }, + { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995 }, + { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640 }, + { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636 }, + { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939 }, + { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580 }, + { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870 }, + { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797 }, + { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224 }, + { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086 }, + { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400 }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175 }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -90,6 +152,15 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -108,6 +179,7 @@ source = { virtual = "." } dev = [ { name = "pytest" }, { name = "pytest-cov" }, + { name = "requests" }, { name = "ruff" }, ] @@ -117,6 +189,7 @@ dev = [ dev = [ { name = "pytest", specifier = ">=8.3" }, { name = "pytest-cov", specifier = ">=6.2" }, + { name = "requests", specifier = ">=2.32.4" }, { name = "ruff", specifier = ">=0.12" }, ] @@ -177,6 +250,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644 }, ] +[[package]] +name = "requests" +version = "2.32.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847 }, +] + [[package]] name = "ruff" version = "0.12.8" @@ -240,3 +328,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, ] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 }, +] From 65ff20a6c5179a6d4581cbe5b882fe31e6c7079a Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Sun, 10 Aug 2025 00:07:15 -0700 Subject: [PATCH 07/19] Add health check and retry for integration testing --- scripts/local-instance.sh | 28 +++++++++++++++++++++-- src/main_test.py | 2 +- tests/integration_test.py | 48 +++++++++++++++++++++++++++------------ 3 files changed, 60 insertions(+), 18 deletions(-) diff --git a/scripts/local-instance.sh b/scripts/local-instance.sh index d4a1eda..76cce8a 100755 --- a/scripts/local-instance.sh +++ b/scripts/local-instance.sh @@ -7,6 +7,7 @@ operation="$1" 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" @@ -22,14 +23,37 @@ function clean_lambda_archive() { rm -rf "${lambda_task_path}" } -function start_container() { +function wait_for_container_ready() { + echo "Waiting for container to be ready..." + local max_attempts=30 + local attempt=1 + + while [ $attempt -le $max_attempts ]; do + if curl -s -f "${invoke_url}" > /dev/null 2>&1; then + echo "Container is ready!" + echo "Waiting 2 seconds for container to stabilize..." + sleep 2 + return 0 + fi + echo "Attempt $attempt/$max_attempts: Container not ready yet, waiting..." + sleep 2 + attempt=$((attempt + 1)) + done + + echo "Container failed to become ready after $max_attempts attempts" + return 1 +} + +function start_container() { docker run --rm -d \ --name "${container_name}" \ -p "${container_port}:8080" \ -v "$PWD/${lambda_task_path}":/var/task:ro \ "${image}" \ - "main.handler" + "${lambda_task_handler}" + + wait_for_container_ready } function stop_container() { diff --git a/src/main_test.py b/src/main_test.py index cfc9041..2362798 100644 --- a/src/main_test.py +++ b/src/main_test.py @@ -45,7 +45,7 @@ def test_handler_with_event_and_context(): def test_main_module_execution(): """Test that the module can be executed directly.""" - with patch("builtins.print") as mock_print: + with patch("builtins.print"): # Import and execute the main block import main diff --git a/tests/integration_test.py b/tests/integration_test.py index 941db4a..b5226e0 100644 --- a/tests/integration_test.py +++ b/tests/integration_test.py @@ -4,6 +4,7 @@ These tests verify the application works end-to-end in a local environment. """ import requests +import time INVOKE_URL = "http://localhost:9000/2015-03-31/functions/function/invocations" @@ -13,18 +14,35 @@ class TestIntegration: def test_handler_function_integration(self): """Test the handler function works as expected in integration context.""" - # Test with None parameters (as used in main block) - response = requests.post(INVOKE_URL, json={"foo": "bar"}) - - # Check if the request was successful - #assert response.status_code == 200, f"Request failed with status {response.status_code}: {response.text}" - - # Parse the JSON response - result = response.json() - - # 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["statusCode"] == 200, "Status code should be 200" - assert result["body"] == "Hello from lambda-application!", "Body should match expected message" + # Test with retry mechanism to handle timing issues + max_retries = 3 + retry_delay = 1 + + for attempt in range(max_retries): + try: + # Test with None parameters (as used in main block) + response = requests.post(INVOKE_URL, json={"foo": "bar"}) + + # Check if the request was successful + #assert response.status_code == 200, f"Request failed with status {response.status_code}: {response.text}" + + # Parse the JSON response + result = response.json() + + # 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["statusCode"] == 200, "Status code should be 200" + assert result["body"] == "Hello from lambda-application!", "Body should match expected message" + + # If we get here, the test passed + return + + except requests.exceptions.ConnectionError as e: + if attempt < max_retries - 1: + print(f"Connection failed on attempt {attempt + 1}/{max_retries}, retrying in {retry_delay} seconds...") + time.sleep(retry_delay) + else: + # Last attempt failed, re-raise the exception + raise e From ddf4cf1b5abbb0697fa1852ea12cd58b6293a131 Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Sun, 10 Aug 2025 00:26:28 -0700 Subject: [PATCH 08/19] Scope unit tests to src --- scripts/unit-test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/unit-test.sh b/scripts/unit-test.sh index 8cdea22..e28e70d 100755 --- a/scripts/unit-test.sh +++ b/scripts/unit-test.sh @@ -1,3 +1,3 @@ #!/bin/bash -uv run pytest . -v --cov=src \ No newline at end of file +uv run pytest ./src/ -v --cov=src \ No newline at end of file From 017c5424166a54b92de0d27947ddfe8a7b5984a1 Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Sun, 10 Aug 2025 00:28:30 -0700 Subject: [PATCH 09/19] Exclude test files from linting --- pyproject.toml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 52cc033..ea292ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,15 @@ python_files = ["test_*.py", "*_test.py"] python_classes = ["Test*"] python_functions = ["test_*"] +[tool.ruff] +# Exclude test files from linting +exclude = [ + "tests/", + "**/test_*.py", + "**/*_test.py", + "**/tests.py", +] + [tool.coverage.run] source = ["src"] omit = [ From a5ef4b4c9282afb77d2f493ce9d61c1c773200ff Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Sun, 10 Aug 2025 00:28:56 -0700 Subject: [PATCH 10/19] Remove unused time import --- tests/integration_test.py | 48 ++++++++++++--------------------------- 1 file changed, 15 insertions(+), 33 deletions(-) diff --git a/tests/integration_test.py b/tests/integration_test.py index b5226e0..941db4a 100644 --- a/tests/integration_test.py +++ b/tests/integration_test.py @@ -4,7 +4,6 @@ These tests verify the application works end-to-end in a local environment. """ import requests -import time INVOKE_URL = "http://localhost:9000/2015-03-31/functions/function/invocations" @@ -14,35 +13,18 @@ class TestIntegration: def test_handler_function_integration(self): """Test the handler function works as expected in integration context.""" - # Test with retry mechanism to handle timing issues - max_retries = 3 - retry_delay = 1 - - for attempt in range(max_retries): - try: - # Test with None parameters (as used in main block) - response = requests.post(INVOKE_URL, json={"foo": "bar"}) - - # Check if the request was successful - #assert response.status_code == 200, f"Request failed with status {response.status_code}: {response.text}" - - # Parse the JSON response - result = response.json() - - # 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["statusCode"] == 200, "Status code should be 200" - assert result["body"] == "Hello from lambda-application!", "Body should match expected message" - - # If we get here, the test passed - return - - except requests.exceptions.ConnectionError as e: - if attempt < max_retries - 1: - print(f"Connection failed on attempt {attempt + 1}/{max_retries}, retrying in {retry_delay} seconds...") - time.sleep(retry_delay) - else: - # Last attempt failed, re-raise the exception - raise e + # Test with None parameters (as used in main block) + response = requests.post(INVOKE_URL, json={"foo": "bar"}) + + # Check if the request was successful + #assert response.status_code == 200, f"Request failed with status {response.status_code}: {response.text}" + + # Parse the JSON response + result = response.json() + + # 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["statusCode"] == 200, "Status code should be 200" + assert result["body"] == "Hello from lambda-application!", "Body should match expected message" From eceaf5017865b3eddf823b587c176e4e19fcf2a3 Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Sun, 10 Aug 2025 00:29:21 -0700 Subject: [PATCH 11/19] Adjust integration test runs --- Taskfile.yml | 32 ++++++++++++++------------------ scripts/local-instance.sh | 10 ++++------ 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index 2f7e608..8639cfa 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -4,12 +4,13 @@ version: "3" tasks: default: - deps: - - setup - - format - - lint - - build - - test + cmds: + - task: setup + - task: format + - task: lint + - task: build + - task: unit-test + - task: integration-test silent: true setup: @@ -27,6 +28,11 @@ tasks: cmds: - ./scripts/lint.sh + build: + run: once + cmds: + - ./scripts/build.sh + unit-test: run: once cmds: @@ -37,19 +43,9 @@ tasks: deps: - build cmds: - - task start-local-instance + - task: start-local-instance - ./scripts/integration-test.sh - - task stop-local-instance - - test: - deps: - - unit-test - - integration-test - - build: - run: once - cmds: - - ./scripts/build.sh + - task: stop-local-instance start-local-instance: run: once diff --git a/scripts/local-instance.sh b/scripts/local-instance.sh index 76cce8a..cec99af 100755 --- a/scripts/local-instance.sh +++ b/scripts/local-instance.sh @@ -24,24 +24,22 @@ function clean_lambda_archive() { } function wait_for_container_ready() { - echo "Waiting for container to be ready..." + echo "Waiting for container ${container_name} to be ready..." local max_attempts=30 local attempt=1 while [ $attempt -le $max_attempts ]; do if curl -s -f "${invoke_url}" > /dev/null 2>&1; then - echo "Container is ready!" - echo "Waiting 2 seconds for container to stabilize..." - sleep 2 + echo "Container ${container_name} is ready!" return 0 fi - echo "Attempt $attempt/$max_attempts: Container not ready yet, waiting..." + echo "Attempt $attempt/$max_attempts: Container ${container_name} not ready yet, waiting..." sleep 2 attempt=$((attempt + 1)) done - echo "Container failed to become ready after $max_attempts attempts" + echo "Container ${container_name} failed to become ready after $max_attempts attempts" return 1 } From 723ad4d244fecb8cc251c879a398edfbe9910db2 Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Sun, 10 Aug 2025 00:33:17 -0700 Subject: [PATCH 12/19] Add status code integration test --- tests/integration_test.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/integration_test.py b/tests/integration_test.py index 941db4a..424c286 100644 --- a/tests/integration_test.py +++ b/tests/integration_test.py @@ -17,14 +17,12 @@ def test_handler_function_integration(self): response = requests.post(INVOKE_URL, json={"foo": "bar"}) # Check if the request was successful - #assert response.status_code == 200, f"Request failed with status {response.status_code}: {response.text}" + assert response.status_code == 200, f"Request failed with status {response.status_code}: {response.text}" # Parse the JSON response result = response.json() # 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["statusCode"] == 200, "Status code should be 200" assert result["body"] == "Hello from lambda-application!", "Body should match expected message" From 76ccc0f19fd44049b1ab25b9801ef01633008d4f Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Sun, 10 Aug 2025 00:38:14 -0700 Subject: [PATCH 13/19] Add integration tests to PR workflow --- .github/workflows/pull-request.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 0f36933..65bd653 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -82,3 +82,28 @@ jobs: - 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: Install dependencies + run: ./scripts/setup.sh + + - name: Run integration tests + run: ./scripts/integration-test.sh From cf8263c859e7805b68bbf3de55b022ac3e7fc765 Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Sun, 10 Aug 2025 01:03:25 -0700 Subject: [PATCH 14/19] Minor improvements to local instance script --- scripts/local-instance.sh | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/scripts/local-instance.sh b/scripts/local-instance.sh index cec99af..d529bdc 100755 --- a/scripts/local-instance.sh +++ b/scripts/local-instance.sh @@ -20,7 +20,9 @@ function unzip_lambda_archive() { } function clean_lambda_archive() { - rm -rf "${lambda_task_path}" + if [ -d "${lambda_task_path}" ]; then + rm -rf "${lambda_task_path}" + fi } function wait_for_container_ready() { @@ -35,15 +37,29 @@ function wait_for_container_ready() { fi echo "Attempt $attempt/$max_attempts: Container ${container_name} not ready yet, waiting..." + + # Check if container is still running + if ! docker ps -q -f name="${container_name}" | grep -q .; then + echo "Container ${container_name} has stopped unexpectedly. Checking logs:" + docker logs "${container_name}" 2>/dev/null || echo "Could not retrieve logs" + return 1 + fi + sleep 2 attempt=$((attempt + 1)) done echo "Container ${container_name} failed to become ready after $max_attempts attempts" + echo "Container logs:" + docker logs "${container_name}" 2>/dev/null || echo "Could not retrieve logs" return 1 } 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" \ @@ -55,7 +71,11 @@ function start_container() { } function stop_container() { - docker stop "${container_name}" + if docker ps -q -f name="${container_name}" | grep -q .; then + docker stop "${container_name}" + else + echo "Container ${container_name} is not running" + fi } function main() { From 6adf3ec18af70b82ecfc68bbe8815f32a967cfbb Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Sun, 10 Aug 2025 01:03:43 -0700 Subject: [PATCH 15/19] Add explicit timeout to test call --- tests/integration_test.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/integration_test.py b/tests/integration_test.py index 424c286..de67288 100644 --- a/tests/integration_test.py +++ b/tests/integration_test.py @@ -13,10 +13,8 @@ class TestIntegration: def test_handler_function_integration(self): """Test the handler function works as expected in integration context.""" - # Test with None parameters (as used in main block) - response = requests.post(INVOKE_URL, json={"foo": "bar"}) + response = requests.post(INVOKE_URL, json={"foo": "bar"}, timeout=10) - # Check if the request was successful assert response.status_code == 200, f"Request failed with status {response.status_code}: {response.text}" # Parse the JSON response From f02ed2ac24312e5738742983fb79558c3e0fcac2 Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Sun, 10 Aug 2025 01:04:12 -0700 Subject: [PATCH 16/19] Add build/start/stop to GHA integration test job --- .github/workflows/pull-request.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 65bd653..f6b7ab9 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -105,5 +105,15 @@ jobs: - name: Install dependencies run: ./scripts/setup.sh + - name: Build Lambda package + run: ./scripts/build.sh + + - 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 From c76edbfabcbd599fe87e17d6c7236e26e7211f7b Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Sun, 10 Aug 2025 01:07:07 -0700 Subject: [PATCH 17/19] Change name of integration test --- tests/integration_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration_test.py b/tests/integration_test.py index de67288..8fedd6e 100644 --- a/tests/integration_test.py +++ b/tests/integration_test.py @@ -7,10 +7,10 @@ INVOKE_URL = "http://localhost:9000/2015-03-31/functions/function/invocations" -class TestIntegration: +class TestHandlerFunction: """Test the complete integration workflow.""" - def test_handler_function_integration(self): + def test_handler_function_returns_expected_message(self): """Test the handler function works as expected in integration context.""" response = requests.post(INVOKE_URL, json={"foo": "bar"}, timeout=10) From f9d2ecc11e46c0fd71b977e8a226eb34ab66e312 Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Sun, 10 Aug 2025 01:07:19 -0700 Subject: [PATCH 18/19] Run integration testing in verbose mode --- scripts/integration-test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/integration-test.sh b/scripts/integration-test.sh index f2810e5..b5f52b8 100755 --- a/scripts/integration-test.sh +++ b/scripts/integration-test.sh @@ -1,3 +1,3 @@ #!/bin/bash -uv run pytest ./tests/ +uv run pytest ./tests/ -v From 194847246fe2295e2eac3b13cfc9a2e8e3a16e9c Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Sun, 10 Aug 2025 01:18:55 -0700 Subject: [PATCH 19/19] Use caching in PR workflow --- .github/workflows/pull-request.yml | 62 ++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index f6b7ab9..c05c622 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -16,7 +16,6 @@ jobs: lint: name: Lint runs-on: ubuntu-latest - steps: - name: Checkout code uses: actions/checkout@v4 @@ -31,9 +30,22 @@ jobs: 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 @@ -41,6 +53,8 @@ jobs: name: Build runs-on: ubuntu-latest needs: lint + outputs: + cache-key: ${{ steps.cache-key.outputs.value }} steps: - name: Checkout code uses: actions/checkout@v4 @@ -55,14 +69,30 @@ jobs: with: version: latest - - name: Build + - 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: lint - + needs: build steps: - name: Checkout code uses: actions/checkout@v4 @@ -77,8 +107,14 @@ jobs: with: version: latest - - name: Install dependencies - run: ./scripts/setup.sh + - 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 @@ -87,7 +123,6 @@ jobs: name: Integration Test runs-on: ubuntu-latest needs: build - steps: - name: Checkout code uses: actions/checkout@v4 @@ -102,11 +137,14 @@ jobs: with: version: latest - - name: Install dependencies - run: ./scripts/setup.sh - - - name: Build Lambda package - run: ./scripts/build.sh + - 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