From 3fdeaa0dd799553f92ebcef919e8755763a166c5 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Sat, 17 May 2025 00:39:34 +0200 Subject: [PATCH 01/14] chore(deps): update pypa/gh-action-pypi-publish digest to db8f07d (#358) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 815c6308..443c7ee8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: - name: Build distributions run: python -m build - name: Publish - uses: pypa/gh-action-pypi-publish@916e57631f04a497e4bec0e29e80684e45b4305e # main + uses: pypa/gh-action-pypi-publish@db8f07d3871a0a180efa06b95d467625c19d5d5f # main with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} From 0ab5f03f5f47f96903197c4e5419f7ac1b238f3c Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Sat, 17 May 2025 00:45:23 +0200 Subject: [PATCH 02/14] chore(deps): update all non-major dependencies (#352) --- .github/workflows/codeql.yml | 10 +++++----- .github/workflows/conformance.yml | 10 +++++----- .github/workflows/dependency-review.yml | 6 +++--- .github/workflows/lint.yml | 6 +++--- .github/workflows/release.yml | 6 +++--- .github/workflows/scorecard.yml | 8 ++++---- .github/workflows/unit.yml | 6 +++--- 7 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index cff560f1..46f6a4d1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,7 +41,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: disable-sudo: true egress-policy: block @@ -53,11 +53,11 @@ jobs: objects.githubusercontent.com:443 - name: Checkout repository - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11 + uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -67,7 +67,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11 + uses: github/codeql-action/autobuild@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -80,6 +80,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11 + uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 4e066990..9dde6036 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -22,7 +22,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: disable-sudo: true egress-policy: block @@ -36,10 +36,10 @@ jobs: storage.googleapis.com:443 - name: Checkout code - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Python - uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python }} @@ -47,9 +47,9 @@ jobs: run: python -m pip install -e . - name: Setup Go - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: - go-version: '1.20' + go-version: '1.24' - name: Run HTTP conformance tests uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index ee09082a..5baf3aa0 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: disable-sudo: true egress-policy: block @@ -25,6 +25,6 @@ jobs: api.github.com:443 github.com:443 - name: 'Checkout Repository' - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: 'Dependency Review' - uses: actions/dependency-review-action@5a2ce3f5b92ee19cbb1541a4984c76d921601d7c # v4.3.4 + uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 718a5ebc..4fa8dff2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: disable-sudo: true egress-policy: block @@ -21,9 +21,9 @@ jobs: github.com:443 pypi.org:443 - - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Python - uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - name: Install tox run: python -m pip install tox - name: Lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 443c7ee8..269f14c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,16 +13,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.event.release.tag_name }} - name: Install Python - uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - name: Install build dependencies run: python -m pip install -U setuptools build wheel - name: Build distributions diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index e6da4669..72b82523 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: disable-sudo: true egress-policy: block @@ -47,12 +47,12 @@ jobs: - name: "Checkout code" - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0 + uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1 with: results_file: results.sarif results_format: sarif @@ -64,6 +64,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11 + uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 with: sarif_file: results.sarif diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 404b5159..e16d4de8 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -41,7 +41,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: disable-sudo: true egress-policy: block @@ -56,9 +56,9 @@ jobs: registry-1.docker.io:443 - name: Checkout - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Use Python ${{ matrix.python }} - uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python }} - name: Install tox From cc2b9b584fcc3daaa2762ae62a3ce1277a488a1c Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Mon, 9 Jun 2025 13:24:43 -0700 Subject: [PATCH 03/14] fix: Pin cloudevent sdk version to support python3.7. (#373) * fix: Pin cloudevent sdk version to support python3.7. * Pin cloudevent version on examples. * Pin cloudevent versions. --- examples/cloud_run_cloud_events/requirements.txt | 2 +- examples/cloud_run_decorator/requirements.txt | 1 + examples/cloud_run_event/requirements.txt | 1 + examples/cloud_run_http/requirements.txt | 1 + examples/docker-compose/requirements.txt | 1 + examples/skaffold/requirements.txt | 1 + pyproject.toml | 10 ++++------ 7 files changed, 10 insertions(+), 7 deletions(-) diff --git a/examples/cloud_run_cloud_events/requirements.txt b/examples/cloud_run_cloud_events/requirements.txt index 0a7427c7..43d925f1 100644 --- a/examples/cloud_run_cloud_events/requirements.txt +++ b/examples/cloud_run_cloud_events/requirements.txt @@ -1,3 +1,3 @@ # Optionally include additional dependencies here -cloudevents>=1.2.0 +cloudevents==1.11.0 # Pin version - last version compatible w/ python3.7 requests diff --git a/examples/cloud_run_decorator/requirements.txt b/examples/cloud_run_decorator/requirements.txt index 33c5f99f..3f8c88a5 100644 --- a/examples/cloud_run_decorator/requirements.txt +++ b/examples/cloud_run_decorator/requirements.txt @@ -1 +1,2 @@ # Optionally include additional dependencies here +cloudevents==1.11.0 # Pin version - last version compatible w/ python3.7 diff --git a/examples/cloud_run_event/requirements.txt b/examples/cloud_run_event/requirements.txt index 33c5f99f..3f8c88a5 100644 --- a/examples/cloud_run_event/requirements.txt +++ b/examples/cloud_run_event/requirements.txt @@ -1 +1,2 @@ # Optionally include additional dependencies here +cloudevents==1.11.0 # Pin version - last version compatible w/ python3.7 diff --git a/examples/cloud_run_http/requirements.txt b/examples/cloud_run_http/requirements.txt index 33c5f99f..3f8c88a5 100644 --- a/examples/cloud_run_http/requirements.txt +++ b/examples/cloud_run_http/requirements.txt @@ -1 +1,2 @@ # Optionally include additional dependencies here +cloudevents==1.11.0 # Pin version - last version compatible w/ python3.7 diff --git a/examples/docker-compose/requirements.txt b/examples/docker-compose/requirements.txt index 3601409f..c856b8d8 100644 --- a/examples/docker-compose/requirements.txt +++ b/examples/docker-compose/requirements.txt @@ -1 +1,2 @@ # Add any Python requirements here +cloudevents==1.11.0 # Pin version - last version compatible w/ python3.7 diff --git a/examples/skaffold/requirements.txt b/examples/skaffold/requirements.txt index 3601409f..c856b8d8 100644 --- a/examples/skaffold/requirements.txt +++ b/examples/skaffold/requirements.txt @@ -1 +1,2 @@ # Add any Python requirements here +cloudevents==1.11.0 # Pin version - last version compatible w/ python3.7 diff --git a/pyproject.toml b/pyproject.toml index c160562f..fa001304 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,12 +6,10 @@ readme = "README.md" requires-python = ">=3.5, <4" # Once we drop support for Python 3.7 and 3.8, this can become # license = "Apache-2.0" -license = {text = "Apache-2.0"} -authors = [ - { name = "Google LLC", email = "googleapis-packages@google.com" } -] +license = { text = "Apache-2.0" } +authors = [{ name = "Google LLC", email = "googleapis-packages@google.com" }] maintainers = [ - { name = "Google LLC", email = "googleapis-packages@google.com" } + { name = "Google LLC", email = "googleapis-packages@google.com" }, ] keywords = ["functions-framework"] classifiers = [ @@ -29,7 +27,7 @@ dependencies = [ "click>=7.0,<9.0", "watchdog>=1.0.0", "gunicorn>=22.0.0; platform_system!='Windows'", - "cloudevents>=1.2.0,<2.0.0", + "cloudevents>=1.2.0,<=1.11.0", # Must support python 3.7 "Werkzeug>=0.14,<4.0.0", ] From 37e0bf764ff24ebb82ba18bcac1bee6b03cecb13 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 10 Jun 2025 12:26:22 -0700 Subject: [PATCH 04/14] fix(ci): specify python version in tox environment (#375) * fix(ci): specify python version in tox environment * Enable shell for github workflows. --- .github/workflows/unit.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index e16d4de8..32b34fdb 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -64,4 +64,8 @@ jobs: - name: Install tox run: python -m pip install tox - name: Test - run: python -m tox -e py-${{ matrix.platform }} + shell: bash + run: | + # Remove dots from python version string, i.e. 3.10 -> 310 + PY_VERSION=$(echo "${{ matrix.python }}" | sed 's/\.//g') + python -m tox -e py${PY_VERSION}-${{ matrix.platform }} From 49f698517a06d0e47b2aadc2d603e0b193770440 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 10 Jun 2025 12:44:30 -0700 Subject: [PATCH 05/14] feat: add support for async functions (#364) * feat: Introduce functions_framework.aio submodule that support async function execution. * Remove httpx. * Update pyproject.toml to include extra async package. * Update test deps. * Improve test coverage. * Make linter happy. * Fix test harness to support py37. * Remove version filter in tox file. * Remove dependency-groups in pyproject.toml for now. * Use py3.8 compatible types. * Fix more incompatibility with python38 * Pin cloudevent sdk to python37 compatible version. * Fix more py37 incompatibility. * fix: Prevent test_aio.py collection errors on Python 3.7 Add pytest_ignore_collect hook to skip test_aio.py entirely on Python 3.7 to prevent ImportError during test collection. The previous approach using only pytest_collection_modifyitems was too late in the process - the error occurred when pytest tried to import the module before skip markers could be applied. Both hooks are marked as safe to remove when Python 3.7 support is dropped. * style: Apply black formatting to conftest.py * fix: Use modern pytest collection_path parameter and return None - Replace deprecated 'path' parameter with 'collection_path' in pytest_ignore_collect - Return None instead of False to let pytest use default behavior - This should fix the issue where pytest was collecting tests from .tox/.pkg/ * fix: Skip tests parametrized with None on Python 3.7 Simplify the check to just skip any test parametrized with None value. On Python 3.7, create_asgi_app is always None due to the conditional import, so this catches all async-related parametrized tests. * fix: Replace asyncio.to_thread with Python 3.8 compatible code Use asyncio.get_event_loop().run_in_executor() instead of asyncio.to_thread() for Python 3.8 compatibility. Added TODO comments to switch back when Python 3.8 support is dropped. * fix: Improve async test detection for Python 3.7 - Use a list of async keywords (async, asgi, aio, starlette) - Check for these keywords in test names, file paths, and parameters - This catches more async-related tests including those with "aio" prefix * fix: Handle Flask vs Starlette redirect behavior differences - Remove unnecessary follow_redirects=True from Starlette TestClient - Make test_http_function_request_url_empty_path aware of framework differences - Starlette TestClient normalizes empty path "" to "/" while Flask preserves it - Test now expects appropriate behavior for each framework * fix: Exclude aio module from coverage on Python 3.7 Add special coverage configuration for Python 3.7 that excludes the aio module since it requires Python 3.8+ due to Starlette dependency. This prevents coverage failures on Python 3.7. * fix: Simplify conftest.py. * fix: Use full environment names for py37 coverage exclusion The tox environment names in GitHub Actions include the OS suffix (e.g., py37-ubuntu-22.04), so we need to match the full names. * fix: Explicitly list each py37 environment for coverage exclusion - List py37-ubuntu-22.04 and py37-macos-13 explicitly - Place py37 settings before general windows-latest setting - This should properly exclude aio module from coverage on Python 3.7 * fix: Add Python 3.7 specific coverage configuration - Create .coveragerc-py37 to exclude aio module from coverage on Python 3.7 - Use --cov-config flag to specify this file for py37 environments only - This prevents the aio module exclusion from affecting Python 3.8+ tests --- .coveragerc-py37 | 10 + conftest.py | 50 +++ pyproject.toml | 6 +- setup.py | 72 ++++ src/functions_framework/aio/__init__.py | 250 ++++++++++++++ tests/test_aio.py | 190 +++++++++++ tests/test_cloud_event_functions.py | 71 ++-- tests/test_decorator_functions.py | 87 ++++- tests/test_functions.py | 323 +++++++++++------- .../cloud_events/async_empty_data.py | 38 +++ .../test_functions/cloud_events/async_main.py | 40 +++ .../decorators/async_decorator.py | 98 ++++++ .../http_check_env/async_main.py | 36 ++ .../http_request_check/async_main.py | 40 +++ .../http_streaming/async_main.py | 46 +++ .../test_functions/http_trigger/async_main.py | 48 +++ .../http_trigger_sleep/async_main.py | 33 ++ .../http_with_import/async_main.py | 29 ++ tests/test_typing.py | 12 + tox.ini | 9 + 20 files changed, 1316 insertions(+), 172 deletions(-) create mode 100644 .coveragerc-py37 create mode 100644 setup.py create mode 100644 src/functions_framework/aio/__init__.py create mode 100644 tests/test_aio.py create mode 100644 tests/test_functions/cloud_events/async_empty_data.py create mode 100644 tests/test_functions/cloud_events/async_main.py create mode 100644 tests/test_functions/decorators/async_decorator.py create mode 100644 tests/test_functions/http_check_env/async_main.py create mode 100644 tests/test_functions/http_request_check/async_main.py create mode 100644 tests/test_functions/http_streaming/async_main.py create mode 100644 tests/test_functions/http_trigger/async_main.py create mode 100644 tests/test_functions/http_trigger_sleep/async_main.py create mode 100644 tests/test_functions/http_with_import/async_main.py diff --git a/.coveragerc-py37 b/.coveragerc-py37 new file mode 100644 index 00000000..13be2ea1 --- /dev/null +++ b/.coveragerc-py37 @@ -0,0 +1,10 @@ +[run] +# Coverage configuration specifically for Python 3.7 environments +# Excludes the aio module which requires Python 3.8+ (Starlette dependency) +# This file is only used by py37-* tox environments +omit = + */functions_framework/aio/* + */.tox/* + */tests/* + */venv/* + */.venv/* \ No newline at end of file diff --git a/conftest.py b/conftest.py index 21572fda..f72314ed 100644 --- a/conftest.py +++ b/conftest.py @@ -42,3 +42,53 @@ def isolate_logging(): sys.stderr = sys.__stderr__ logging.shutdown() reload(logging) + + +# Safe to remove when we drop Python 3.7 support +def pytest_ignore_collect(collection_path, config): + """Ignore async test files on Python 3.7 since Starlette requires Python 3.8+""" + if sys.version_info >= (3, 8): + return None + + # Skip test_aio.py entirely on Python 3.7 + if collection_path.name == "test_aio.py": + return True + + return None + + +# Safe to remove when we drop Python 3.7 support +def pytest_collection_modifyitems(config, items): + """Skip async-related tests on Python 3.7 since Starlette requires Python 3.8+""" + if sys.version_info >= (3, 8): + return + + skip_async = pytest.mark.skip( + reason="Async features require Python 3.8+ (Starlette dependency)" + ) + + # Keywords that indicate async-related tests + async_keywords = ["async", "asgi", "aio", "starlette"] + + for item in items: + skip_test = False + + if hasattr(item, "callspec") and hasattr(item.callspec, "params"): + for param_name, param_value in item.callspec.params.items(): + # Check if test has fixtures with async-related parameters + if isinstance(param_value, str) and any( + keyword in param_value.lower() for keyword in async_keywords + ): + skip_test = True + break + # Skip tests parametrized with None (create_asgi_app on Python 3.7) + if param_value is None: + skip_test = True + break + + # Skip tests that explicitly test async functionality + if any(keyword in item.name.lower() for keyword in async_keywords): + skip_test = True + + if skip_test: + item.add_marker(skip_async) diff --git a/pyproject.toml b/pyproject.toml index fa001304..3a631b5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "functions-framework" version = "3.8.3" description = "An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team." readme = "README.md" -requires-python = ">=3.5, <4" +requires-python = ">=3.7, <4" # Once we drop support for Python 3.7 and 3.8, this can become # license = "Apache-2.0" license = { text = "Apache-2.0" } @@ -29,11 +29,15 @@ dependencies = [ "gunicorn>=22.0.0; platform_system!='Windows'", "cloudevents>=1.2.0,<=1.11.0", # Must support python 3.7 "Werkzeug>=0.14,<4.0.0", + "httpx>=0.24.1", ] [project.urls] Homepage = "https://github.com/googlecloudplatform/functions-framework-python" +[project.optional-dependencies] +async = ["starlette>=0.37.0,<1.0.0; python_version>='3.8'"] + [project.scripts] ff = "functions_framework._cli:_cli" functions-framework = "functions_framework._cli:_cli" diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..1c35d39b --- /dev/null +++ b/setup.py @@ -0,0 +1,72 @@ +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from io import open +from os import path + +from setuptools import find_packages, setup + +here = path.abspath(path.dirname(__file__)) + +# Get the long description from the README file +with open(path.join(here, "README.md"), encoding="utf-8") as f: + long_description = f.read() + +setup( + name="functions-framework", + version="3.8.2", + description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/googlecloudplatform/functions-framework-python", + author="Google LLC", + author_email="googleapis-packages@google.com", + classifiers=[ + "Development Status :: 5 - Production/Stable ", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + ], + keywords="functions-framework", + packages=find_packages(where="src"), + package_data={"functions_framework": ["py.typed"]}, + namespace_packages=["google", "google.cloud"], + package_dir={"": "src"}, + python_requires=">=3.5, <4", + install_requires=[ + "flask>=1.0,<4.0", + "click>=7.0,<9.0", + "watchdog>=1.0.0", + "gunicorn>=22.0.0; platform_system!='Windows'", + "cloudevents>=1.2.0,<2.0.0", + "Werkzeug>=0.14,<4.0.0", + ], + extras_require={ + "async": ["starlette>=0.37.0,<1.0.0"], + }, + entry_points={ + "console_scripts": [ + "ff=functions_framework._cli:_cli", + "functions-framework=functions_framework._cli:_cli", + "functions_framework=functions_framework._cli:_cli", + "functions-framework-python=functions_framework._cli:_cli", + "functions_framework_python=functions_framework._cli:_cli", + ] + }, +) diff --git a/src/functions_framework/aio/__init__.py b/src/functions_framework/aio/__init__.py new file mode 100644 index 00000000..832d6818 --- /dev/null +++ b/src/functions_framework/aio/__init__.py @@ -0,0 +1,250 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import functools +import inspect +import os + +from typing import Any, Awaitable, Callable, Dict, Tuple, Union + +from cloudevents.http import from_http +from cloudevents.http.event import CloudEvent + +from functions_framework import _function_registry +from functions_framework.exceptions import ( + FunctionsFrameworkException, + MissingSourceException, +) + +try: + from starlette.applications import Starlette + from starlette.exceptions import HTTPException + from starlette.requests import Request + from starlette.responses import JSONResponse, Response + from starlette.routing import Route +except ImportError: + raise FunctionsFrameworkException( + "Starlette is not installed. Install the framework with the 'async' extra: " + "pip install functions-framework[async]" + ) + +HTTPResponse = Union[ + Response, # Functions can return a full Starlette Response object + str, # Str returns are wrapped in Response(result) + Dict[Any, Any], # Dict returns are wrapped in JSONResponse(result) + Tuple[Any, int], # Flask-style (content, status_code) supported + None, # None raises HTTPException +] + +_FUNCTION_STATUS_HEADER_FIELD = "X-Google-Status" +_CRASH = "crash" + +CloudEventFunction = Callable[[CloudEvent], Union[None, Awaitable[None]]] +HTTPFunction = Callable[[Request], Union[HTTPResponse, Awaitable[HTTPResponse]]] + + +def cloud_event(func: CloudEventFunction) -> CloudEventFunction: + """Decorator that registers cloudevent as user function signature type.""" + _function_registry.REGISTRY_MAP[func.__name__] = ( + _function_registry.CLOUDEVENT_SIGNATURE_TYPE + ) + if inspect.iscoroutinefunction(func): + + @functools.wraps(func) + async def async_wrapper(*args, **kwargs): + return await func(*args, **kwargs) + + return async_wrapper + + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + +def http(func: HTTPFunction) -> HTTPFunction: + """Decorator that registers http as user function signature type.""" + _function_registry.REGISTRY_MAP[func.__name__] = ( + _function_registry.HTTP_SIGNATURE_TYPE + ) + + if inspect.iscoroutinefunction(func): + + @functools.wraps(func) + async def async_wrapper(*args, **kwargs): + return await func(*args, **kwargs) + + return async_wrapper + + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + +async def _crash_handler(request, exc): + headers = {_FUNCTION_STATUS_HEADER_FIELD: _CRASH} + return Response(f"Internal Server Error: {exc}", status_code=500, headers=headers) + + +def _http_func_wrapper(function, is_async): + @functools.wraps(function) + async def handler(request): + if is_async: + result = await function(request) + else: + # TODO: Use asyncio.to_thread when we drop Python 3.8 support + # Python 3.8 compatible version of asyncio.to_thread + loop = asyncio.get_event_loop() + result = await loop.run_in_executor(None, function, request) + if isinstance(result, str): + return Response(result) + elif isinstance(result, dict): + return JSONResponse(result) + elif isinstance(result, tuple) and len(result) == 2: + # Support Flask-style tuple response + content, status_code = result + return Response(content, status_code=status_code) + elif result is None: + raise HTTPException(status_code=500, detail="No response returned") + else: + return result + + return handler + + +def _cloudevent_func_wrapper(function, is_async): + @functools.wraps(function) + async def handler(request): + data = await request.body() + + try: + event = from_http(request.headers, data) + except Exception as e: + raise HTTPException( + 400, detail=f"Bad Request: Got CloudEvent exception: {repr(e)}" + ) + if is_async: + await function(event) + else: + # TODO: Use asyncio.to_thread when we drop Python 3.8 support + # Python 3.8 compatible version of asyncio.to_thread + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, function, event) + return Response("OK") + + return handler + + +async def _handle_not_found(request: Request): + raise HTTPException(status_code=404, detail="Not Found") + + +def create_asgi_app(target=None, source=None, signature_type=None): + """Create an ASGI application for the function. + + Args: + target: The name of the target function to invoke + source: The source file containing the function + signature_type: The signature type of the function + ('http', 'event', 'cloudevent', or 'typed') + + Returns: + A Starlette ASGI application instance + """ + target = _function_registry.get_function_target(target) + source = _function_registry.get_function_source(source) + + if not os.path.exists(source): + raise MissingSourceException( + f"File {source} that is expected to define function doesn't exist" + ) + + source_module, spec = _function_registry.load_function_module(source) + spec.loader.exec_module(source_module) + function = _function_registry.get_user_function(source, source_module, target) + signature_type = _function_registry.get_func_signature_type(target, signature_type) + + is_async = inspect.iscoroutinefunction(function) + routes = [] + if signature_type == _function_registry.HTTP_SIGNATURE_TYPE: + http_handler = _http_func_wrapper(function, is_async) + routes.append( + Route( + "/", + endpoint=http_handler, + methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH"], + ), + ) + routes.append(Route("/robots.txt", endpoint=_handle_not_found, methods=["GET"])) + routes.append( + Route("/favicon.ico", endpoint=_handle_not_found, methods=["GET"]) + ) + routes.append( + Route( + "/{path:path}", + http_handler, + methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH"], + ) + ) + elif signature_type == _function_registry.CLOUDEVENT_SIGNATURE_TYPE: + cloudevent_handler = _cloudevent_func_wrapper(function, is_async) + routes.append(Route("/{path:path}", cloudevent_handler, methods=["POST"])) + routes.append(Route("/", cloudevent_handler, methods=["POST"])) + elif signature_type == _function_registry.TYPED_SIGNATURE_TYPE: + raise FunctionsFrameworkException( + f"ASGI server does not support typed events (signature type: '{signature_type}'). " + ) + elif signature_type == _function_registry.BACKGROUNDEVENT_SIGNATURE_TYPE: + raise FunctionsFrameworkException( + f"ASGI server does not support legacy background events (signature type: '{signature_type}'). " + "Use 'cloudevent' signature type instead." + ) + else: + raise FunctionsFrameworkException( + f"Unsupported signature type for ASGI server: {signature_type}" + ) + + exception_handlers = { + 500: _crash_handler, + } + app = Starlette(routes=routes, exception_handlers=exception_handlers) + return app + + +class LazyASGIApp: + """ + Wrap the ASGI app in a lazily initialized wrapper to prevent initialization + at import-time + """ + + def __init__(self, target=None, source=None, signature_type=None): + self.target = target + self.source = source + self.signature_type = signature_type + + self.app = None + self._app_initialized = False + + async def __call__(self, scope, receive, send): + if not self._app_initialized: + self.app = create_asgi_app(self.target, self.source, self.signature_type) + self._app_initialized = True + await self.app(scope, receive, send) + + +app = LazyASGIApp() diff --git a/tests/test_aio.py b/tests/test_aio.py new file mode 100644 index 00000000..cf69479a --- /dev/null +++ b/tests/test_aio.py @@ -0,0 +1,190 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pathlib +import re +import sys +import tempfile + +from unittest.mock import Mock, call + +if sys.version_info >= (3, 8): + from unittest.mock import AsyncMock + +import pytest + +from functions_framework import exceptions +from functions_framework.aio import ( + LazyASGIApp, + _cloudevent_func_wrapper, + _http_func_wrapper, + create_asgi_app, +) + +TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" + + +def test_import_error_without_starlette(monkeypatch): + import builtins + + original_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name.startswith("starlette"): + raise ImportError(f"No module named '{name}'") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", mock_import) + + # Remove the module from sys.modules to force re-import + if "functions_framework.aio" in sys.modules: + del sys.modules["functions_framework.aio"] + + with pytest.raises(exceptions.FunctionsFrameworkException) as excinfo: + import functions_framework.aio + + assert "Starlette is not installed" in str(excinfo.value) + assert "pip install functions-framework[async]" in str(excinfo.value) + + +def test_invalid_function_definition_missing_function_file(): + source = TEST_FUNCTIONS_DIR / "missing_function_file" / "main.py" + target = "function" + + with pytest.raises(exceptions.MissingSourceException) as excinfo: + create_asgi_app(target, source) + + assert re.match( + r"File .* that is expected to define function doesn't exist", str(excinfo.value) + ) + + +def test_asgi_typed_signature_not_supported(): + source = TEST_FUNCTIONS_DIR / "typed_events" / "typed_event.py" + target = "function_typed" + + with pytest.raises(exceptions.FunctionsFrameworkException) as excinfo: + create_asgi_app(target, source, "typed") + + assert "ASGI server does not support typed events (signature type: 'typed')" in str( + excinfo.value + ) + + +def test_asgi_background_event_not_supported(): + source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py" + target = "function" + + with pytest.raises(exceptions.FunctionsFrameworkException) as excinfo: + create_asgi_app(target, source, "event") + + assert ( + "ASGI server does not support legacy background events (signature type: 'event')" + in str(excinfo.value) + ) + assert "Use 'cloudevent' signature type instead" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_lazy_asgi_app(monkeypatch): + actual_app = AsyncMock() + create_asgi_app_mock = Mock(return_value=actual_app) + monkeypatch.setattr("functions_framework.aio.create_asgi_app", create_asgi_app_mock) + + # Test that it's lazy + target, source, signature_type = "func", "source.py", "http" + lazy_app = LazyASGIApp(target, source, signature_type) + + assert lazy_app.app is None + assert lazy_app._app_initialized is False + + # Mock ASGI call parameters + scope = {"type": "http", "method": "GET", "path": "/"} + receive = AsyncMock() + send = AsyncMock() + + # Test that it's initialized when called + await lazy_app(scope, receive, send) + + assert lazy_app.app is actual_app + assert lazy_app._app_initialized is True + assert create_asgi_app_mock.call_count == 1 + assert create_asgi_app_mock.call_args == call(target, source, signature_type) + + # Verify the app was called + actual_app.assert_called_once_with(scope, receive, send) + + # Test that subsequent calls use the same app + create_asgi_app_mock.reset_mock() + actual_app.reset_mock() + + await lazy_app(scope, receive, send) + + assert create_asgi_app_mock.call_count == 0 # Should not create app again + actual_app.assert_called_once_with(scope, receive, send) # Should be called again + + +@pytest.mark.asyncio +async def test_http_func_wrapper_json_response(): + async def http_func(request): + return {"message": "hello", "count": 42} + + wrapper = _http_func_wrapper(http_func, is_async=True) + + request = Mock() + response = await wrapper(request) + + assert response.__class__.__name__ == "JSONResponse" + assert b'"message":"hello"' in response.body + assert b'"count":42' in response.body + + +@pytest.mark.asyncio +async def test_http_func_wrapper_sync_function(): + def sync_http_func(request): + return "sync response" + + wrapper = _http_func_wrapper(sync_http_func, is_async=False) + + request = Mock() + response = await wrapper(request) + + assert response.__class__.__name__ == "Response" + assert response.body == b"sync response" + + +@pytest.mark.asyncio +async def test_cloudevent_func_wrapper_sync_function(): + called_with_event = None + + def sync_cloud_event(event): + nonlocal called_with_event + called_with_event = event + + wrapper = _cloudevent_func_wrapper(sync_cloud_event, is_async=False) + + request = Mock() + request.body = AsyncMock( + return_value=b'{"specversion": "1.0", "type": "test.event", "source": "test-source", "id": "123", "data": {"test": "data"}}' + ) + request.headers = {"content-type": "application/cloudevents+json"} + + response = await wrapper(request) + + assert response.body == b"OK" + assert response.status_code == 200 + + assert called_with_event is not None + assert called_with_event["type"] == "test.event" + assert called_with_event["source"] == "test-source" diff --git a/tests/test_cloud_event_functions.py b/tests/test_cloud_event_functions.py index 691fe388..2e7c281d 100644 --- a/tests/test_cloud_event_functions.py +++ b/tests/test_cloud_event_functions.py @@ -13,13 +13,25 @@ # limitations under the License. import json import pathlib +import sys import pytest -from cloudevents.http import CloudEvent, to_binary, to_structured +from cloudevents import conversion as ce_conversion +from cloudevents.http import CloudEvent + +if sys.version_info >= (3, 8): + from starlette.testclient import TestClient as StarletteTestClient +else: + StarletteTestClient = None from functions_framework import create_app +if sys.version_info >= (3, 8): + from functions_framework.aio import create_asgi_app +else: + create_asgi_app = None + TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" TEST_DATA_DIR = pathlib.Path(__file__).resolve().parent / "test_data" @@ -89,57 +101,63 @@ def background_event(): return json.load(f) -@pytest.fixture -def client(): - source = TEST_FUNCTIONS_DIR / "cloud_events" / "main.py" +@pytest.fixture(params=["main.py", "async_main.py"]) +def client(request): + source = TEST_FUNCTIONS_DIR / "cloud_events" / request.param target = "function" - return create_app(target, source, "cloudevent").test_client() + if not request.param.startswith("async_"): + return create_app(target, source, "cloudevent").test_client() + app = create_asgi_app(target, source, "cloudevent") + return StarletteTestClient(app) -@pytest.fixture -def empty_client(): - source = TEST_FUNCTIONS_DIR / "cloud_events" / "empty_data.py" +@pytest.fixture(params=["empty_data.py", "async_empty_data.py"]) +def empty_client(request): + source = TEST_FUNCTIONS_DIR / "cloud_events" / request.param target = "function" - return create_app(target, source, "cloudevent").test_client() + if not request.param.startswith("async_"): + return create_app(target, source, "cloudevent").test_client() + app = create_asgi_app(target, source, "cloudevent") + return StarletteTestClient(app) @pytest.fixture -def converted_background_event_client(): +def converted_background_event_client(request): source = TEST_FUNCTIONS_DIR / "cloud_events" / "converted_background_event.py" target = "function" return create_app(target, source, "cloudevent").test_client() def test_event(client, cloud_event_1_0): - headers, data = to_structured(cloud_event_1_0) + headers, data = ce_conversion.to_structured(cloud_event_1_0) resp = client.post("/", headers=headers, data=data) assert resp.status_code == 200 - assert resp.data == b"OK" + assert resp.text == "OK" def test_binary_event(client, cloud_event_1_0): - headers, data = to_binary(cloud_event_1_0) + headers, data = ce_conversion.to_binary(cloud_event_1_0) resp = client.post("/", headers=headers, data=data) assert resp.status_code == 200 - assert resp.data == b"OK" + assert resp.text == "OK" def test_event_0_3(client, cloud_event_0_3): - headers, data = to_structured(cloud_event_0_3) + headers, data = ce_conversion.to_structured(cloud_event_0_3) resp = client.post("/", headers=headers, data=data) assert resp.status_code == 200 - assert resp.data == b"OK" + assert resp.text == "OK" def test_binary_event_0_3(client, cloud_event_0_3): - headers, data = to_binary(cloud_event_0_3) + headers, data = ce_conversion.to_binary(cloud_event_0_3) resp = client.post("/", headers=headers, data=data) assert resp.status_code == 200 - assert resp.data == b"OK" + assert resp.text == "OK" @pytest.mark.parametrize("specversion", ["0.3", "1.0"]) @@ -156,7 +174,7 @@ def test_cloud_event_missing_required_binary_fields( resp = client.post("/", headers=invalid_headers, json=data_payload) assert resp.status_code == 400 - assert "MissingRequiredFields" in resp.get_data().decode() + assert "MissingRequiredFields" in resp.text @pytest.mark.parametrize("specversion", ["0.3", "1.0"]) @@ -174,7 +192,7 @@ def test_cloud_event_missing_required_structured_fields( resp = client.post("/", headers=headers, json=invalid_data) assert resp.status_code == 400 - assert "MissingRequiredFields" in resp.data.decode() + assert "MissingRequiredFields" in resp.text def test_invalid_fields_binary(client, create_headers_binary, data_payload): @@ -183,7 +201,7 @@ def test_invalid_fields_binary(client, create_headers_binary, data_payload): resp = client.post("/", headers=headers, json=data_payload) assert resp.status_code == 400 - assert "InvalidRequiredFields" in resp.data.decode() + assert "InvalidRequiredFields" in resp.text def test_unparsable_cloud_event(client): @@ -191,7 +209,7 @@ def test_unparsable_cloud_event(client): resp = client.post("/", headers=headers, data="") assert resp.status_code == 400 - assert "Bad Request" in resp.data.decode() + assert "Bad Request" in resp.text @pytest.mark.parametrize("specversion", ["0.3", "1.0"]) @@ -200,7 +218,7 @@ def test_empty_data_binary(empty_client, create_headers_binary, specversion): resp = empty_client.post("/", headers=headers, json="") assert resp.status_code == 200 - assert resp.get_data() == b"OK" + assert resp.text == "OK" @pytest.mark.parametrize("specversion", ["0.3", "1.0"]) @@ -211,7 +229,7 @@ def test_empty_data_structured(empty_client, specversion, create_structured_data resp = empty_client.post("/", headers=headers, json=data) assert resp.status_code == 200 - assert resp.get_data() == b"OK" + assert resp.text == "OK" @pytest.mark.parametrize("specversion", ["0.3", "1.0"]) @@ -220,7 +238,7 @@ def test_no_mime_type_structured(empty_client, specversion, create_structured_da resp = empty_client.post("/", headers={}, json=data) assert resp.status_code == 200 - assert resp.get_data() == b"OK" + assert resp.text == "OK" def test_background_event(converted_background_event_client, background_event): @@ -228,5 +246,6 @@ def test_background_event(converted_background_event_client, background_event): "/", headers={}, json=background_event ) + print(resp.text) assert resp.status_code == 200 - assert resp.get_data() == b"OK" + assert resp.text == "OK" diff --git a/tests/test_decorator_functions.py b/tests/test_decorator_functions.py index e8c9bc70..435aa815 100644 --- a/tests/test_decorator_functions.py +++ b/tests/test_decorator_functions.py @@ -12,13 +12,27 @@ # See the License for the specific language governing permissions and # limitations under the License. import pathlib +import sys import pytest -from cloudevents.http import CloudEvent, to_binary, to_structured +from cloudevents import conversion as ce_conversion +from cloudevents.http import CloudEvent + +# Conditional import for Starlette +if sys.version_info >= (3, 8): + from starlette.testclient import TestClient as StarletteTestClient +else: + StarletteTestClient = None from functions_framework import create_app +# Conditional import for async functionality +if sys.version_info >= (3, 8): + from functions_framework.aio import create_asgi_app +else: + create_asgi_app = None + TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" # Python 3.5: ModuleNotFoundError does not exist @@ -28,18 +42,24 @@ _ModuleNotFoundError = ImportError -@pytest.fixture -def cloud_event_decorator_client(): - source = TEST_FUNCTIONS_DIR / "decorators" / "decorator.py" +@pytest.fixture(params=["decorator.py", "async_decorator.py"]) +def cloud_event_decorator_client(request): + source = TEST_FUNCTIONS_DIR / "decorators" / request.param target = "function_cloud_event" - return create_app(target, source).test_client() + if not request.param.startswith("async_"): + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app) -@pytest.fixture -def http_decorator_client(): - source = TEST_FUNCTIONS_DIR / "decorators" / "decorator.py" +@pytest.fixture(params=["decorator.py", "async_decorator.py"]) +def http_decorator_client(request): + source = TEST_FUNCTIONS_DIR / "decorators" / request.param target = "function_http" - return create_app(target, source).test_client() + if not request.param.startswith("async_"): + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app) @pytest.fixture @@ -56,14 +76,55 @@ def cloud_event_1_0(): def test_cloud_event_decorator(cloud_event_decorator_client, cloud_event_1_0): - headers, data = to_structured(cloud_event_1_0) + headers, data = ce_conversion.to_structured(cloud_event_1_0) resp = cloud_event_decorator_client.post("/", headers=headers, data=data) - assert resp.status_code == 200 - assert resp.data == b"OK" + assert resp.text == "OK" def test_http_decorator(http_decorator_client): resp = http_decorator_client.post("/my_path", json={"mode": "path"}) assert resp.status_code == 200 - assert resp.data == b"/my_path" + assert resp.text == "/my_path" + + +def test_aio_sync_cloud_event_decorator(cloud_event_1_0): + """Test aio decorator with sync cloud event function.""" + source = TEST_FUNCTIONS_DIR / "decorators" / "async_decorator.py" + target = "function_cloud_event_sync" + + app = create_asgi_app(target, source) + client = StarletteTestClient(app) + + headers, data = ce_conversion.to_structured(cloud_event_1_0) + resp = client.post("/", headers=headers, data=data) + assert resp.status_code == 200 + assert resp.text == "OK" + + +def test_aio_sync_http_decorator(): + source = TEST_FUNCTIONS_DIR / "decorators" / "async_decorator.py" + target = "function_http_sync" + + app = create_asgi_app(target, source) + client = StarletteTestClient(app) + + resp = client.post("/my_path?mode=path") + assert resp.status_code == 200 + assert resp.text == "/my_path" + + resp = client.post("/other_path") + assert resp.status_code == 200 + assert resp.text == "sync response" + + +def test_aio_http_dict_response(): + source = TEST_FUNCTIONS_DIR / "decorators" / "async_decorator.py" + target = "function_http_dict_response" + + app = create_asgi_app(target, source) + client = StarletteTestClient(app) + + resp = client.post("/") + assert resp.status_code == 200 + assert resp.json() == {"message": "hello", "count": 42, "success": True} diff --git a/tests/test_functions.py b/tests/test_functions.py index f0bd7793..9107dc68 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -12,19 +12,31 @@ # See the License for the specific language governing permissions and # limitations under the License. -import io import json import pathlib import re +import sys import time import pretend import pytest +# Conditional import for Starlette +if sys.version_info >= (3, 8): + from starlette.testclient import TestClient as StarletteTestClient +else: + StarletteTestClient = None + import functions_framework from functions_framework import LazyWSGIApp, create_app, errorhandler, exceptions +# Conditional import for async functionality +if sys.version_info >= (3, 8): + from functions_framework.aio import create_asgi_app +else: + create_asgi_app = None + TEST_FUNCTIONS_DIR = pathlib.Path.cwd() / "tests" / "test_functions" @@ -72,127 +84,181 @@ def create_ce_headers(): } -def test_http_function_executes_success(): - source = TEST_FUNCTIONS_DIR / "http_trigger" / "main.py" +@pytest.fixture(params=["main.py", "async_main.py"]) +def http_trigger_client(request): + source = TEST_FUNCTIONS_DIR / "http_trigger" / request.param target = "function" + if not request.param.startswith("async_"): + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app, raise_server_exceptions=False) - client = create_app(target, source).test_client() - - resp = client.post("/my_path", json={"mode": "SUCCESS"}) - assert resp.status_code == 200 - assert resp.data == b"success" - -def test_http_function_executes_failure(): - source = TEST_FUNCTIONS_DIR / "http_trigger" / "main.py" +@pytest.fixture(params=["main.py", "async_main.py"]) +def http_request_check_client(request): + source = TEST_FUNCTIONS_DIR / "http_request_check" / request.param target = "function" - - client = create_app(target, source).test_client() - - resp = client.get("/", json={"mode": "FAILURE"}) - assert resp.status_code == 400 - assert resp.data == b"failure" + if not request.param.startswith("async_"): + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient( + app, + # Override baseurl to use localhost instead of default http://testserver. + base_url="http://localhost", + ) -def test_http_function_executes_throw(): - source = TEST_FUNCTIONS_DIR / "http_trigger" / "main.py" +@pytest.fixture(params=["main.py", "async_main.py"]) +def http_check_env_client(request): + source = TEST_FUNCTIONS_DIR / "http_check_env" / request.param target = "function" + if not request.param.startswith("async_"): + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app) - client = create_app(target, source).test_client() - resp = client.put("/", json={"mode": "THROW"}) - assert resp.status_code == 500 +@pytest.fixture(params=["main.py", "async_main.py"]) +def http_trigger_sleep_client(request): + source = TEST_FUNCTIONS_DIR / "http_trigger_sleep" / request.param + target = "function" + if not request.param.startswith("async_"): + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app) -def test_http_function_request_url_empty_path(): - source = TEST_FUNCTIONS_DIR / "http_request_check" / "main.py" +@pytest.fixture(params=["main.py", "async_main.py"]) +def http_with_import_client(request): + source = TEST_FUNCTIONS_DIR / "http_with_import" / request.param target = "function" + if not request.param.startswith("async_"): + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app) - client = create_app(target, source).test_client() - resp = client.get("", json={"mode": "url"}) - assert resp.status_code == 308 - assert resp.location == "http://localhost/" +@pytest.fixture(params=["sync", "async"]) +def http_method_check_client(request): + source = TEST_FUNCTIONS_DIR / "http_method_check" / "main.py" + target = "function" + if not request.param == "async": + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app) -def test_http_function_request_url_slash(): - source = TEST_FUNCTIONS_DIR / "http_request_check" / "main.py" +@pytest.fixture(params=["sync", "async"]) +def module_is_correct_client(request): + source = TEST_FUNCTIONS_DIR / "module_is_correct" / "main.py" target = "function" + if not request.param == "async": + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app) - client = create_app(target, source).test_client() - resp = client.get("/", json={"mode": "url"}) - assert resp.status_code == 200 - assert resp.data == b"http://localhost/" +@pytest.fixture(params=["sync", "async"]) +def returns_none_client(request): + source = TEST_FUNCTIONS_DIR / "returns_none" / "main.py" + target = "function" + if not request.param == "async": + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app) -def test_http_function_rquest_url_path(): - source = TEST_FUNCTIONS_DIR / "http_request_check" / "main.py" +@pytest.fixture(params=["sync", "async"]) +def relative_imports_client(request): + source = TEST_FUNCTIONS_DIR / "relative_imports" / "main.py" target = "function" + if not request.param == "async": + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app) - client = create_app(target, source).test_client() - resp = client.get("/my_path", json={"mode": "url"}) +def test_http_function_executes_success(http_trigger_client): + resp = http_trigger_client.post("/my_path", json={"mode": "SUCCESS"}) assert resp.status_code == 200 - assert resp.data == b"http://localhost/my_path" + assert resp.text == "success" -def test_http_function_request_path_slash(): - source = TEST_FUNCTIONS_DIR / "http_request_check" / "main.py" - target = "function" +def test_http_function_executes_failure(http_trigger_client): + resp = http_trigger_client.post("/", json={"mode": "FAILURE"}) + assert resp.status_code == 400 + assert resp.text == "failure" - client = create_app(target, source).test_client() - resp = client.get("/", json={"mode": "path"}) - assert resp.status_code == 200 - assert resp.data == b"/" +def test_http_function_executes_throw(http_trigger_client): + resp = http_trigger_client.put("/", json={"mode": "THROW"}) + assert resp.status_code == 500 -def test_http_function_request_path_path(): - source = TEST_FUNCTIONS_DIR / "http_request_check" / "main.py" - target = "function" +def test_http_function_request_url_empty_path(http_request_check_client): + # Starlette TestClient normalizes empty path "" to "/" before making the request, + # while Flask preserves the empty path and lets the server handle the redirect + if StarletteTestClient and isinstance( + http_request_check_client, StarletteTestClient + ): + # Starlette TestClient converts "" to "/" so we get a direct 200 response + resp = http_request_check_client.post("", json={"mode": "url"}) + assert resp.status_code == 200 + assert resp.text == "http://localhost/" + else: + # Flask returns a 308 redirect from empty path to "/" + resp = http_request_check_client.post("", json={"mode": "url"}) + assert resp.status_code == 308 + assert resp.location == "http://localhost/" + + +def test_http_function_request_url_slash(http_request_check_client): + resp = http_request_check_client.post("/", json={"mode": "url"}) + assert resp.status_code == 200 + assert resp.text == "http://localhost/" - client = create_app(target, source).test_client() - resp = client.get("/my_path", json={"mode": "path"}) +def test_http_function_rquest_url_path(http_request_check_client): + resp = http_request_check_client.post("/my_path", json={"mode": "url"}) assert resp.status_code == 200 - assert resp.data == b"/my_path" + assert resp.text == "http://localhost/my_path" -def test_http_function_check_env_function_target(): - source = TEST_FUNCTIONS_DIR / "http_check_env" / "main.py" - target = "function" +def test_http_function_request_path_slash(http_request_check_client): + resp = http_request_check_client.post("/", json={"mode": "path"}) + assert resp.status_code == 200 + assert resp.text == "/" - client = create_app(target, source).test_client() - resp = client.post("/", json={"mode": "FUNCTION_TARGET"}) +def test_http_function_request_path_path(http_request_check_client): + resp = http_request_check_client.post("/my_path", json={"mode": "path"}) assert resp.status_code == 200 - assert resp.data == b"function" + assert resp.text == "/my_path" -def test_http_function_check_env_function_signature_type(): - source = TEST_FUNCTIONS_DIR / "http_check_env" / "main.py" - target = "function" - - client = create_app(target, source).test_client() - - resp = client.post("/", json={"mode": "FUNCTION_SIGNATURE_TYPE"}) +def test_http_function_check_env_function_target(http_check_env_client): + resp = http_check_env_client.post("/", json={"mode": "FUNCTION_TARGET"}) assert resp.status_code == 200 - assert resp.data == b"http" + # Use .content for StarletteTestClient, .data for Flask test client (both return bytes) + data = getattr(resp, "content", getattr(resp, "data", None)) + assert data == b"function" -def test_http_function_execution_time(): - source = TEST_FUNCTIONS_DIR / "http_trigger_sleep" / "main.py" - target = "function" +def test_http_function_check_env_function_signature_type(http_check_env_client): + resp = http_check_env_client.post("/", json={"mode": "FUNCTION_SIGNATURE_TYPE"}) + assert resp.status_code == 200 + assert resp.text == "http" - client = create_app(target, source).test_client() +def test_http_function_execution_time(http_trigger_sleep_client): start_time = time.time() - resp = client.get("/", json={"mode": "1000"}) + resp = http_trigger_sleep_client.post("/", json={"mode": "1000"}) execution_time_sec = time.time() - start_time assert resp.status_code == 200 - assert resp.data == b"OK" + assert resp.text == "OK" + # Check that the execution time is roughly correct (allowing some buffer) + assert execution_time_sec > 0.9 def test_background_function_executes(background_event_client, background_json): @@ -268,7 +334,8 @@ def test_invalid_function_definition_missing_function_file(): ) -def test_invalid_function_definition_multiple_entry_points(): +@pytest.mark.parametrize("create_app", [create_app, create_asgi_app]) +def test_invalid_function_definition_multiple_entry_points(create_app): source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" target = "function" @@ -281,7 +348,8 @@ def test_invalid_function_definition_multiple_entry_points(): ) -def test_invalid_function_definition_multiple_entry_points_invalid_function(): +@pytest.mark.parametrize("create_app", [create_app, create_asgi_app]) +def test_invalid_function_definition_multiple_entry_points_invalid_function(create_app): source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" target = "invalidFunction" @@ -294,7 +362,8 @@ def test_invalid_function_definition_multiple_entry_points_invalid_function(): ) -def test_invalid_function_definition_multiple_entry_points_not_a_function(): +@pytest.mark.parametrize("create_app", [create_app, create_asgi_app]) +def test_invalid_function_definition_multiple_entry_points_not_a_function(create_app): source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" target = "notAFunction" @@ -308,7 +377,8 @@ def test_invalid_function_definition_multiple_entry_points_not_a_function(): ) -def test_invalid_function_definition_function_syntax_error(): +@pytest.mark.parametrize("create_app", [create_app, create_asgi_app]) +def test_invalid_function_definition_function_syntax_error(create_app): source = TEST_FUNCTIONS_DIR / "background_load_error" / "main.py" target = "function" @@ -336,7 +406,8 @@ def test_invalid_function_definition_function_syntax_robustness_with_debug(monke assert resp.status_code == 500 -def test_invalid_function_definition_missing_dependency(): +@pytest.mark.parametrize("create_app", [create_app, create_asgi_app]) +def test_invalid_function_definition_missing_dependency(create_app): source = TEST_FUNCTIONS_DIR / "background_missing_dependency" / "main.py" target = "function" @@ -346,7 +417,8 @@ def test_invalid_function_definition_missing_dependency(): assert "No module named 'nonexistentpackage'" in str(excinfo.value) -def test_invalid_configuration(): +@pytest.mark.parametrize("create_app", [create_app, create_asgi_app]) +def test_invalid_configuration(create_app): with pytest.raises(exceptions.InvalidConfigurationException) as excinfo: create_app(None, None, None) @@ -356,7 +428,8 @@ def test_invalid_configuration(): ) -def test_invalid_signature_type(): +@pytest.mark.parametrize("create_app", [create_app, create_asgi_app]) +def test_invalid_signature_type(create_app): source = TEST_FUNCTIONS_DIR / "http_trigger" / "main.py" target = "function" @@ -382,54 +455,39 @@ def test_http_function_flask_render_template(): ) -def test_http_function_with_import(): - source = TEST_FUNCTIONS_DIR / "http_with_import" / "main.py" - target = "function" - - client = create_app(target, source).test_client() - - resp = client.get("/") +def test_http_function_with_import(http_with_import_client): + resp = http_with_import_client.get("/") assert resp.status_code == 200 - assert resp.data == b"Hello" + assert resp.text == "Hello" @pytest.mark.parametrize( - "method, data", + "method, text", [ - ("get", b"GET"), - ("head", b""), # body will be empty - ("post", b"POST"), - ("put", b"PUT"), - ("delete", b"DELETE"), - ("options", b"OPTIONS"), - ("trace", b"TRACE"), - ("patch", b"PATCH"), + ("get", "GET"), + ("head", ""), # body will be empty + ("post", "POST"), + ("put", "PUT"), + ("delete", "DELETE"), + ("options", "OPTIONS"), + # ("trace", "TRACE"), # unsupported in httpx + ("patch", "PATCH"), ], ) -def test_http_function_all_methods(method, data): - source = TEST_FUNCTIONS_DIR / "http_method_check" / "main.py" - target = "function" - - client = create_app(target, source).test_client() - - resp = getattr(client, method)("/") +def test_http_function_all_methods(http_method_check_client, method, text): + resp = getattr(http_method_check_client, method)("/") assert resp.status_code == 200 - assert resp.data == data + assert resp.text == text @pytest.mark.parametrize("path", ["robots.txt", "favicon.ico"]) -def test_error_paths(path): - source = TEST_FUNCTIONS_DIR / "http_trigger" / "main.py" - target = "function" - - client = create_app(target, source).test_client() - - resp = client.get("/{}".format(path)) +def test_error_paths(http_trigger_client, path): + resp = http_trigger_client.get("/{}".format(path)) assert resp.status_code == 404 - assert b"Not Found" in resp.data + assert "Not Found" in resp.text @pytest.mark.parametrize( @@ -473,12 +531,8 @@ def function(): pass -def test_class_in_main_is_in_right_module(): - source = TEST_FUNCTIONS_DIR / "module_is_correct" / "main.py" - target = "function" - - client = create_app(target, source).test_client() - resp = client.get("/") +def test_class_in_main_is_in_right_module(module_is_correct_client): + resp = module_is_correct_client.get("/") assert resp.status_code == 200 @@ -493,12 +547,8 @@ def test_flask_current_app_is_available(): assert resp.status_code == 200 -def test_function_returns_none(): - source = TEST_FUNCTIONS_DIR / "returns_none" / "main.py" - target = "function" - - client = create_app(target, source).test_client() - resp = client.get("/") +def test_function_returns_none(returns_none_client): + resp = returns_none_client.get("/") assert resp.status_code == 500 @@ -515,6 +565,20 @@ def test_function_returns_stream(): assert resp.data.decode("utf-8") == "1.0\n3.0\n6.0\n10.0\n" +def test_async_function_returns_stream(): + source = TEST_FUNCTIONS_DIR / "http_streaming" / "async_main.py" + target = "function" + + client = StarletteTestClient(create_asgi_app(target, source)) + + collected_response = "" + with client.stream("POST", "/", content="1\n2\n3\n4\n") as resp: + assert resp.status_code == 200 + for text in resp.iter_text(): + collected_response += text + assert collected_response == "1.0\n3.0\n6.0\n10.0\n" + + def test_legacy_function_check_env(monkeypatch): source = TEST_FUNCTIONS_DIR / "http_check_env" / "main.py" target = "function" @@ -633,12 +697,7 @@ def tests_cloud_to_background_event_client_invalid_source( assert resp.status_code == 500 -def test_relative_imports(): - source = TEST_FUNCTIONS_DIR / "relative_imports" / "main.py" - target = "function" - - client = create_app(target, source).test_client() - - resp = client.get("/") +def test_relative_imports(relative_imports_client): + resp = relative_imports_client.get("/") assert resp.status_code == 200 - assert resp.data == b"success" + assert resp.text == "success" diff --git a/tests/test_functions/cloud_events/async_empty_data.py b/tests/test_functions/cloud_events/async_empty_data.py new file mode 100644 index 00000000..afc94c99 --- /dev/null +++ b/tests/test_functions/cloud_events/async_empty_data.py @@ -0,0 +1,38 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used to test handling CloudEvent (async) functions.""" +from starlette.exceptions import HTTPException + + +async def function(cloud_event): + """Test Event function that checks to see if a valid CloudEvent was sent. + + The function returns 200 if it received the expected event, otherwise 500. + + Args: + cloud_event: A CloudEvent as defined by https://github.com/cloudevents/sdk-python. + + Returns: + HTTP status code indicating whether valid event was sent or not. + + """ + valid_event = ( + cloud_event["id"] == "my-id" + and cloud_event["source"] == "from-galaxy-far-far-away" + and cloud_event["type"] == "cloud_event.greet.you" + ) + + if not valid_event: + raise HTTPException(status_code=500, detail="Something went wrong internally.") diff --git a/tests/test_functions/cloud_events/async_main.py b/tests/test_functions/cloud_events/async_main.py new file mode 100644 index 00000000..7e9b5423 --- /dev/null +++ b/tests/test_functions/cloud_events/async_main.py @@ -0,0 +1,40 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used to test handling CloudEvent (async) functions.""" +from starlette.exceptions import HTTPException + + +async def function(cloud_event): + """Test Event function that checks to see if a valid CloudEvent was sent. + + The function returns 200 if it received the expected event, otherwise 500. + + Args: + cloud_event: A CloudEvent as defined by https://github.com/cloudevents/sdk-python. + + Returns: + HTTP status code indicating whether valid event was sent or not. + + """ + valid_event = ( + cloud_event["id"] == "my-id" + and cloud_event.data == {"name": "john"} + and cloud_event["source"] == "from-galaxy-far-far-away" + and cloud_event["type"] == "cloud_event.greet.you" + and cloud_event["time"] == "2020-08-16T13:58:54.471765" + ) + + if not valid_event: + raise HTTPException(status_code=500, detail="Something went wrong internally.") diff --git a/tests/test_functions/decorators/async_decorator.py b/tests/test_functions/decorators/async_decorator.py new file mode 100644 index 00000000..0c0db7e4 --- /dev/null +++ b/tests/test_functions/decorators/async_decorator.py @@ -0,0 +1,98 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used to test handling functions using decorators.""" +from starlette.exceptions import HTTPException + +import functions_framework.aio + + +@functions_framework.aio.cloud_event +async def function_cloud_event(cloud_event): + """Test Event function that checks to see if a valid CloudEvent was sent. + + The function returns 200 if it received the expected event, otherwise 500. + + Args: + cloud_event: A CloudEvent as defined by https://github.com/cloudevents/sdk-python. + + Returns: + HTTP status code indicating whether valid event was sent or not. + """ + valid_event = ( + cloud_event["id"] == "my-id" + and cloud_event.data == {"name": "john"} + and cloud_event["source"] == "from-galaxy-far-far-away" + and cloud_event["type"] == "cloud_event.greet.you" + and cloud_event["time"] == "2020-08-16T13:58:54.471765" + ) + + if not valid_event: + raise HTTPException(500) + + +@functions_framework.aio.http +async def function_http(request): + """Test function which returns the requested element of the HTTP request. + + Name of the requested HTTP request element is provided in the 'mode' field in + the incoming JSON document. + + Args: + request: The HTTP request which triggered this function. Must contain name + of the requested HTTP request element in the 'mode' field in JSON document + in request body. + + Returns: + Value of the requested HTTP request element, or 'Bad Request' status in case + of unrecognized incoming request. + """ + data = await request.json() + mode = data["mode"] + if mode == "path": + return request.url.path + else: + raise HTTPException(400) + + +@functions_framework.aio.cloud_event +def function_cloud_event_sync(cloud_event): + """Test sync CloudEvent function with aio decorator.""" + valid_event = ( + cloud_event["id"] == "my-id" + and cloud_event.data == {"name": "john"} + and cloud_event["source"] == "from-galaxy-far-far-away" + and cloud_event["type"] == "cloud_event.greet.you" + and cloud_event["time"] == "2020-08-16T13:58:54.471765" + ) + + if not valid_event: + raise HTTPException(500) + + +@functions_framework.aio.http +def function_http_sync(request): + """Test sync HTTP function with aio decorator.""" + # Use query params since they're accessible synchronously + mode = request.query_params.get("mode") + if mode == "path": + return request.url.path + else: + return "sync response" + + +@functions_framework.aio.http +def function_http_dict_response(request): + """Test sync HTTP function returning dict with aio decorator.""" + return {"message": "hello", "count": 42, "success": True} diff --git a/tests/test_functions/http_check_env/async_main.py b/tests/test_functions/http_check_env/async_main.py new file mode 100644 index 00000000..dd91faec --- /dev/null +++ b/tests/test_functions/http_check_env/async_main.py @@ -0,0 +1,36 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used in Worker tests of environment variables setup.""" +import os + +X_GOOGLE_FUNCTION_NAME = "gcf-function" +X_GOOGLE_ENTRY_POINT = "function" +HOME = "/tmp" + + +async def function(request): + """Test function which returns the requested environment variable value. + + Args: + request: The HTTP request which triggered this function. Must contain name + of the requested environment variable in the 'mode' field in JSON document + in request body. + + Returns: + Value of the requested environment variable. + """ + data = await request.json() + name = data.get("mode") + return os.environ[name] diff --git a/tests/test_functions/http_request_check/async_main.py b/tests/test_functions/http_request_check/async_main.py new file mode 100644 index 00000000..bf0e7ce5 --- /dev/null +++ b/tests/test_functions/http_request_check/async_main.py @@ -0,0 +1,40 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used in Worker tests of HTTP request contents.""" + + +async def function(request): + """Test function which returns the requested element of the HTTP request. + + Name of the requested HTTP request element is provided in the 'mode' field in + the incoming JSON document. + + Args: + request: The HTTP request which triggered this function. Must contain name + of the requested HTTP request element in the 'mode' field in JSON document + in request body. + + Returns: + Value of the requested HTTP request element, or 'Bad Request' status in case + of unrecognized incoming request. + """ + data = await request.json() + mode = data.get("mode") + if mode == "path": + return request.url.path + elif mode == "url": + return str(request.url) + else: + return "invalid request", 400 diff --git a/tests/test_functions/http_streaming/async_main.py b/tests/test_functions/http_streaming/async_main.py new file mode 100644 index 00000000..1db2a7b9 --- /dev/null +++ b/tests/test_functions/http_streaming/async_main.py @@ -0,0 +1,46 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Async function used in Worker tests of handling HTTP functions.""" + +import asyncio + +from starlette.responses import StreamingResponse + + +async def function(request): + """Test async HTTP function that reads a stream of integers and returns a stream + providing the sum of values read so far. + + Args: + request: The HTTP request which triggered this function. Must contain a + stream of new line separated integers. + + Returns: + A Starlette StreamingResponse. + """ + print("INVOKED THE ASYNC STREAM FUNCTION!!!") + + body = await request.body() + body_str = body.decode("utf-8") + lines = body_str.strip().split("\n") if body_str.strip() else [] + + def generate(): + sum_so_far = 0 + for line in lines: + if line.strip(): + sum_so_far += float(line) + yield (str(sum_so_far) + "\n").encode("utf-8") + + return StreamingResponse(generate()) diff --git a/tests/test_functions/http_trigger/async_main.py b/tests/test_functions/http_trigger/async_main.py new file mode 100644 index 00000000..0e487d52 --- /dev/null +++ b/tests/test_functions/http_trigger/async_main.py @@ -0,0 +1,48 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used in Worker tests of handling HTTP functions.""" + +from starlette.exceptions import HTTPException +from starlette.responses import Response + + +async def function(request): + """Test HTTP function whose behavior depends on the given mode. + + The function returns a success, a failure, or throws an exception, depending + on the given mode. + + Args: + request: The HTTP request which triggered this function. Must contain name + of the requested mode in the 'mode' field in JSON document in request + body. + + Returns: + Value and status code defined for the given mode. + + Raises: + Exception: Thrown when requested in the incoming mode specification. + """ + data = await request.json() + mode = data.get("mode") + print("Mode: " + mode) + if mode == "SUCCESS": + return "success", 200 + elif mode == "FAILURE": + raise HTTPException(status_code=400, detail="failure") + elif mode == "THROW": + raise Exception("omg") + else: + return "invalid request", 400 diff --git a/tests/test_functions/http_trigger_sleep/async_main.py b/tests/test_functions/http_trigger_sleep/async_main.py new file mode 100644 index 00000000..fe77be1e --- /dev/null +++ b/tests/test_functions/http_trigger_sleep/async_main.py @@ -0,0 +1,33 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Async function used in Worker tests of function execution time.""" +import asyncio + + +async def function(request): + """Async test function which sleeps for the given number of seconds. + + The test verifies that it gets the response from the function only after the + given number of seconds. + + Args: + request: The HTTP request which triggered this function. Must contain the + requested number of seconds in the 'mode' field in JSON document in + request body. + """ + payload = await request.json() + sleep_sec = int(payload.get("mode")) / 1000.0 + await asyncio.sleep(sleep_sec) + return "OK" diff --git a/tests/test_functions/http_with_import/async_main.py b/tests/test_functions/http_with_import/async_main.py new file mode 100644 index 00000000..75a1dcac --- /dev/null +++ b/tests/test_functions/http_with_import/async_main.py @@ -0,0 +1,29 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used in Worker tests of handling HTTP functions.""" + +from foo import bar + + +async def function(request): + """Test HTTP function which imports from another file + + Args: + request: The HTTP request which triggered this function. + + Returns: + The imported return value and status code defined for the given mode. + """ + return bar diff --git a/tests/test_typing.py b/tests/test_typing.py index 279cd636..0ca90b47 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -14,3 +14,15 @@ def hello(request: flask.Request) -> flask.typing.ResponseReturnValue: @functions_framework.cloud_event def hello_cloud_event(cloud_event: CloudEvent) -> None: print(f"Received event: id={cloud_event['id']} and data={cloud_event.data}") + + from starlette.requests import Request + + import functions_framework.aio + + @functions_framework.aio.http + async def hello_async(request: Request) -> str: + return "Hello world!" + + @functions_framework.aio.cloud_event + async def hello_cloud_event_async(cloud_event: CloudEvent) -> None: + print(f"Received event: id={cloud_event['id']} and data={cloud_event.data}") diff --git a/tox.ini b/tox.ini index 1599608c..fd3e38a6 100644 --- a/tox.ini +++ b/tox.ini @@ -24,12 +24,19 @@ envlist = usedevelop = true deps = docker + httpx pytest-asyncio pytest-cov pytest-integration pretend +extras = + async setenv = PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 + # Python 3.7: Use .coveragerc-py37 to exclude aio module from coverage since it requires Python 3.8+ (Starlette dependency) + py37-ubuntu-22.04: PYTESTARGS = --cov=functions_framework --cov-config=.coveragerc-py37 --cov-branch --cov-report term-missing --cov-fail-under=100 + py37-macos-13: PYTESTARGS = --cov=functions_framework --cov-config=.coveragerc-py37 --cov-branch --cov-report term-missing --cov-fail-under=100 + py37-windows-latest: PYTESTARGS = windows-latest: PYTESTARGS = commands = pytest {env:PYTESTARGS} {posargs} @@ -41,6 +48,8 @@ deps = isort mypy build +extras = + async commands = black --check src tests conftest.py --exclude tests/test_functions/background_load_error/main.py isort -c src tests conftest.py From 268acf121015bf2a5592715e4cfe582f9d236ff8 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Thu, 12 Jun 2025 15:41:35 -0700 Subject: [PATCH 06/14] feat: add flag to run functions framework in asgi stack (#376) * feat: add ASGI server support for async functions - Add uvicorn and uvicorn-worker to async optional dependencies - Refactor gunicorn.py with BaseGunicornApplication for shared config - Add UvicornApplication class for ASGI apps - Add StarletteApplication in asgi.py for development mode - Update HTTPServer to auto-detect Flask (WSGI) vs other (ASGI) apps - Add --gateway CLI flag to choose between wsgi and asgi - Update test_http.py to use Flask instance in tests * fix: apply black and isort formatting to source files * test: add comprehensive tests for ASGI server support - Add tests for HTTPServer ASGI/WSGI auto-detection - Add tests for StarletteApplication and UvicornApplication - Add tests for CLI --gateway flag functionality - Add integration tests for async functions with ASGI - Ensure 100% code coverage for new ASGI features - Apply black and isort formatting to test files * fix: skip async test files on Python 3.7 * fix: exclude async code from Python 3.7 coverage * fix: Install uvicorn on windows. * fix: unable to use reload in starlette. * fix: add missing endpoint parameter to Route constructors in ASGI * feat: add async conformance tests with ASGI gateway * fix: set UvicornWorker class before parent init and update tests * fix: update asgi tests to remove reload option * feat: add async-specific conformance tests for ASGI mode * fix: apply black formatting to async files * fix: disable validateMapping for CloudEvent tests in ASGI mode ASGI mode does not support automatic conversion from legacy events to CloudEvents, so validateMapping must be false for CloudEvent conformance tests. * fix: avoid mutating options dict in Gunicorn applications Create a copy of the options dict before modifying it to prevent side effects when the same options dict is reused elsewhere. This could cause issues with timeout tests. * fix: add pragma comments for Python 3.7 coverage and fix options handling - Add pragma: no cover comments for ASGI-specific code paths that won't execute in Python 3.7 - Fix options dict handling to use consistent variable names to avoid confusion * fix: revert to separate GunicornApplication and UvicornApplication classes Remove the BaseGunicornApplication abstraction as it was causing issues with the timeout mechanism. Each class now independently extends gunicorn.app.base.BaseApplication, which is cleaner and avoids the problems we were seeing with shared state and options handling. * refactor: restore GunicornApplication to match main branch Remove unnecessary changes to GunicornApplication class, keeping only the UvicornApplication addition for ASGI support. * chore: Untrack uv.lock * chore: rename confirmance test (asgi) github workflow * chore: cleanup .gitignore. * chore: clean up unncessary comments. --- .coveragerc-py37 | 13 ++- .github/workflows/conformance-asgi.yml | 91 ++++++++++++++++ .gitignore | 1 + conftest.py | 4 +- pyproject.toml | 6 +- src/functions_framework/_cli.py | 17 ++- src/functions_framework/_http/__init__.py | 37 +++++-- src/functions_framework/_http/asgi.py | 43 ++++++++ src/functions_framework/_http/gunicorn.py | 25 +++++ src/functions_framework/aio/__init__.py | 8 +- tests/conformance/async_main.py | 59 +++++++++++ tests/test_asgi.py | 120 ++++++++++++++++++++++ tests/test_cli.py | 21 ++++ tests/test_functions.py | 2 - tests/test_http.py | 119 ++++++++++++++++++++- 15 files changed, 544 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/conformance-asgi.yml create mode 100644 src/functions_framework/_http/asgi.py create mode 100644 tests/conformance/async_main.py create mode 100644 tests/test_asgi.py diff --git a/.coveragerc-py37 b/.coveragerc-py37 index 13be2ea1..fb6dbb6e 100644 --- a/.coveragerc-py37 +++ b/.coveragerc-py37 @@ -4,7 +4,18 @@ # This file is only used by py37-* tox environments omit = */functions_framework/aio/* + */functions_framework/_http/asgi.py */.tox/* */tests/* */venv/* - */.venv/* \ No newline at end of file + */.venv/* + +[report] +exclude_lines = + # Have to re-enable the standard pragma + pragma: no cover + + # Don't complain about async-specific imports and code + from functions_framework.aio import + from functions_framework._http.asgi import + from functions_framework._http.gunicorn import UvicornApplication \ No newline at end of file diff --git a/.github/workflows/conformance-asgi.yml b/.github/workflows/conformance-asgi.yml new file mode 100644 index 00000000..c69d1862 --- /dev/null +++ b/.github/workflows/conformance-asgi.yml @@ -0,0 +1,91 @@ +name: Python Conformance CI (asgi) +on: + push: + branches: + - 'main' + pull_request: + +# Declare default permissions as read only. +permissions: read-all + +jobs: + build: + strategy: + matrix: + python: ['3.8', '3.9', '3.10', '3.11', '3.12'] + platform: [ubuntu-latest] + runs-on: ${{ matrix.platform }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 + with: + disable-sudo: true + egress-policy: block + allowed-endpoints: > + api.github.com:443 + files.pythonhosted.org:443 + github.com:443 + objects.githubusercontent.com:443 + proxy.golang.org:443 + pypi.org:443 + storage.googleapis.com:443 + + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: ${{ matrix.python }} + + - name: Install the framework with async extras + run: python -m pip install -e .[async] + + - name: Setup Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version: '1.24' + + - name: Run HTTP conformance tests + uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + with: + functionType: 'http' + useBuildpacks: false + validateMapping: false + cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http --signature-type http --gateway asgi'" + + - name: Run CloudEvents conformance tests + uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + with: + functionType: 'cloudevent' + useBuildpacks: false + validateMapping: false + cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event --signature-type cloudevent --gateway asgi'" + + - name: Run HTTP conformance tests declarative + uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + with: + functionType: 'http' + useBuildpacks: false + validateMapping: false + cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative --gateway asgi'" + + - name: Run CloudEvents conformance tests declarative + uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + with: + functionType: 'cloudevent' + useBuildpacks: false + validateMapping: false + cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event_declarative --gateway asgi'" + + - name: Run HTTP concurrency tests declarative + uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + with: + functionType: 'http' + useBuildpacks: false + validateConcurrency: true + cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative_concurrent --gateway asgi'" + + # Note: Event (legacy) and Typed tests are not supported in ASGI mode + # Note: validateMapping is set to false for CloudEvent tests because ASGI mode + # does not support automatic conversion from legacy events to CloudEvents \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8b5379fe..967d4513 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ dist/ function_output.json serverlog_stderr.txt serverlog_stdout.txt +venv/ diff --git a/conftest.py b/conftest.py index f72314ed..1d17e9bf 100644 --- a/conftest.py +++ b/conftest.py @@ -50,8 +50,8 @@ def pytest_ignore_collect(collection_path, config): if sys.version_info >= (3, 8): return None - # Skip test_aio.py entirely on Python 3.7 - if collection_path.name == "test_aio.py": + # Skip test_aio.py and test_asgi.py entirely on Python 3.7 + if collection_path.name in ["test_aio.py", "test_asgi.py"]: return True return None diff --git a/pyproject.toml b/pyproject.toml index 3a631b5d..350d8997 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,11 @@ dependencies = [ Homepage = "https://github.com/googlecloudplatform/functions-framework-python" [project.optional-dependencies] -async = ["starlette>=0.37.0,<1.0.0; python_version>='3.8'"] +async = [ + "starlette>=0.37.0,<1.0.0; python_version>='3.8'", + "uvicorn>=0.18.0,<1.0.0; python_version>='3.8'", + "uvicorn-worker>=0.2.0,<1.0.0; python_version>='3.8'" +] [project.scripts] ff = "functions_framework._cli:_cli" diff --git a/src/functions_framework/_cli.py b/src/functions_framework/_cli.py index 773dd4cd..e27b5446 100644 --- a/src/functions_framework/_cli.py +++ b/src/functions_framework/_cli.py @@ -32,6 +32,19 @@ @click.option("--host", envvar="HOST", type=click.STRING, default="0.0.0.0") @click.option("--port", envvar="PORT", type=click.INT, default=8080) @click.option("--debug", envvar="DEBUG", is_flag=True) -def _cli(target, source, signature_type, host, port, debug): - app = create_app(target, source, signature_type) +@click.option( + "--gateway", + envvar="GATEWAY", + type=click.Choice(["wsgi", "asgi"]), + default="wsgi", + help="Server gateway interface type (wsgi for sync, asgi for async)", +) +def _cli(target, source, signature_type, host, port, debug, gateway): + if gateway == "asgi": # pragma: no cover + from functions_framework.aio import create_asgi_app + + app = create_asgi_app(target, source, signature_type) + else: + app = create_app(target, source, signature_type) + create_server(app, debug).run(host, port) diff --git a/src/functions_framework/_http/__init__.py b/src/functions_framework/_http/__init__.py index ca9b0f5c..fa2cbc09 100644 --- a/src/functions_framework/_http/__init__.py +++ b/src/functions_framework/_http/__init__.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from flask import Flask + from functions_framework._http.flask import FlaskApplication @@ -21,15 +23,30 @@ def __init__(self, app, debug, **options): self.debug = debug self.options = options - if self.debug: - self.server_class = FlaskApplication - else: - try: - from functions_framework._http.gunicorn import GunicornApplication - - self.server_class = GunicornApplication - except ImportError as e: + if isinstance(app, Flask): + if self.debug: self.server_class = FlaskApplication + else: + try: + from functions_framework._http.gunicorn import GunicornApplication + + self.server_class = GunicornApplication + except ImportError as e: + self.server_class = FlaskApplication + else: # pragma: no cover + if self.debug: + from functions_framework._http.asgi import StarletteApplication + + self.server_class = StarletteApplication + else: + try: + from functions_framework._http.gunicorn import UvicornApplication + + self.server_class = UvicornApplication + except ImportError as e: + from functions_framework._http.asgi import StarletteApplication + + self.server_class = StarletteApplication def run(self, host, port): http_server = self.server_class( @@ -38,5 +55,5 @@ def run(self, host, port): http_server.run() -def create_server(wsgi_app, debug, **options): - return HTTPServer(wsgi_app, debug, **options) +def create_server(app, debug, **options): + return HTTPServer(app, debug, **options) diff --git a/src/functions_framework/_http/asgi.py b/src/functions_framework/_http/asgi.py new file mode 100644 index 00000000..083ffc2e --- /dev/null +++ b/src/functions_framework/_http/asgi.py @@ -0,0 +1,43 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import uvicorn + + +class StarletteApplication: + """A Starlette application that uses Uvicorn for direct serving (development mode).""" + + def __init__(self, app, host, port, debug, **options): + """Initialize the Starlette application. + + Args: + app: The ASGI application to serve + host: The host to bind to + port: The port to bind to + debug: Whether to run in debug mode + **options: Additional options to pass to Uvicorn + """ + self.app = app + self.host = host + self.port = port + self.debug = debug + + self.options = { + "log_level": "debug" if debug else "error", + } + self.options.update(options) + + def run(self): + """Run the Uvicorn server directly.""" + uvicorn.run(self.app, host=self.host, port=int(self.port), **self.options) diff --git a/src/functions_framework/_http/gunicorn.py b/src/functions_framework/_http/gunicorn.py index 92cad90e..745ce2f8 100644 --- a/src/functions_framework/_http/gunicorn.py +++ b/src/functions_framework/_http/gunicorn.py @@ -70,3 +70,28 @@ class GThreadWorkerWithTimeoutSupport(ThreadWorker): # pragma: no cover def handle_request(self, req, conn): with ThreadingTimeout(TIMEOUT_SECONDS): super(GThreadWorkerWithTimeoutSupport, self).handle_request(req, conn) + + +class UvicornApplication(gunicorn.app.base.BaseApplication): + """Gunicorn application for ASGI apps using Uvicorn workers.""" + + def __init__(self, app, host, port, debug, **options): + self.options = { + "bind": "%s:%s" % (host, port), + "workers": int(os.environ.get("WORKERS", 1)), + "worker_class": "uvicorn_worker.UvicornWorker", + "timeout": int(os.environ.get("CLOUD_RUN_TIMEOUT_SECONDS", 0)), + "loglevel": os.environ.get("GUNICORN_LOG_LEVEL", "error"), + "limit_request_line": 0, + } + self.options.update(options) + self.app = app + + super().__init__() + + def load_config(self): + for key, value in self.options.items(): + self.cfg.set(key, value) + + def load(self): + return self.app diff --git a/src/functions_framework/aio/__init__.py b/src/functions_framework/aio/__init__.py index 832d6818..21f12754 100644 --- a/src/functions_framework/aio/__init__.py +++ b/src/functions_framework/aio/__init__.py @@ -197,14 +197,16 @@ def create_asgi_app(target=None, source=None, signature_type=None): routes.append( Route( "/{path:path}", - http_handler, + endpoint=http_handler, methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH"], ) ) elif signature_type == _function_registry.CLOUDEVENT_SIGNATURE_TYPE: cloudevent_handler = _cloudevent_func_wrapper(function, is_async) - routes.append(Route("/{path:path}", cloudevent_handler, methods=["POST"])) - routes.append(Route("/", cloudevent_handler, methods=["POST"])) + routes.append( + Route("/{path:path}", endpoint=cloudevent_handler, methods=["POST"]) + ) + routes.append(Route("/", endpoint=cloudevent_handler, methods=["POST"])) elif signature_type == _function_registry.TYPED_SIGNATURE_TYPE: raise FunctionsFrameworkException( f"ASGI server does not support typed events (signature type: '{signature_type}'). " diff --git a/tests/conformance/async_main.py b/tests/conformance/async_main.py new file mode 100644 index 00000000..2a7b30a1 --- /dev/null +++ b/tests/conformance/async_main.py @@ -0,0 +1,59 @@ +import asyncio +import json + +from cloudevents.http import to_json + +import functions_framework.aio + +filename = "function_output.json" + + +class RawJson: + data: dict + + def __init__(self, data): + self.data = data + + @staticmethod + def from_dict(obj: dict) -> "RawJson": + return RawJson(obj) + + def to_dict(self) -> dict: + return self.data + + +def _write_output(content): + with open(filename, "w") as f: + f.write(content) + + +async def write_http(request): + json_data = await request.json() + _write_output(json.dumps(json_data)) + return "OK", 200 + + +async def write_cloud_event(cloud_event): + _write_output(to_json(cloud_event).decode()) + + +@functions_framework.aio.http +async def write_http_declarative(request): + json_data = await request.json() + _write_output(json.dumps(json_data)) + return "OK", 200 + + +@functions_framework.aio.cloud_event +async def write_cloud_event_declarative(cloud_event): + _write_output(to_json(cloud_event).decode()) + + +@functions_framework.aio.http +async def write_http_declarative_concurrent(request): + await asyncio.sleep(1) + return "OK", 200 + + +# Note: Typed events are not supported in ASGI mode yet +# Legacy event functions are also not supported in ASGI mode diff --git a/tests/test_asgi.py b/tests/test_asgi.py new file mode 100644 index 00000000..cd117bd3 --- /dev/null +++ b/tests/test_asgi.py @@ -0,0 +1,120 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys + +import flask +import pretend +import pytest + +import functions_framework._http + +try: + from starlette.applications import Starlette +except ImportError: + pass + + +def test_httpserver_detects_asgi_app(): + flask_app = flask.Flask("test") + flask_wrapper = functions_framework._http.HTTPServer(flask_app, debug=True) + assert flask_wrapper.server_class.__name__ == "FlaskApplication" + + starlette_app = Starlette(routes=[]) + starlette_wrapper = functions_framework._http.HTTPServer(starlette_app, debug=True) + assert starlette_wrapper.server_class.__name__ == "StarletteApplication" + + +@pytest.mark.skipif("platform.system() == 'Windows'") +def test_httpserver_production_asgi(): + starlette_app = Starlette(routes=[]) + wrapper = functions_framework._http.HTTPServer(starlette_app, debug=False) + assert wrapper.server_class.__name__ == "UvicornApplication" + + +def test_starlette_application_init(): + from functions_framework._http.asgi import StarletteApplication + + app = pretend.stub() + host = "1.2.3.4" + port = "5678" + + # Test debug mode + starlette_app = StarletteApplication(app, host, port, debug=True, custom="value") + assert starlette_app.app == app + assert starlette_app.host == host + assert starlette_app.port == port + assert starlette_app.debug is True + assert starlette_app.options["log_level"] == "debug" + assert starlette_app.options["custom"] == "value" + + # Test production mode + starlette_app = StarletteApplication(app, host, port, debug=False) + assert starlette_app.options["log_level"] == "error" + + +@pytest.mark.skipif("platform.system() == 'Windows'") +def test_uvicorn_application_init(): + from functions_framework._http.gunicorn import UvicornApplication + + app = pretend.stub() + host = "1.2.3.4" + port = "1234" + + uvicorn_app = UvicornApplication(app, host, port, debug=False) + assert uvicorn_app.app == app + assert uvicorn_app.options["worker_class"] == "uvicorn_worker.UvicornWorker" + assert uvicorn_app.options["bind"] == "1.2.3.4:1234" + assert uvicorn_app.load() == app + + +def test_httpserver_fallback_on_import_error(monkeypatch): + starlette_app = Starlette(routes=[]) + + monkeypatch.setitem(sys.modules, "functions_framework._http.gunicorn", None) + + wrapper = functions_framework._http.HTTPServer(starlette_app, debug=False) + assert wrapper.server_class.__name__ == "StarletteApplication" + + +def test_starlette_application_run(monkeypatch): + uvicorn_run_calls = [] + + def mock_uvicorn_run(app, **kwargs): + uvicorn_run_calls.append((app, kwargs)) + + uvicorn_stub = pretend.stub(run=mock_uvicorn_run) + monkeypatch.setitem(sys.modules, "uvicorn", uvicorn_stub) + + # Clear and re-import to get fresh module with mocked uvicorn + if "functions_framework._http.asgi" in sys.modules: + del sys.modules["functions_framework._http.asgi"] + + from functions_framework._http.asgi import StarletteApplication + + app = pretend.stub() + host = "1.2.3.4" + port = "5678" + + starlette_app = StarletteApplication(app, host, port, debug=True, custom="value") + starlette_app.run() + + assert len(uvicorn_run_calls) == 1 + assert uvicorn_run_calls[0][0] == app + assert uvicorn_run_calls[0][1] == { + "host": host, + "port": int(port), + "log_level": "debug", + "custom": "value", + } diff --git a/tests/test_cli.py b/tests/test_cli.py index 7613b649..17445d11 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import sys + import pretend import pytest @@ -103,3 +105,22 @@ def test_cli(monkeypatch, args, env, create_app_calls, run_calls): assert result.exit_code == 0 assert create_app.calls == create_app_calls assert wsgi_server.run.calls == run_calls + + +def test_asgi_cli(monkeypatch): + asgi_server = pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None)) + asgi_app = pretend.stub() + + create_asgi_app = pretend.call_recorder(lambda *a, **kw: asgi_app) + aio_module = pretend.stub(create_asgi_app=create_asgi_app) + monkeypatch.setitem(sys.modules, "functions_framework.aio", aio_module) + + create_server = pretend.call_recorder(lambda *a, **kw: asgi_server) + monkeypatch.setattr(functions_framework._cli, "create_server", create_server) + + runner = CliRunner() + result = runner.invoke(_cli, ["--target", "foo", "--gateway", "asgi"]) + + assert result.exit_code == 0 + assert create_asgi_app.calls == [pretend.call("foo", None, "http")] + assert asgi_server.run.calls == [pretend.call("0.0.0.0", 8080)] diff --git a/tests/test_functions.py b/tests/test_functions.py index 9107dc68..534f4a88 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -21,7 +21,6 @@ import pretend import pytest -# Conditional import for Starlette if sys.version_info >= (3, 8): from starlette.testclient import TestClient as StarletteTestClient else: @@ -31,7 +30,6 @@ from functions_framework import LazyWSGIApp, create_app, errorhandler, exceptions -# Conditional import for async functionality if sys.version_info >= (3, 8): from functions_framework.aio import create_asgi_app else: diff --git a/tests/test_http.py b/tests/test_http.py index fbfac9d2..df9d4c6c 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -16,6 +16,7 @@ import platform import sys +import flask import pretend import pytest @@ -45,7 +46,7 @@ def test_create_server(monkeypatch, debug): ], ) def test_httpserver(monkeypatch, debug, gunicorn_missing, expected): - app = pretend.stub() + app = flask.Flask("test") http_server = pretend.stub(run=pretend.call_recorder(lambda: None)) server_classes = { "flask": pretend.call_recorder(lambda *a, **kw: http_server), @@ -133,3 +134,119 @@ def test_flask_application(debug): assert app.run.calls == [ pretend.call(host, port, debug=debug, a=options["a"], b=options["b"]), ] + + +@pytest.mark.parametrize( + "debug, uvicorn_missing, expected", + [ + (True, False, "starlette"), + (False, False, "uvicorn" if platform.system() != "Windows" else "starlette"), + (True, True, "starlette"), + (False, True, "starlette"), + ], +) +def test_httpserver_asgi(monkeypatch, debug, uvicorn_missing, expected): + app = pretend.stub() + http_server = pretend.stub(run=pretend.call_recorder(lambda: None)) + server_classes = { + "starlette": pretend.call_recorder(lambda *a, **kw: http_server), + "uvicorn": pretend.call_recorder(lambda *a, **kw: http_server), + } + options = {"a": pretend.stub(), "b": pretend.stub()} + + from functions_framework._http import asgi + + monkeypatch.setattr(asgi, "StarletteApplication", server_classes["starlette"]) + + if uvicorn_missing or platform.system() == "Windows": + monkeypatch.setitem(sys.modules, "functions_framework._http.gunicorn", None) + else: + from functions_framework._http import gunicorn + + monkeypatch.setattr(gunicorn, "UvicornApplication", server_classes["uvicorn"]) + + wrapper = functions_framework._http.HTTPServer(app, debug, **options) + + assert wrapper.app == app + assert wrapper.server_class == server_classes[expected] + assert wrapper.options == options + + host = pretend.stub() + port = pretend.stub() + + wrapper.run(host, port) + + assert wrapper.server_class.calls == [ + pretend.call(app, host, port, debug, **options) + ] + assert http_server.run.calls == [pretend.call()] + + +@pytest.mark.skipif("platform.system() == 'Windows'") +def test_uvicorn_application(): + app = pretend.stub() + host = "1.2.3.4" + port = "1234" + options = {} + + import functions_framework._http.gunicorn + + uvicorn_app = functions_framework._http.gunicorn.UvicornApplication( + app, host, port, debug=False, **options + ) + + assert uvicorn_app.app == app + assert uvicorn_app.options == { + "bind": "%s:%s" % (host, port), + "workers": 1, + "timeout": 0, + "loglevel": "error", + "limit_request_line": 0, + "worker_class": "uvicorn_worker.UvicornWorker", + } + + assert uvicorn_app.cfg.bind == ["1.2.3.4:1234"] + assert uvicorn_app.cfg.workers == 1 + assert uvicorn_app.cfg.timeout == 0 + assert uvicorn_app.load() == app + + +@pytest.mark.parametrize("debug", [True, False]) +def test_starlette_application(monkeypatch, debug): + uvicorn_run = pretend.call_recorder(lambda *a, **kw: None) + uvicorn_stub = pretend.stub(run=uvicorn_run) + monkeypatch.setitem(sys.modules, "uvicorn", uvicorn_stub) + + # Clear and re-import to get fresh module with mocked uvicorn + if "functions_framework._http.asgi" in sys.modules: + del sys.modules["functions_framework._http.asgi"] + + from functions_framework._http.asgi import StarletteApplication + + app = pretend.stub() + host = "1.2.3.4" + port = "5678" + options = {"custom": "value"} + + starlette_app = StarletteApplication(app, host, port, debug, **options) + + assert starlette_app.app == app + assert starlette_app.host == host + assert starlette_app.port == port + assert starlette_app.debug == debug + assert starlette_app.options == { + "log_level": "debug" if debug else "error", + "custom": "value", + } + + starlette_app.run() + + assert uvicorn_run.calls == [ + pretend.call( + app, + host=host, + port=int(port), + log_level="debug" if debug else "error", + custom="value", + ) + ] From 1123eeac8cedae23af8980a928f01f5ad100d9de Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 17 Jun 2025 14:04:37 -0700 Subject: [PATCH 07/14] feat: add execution_id support for async stack (#377) * feat: add execution_id support for async stack - Add contextvars support to execution_id.py for async-safe context storage - Create AsgiMiddleware class to inject execution_id into ASGI requests - Add set_execution_context_async decorator for both sync and async functions - Update LoggingHandlerAddExecutionId to support both Flask g and contextvars - Integrate execution_id support in aio/__init__.py with proper exception handling - Add comprehensive async tests matching sync test functionality - Follow Starlette best practices for exception handling The implementation enables automatic execution_id injection and logging for async functions when LOG_EXECUTION_ID=true, matching the existing sync stack behavior. * refactor: move exception logging to crash handler for cleaner code - Remove try/catch blocks from wrapper functions - Centralize exception logging in _crash_handler - Extract execution_id directly from request headers in crash handler - Temporarily set context when logging exceptions to ensure execution_id is included - This approach is cleaner and more similar to Flask's centralized exception handling * refactor: improve code organization based on feedback - Move imports to top of file instead of inside functions - Extract common header parsing logic into _extract_context_from_headers helper - Reduce code duplication between sync and async decorators - Add comment explaining why crash handler needs to extract context from headers - This addresses the context reset issue where decorators clean up before exception handlers run * fix: preserve execution context for exception handlers - Don't reset context on exception, only on successful completion - This allows exception handlers to access execution_id naturally - Simplify crash handler since context is now available - Rely on Python's automatic contextvar cleanup when task completes - Each request runs in its own task, so no risk of context leakage This is more correct and follows the principle that context should be available throughout the entire request lifecycle, including error handling. * style: apply black and isort formatting - Format code with black for consistent style - Sort imports with isort for better organization - All linting checks now pass * refactor: clean up async tests and remove redundant comments * chore: remove uv.lock from version control * style: fix black formatting * fix: skip async execution_id tests on Python 3.7 * refactor: reuse _enable_execution_id_logging from main module * chore: more cleanup. * test: remove unnecessary pragma no cover for sync_wrapper * test: improve coverage by removing unnecessary pragma no cover annotations * style: fix black formatting * style: fix isort import ordering * test: add back pragma no cover for genuinely hard-to-test edge cases * refactor: simplify async decorator by removing dead code branch * fix: exclude async-specific code from py37 coverage The AsgiMiddleware class and set_execution_context_async function in execution_id.py require Python 3.8+ due to async dependencies. These are now excluded from coverage calculations in Python 3.7 environments. * fix: improve async execution ID context propagation using contextvars - Use contextvars.copy_context() to properly propagate execution context in async functions - Implement AsyncExecutionIdHandler to handle JSON logging with execution_id - Redirect logging output from stderr to stdout for consistency - Add build dependency to dev dependencies - Update tests to reflect new logging output location * feat: Add execution ID logging for async functions Refactors the async logging implementation to align with the sync version, ensuring consistent execution ID logging across both stacks. * chore: clean up impl. * refactor: define custom exception handling middleware to avoid duplicate log of traceback. * style: run black * chore: clean up code a little more. * fix: propagate context in ce fns. * style: more nits. * chore: remove unncessary debug flag. * fix: respond to PR comments * fix: respond to more PR comments --- .coveragerc-py37 | 6 +- conftest.py | 8 +- pyproject.toml | 10 + src/functions_framework/aio/__init__.py | 117 +++++- src/functions_framework/execution_id.py | 125 +++++- tests/test_aio.py | 4 + tests/test_execution_id.py | 1 + tests/test_execution_id_async.py | 365 ++++++++++++++++++ .../test_functions/execution_id/async_main.py | 62 +++ 9 files changed, 665 insertions(+), 33 deletions(-) create mode 100644 tests/test_execution_id_async.py create mode 100644 tests/test_functions/execution_id/async_main.py diff --git a/.coveragerc-py37 b/.coveragerc-py37 index fb6dbb6e..b1c98d23 100644 --- a/.coveragerc-py37 +++ b/.coveragerc-py37 @@ -18,4 +18,8 @@ exclude_lines = # Don't complain about async-specific imports and code from functions_framework.aio import from functions_framework._http.asgi import - from functions_framework._http.gunicorn import UvicornApplication \ No newline at end of file + from functions_framework._http.gunicorn import UvicornApplication + + # Exclude async-specific classes and functions in execution_id.py + class AsgiMiddleware: + def set_execution_context_async \ No newline at end of file diff --git a/conftest.py b/conftest.py index 1d17e9bf..257f60d4 100644 --- a/conftest.py +++ b/conftest.py @@ -50,8 +50,12 @@ def pytest_ignore_collect(collection_path, config): if sys.version_info >= (3, 8): return None - # Skip test_aio.py and test_asgi.py entirely on Python 3.7 - if collection_path.name in ["test_aio.py", "test_asgi.py"]: + # Skip test_aio.py, test_asgi.py, and test_execution_id_async.py entirely on Python 3.7 + if collection_path.name in [ + "test_aio.py", + "test_asgi.py", + "test_execution_id_async.py", + ]: return True return None diff --git a/pyproject.toml b/pyproject.toml index 350d8997..2b6e0639 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,3 +61,13 @@ functions_framework = ["py.typed"] [tool.setuptools.package-dir] "" = "src" + +[dependency-groups] +dev = [ + "black>=23.3.0", + "build>=1.1.1", + "isort>=5.11.5", + "pretend>=1.0.9", + "pytest>=7.4.4", + "pytest-asyncio>=0.21.2", +] diff --git a/src/functions_framework/aio/__init__.py b/src/functions_framework/aio/__init__.py index 21f12754..4245f2d1 100644 --- a/src/functions_framework/aio/__init__.py +++ b/src/functions_framework/aio/__init__.py @@ -13,16 +13,24 @@ # limitations under the License. import asyncio +import contextvars import functools import inspect +import logging +import logging.config import os +import traceback from typing import Any, Awaitable, Callable, Dict, Tuple, Union from cloudevents.http import from_http from cloudevents.http.event import CloudEvent -from functions_framework import _function_registry +from functions_framework import ( + _enable_execution_id_logging, + _function_registry, + execution_id, +) from functions_framework.exceptions import ( FunctionsFrameworkException, MissingSourceException, @@ -31,6 +39,7 @@ try: from starlette.applications import Starlette from starlette.exceptions import HTTPException + from starlette.middleware import Middleware from starlette.requests import Request from starlette.responses import JSONResponse, Response from starlette.routing import Route @@ -96,29 +105,27 @@ def wrapper(*args, **kwargs): return wrapper -async def _crash_handler(request, exc): - headers = {_FUNCTION_STATUS_HEADER_FIELD: _CRASH} - return Response(f"Internal Server Error: {exc}", status_code=500, headers=headers) - - -def _http_func_wrapper(function, is_async): +def _http_func_wrapper(function, is_async, enable_id_logging=False): + @execution_id.set_execution_context_async(enable_id_logging) @functools.wraps(function) async def handler(request): if is_async: result = await function(request) else: # TODO: Use asyncio.to_thread when we drop Python 3.8 support - # Python 3.8 compatible version of asyncio.to_thread loop = asyncio.get_event_loop() - result = await loop.run_in_executor(None, function, request) + ctx = contextvars.copy_context() + result = await loop.run_in_executor(None, ctx.run, function, request) if isinstance(result, str): return Response(result) elif isinstance(result, dict): return JSONResponse(result) elif isinstance(result, tuple) and len(result) == 2: - # Support Flask-style tuple response content, status_code = result - return Response(content, status_code=status_code) + if isinstance(content, dict): + return JSONResponse(content, status_code=status_code) + else: + return Response(content, status_code=status_code) elif result is None: raise HTTPException(status_code=500, detail="No response returned") else: @@ -127,7 +134,8 @@ async def handler(request): return handler -def _cloudevent_func_wrapper(function, is_async): +def _cloudevent_func_wrapper(function, is_async, enable_id_logging=False): + @execution_id.set_execution_context_async(enable_id_logging) @functools.wraps(function) async def handler(request): data = await request.body() @@ -142,9 +150,9 @@ async def handler(request): await function(event) else: # TODO: Use asyncio.to_thread when we drop Python 3.8 support - # Python 3.8 compatible version of asyncio.to_thread loop = asyncio.get_event_loop() - await loop.run_in_executor(None, function, event) + ctx = contextvars.copy_context() + await loop.run_in_executor(None, ctx.run, function, event) return Response("OK") return handler @@ -154,6 +162,64 @@ async def _handle_not_found(request: Request): raise HTTPException(status_code=404, detail="Not Found") +def _configure_app_execution_id_logging(): + logging.config.dictConfig( + { + "version": 1, + "handlers": { + "asgi": { + "class": "logging.StreamHandler", + "stream": "ext://functions_framework.execution_id.logging_stream", + }, + }, + "root": {"level": "INFO", "handlers": ["asgi"]}, + } + ) + + +class ExceptionHandlerMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": # pragma: no cover + await self.app(scope, receive, send) + return + + try: + await self.app(scope, receive, send) + except Exception as exc: + logger = logging.getLogger() + tb_lines = traceback.format_exception(type(exc), exc, exc.__traceback__) + tb_text = "".join(tb_lines) + + path = scope.get("path", "/") + method = scope.get("method", "GET") + error_msg = f"Exception on {path} [{method}]\n{tb_text}".rstrip() + + logger.error(error_msg) + + headers = [ + [b"content-type", b"text/plain"], + [_FUNCTION_STATUS_HEADER_FIELD.encode(), _CRASH.encode()], + ] + + await send( + { + "type": "http.response.start", + "status": 500, + "headers": headers, + } + ) + await send( + { + "type": "http.response.body", + "body": b"Internal Server Error", + } + ) + # Don't re-raise to prevent starlette from printing traceback again + + def create_asgi_app(target=None, source=None, signature_type=None): """Create an ASGI application for the function. @@ -175,6 +241,11 @@ def create_asgi_app(target=None, source=None, signature_type=None): ) source_module, spec = _function_registry.load_function_module(source) + + enable_id_logging = _enable_execution_id_logging() + if enable_id_logging: + _configure_app_execution_id_logging() + spec.loader.exec_module(source_module) function = _function_registry.get_user_function(source, source_module, target) signature_type = _function_registry.get_func_signature_type(target, signature_type) @@ -182,7 +253,7 @@ def create_asgi_app(target=None, source=None, signature_type=None): is_async = inspect.iscoroutinefunction(function) routes = [] if signature_type == _function_registry.HTTP_SIGNATURE_TYPE: - http_handler = _http_func_wrapper(function, is_async) + http_handler = _http_func_wrapper(function, is_async, enable_id_logging) routes.append( Route( "/", @@ -202,7 +273,9 @@ def create_asgi_app(target=None, source=None, signature_type=None): ) ) elif signature_type == _function_registry.CLOUDEVENT_SIGNATURE_TYPE: - cloudevent_handler = _cloudevent_func_wrapper(function, is_async) + cloudevent_handler = _cloudevent_func_wrapper( + function, is_async, enable_id_logging + ) routes.append( Route("/{path:path}", endpoint=cloudevent_handler, methods=["POST"]) ) @@ -221,10 +294,14 @@ def create_asgi_app(target=None, source=None, signature_type=None): f"Unsupported signature type for ASGI server: {signature_type}" ) - exception_handlers = { - 500: _crash_handler, - } - app = Starlette(routes=routes, exception_handlers=exception_handlers) + app = Starlette( + routes=routes, + middleware=[ + Middleware(ExceptionHandlerMiddleware), + Middleware(execution_id.AsgiMiddleware), + ], + ) + return app diff --git a/src/functions_framework/execution_id.py b/src/functions_framework/execution_id.py index 2b106531..df412187 100644 --- a/src/functions_framework/execution_id.py +++ b/src/functions_framework/execution_id.py @@ -13,7 +13,9 @@ # limitations under the License. import contextlib +import contextvars import functools +import inspect import io import json import logging @@ -38,6 +40,9 @@ logger = logging.getLogger(__name__) +# Context variable for async execution context +execution_context_var = contextvars.ContextVar("execution_context", default=None) + class ExecutionContext: def __init__(self, execution_id=None, span_id=None): @@ -46,7 +51,10 @@ def __init__(self, execution_id=None, span_id=None): def _get_current_context(): - return ( + context = execution_context_var.get() + if context is not None: + return context + return ( # pragma: no cover flask.g.execution_id_context if flask.has_request_context() and "execution_id_context" in flask.g else None @@ -54,6 +62,8 @@ def _get_current_context(): def _set_current_context(context): + execution_context_var.set(context) + # Also set in Flask context if available for sync if flask.has_request_context(): flask.g.execution_id_context = context @@ -65,6 +75,18 @@ def _generate_execution_id(): ) +def _extract_context_from_headers(headers): + """Extract execution context from request headers.""" + trace_context = re.match( + _TRACE_CONTEXT_REGEX_PATTERN, + headers.get(TRACE_CONTEXT_REQUEST_HEADER, ""), + ) + execution_id = headers.get(EXECUTION_ID_REQUEST_HEADER) + span_id = trace_context.group("span_id") if trace_context else None + + return ExecutionContext(execution_id, span_id) + + # Middleware to add execution id to request header if one does not already exist class WsgiMiddleware: def __init__(self, wsgi_app): @@ -78,8 +100,42 @@ def __call__(self, environ, start_response): return self.wsgi_app(environ, start_response) -# Sets execution id and span id for the request +class AsgiMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] == "http": # pragma: no branch + execution_id_header = b"function-execution-id" + execution_id = None + + for name, value in scope.get("headers", []): + if name.lower() == execution_id_header: + execution_id = value.decode("latin-1") + break + + if not execution_id: + execution_id = _generate_execution_id() + new_headers = list(scope.get("headers", [])) + new_headers.append( + (execution_id_header, execution_id.encode("latin-1")) + ) + scope["headers"] = new_headers + + await self.app(scope, receive, send) + + def set_execution_context(request, enable_id_logging=False): + """Decorator for Flask/WSGI handlers that sets execution context. + + Takes request object at decoration time (Flask pattern where request is available + via thread-local context when decorator is applied). + + Usage: + @set_execution_context(request, enable_id_logging=True) + def view_func(path): + ... + """ if enable_id_logging: stdout_redirect = contextlib.redirect_stdout( LoggingHandlerAddExecutionId(sys.stdout) @@ -94,22 +150,71 @@ def set_execution_context(request, enable_id_logging=False): def decorator(view_function): @functools.wraps(view_function) def wrapper(*args, **kwargs): - trace_context = re.match( - _TRACE_CONTEXT_REGEX_PATTERN, - request.headers.get(TRACE_CONTEXT_REQUEST_HEADER, ""), - ) - execution_id = request.headers.get(EXECUTION_ID_REQUEST_HEADER) - span_id = trace_context.group("span_id") if trace_context else None - _set_current_context(ExecutionContext(execution_id, span_id)) + context = _extract_context_from_headers(request.headers) + _set_current_context(context) with stderr_redirect, stdout_redirect: - return view_function(*args, **kwargs) + result = view_function(*args, **kwargs) + return result return wrapper return decorator +def set_execution_context_async(enable_id_logging=False): + """Decorator for ASGI/async handlers that sets execution context. + + Unlike set_execution_context which takes request at decoration time (Flask pattern), + this expects the decorated function to receive request as its first parameter (ASGI pattern). + + Usage: + @set_execution_context_async(enable_id_logging=True) + async def handler(request, *args, **kwargs): + ... + """ + if enable_id_logging: + stdout_redirect = contextlib.redirect_stdout( + LoggingHandlerAddExecutionId(sys.stdout) + ) + stderr_redirect = contextlib.redirect_stderr( + LoggingHandlerAddExecutionId(sys.stderr) + ) + else: + stdout_redirect = contextlib.nullcontext() + stderr_redirect = contextlib.nullcontext() + + def decorator(func): + @functools.wraps(func) + async def async_wrapper(request, *args, **kwargs): + context = _extract_context_from_headers(request.headers) + token = execution_context_var.set(context) + + with stderr_redirect, stdout_redirect: + result = await func(request, *args, **kwargs) + + execution_context_var.reset(token) + return result + + @functools.wraps(func) + def sync_wrapper(request, *args, **kwargs): + context = _extract_context_from_headers(request.headers) + token = execution_context_var.set(context) + + with stderr_redirect, stdout_redirect: + result = func(request, *args, **kwargs) + + execution_context_var.reset(token) + return result + + if inspect.iscoroutinefunction(func): + return async_wrapper + else: + return sync_wrapper + + return decorator + + @LocalProxy def logging_stream(): return LoggingHandlerAddExecutionId(stream=flask.logging.wsgi_errors_stream) diff --git a/tests/test_aio.py b/tests/test_aio.py index cf69479a..4f34c279 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -143,6 +143,8 @@ async def http_func(request): wrapper = _http_func_wrapper(http_func, is_async=True) request = Mock() + request.headers = Mock() + request.headers.get = Mock(return_value="") response = await wrapper(request) assert response.__class__.__name__ == "JSONResponse" @@ -158,6 +160,8 @@ def sync_http_func(request): wrapper = _http_func_wrapper(sync_http_func, is_async=False) request = Mock() + request.headers = Mock() + request.headers.get = Mock(return_value="") response = await wrapper(request) assert response.__class__.__name__ == "Response" diff --git a/tests/test_execution_id.py b/tests/test_execution_id.py index a2601817..b8c5b9f0 100644 --- a/tests/test_execution_id.py +++ b/tests/test_execution_id.py @@ -223,6 +223,7 @@ def view_func(): monkeypatch.setattr( execution_id, "_generate_execution_id", lambda: TEST_EXECUTION_ID ) + mock_g = Mock() monkeypatch.setattr(execution_id.flask, "g", mock_g) monkeypatch.setattr(execution_id.flask, "has_request_context", lambda: True) diff --git a/tests/test_execution_id_async.py b/tests/test_execution_id_async.py new file mode 100644 index 00000000..01e638a1 --- /dev/null +++ b/tests/test_execution_id_async.py @@ -0,0 +1,365 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import asyncio +import json +import pathlib +import re + +from functools import partial +from unittest.mock import Mock + +import pytest + +from starlette.testclient import TestClient + +from functions_framework import execution_id +from functions_framework.aio import create_asgi_app + +TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" +TEST_EXECUTION_ID = "test_execution_id" +TEST_SPAN_ID = "123456" + + +def test_user_function_can_retrieve_execution_id_from_header(): + source = TEST_FUNCTIONS_DIR / "execution_id" / "async_main.py" + target = "async_function" + app = create_asgi_app(target, source) + client = TestClient(app) + resp = client.post( + "/", + headers={ + "Function-Execution-Id": TEST_EXECUTION_ID, + "Content-Type": "application/json", + }, + ) + + assert resp.json()["execution_id"] == TEST_EXECUTION_ID + + +def test_uncaught_exception_in_user_function_sets_execution_id(capsys, monkeypatch): + monkeypatch.setenv("LOG_EXECUTION_ID", "true") + source = TEST_FUNCTIONS_DIR / "execution_id" / "async_main.py" + target = "async_error" + app = create_asgi_app(target, source) + # Don't raise server exceptions so we can capture the logs + client = TestClient(app, raise_server_exceptions=False) + resp = client.post( + "/", + headers={ + "Function-Execution-Id": TEST_EXECUTION_ID, + "Content-Type": "application/json", + }, + ) + assert resp.status_code == 500 + record = capsys.readouterr() + assert f'"execution_id": "{TEST_EXECUTION_ID}"' in record.err + assert '"logging.googleapis.com/labels"' in record.err + assert "ZeroDivisionError" in record.err + + +def test_print_from_user_function_sets_execution_id(capsys, monkeypatch): + monkeypatch.setenv("LOG_EXECUTION_ID", "true") + source = TEST_FUNCTIONS_DIR / "execution_id" / "async_main.py" + target = "async_print_message" + app = create_asgi_app(target, source) + client = TestClient(app) + client.post( + "/", + headers={ + "Function-Execution-Id": TEST_EXECUTION_ID, + "Content-Type": "application/json", + }, + json={"message": "some-message"}, + ) + record = capsys.readouterr() + assert f'"execution_id": "{TEST_EXECUTION_ID}"' in record.out + assert '"message": "some-message"' in record.out + + +def test_log_from_user_function_sets_execution_id(capsys, monkeypatch): + monkeypatch.setenv("LOG_EXECUTION_ID", "true") + source = TEST_FUNCTIONS_DIR / "execution_id" / "async_main.py" + target = "async_log_message" + app = create_asgi_app(target, source) + client = TestClient(app) + client.post( + "/", + headers={ + "Function-Execution-Id": TEST_EXECUTION_ID, + "Content-Type": "application/json", + }, + json={"message": json.dumps({"custom-field": "some-message"})}, + ) + record = capsys.readouterr() + assert f'"execution_id": "{TEST_EXECUTION_ID}"' in record.err + assert '"custom-field": "some-message"' in record.err + assert '"logging.googleapis.com/labels"' in record.err + + +def test_user_function_can_retrieve_generated_execution_id(monkeypatch): + monkeypatch.setattr( + execution_id, "_generate_execution_id", lambda: TEST_EXECUTION_ID + ) + source = TEST_FUNCTIONS_DIR / "execution_id" / "async_main.py" + target = "async_function" + app = create_asgi_app(target, source) + client = TestClient(app) + resp = client.post( + "/", + headers={ + "Content-Type": "application/json", + }, + ) + + assert resp.json()["execution_id"] == TEST_EXECUTION_ID + + +def test_does_not_set_execution_id_when_not_enabled(capsys): + source = TEST_FUNCTIONS_DIR / "execution_id" / "async_main.py" + target = "async_print_message" + app = create_asgi_app(target, source) + client = TestClient(app) + client.post( + "/", + headers={ + "Function-Execution-Id": TEST_EXECUTION_ID, + "Content-Type": "application/json", + }, + json={"message": "some-message"}, + ) + record = capsys.readouterr() + assert f'"execution_id": "{TEST_EXECUTION_ID}"' not in record.out + assert "some-message" in record.out + + +def test_does_not_set_execution_id_when_env_var_is_false(capsys, monkeypatch): + monkeypatch.setenv("LOG_EXECUTION_ID", "false") + source = TEST_FUNCTIONS_DIR / "execution_id" / "async_main.py" + target = "async_print_message" + app = create_asgi_app(target, source) + client = TestClient(app) + client.post( + "/", + headers={ + "Function-Execution-Id": TEST_EXECUTION_ID, + "Content-Type": "application/json", + }, + json={"message": "some-message"}, + ) + record = capsys.readouterr() + assert f'"execution_id": "{TEST_EXECUTION_ID}"' not in record.out + assert "some-message" in record.out + + +def test_does_not_set_execution_id_when_env_var_is_not_bool_like(capsys, monkeypatch): + monkeypatch.setenv("LOG_EXECUTION_ID", "maybe") + source = TEST_FUNCTIONS_DIR / "execution_id" / "async_main.py" + target = "async_print_message" + app = create_asgi_app(target, source) + client = TestClient(app) + client.post( + "/", + headers={ + "Function-Execution-Id": TEST_EXECUTION_ID, + "Content-Type": "application/json", + }, + json={"message": "some-message"}, + ) + record = capsys.readouterr() + assert f'"execution_id": "{TEST_EXECUTION_ID}"' not in record.out + assert "some-message" in record.out + + +def test_generate_execution_id(): + expected_matching_regex = "^[0-9a-zA-Z]{12}$" + actual_execution_id = execution_id._generate_execution_id() + + match = re.match(expected_matching_regex, actual_execution_id).group(0) + assert match == actual_execution_id + + +@pytest.mark.parametrize( + "headers,expected_execution_id,expected_span_id,should_generate", + [ + ( + { + "X-Cloud-Trace-Context": f"TRACE_ID/{TEST_SPAN_ID};o=1", + "Function-Execution-Id": TEST_EXECUTION_ID, + }, + TEST_EXECUTION_ID, + TEST_SPAN_ID, + False, + ), + ({}, None, None, True), # Middleware will generate an ID + ( + { + "X-Cloud-Trace-Context": "malformed trace context string", + "Function-Execution-Id": TEST_EXECUTION_ID, + }, + TEST_EXECUTION_ID, + None, + False, + ), + ], +) +def test_set_execution_context_headers( + headers, expected_execution_id, expected_span_id, should_generate +): + source = TEST_FUNCTIONS_DIR / "execution_id" / "async_main.py" + target = "async_trace_test" + app = create_asgi_app(target, source) + client = TestClient(app) + + resp = client.post("/", headers=headers) + + result = resp.json() + if should_generate: + # When no execution ID is provided, middleware generates one + assert result.get("execution_id") is not None + assert len(result.get("execution_id")) == 12 # Generated IDs are 12 chars + else: + assert result.get("execution_id") == expected_execution_id + assert result.get("span_id") == expected_span_id + + +@pytest.mark.asyncio +async def test_maintains_execution_id_for_concurrent_requests(monkeypatch, capsys): + monkeypatch.setenv("LOG_EXECUTION_ID", "true") + + expected_logs = ( + { + "message": "message1", + "logging.googleapis.com/labels": {"execution_id": "test-execution-id-1"}, + }, + { + "message": "message2", + "logging.googleapis.com/labels": {"execution_id": "test-execution-id-2"}, + }, + { + "message": "message1", + "logging.googleapis.com/labels": {"execution_id": "test-execution-id-1"}, + }, + { + "message": "message2", + "logging.googleapis.com/labels": {"execution_id": "test-execution-id-2"}, + }, + ) + + source = TEST_FUNCTIONS_DIR / "execution_id" / "async_main.py" + target = "async_sleep" + app = create_asgi_app(target, source) + client = TestClient(app) + loop = asyncio.get_event_loop() + response1 = loop.run_in_executor( + None, + partial( + client.post, + "/", + headers={ + "Content-Type": "application/json", + "Function-Execution-Id": "test-execution-id-1", + }, + json={"message": "message1"}, + ), + ) + response2 = loop.run_in_executor( + None, + partial( + client.post, + "/", + headers={ + "Content-Type": "application/json", + "Function-Execution-Id": "test-execution-id-2", + }, + json={"message": "message2"}, + ), + ) + await asyncio.wait((response1, response2)) + record = capsys.readouterr() + logs = record.err.strip().split("\n") + logs_as_json = tuple(json.loads(log) for log in logs) + + sort_key = lambda d: d["message"] + assert sorted(logs_as_json, key=sort_key) == sorted(expected_logs, key=sort_key) + + +def test_async_decorator_with_sync_function(): + def sync_func(request): + return {"status": "ok"} + + wrapped = execution_id.set_execution_context_async(enable_id_logging=False)( + sync_func + ) + + request = Mock() + request.headers = Mock() + request.headers.get = Mock(return_value="") + + result = wrapped(request) + + assert result == {"status": "ok"} + + +def test_sync_cloudevent_function_has_execution_context(monkeypatch, capsys): + """Test that sync CloudEvent functions can access execution context.""" + monkeypatch.setenv("LOG_EXECUTION_ID", "true") + + source = TEST_FUNCTIONS_DIR / "execution_id" / "async_main.py" + target = "sync_cloudevent_with_context" + app = create_asgi_app(target, source, signature_type="cloudevent") + client = TestClient(app) + + response = client.post( + "/", + headers={ + "ce-specversion": "1.0", + "ce-type": "com.example.test", + "ce-source": "test-source", + "ce-id": "test-id", + "Function-Execution-Id": TEST_EXECUTION_ID, + "Content-Type": "application/json", + }, + json={"message": "test"}, + ) + + assert response.status_code == 200 + assert response.text == "OK" + + record = capsys.readouterr() + assert f"Execution ID in sync CloudEvent: {TEST_EXECUTION_ID}" in record.err + assert "No execution context in sync CloudEvent function!" not in record.err + + +def test_cloudevent_returns_500(capsys, monkeypatch): + monkeypatch.setenv("LOG_EXECUTION_ID", "true") + source = TEST_FUNCTIONS_DIR / "execution_id" / "async_main.py" + target = "async_cloudevent_error" + app = create_asgi_app(target, source, signature_type="cloudevent") + client = TestClient(app, raise_server_exceptions=False) + resp = client.post( + "/", + headers={ + "ce-specversion": "1.0", + "ce-type": "com.example.test", + "ce-source": "test-source", + "ce-id": "test-id", + "Function-Execution-Id": TEST_EXECUTION_ID, + "Content-Type": "application/json", + }, + ) + assert resp.status_code == 500 + record = capsys.readouterr() + assert f'"execution_id": "{TEST_EXECUTION_ID}"' in record.err + assert '"logging.googleapis.com/labels"' in record.err + assert "ValueError" in record.err diff --git a/tests/test_functions/execution_id/async_main.py b/tests/test_functions/execution_id/async_main.py new file mode 100644 index 00000000..7149e7fb --- /dev/null +++ b/tests/test_functions/execution_id/async_main.py @@ -0,0 +1,62 @@ +import asyncio +import logging + +from functions_framework import execution_id + +logger = logging.getLogger(__name__) + + +async def async_print_message(request): + json = await request.json() + print(json.get("message")) + return {"status": "success"}, 200 + + +async def async_log_message(request): + json = await request.json() + logger.warning(json.get("message")) + return {"status": "success"}, 200 + + +async def async_function(request): + return {"execution_id": request.headers.get("Function-Execution-Id")} + + +async def async_error(request): + return 1 / 0 + + +async def async_sleep(request): + json = await request.json() + message = json.get("message") + logger.warning(message) + await asyncio.sleep(1) + logger.warning(message) + return {"status": "success"}, 200 + + +async def async_trace_test(request): + context = execution_id._get_current_context() + return { + "execution_id": context.execution_id if context else None, + "span_id": context.span_id if context else None, + } + + +def sync_function_in_async_context(request): + return { + "execution_id": request.headers.get("Function-Execution-Id"), + "type": "sync", + } + + +def sync_cloudevent_with_context(cloud_event): + context = execution_id._get_current_context() + if context: + logger.info(f"Execution ID in sync CloudEvent: {context.execution_id}") + else: + logger.error("No execution context in sync CloudEvent function!") + + +async def async_cloudevent_error(cloudevent): + raise ValueError("This is a test error") From 42b7fdd153f2360e3e2fc92a74d0674f424a99e4 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Wed, 18 Jun 2025 10:23:17 -0700 Subject: [PATCH 08/14] refactor: rename GATEWAY flag to FUNCTION_GATEWAY. (#379) --- src/functions_framework/_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/functions_framework/_cli.py b/src/functions_framework/_cli.py index e27b5446..ec2474d4 100644 --- a/src/functions_framework/_cli.py +++ b/src/functions_framework/_cli.py @@ -34,7 +34,7 @@ @click.option("--debug", envvar="DEBUG", is_flag=True) @click.option( "--gateway", - envvar="GATEWAY", + envvar="FUNCTION_GATEWAY", type=click.Choice(["wsgi", "asgi"]), default="wsgi", help="Server gateway interface type (wsgi for sync, asgi for async)", From a576a8f28a6029fc5b5ab0725d2aa9c6c5f4304f Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Wed, 18 Jun 2025 10:25:22 -0700 Subject: [PATCH 09/14] fix: set default log level for asgi logger to WARNING to match default python behavior (#381) * fix: set default log level for asgi logger to WARNING to match default python behavior. * fix: fix broken test. --- src/functions_framework/aio/__init__.py | 2 +- tests/test_functions/execution_id/async_main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/functions_framework/aio/__init__.py b/src/functions_framework/aio/__init__.py index 4245f2d1..e30b5f99 100644 --- a/src/functions_framework/aio/__init__.py +++ b/src/functions_framework/aio/__init__.py @@ -172,7 +172,7 @@ def _configure_app_execution_id_logging(): "stream": "ext://functions_framework.execution_id.logging_stream", }, }, - "root": {"level": "INFO", "handlers": ["asgi"]}, + "root": {"level": "WARNING", "handlers": ["asgi"]}, } ) diff --git a/tests/test_functions/execution_id/async_main.py b/tests/test_functions/execution_id/async_main.py index 7149e7fb..4485e3f4 100644 --- a/tests/test_functions/execution_id/async_main.py +++ b/tests/test_functions/execution_id/async_main.py @@ -53,7 +53,7 @@ def sync_function_in_async_context(request): def sync_cloudevent_with_context(cloud_event): context = execution_id._get_current_context() if context: - logger.info(f"Execution ID in sync CloudEvent: {context.execution_id}") + logger.warning(f"Execution ID in sync CloudEvent: {context.execution_id}") else: logger.error("No execution context in sync CloudEvent function!") From 58deaf1e819c73ee38294f4dc8837bac0c533d35 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Mon, 23 Jun 2025 08:54:16 -0700 Subject: [PATCH 10/14] refactor: replace --gateway flag with --asgi boolean flag (#383) * refactor: replace --gateway flag with --asgi boolean flag Simplify the CLI by replacing `--gateway asgi` with `--asgi`. The new flag is more intuitive as WSGI remains the default and ASGI is opt-in. Also updates the environment variable to FUNCTION_USE_ASGI for clarity. * fix: update conformance tests to use --asgi flag --- .github/workflows/conformance-asgi.yml | 10 +++++----- src/functions_framework/_cli.py | 13 ++++++------- tests/test_cli.py | 2 +- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/.github/workflows/conformance-asgi.yml b/.github/workflows/conformance-asgi.yml index c69d1862..a62fcb71 100644 --- a/.github/workflows/conformance-asgi.yml +++ b/.github/workflows/conformance-asgi.yml @@ -52,7 +52,7 @@ jobs: functionType: 'http' useBuildpacks: false validateMapping: false - cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http --signature-type http --gateway asgi'" + cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http --signature-type http --asgi'" - name: Run CloudEvents conformance tests uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 @@ -60,7 +60,7 @@ jobs: functionType: 'cloudevent' useBuildpacks: false validateMapping: false - cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event --signature-type cloudevent --gateway asgi'" + cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event --signature-type cloudevent --asgi'" - name: Run HTTP conformance tests declarative uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 @@ -68,7 +68,7 @@ jobs: functionType: 'http' useBuildpacks: false validateMapping: false - cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative --gateway asgi'" + cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative --asgi'" - name: Run CloudEvents conformance tests declarative uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 @@ -76,7 +76,7 @@ jobs: functionType: 'cloudevent' useBuildpacks: false validateMapping: false - cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event_declarative --gateway asgi'" + cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event_declarative --asgi'" - name: Run HTTP concurrency tests declarative uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 @@ -84,7 +84,7 @@ jobs: functionType: 'http' useBuildpacks: false validateConcurrency: true - cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative_concurrent --gateway asgi'" + cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative_concurrent --asgi'" # Note: Event (legacy) and Typed tests are not supported in ASGI mode # Note: validateMapping is set to false for CloudEvent tests because ASGI mode diff --git a/src/functions_framework/_cli.py b/src/functions_framework/_cli.py index ec2474d4..c2ba9f4b 100644 --- a/src/functions_framework/_cli.py +++ b/src/functions_framework/_cli.py @@ -33,14 +33,13 @@ @click.option("--port", envvar="PORT", type=click.INT, default=8080) @click.option("--debug", envvar="DEBUG", is_flag=True) @click.option( - "--gateway", - envvar="FUNCTION_GATEWAY", - type=click.Choice(["wsgi", "asgi"]), - default="wsgi", - help="Server gateway interface type (wsgi for sync, asgi for async)", + "--asgi", + envvar="FUNCTION_USE_ASGI", + is_flag=True, + help="Use ASGI server for function execution", ) -def _cli(target, source, signature_type, host, port, debug, gateway): - if gateway == "asgi": # pragma: no cover +def _cli(target, source, signature_type, host, port, debug, asgi): + if asgi: # pragma: no cover from functions_framework.aio import create_asgi_app app = create_asgi_app(target, source, signature_type) diff --git a/tests/test_cli.py b/tests/test_cli.py index 17445d11..4e5a0a08 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -119,7 +119,7 @@ def test_asgi_cli(monkeypatch): monkeypatch.setattr(functions_framework._cli, "create_server", create_server) runner = CliRunner() - result = runner.invoke(_cli, ["--target", "foo", "--gateway", "asgi"]) + result = runner.invoke(_cli, ["--target", "foo", "--asgi"]) assert result.exit_code == 0 assert create_asgi_app.calls == [pretend.call("foo", None, "http")] From 2de6eec6fae132b8b1fb41e7024a2260a05bc072 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 22 Jul 2025 11:51:04 -0700 Subject: [PATCH 11/14] fix: resolve CI failures for egress policies and Python 3.7 buildpack support (#388) * fix: add GitHub Actions CDN to egress allowlist The conformance workflow was failing with ECONNREFUSED errors when trying to download Python binaries from GitHub releases. This was caused by the harden-runner egress policy blocking connections to the GitHub Actions CDN IP addresses. Added *.actions.githubusercontent.com:443 to the allowed endpoints to fix Python setup for all versions (3.7, 3.8, etc). * fix: remove Python 3.7 from buildpack integration tests Google Cloud Buildpacks dropped Python 3.7 support for Ubuntu 22.04. The version is not available in their runtime manifest. Note: Functions Framework still supports Python 3.7, which is tested in unit and conformance tests using GitHub Actions with Ubuntu 20.04. * fix: use correct domain for GitHub release assets The Python binaries are actually hosted on release-assets.githubusercontent.com, not *.actions.githubusercontent.com * fix: add release-assets domain to unit and conformance-asgi workflows The same ECONNREFUSED issue was affecting multiple workflows with harden-runner egress policies --- .github/workflows/buildpack-integration-test.yml | 11 ----------- .github/workflows/conformance-asgi.yml | 1 + .github/workflows/conformance.yml | 1 + .github/workflows/unit.yml | 1 + 4 files changed, 3 insertions(+), 11 deletions(-) diff --git a/.github/workflows/buildpack-integration-test.yml b/.github/workflows/buildpack-integration-test.yml index 234c24ef..2c028fa9 100644 --- a/.github/workflows/buildpack-integration-test.yml +++ b/.github/workflows/buildpack-integration-test.yml @@ -14,17 +14,6 @@ on: permissions: read-all jobs: - python37: - uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@main - with: - http-builder-source: 'tests/conformance' - http-builder-target: 'write_http_declarative' - cloudevent-builder-source: 'tests/conformance' - cloudevent-builder-target: 'write_cloud_event_declarative' - prerun: 'tests/conformance/prerun.sh ${{ github.sha }}' - builder-runtime: 'python37' - builder-runtime-version: '3.7' - start-delay: 5 python38: uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@main with: diff --git a/.github/workflows/conformance-asgi.yml b/.github/workflows/conformance-asgi.yml index a62fcb71..904a7773 100644 --- a/.github/workflows/conformance-asgi.yml +++ b/.github/workflows/conformance-asgi.yml @@ -29,6 +29,7 @@ jobs: proxy.golang.org:443 pypi.org:443 storage.googleapis.com:443 + release-assets.githubusercontent.com:443 - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 9dde6036..7d10b8af 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -34,6 +34,7 @@ jobs: proxy.golang.org:443 pypi.org:443 storage.googleapis.com:443 + release-assets.githubusercontent.com:443 - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 32b34fdb..28ed5b1e 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -54,6 +54,7 @@ jobs: production.cloudflare.docker.com:443 pypi.org:443 registry-1.docker.io:443 + release-assets.githubusercontent.com:443 - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 From 82ba117e1309c52e6ba8c11c5d8cb22f253c256d Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 22 Jul 2025 12:20:03 -0700 Subject: [PATCH 12/14] refactor: move async dependencies from optional to direct (#386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR moves starlette, uvicorn, and uvicorn-worker from optional dependencies to direct dependencies, simplifying the installation process for users who need async functionality. ## Impact Analysis - **Installation size increase: < 1MB** (specifically 816KB, ~3.5% increase) - Base installation: 23MB → With async deps: 24MB - Removes the need for `pip install functions-framework[async]` - Maintains Python 3.8+ requirement for these dependencies ## Changes - Move async dependencies to direct dependencies in `pyproject.toml` - Remove `[async]` extra dependency configuration - Update imports to be direct instead of conditional - Remove "Starlette is not installed" error messages - Update tests to reflect direct dependency availability ## Test Plan All existing tests pass with 100% coverage. The async functionality remains unchanged, just the installation method is simplified. --- .github/workflows/conformance-asgi.yml | 4 ++-- pyproject.toml | 10 +++------- src/functions_framework/aio/__init__.py | 19 ++++++------------- tests/test_aio.py | 23 ----------------------- tests/test_asgi.py | 7 ++----- tox.ini | 4 ---- 6 files changed, 13 insertions(+), 54 deletions(-) diff --git a/.github/workflows/conformance-asgi.yml b/.github/workflows/conformance-asgi.yml index 904a7773..8a61cd4c 100644 --- a/.github/workflows/conformance-asgi.yml +++ b/.github/workflows/conformance-asgi.yml @@ -39,8 +39,8 @@ jobs: with: python-version: ${{ matrix.python }} - - name: Install the framework with async extras - run: python -m pip install -e .[async] + - name: Install the framework + run: python -m pip install -e . - name: Setup Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 diff --git a/pyproject.toml b/pyproject.toml index 2b6e0639..9aa34365 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,18 +30,14 @@ dependencies = [ "cloudevents>=1.2.0,<=1.11.0", # Must support python 3.7 "Werkzeug>=0.14,<4.0.0", "httpx>=0.24.1", + "starlette>=0.37.0,<1.0.0; python_version>='3.8'", + "uvicorn>=0.18.0,<1.0.0; python_version>='3.8'", + "uvicorn-worker>=0.2.0,<1.0.0; python_version>='3.8'", ] [project.urls] Homepage = "https://github.com/googlecloudplatform/functions-framework-python" -[project.optional-dependencies] -async = [ - "starlette>=0.37.0,<1.0.0; python_version>='3.8'", - "uvicorn>=0.18.0,<1.0.0; python_version>='3.8'", - "uvicorn-worker>=0.2.0,<1.0.0; python_version>='3.8'" -] - [project.scripts] ff = "functions_framework._cli:_cli" functions-framework = "functions_framework._cli:_cli" diff --git a/src/functions_framework/aio/__init__.py b/src/functions_framework/aio/__init__.py index e30b5f99..8e5f9dc7 100644 --- a/src/functions_framework/aio/__init__.py +++ b/src/functions_framework/aio/__init__.py @@ -25,6 +25,12 @@ from cloudevents.http import from_http from cloudevents.http.event import CloudEvent +from starlette.applications import Starlette +from starlette.exceptions import HTTPException +from starlette.middleware import Middleware +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Route from functions_framework import ( _enable_execution_id_logging, @@ -36,19 +42,6 @@ MissingSourceException, ) -try: - from starlette.applications import Starlette - from starlette.exceptions import HTTPException - from starlette.middleware import Middleware - from starlette.requests import Request - from starlette.responses import JSONResponse, Response - from starlette.routing import Route -except ImportError: - raise FunctionsFrameworkException( - "Starlette is not installed. Install the framework with the 'async' extra: " - "pip install functions-framework[async]" - ) - HTTPResponse = Union[ Response, # Functions can return a full Starlette Response object str, # Str returns are wrapped in Response(result) diff --git a/tests/test_aio.py b/tests/test_aio.py index 4f34c279..e7533b1d 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -35,29 +35,6 @@ TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" -def test_import_error_without_starlette(monkeypatch): - import builtins - - original_import = builtins.__import__ - - def mock_import(name, *args, **kwargs): - if name.startswith("starlette"): - raise ImportError(f"No module named '{name}'") - return original_import(name, *args, **kwargs) - - monkeypatch.setattr(builtins, "__import__", mock_import) - - # Remove the module from sys.modules to force re-import - if "functions_framework.aio" in sys.modules: - del sys.modules["functions_framework.aio"] - - with pytest.raises(exceptions.FunctionsFrameworkException) as excinfo: - import functions_framework.aio - - assert "Starlette is not installed" in str(excinfo.value) - assert "pip install functions-framework[async]" in str(excinfo.value) - - def test_invalid_function_definition_missing_function_file(): source = TEST_FUNCTIONS_DIR / "missing_function_file" / "main.py" target = "function" diff --git a/tests/test_asgi.py b/tests/test_asgi.py index cd117bd3..e5b97e60 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -18,12 +18,9 @@ import pretend import pytest -import functions_framework._http +from starlette.applications import Starlette -try: - from starlette.applications import Starlette -except ImportError: - pass +import functions_framework._http def test_httpserver_detects_asgi_app(): diff --git a/tox.ini b/tox.ini index fd3e38a6..cb0873b6 100644 --- a/tox.ini +++ b/tox.ini @@ -29,8 +29,6 @@ deps = pytest-cov pytest-integration pretend -extras = - async setenv = PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 # Python 3.7: Use .coveragerc-py37 to exclude aio module from coverage since it requires Python 3.8+ (Starlette dependency) @@ -48,8 +46,6 @@ deps = isort mypy build -extras = - async commands = black --check src tests conftest.py --exclude tests/test_functions/background_load_error/main.py isort -c src tests conftest.py From ef48e70ee21432a5c7ff014e064b8424254ef289 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 22 Jul 2025 17:50:07 -0700 Subject: [PATCH 13/14] feat: auto-detect ASGI mode for @aio decorated functions (#387) ## Summary - Implement automatic ASGI mode detection for functions decorated with `@aio.http` or `@aio.cloud_event` - The approach creates a Flask app first, loads the module within its context, then checks if ASGI is needed - This results in an unused Flask app for ASGI functions, but we accept this memory overhead as a trade-off - The `--asgi` CLI flag still works and skips the Flask app creation for optimization ## Implementation Details - Added `ASGI_FUNCTIONS` set to `_function_registry.py` to track functions that require ASGI - Updated `@aio.http` and `@aio.cloud_event` decorators to register functions in `ASGI_FUNCTIONS` - Modified `create_app()` to auto-detect ASGI requirements after module loading: 1. Always creates a Flask app first 2. Loads the user module within Flask app context 3. Checks if target function is in `ASGI_FUNCTIONS` registry 4. If ASGI is needed, delegates to `create_asgi_app_from_module()` - The `--asgi` CLI flag continues to work, bypassing Flask app creation entirely for performance ## Trade-offs - **Memory overhead**: ASGI functions will have an unused Flask app instance created during auto-detection - **Accepted trade-off**: This avoids loading modules twice which could cause side effects - **Optimization available**: Users can still use `--asgi` flag to skip Flask app creation entirely ## Test plan - [x] Added tests to verify decorators register functions in `ASGI_FUNCTIONS` - [x] Added CLI tests to verify auto-detection works for `@aio` decorated functions - [x] Added CLI tests to verify regular functions still use Flask/WSGI mode - [x] Added proper test isolation with registry cleanup fixtures - [x] All existing tests pass - [x] Linting passes --- .coveragerc-py37 | 9 ++-- src/functions_framework/__init__.py | 28 +++++++++++ src/functions_framework/_cli.py | 5 +- src/functions_framework/_function_registry.py | 4 ++ src/functions_framework/aio/__init__.py | 30 ++++++++++++ tests/test_cli.py | 39 +++++++++++++++ tests/test_decorator_functions.py | 47 +++++++++++++++++++ tests/test_function_registry.py | 16 +++++++ tests/test_functions.py | 2 +- 9 files changed, 170 insertions(+), 10 deletions(-) diff --git a/.coveragerc-py37 b/.coveragerc-py37 index b1c98d23..efb63fec 100644 --- a/.coveragerc-py37 +++ b/.coveragerc-py37 @@ -12,14 +12,11 @@ omit = [report] exclude_lines = - # Have to re-enable the standard pragma pragma: no cover - - # Don't complain about async-specific imports and code from functions_framework.aio import from functions_framework._http.asgi import from functions_framework._http.gunicorn import UvicornApplication - - # Exclude async-specific classes and functions in execution_id.py class AsgiMiddleware: - def set_execution_context_async \ No newline at end of file + def set_execution_context_async + return create_asgi_app_from_module + app = create_asgi_app\(target, source, signature_type\) \ No newline at end of file diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 22fbf44c..31169f4c 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -327,6 +327,16 @@ def crash_handler(e): def create_app(target=None, source=None, signature_type=None): + """Create an app for the function. + + Args: + target: The name of the target function to invoke + source: The source file containing the function + signature_type: The signature type of the function + + Returns: + A Flask WSGI app or Starlette ASGI app depending on function decorators + """ target = _function_registry.get_function_target(target) source = _function_registry.get_function_source(source) @@ -370,6 +380,7 @@ def handle_none(rv): setup_logging() _app.wsgi_app = execution_id.WsgiMiddleware(_app.wsgi_app) + # Execute the module, within the application context with _app.app_context(): try: @@ -394,6 +405,23 @@ def function(*_args, **_kwargs): # command fails. raise e from None + use_asgi = target in _function_registry.ASGI_FUNCTIONS + if use_asgi: + # This function needs ASGI, delegate to create_asgi_app + # Note: @aio decorators only register functions in ASGI_FUNCTIONS when the + # module is imported. We can't know if a function uses @aio until after + # we load the module. + # + # To avoid loading modules twice, we always create a Flask app first, load the + # module within its context, then check if ASGI is needed. This results in an + # unused Flask app for ASGI functions, but we accept this memory overhead as a + # trade-off. + from functions_framework.aio import create_asgi_app_from_module + + return create_asgi_app_from_module( + target, source, signature_type, source_module, spec + ) + # Get the configured function signature type signature_type = _function_registry.get_func_signature_type(target, signature_type) diff --git a/src/functions_framework/_cli.py b/src/functions_framework/_cli.py index c2ba9f4b..48455ea6 100644 --- a/src/functions_framework/_cli.py +++ b/src/functions_framework/_cli.py @@ -16,7 +16,7 @@ import click -from functions_framework import create_app +from functions_framework import _function_registry, create_app from functions_framework._http import create_server @@ -39,11 +39,10 @@ help="Use ASGI server for function execution", ) def _cli(target, source, signature_type, host, port, debug, asgi): - if asgi: # pragma: no cover + if asgi: from functions_framework.aio import create_asgi_app app = create_asgi_app(target, source, signature_type) else: app = create_app(target, source, signature_type) - create_server(app, debug).run(host, port) diff --git a/src/functions_framework/_function_registry.py b/src/functions_framework/_function_registry.py index 2214b5fd..1f08c794 100644 --- a/src/functions_framework/_function_registry.py +++ b/src/functions_framework/_function_registry.py @@ -40,6 +40,10 @@ # Keys are the user function name, values are the type of the function input INPUT_TYPE_MAP = {} +# ASGI_FUNCTIONS stores function names that require ASGI mode. +# Functions decorated with @aio.http or @aio.cloud_event are added here. +ASGI_FUNCTIONS = set() + def get_user_function(source, source_module, target): """Returns user function, raises exception for invalid function.""" diff --git a/src/functions_framework/aio/__init__.py b/src/functions_framework/aio/__init__.py index 8e5f9dc7..a56fe942 100644 --- a/src/functions_framework/aio/__init__.py +++ b/src/functions_framework/aio/__init__.py @@ -62,6 +62,7 @@ def cloud_event(func: CloudEventFunction) -> CloudEventFunction: _function_registry.REGISTRY_MAP[func.__name__] = ( _function_registry.CLOUDEVENT_SIGNATURE_TYPE ) + _function_registry.ASGI_FUNCTIONS.add(func.__name__) if inspect.iscoroutinefunction(func): @functools.wraps(func) @@ -82,6 +83,7 @@ def http(func: HTTPFunction) -> HTTPFunction: _function_registry.REGISTRY_MAP[func.__name__] = ( _function_registry.HTTP_SIGNATURE_TYPE ) + _function_registry.ASGI_FUNCTIONS.add(func.__name__) if inspect.iscoroutinefunction(func): @@ -213,6 +215,29 @@ async def __call__(self, scope, receive, send): # Don't re-raise to prevent starlette from printing traceback again +def create_asgi_app_from_module(target, source, signature_type, source_module, spec): + """Create an ASGI application from an already-loaded module. + + Args: + target: The name of the target function to invoke + source: The source file containing the function + signature_type: The signature type of the function + source_module: The already-loaded module + spec: The module spec + + Returns: + A Starlette ASGI application instance + """ + enable_id_logging = _enable_execution_id_logging() + if enable_id_logging: # pragma: no cover + _configure_app_execution_id_logging() + + function = _function_registry.get_user_function(source, source_module, target) + signature_type = _function_registry.get_func_signature_type(target, signature_type) + + return _create_asgi_app_with_function(function, signature_type, enable_id_logging) + + def create_asgi_app(target=None, source=None, signature_type=None): """Create an ASGI application for the function. @@ -243,6 +268,11 @@ def create_asgi_app(target=None, source=None, signature_type=None): function = _function_registry.get_user_function(source, source_module, target) signature_type = _function_registry.get_func_signature_type(target, signature_type) + return _create_asgi_app_with_function(function, signature_type, enable_id_logging) + + +def _create_asgi_app_with_function(function, signature_type, enable_id_logging): + """Create an ASGI app with the given function and signature type.""" is_async = inspect.iscoroutinefunction(function) routes = [] if signature_type == _function_registry.HTTP_SIGNATURE_TYPE: diff --git a/tests/test_cli.py b/tests/test_cli.py index 4e5a0a08..75c93f20 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os +import pathlib import sys import pretend @@ -20,9 +22,30 @@ from click.testing import CliRunner import functions_framework +import functions_framework._function_registry as _function_registry from functions_framework._cli import _cli +# Conditional import for Starlette (Python 3.8+) +if sys.version_info >= (3, 8): + from starlette.applications import Starlette +else: + Starlette = None + + +@pytest.fixture(autouse=True) +def clean_registries(): + """Clean up both REGISTRY_MAP and ASGI_FUNCTIONS registries.""" + original_registry_map = _function_registry.REGISTRY_MAP.copy() + original_asgi = _function_registry.ASGI_FUNCTIONS.copy() + _function_registry.REGISTRY_MAP.clear() + _function_registry.ASGI_FUNCTIONS.clear() + yield + _function_registry.REGISTRY_MAP.clear() + _function_registry.REGISTRY_MAP.update(original_registry_map) + _function_registry.ASGI_FUNCTIONS.clear() + _function_registry.ASGI_FUNCTIONS.update(original_asgi) + def test_cli_no_arguments(): runner = CliRunner() @@ -124,3 +147,19 @@ def test_asgi_cli(monkeypatch): assert result.exit_code == 0 assert create_asgi_app.calls == [pretend.call("foo", None, "http")] assert asgi_server.run.calls == [pretend.call("0.0.0.0", 8080)] + + +def test_cli_auto_detects_asgi_decorator(): + """Test that CLI auto-detects @aio decorated functions without --asgi flag.""" + # Use the actual async_decorator.py test file which has @aio.http decorated functions + test_functions_dir = pathlib.Path(__file__).parent / "test_functions" / "decorators" + source = test_functions_dir / "async_decorator.py" + + # Call create_app without any asgi flag - should auto-detect + app = functions_framework.create_app(target="function_http", source=str(source)) + + # Verify it created a Starlette app (ASGI) + assert isinstance(app, Starlette) + + # Verify the function was registered in ASGI_FUNCTIONS + assert "function_http" in _function_registry.ASGI_FUNCTIONS diff --git a/tests/test_decorator_functions.py b/tests/test_decorator_functions.py index 435aa815..3a6e5e99 100644 --- a/tests/test_decorator_functions.py +++ b/tests/test_decorator_functions.py @@ -19,6 +19,8 @@ from cloudevents import conversion as ce_conversion from cloudevents.http import CloudEvent +import functions_framework._function_registry as registry + # Conditional import for Starlette if sys.version_info >= (3, 8): from starlette.testclient import TestClient as StarletteTestClient @@ -35,6 +37,21 @@ TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" + +@pytest.fixture(autouse=True) +def clean_registries(): + """Clean up both REGISTRY_MAP and ASGI_FUNCTIONS registries.""" + original_registry_map = registry.REGISTRY_MAP.copy() + original_asgi = registry.ASGI_FUNCTIONS.copy() + registry.REGISTRY_MAP.clear() + registry.ASGI_FUNCTIONS.clear() + yield + registry.REGISTRY_MAP.clear() + registry.REGISTRY_MAP.update(original_registry_map) + registry.ASGI_FUNCTIONS.clear() + registry.ASGI_FUNCTIONS.update(original_asgi) + + # Python 3.5: ModuleNotFoundError does not exist try: _ModuleNotFoundError = ModuleNotFoundError @@ -128,3 +145,33 @@ def test_aio_http_dict_response(): resp = client.post("/") assert resp.status_code == 200 assert resp.json() == {"message": "hello", "count": 42, "success": True} + + +def test_aio_decorators_register_asgi_functions(): + """Test that @aio decorators add function names to ASGI_FUNCTIONS registry.""" + from functions_framework.aio import cloud_event, http + + @http + async def test_http_func(request): + return "test" + + @cloud_event + async def test_cloud_event_func(event): + pass + + assert "test_http_func" in registry.ASGI_FUNCTIONS + assert "test_cloud_event_func" in registry.ASGI_FUNCTIONS + + assert registry.REGISTRY_MAP["test_http_func"] == "http" + assert registry.REGISTRY_MAP["test_cloud_event_func"] == "cloudevent" + + @http + def test_http_sync(request): + return "sync" + + @cloud_event + def test_cloud_event_sync(event): + pass + + assert "test_http_sync" in registry.ASGI_FUNCTIONS + assert "test_cloud_event_sync" in registry.ASGI_FUNCTIONS diff --git a/tests/test_function_registry.py b/tests/test_function_registry.py index e3ae3c7e..5b517cdc 100644 --- a/tests/test_function_registry.py +++ b/tests/test_function_registry.py @@ -13,9 +13,25 @@ # limitations under the License. import os +import pytest + from functions_framework import _function_registry +@pytest.fixture(autouse=True) +def clean_registries(): + """Clean up both REGISTRY_MAP and ASGI_FUNCTIONS registries.""" + original_registry_map = _function_registry.REGISTRY_MAP.copy() + original_asgi = _function_registry.ASGI_FUNCTIONS.copy() + _function_registry.REGISTRY_MAP.clear() + _function_registry.ASGI_FUNCTIONS.clear() + yield + _function_registry.REGISTRY_MAP.clear() + _function_registry.REGISTRY_MAP.update(original_registry_map) + _function_registry.ASGI_FUNCTIONS.clear() + _function_registry.ASGI_FUNCTIONS.update(original_asgi) + + def test_get_function_signature(): test_cases = [ { diff --git a/tests/test_functions.py b/tests/test_functions.py index 534f4a88..bafb5e8b 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -495,7 +495,7 @@ def test_error_paths(http_trigger_client, path): def test_lazy_wsgi_app(monkeypatch, target, source, signature_type): actual_app_stub = pretend.stub() wsgi_app = pretend.call_recorder(lambda *a, **kw: actual_app_stub) - create_app = pretend.call_recorder(lambda *a: wsgi_app) + create_app = pretend.call_recorder(lambda *a, **kw: wsgi_app) monkeypatch.setattr(functions_framework, "create_app", create_app) # Test that it's lazy From d5ac6b4329683993a15d05a19ffdc97c57e04759 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 23 Jul 2025 16:04:49 -0700 Subject: [PATCH 14/14] chore(main): release 3.9.0 (#374) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 18 ++++++++++++++++++ pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8231717d..465d5c1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.9.0](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.8.3...v3.9.0) (2025-07-23) + + +### Features + +* add execution_id support for async stack ([#377](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/377)) ([1123eea](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/1123eeac8cedae23af8980a928f01f5ad100d9de)) +* add flag to run functions framework in asgi stack ([#376](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/376)) ([268acf1](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/268acf121015bf2a5592715e4cfe582f9d236ff8)) +* add support for async functions ([#364](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/364)) ([49f6985](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/49f698517a06d0e47b2aadc2d603e0b193770440)) +* auto-detect ASGI mode for [@aio](https://github.com/aio) decorated functions ([#387](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/387)) ([ef48e70](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/ef48e70ee21432a5c7ff014e064b8424254ef289)) + + +### Bug Fixes + +* **ci:** specify python version in tox environment ([#375](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/375)) ([37e0bf7](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/37e0bf764ff24ebb82ba18bcac1bee6b03cecb13)) +* Pin cloudevent sdk version to support python3.7. ([#373](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/373)) ([cc2b9b5](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/cc2b9b584fcc3daaa2762ae62a3ce1277a488a1c)) +* resolve CI failures for egress policies and Python 3.7 buildpack support ([#388](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/388)) ([2de6eec](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/2de6eec6fae132b8b1fb41e7024a2260a05bc072)) +* set default log level for asgi logger to WARNING to match default python behavior ([#381](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/381)) ([a576a8f](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/a576a8f28a6029fc5b5ab0725d2aa9c6c5f4304f)) + ## [3.8.3](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.8.2...v3.8.3) (2025-05-14) diff --git a/pyproject.toml b/pyproject.toml index 9aa34365..2fcbabbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "functions-framework" -version = "3.8.3" +version = "3.9.0" description = "An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team." readme = "README.md" requires-python = ">=3.7, <4" diff --git a/setup.py b/setup.py index 1c35d39b..1dd17d7a 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="3.8.2", + version="3.9.0", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown",