From 1d389748f924ee022cba995edced0a1a920c0d04 Mon Sep 17 00:00:00 2001 From: HKWinterhalter Date: Mon, 17 Mar 2025 15:53:13 -0700 Subject: [PATCH 01/42] chore: remove all assignees from blunderbuss.yml (#362) * chore: remove all assignees from blunderbuss.yml --- .github/blunderbuss.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/blunderbuss.yml b/.github/blunderbuss.yml index e913bef1..8b137891 100644 --- a/.github/blunderbuss.yml +++ b/.github/blunderbuss.yml @@ -1,9 +1 @@ -assign_prs: - - HKwinterhalter - - nifflets - - liuyunnnn -assign_issues: - - HKwinterhalter - - nifflets - - liuyunnnn From c6eab2fecb913d5bab1d5dbd6ba2e34b7d6cf9b9 Mon Sep 17 00:00:00 2001 From: nifflets <5343516+nifflets@users.noreply.github.com> Date: Wed, 19 Mar 2025 11:24:18 -0700 Subject: [PATCH 02/42] fix: Update minimum required version of Flask to 2.0 (#356) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 14f0b106..016c4e3e 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,7 @@ package_dir={"": "src"}, python_requires=">=3.5, <4", install_requires=[ - "flask>=1.0,<4.0", + "flask>=2.0,<4.0", "click>=7.0,<9.0", "watchdog>=1.0.0", "gunicorn>=22.0.0; platform_system!='Windows'", From 9348c87cf05eae3726d041f26e43db586951cebb Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Tue, 6 May 2025 13:54:32 -0400 Subject: [PATCH 03/42] docs: Add a development guide (#359) --- DEVELOPMENT.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 DEVELOPMENT.md diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 00000000..f6966656 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,55 @@ +# Development + +Quickstart for functions-framework maintainers. + +## Environment setup + +Install [tox](https://pypi.org/p/tox), the test runner: + +``` +$ python -m pip install -U tox +``` + +## Running linting + +Linting can be run with: + +``` +$ python -m tox -e lint +``` + +## Running tests + +All tests can be run with: + +``` +$ python -m tox +``` + +Tests for the current Python version can be run with: + +``` +$ python -m tox -e py +``` + +Tests for a specific Python version can be run with: + +``` +$ python -m tox -e py3.12 +``` + +A specific test file (e.g. `tests/test_cli.py`) can be run with: + +``` +$ python -m tox -e py -- tests/test_cli.py +``` + +A specific test in the file (e.g. `test_cli_no_arguements` in `tests/test_cli.py`) can be run with: + +``` +$ python -m tox -e py -- tests/test_cli.py::test_cli_no_arguments +``` + +## Releasing + +Releases are triggered via the [Release Please](https://github.com/apps/release-please) app, which in turn kicks off the [Release to PyPI](https://github.com/GoogleCloudPlatform/functions-framework-python/blob/main/.github/workflows/release.yml) workflow. From c0fa420970d36d57adceaf70bbaea784e427e594 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Tue, 6 May 2025 13:54:49 -0400 Subject: [PATCH 04/42] fix: Update test suite for EOL Python versions (#360) --- .github/workflows/conformance.yml | 10 ++++++++-- .github/workflows/unit.yml | 15 +++++++++++++-- tox.ini | 24 ++++++++++++++++++++++-- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 0d86d788..4e066990 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -10,10 +10,16 @@ permissions: read-all jobs: build: - runs-on: ubuntu-latest strategy: matrix: - python: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] + python: ['3.9', '3.10', '3.11', '3.12'] + platform: [ubuntu-latest] + include: + - platform: ubuntu-22.04 + python: '3.8' + - platform: ubuntu-22.04 + python: '3.7' + runs-on: ${{ matrix.platform }} steps: - name: Harden Runner uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 463340a5..404b5159 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -1,5 +1,5 @@ name: Python Unit CI -on: +on: push: branches: - main @@ -13,8 +13,9 @@ jobs: matrix: python: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] platform: [ubuntu-latest, macos-latest, windows-latest] - # Python <= 3.9 is not available on macos-14 + # Python <= 3.9 is not available on macos-latest # Workaround for https://github.com/actions/setup-python/issues/696 + # Python <= 3.8 is not available on ubuntu-latest exclude: - platform: macos-latest python: '3.9' @@ -22,6 +23,10 @@ jobs: python: '3.8' - platform: macos-latest python: '3.7' + - platform: ubuntu-latest + python: '3.8' + - platform: ubuntu-latest + python: '3.7' include: - platform: macos-latest python: '3.9' @@ -29,6 +34,10 @@ jobs: python: '3.8' - platform: macos-13 python: '3.7' + - platform: ubuntu-22.04 + python: '3.8' + - platform: ubuntu-22.04 + python: '3.7' runs-on: ${{ matrix.platform }} steps: - name: Harden Runner @@ -39,7 +48,9 @@ jobs: allowed-endpoints: > auth.docker.io:443 files.pythonhosted.org:443 + api.github.com:443 github.com:443 + objects.githubusercontent.com:443 production.cloudflare.docker.com:443 pypi.org:443 registry-1.docker.io:443 diff --git a/tox.ini b/tox.ini index 6ba6d3b4..71550e76 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,24 @@ [tox] -envlist = py{35,36,37,38,39,310}-{ubuntu-latest,macos-latest,windows-latest},lint +envlist = + lint + py312-ubuntu-latest + py312-macos-latest + py312-windows-latest + py311-ubuntu-latest + py311-macos-latest + py311-windows-latest + py310-ubuntu-latest + py310-macos-latest + py310-windows-latest + py39-ubuntu-latest + py39-macos-13 + py39-windows-latest + py38-ubuntu-22.04 + py38-macos-13 + py38-windows-latest + py37-ubuntu-22.04 + py37-macos-13 + py37-windows-latest [testenv] usedevelop = true @@ -21,9 +40,10 @@ deps = twine isort mypy + build commands = black --check src tests setup.py conftest.py --exclude tests/test_functions/background_load_error/main.py isort -c src tests setup.py conftest.py mypy tests/test_typing.py - python setup.py --quiet sdist bdist_wheel + python -m build twine check dist/* From 4c44d08c5ebb304e80a3adc3f8e6d150987b29bb Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Wed, 14 May 2025 14:35:46 -0400 Subject: [PATCH 05/42] fix: Switch to `pyproject.toml` based builds (#365) * fix: Switch to `pyproject.toml` based builds * chore: Add newly supported Python versions (3.11 & 3.12) * fix: remove `setup.py` from linting steps * chore: disable Python 3.12 conformance tests --- .../workflows/buildpack-integration-test.yml | 26 +++++++ pyproject.toml | 57 +++++++++++++++ setup.py | 69 ------------------- tox.ini | 4 +- 4 files changed, 85 insertions(+), 71 deletions(-) create mode 100644 pyproject.toml delete mode 100644 setup.py diff --git a/.github/workflows/buildpack-integration-test.yml b/.github/workflows/buildpack-integration-test.yml index 0c9e6eff..234c24ef 100644 --- a/.github/workflows/buildpack-integration-test.yml +++ b/.github/workflows/buildpack-integration-test.yml @@ -58,3 +58,29 @@ jobs: builder-runtime: 'python310' builder-runtime-version: '3.10' start-delay: 5 + python311: + 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: 'python311' + builder-runtime-version: '3.11' + start-delay: 5 +# Python 3.12 conformance tests are disabled due to the buildpack defaulting to +# Ubuntu 18.04, which has no Python 3.12 version, and being unable to specify +# the OS/stack via the conformance test configuration +# +# python312: +# 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: 'python312' +# builder-runtime-version: '3.12' +# start-delay: 5 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..5f25ecf8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,57 @@ +[project] +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." +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" } +] +maintainers = [ + { name = "Google LLC", email = "googleapis-packages@google.com" } +] +keywords = ["functions-framework"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "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", +] +dependencies = [ + "flask>=2.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", +] + +[project.urls] +Homepage = "https://github.com/googlecloudplatform/functions-framework-python" + +[project.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" + +[build-system] +requires = ["setuptools>=61.0.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +functions_framework = ["py.typed"] + +[tool.setuptools.package-dir] +"" = "src" diff --git a/setup.py b/setup.py deleted file mode 100644 index 016c4e3e..00000000 --- a/setup.py +++ /dev/null @@ -1,69 +0,0 @@ -# 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>=2.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", - ], - 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/tox.ini b/tox.ini index 71550e76..1599608c 100644 --- a/tox.ini +++ b/tox.ini @@ -42,8 +42,8 @@ deps = mypy build commands = - black --check src tests setup.py conftest.py --exclude tests/test_functions/background_load_error/main.py - isort -c src tests setup.py conftest.py + black --check src tests conftest.py --exclude tests/test_functions/background_load_error/main.py + isort -c src tests conftest.py mypy tests/test_typing.py python -m build twine check dist/* From f57a0163d0b20eac4a77d5776262a3942780df9a Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Fri, 16 May 2025 11:26:56 -0700 Subject: [PATCH 06/42] chore(main): release 3.8.3 (#363) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 14 ++++++++++++++ pyproject.toml | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2438f81..8231717d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ 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.8.3](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.8.2...v3.8.3) (2025-05-14) + + +### Bug Fixes + +* Switch to `pyproject.toml` based builds ([#365](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/365)) ([4c44d08](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/4c44d08c5ebb304e80a3adc3f8e6d150987b29bb)) +* Update minimum required version of Flask to 2.0 ([#356](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/356)) ([c6eab2f](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/c6eab2fecb913d5bab1d5dbd6ba2e34b7d6cf9b9)) +* Update test suite for EOL Python versions ([#360](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/360)) ([c0fa420](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/c0fa420970d36d57adceaf70bbaea784e427e594)) + + +### Documentation + +* Add a development guide ([#359](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/359)) ([9348c87](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/9348c87cf05eae3726d041f26e43db586951cebb)) + ## [3.8.2](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.8.1...v3.8.2) (2024-11-13) diff --git a/pyproject.toml b/pyproject.toml index 5f25ecf8..c160562f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "functions-framework" -version = "3.8.2" +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" From 3fdeaa0dd799553f92ebcef919e8755763a166c5 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Sat, 17 May 2025 00:39:34 +0200 Subject: [PATCH 07/42] 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 08/42] 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 09/42] 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 10/42] 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 11/42] 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 12/42] 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 13/42] 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 14/42] 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 15/42] 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 16/42] 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 17/42] 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 18/42] 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 19/42] 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 20/42] 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", From 26fb101d556a39262855a304c1ac351856ce4b61 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Thu, 24 Jul 2025 10:28:56 -0700 Subject: [PATCH 21/42] fix: remove unused httpx dependency (#389) httpx was accidentally added in #364 but is not used anywhere in the codebase. Removing it reduces our dependency count from 28 to 20 packages. Impact: - Removes 8 unnecessary packages (httpx + 7 transitive deps) - Reduces installation size by ~2MB - Keeps only the actually needed async dependencies --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2fcbabbc..a7c1778a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,6 @@ 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", "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'", From a1c557c26588ebeacd6852ddbce9c7866006bc39 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Thu, 24 Jul 2025 19:29:35 +0200 Subject: [PATCH 22/42] chore(deps): update pypa/gh-action-pypi-publish digest to e9ccbe5 (#369) --- .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 269f14c7..77207e54 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@db8f07d3871a0a180efa06b95d467625c19d5d5f # main + uses: pypa/gh-action-pypi-publish@e9ccbe5a211ba3e8363f472cae362b56b104e796 # main with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} From 0c643782d30d7295c10a3ad5ffd1e5f448bfd16f Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Thu, 24 Jul 2025 19:52:44 +0200 Subject: [PATCH 23/42] chore(deps): update all non-major dependencies (#370) * chore(deps): update all non-major dependencies * keep cloudevents version --------- Co-authored-by: Andras Kerekes --- .github/workflows/codeql.yml | 8 ++++---- .github/workflows/conformance-asgi.yml | 12 ++++++------ .github/workflows/conformance.yml | 16 ++++++++-------- .github/workflows/dependency-review.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/scorecard.yml | 6 +++--- .github/workflows/unit.yml | 2 +- pyproject.toml | 2 +- 9 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 46f6a4d1..270f890c 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@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: disable-sudo: true egress-policy: block @@ -57,7 +57,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 + uses: github/codeql-action/init@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4 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@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 + uses: github/codeql-action/autobuild@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4 # â„šī¸ 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@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 + uses: github/codeql-action/analyze@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/conformance-asgi.yml b/.github/workflows/conformance-asgi.yml index 8a61cd4c..b3f3601d 100644 --- a/.github/workflows/conformance-asgi.yml +++ b/.github/workflows/conformance-asgi.yml @@ -17,7 +17,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: disable-sudo: true egress-policy: block @@ -48,7 +48,7 @@ jobs: go-version: '1.24' - name: Run HTTP conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 with: functionType: 'http' useBuildpacks: false @@ -56,7 +56,7 @@ jobs: 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 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 with: functionType: 'cloudevent' useBuildpacks: false @@ -64,7 +64,7 @@ jobs: 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 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 with: functionType: 'http' useBuildpacks: false @@ -72,7 +72,7 @@ jobs: 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 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 with: functionType: 'cloudevent' useBuildpacks: false @@ -80,7 +80,7 @@ jobs: 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 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 with: functionType: 'http' useBuildpacks: false diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 7d10b8af..9eedb0a2 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@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: disable-sudo: true egress-policy: block @@ -53,7 +53,7 @@ jobs: go-version: '1.24' - name: Run HTTP conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 with: functionType: 'http' useBuildpacks: false @@ -61,7 +61,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http --signature-type http'" - name: Run event conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 with: functionType: 'legacyevent' useBuildpacks: false @@ -69,7 +69,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_legacy_event --signature-type event'" - name: Run CloudEvents conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 with: functionType: 'cloudevent' useBuildpacks: false @@ -77,7 +77,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_cloud_event --signature-type cloudevent'" - name: Run HTTP conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 with: functionType: 'http' useBuildpacks: false @@ -85,7 +85,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative'" - name: Run CloudEvents conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 with: functionType: 'cloudevent' useBuildpacks: false @@ -93,7 +93,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_cloud_event_declarative'" - name: Run HTTP concurrency tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 with: functionType: 'http' useBuildpacks: false @@ -101,7 +101,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative_concurrent'" - name: Run Typed tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 with: functionType: 'http' declarativeType: 'typed' diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 5baf3aa0..02009c72 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@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: disable-sudo: true egress-policy: block diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 4fa8dff2..1c99b0f7 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@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: disable-sudo: true egress-policy: block diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 77207e54..6bb76655 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 72b82523..23b0c7c0 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@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: disable-sudo: true egress-policy: block @@ -52,7 +52,7 @@ jobs: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1 + uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 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@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 + uses: github/codeql-action/upload-sarif@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4 with: sarif_file: results.sarif diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 28ed5b1e..90e9e915 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@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: disable-sudo: true egress-policy: block diff --git a/pyproject.toml b/pyproject.toml index a7c1778a..4a00ace8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "click>=7.0,<9.0", "watchdog>=1.0.0", "gunicorn>=22.0.0; platform_system!='Windows'", - "cloudevents>=1.2.0,<=1.11.0", # Must support python 3.7 + "cloudevents>=1.2.0,<=1.11.0", # Must support python 3.7 "Werkzeug>=0.14,<4.0.0", "starlette>=0.37.0,<1.0.0; python_version>='3.8'", "uvicorn>=0.18.0,<1.0.0; python_version>='3.8'", From 9e625c9a79cf9a0a35778adad285c20be7535b8f Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Thu, 24 Jul 2025 11:07:58 -0700 Subject: [PATCH 24/42] chore(main): release 3.9.1 (#390) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 465d5c1b..f7be45f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ 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.1](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.9.0...v3.9.1) (2025-07-24) + + +### Bug Fixes + +* remove unused httpx dependency ([#389](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/389)) ([26fb101](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/26fb101d556a39262855a304c1ac351856ce4b61)) + ## [3.9.0](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.8.3...v3.9.0) (2025-07-23) diff --git a/pyproject.toml b/pyproject.toml index 4a00ace8..968ed607 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "functions-framework" -version = "3.9.0" +version = "3.9.1" 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 1dd17d7a..e1467ced 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="3.9.0", + version="3.9.1", 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", From 1b6c428d559991134240473f6622f8759a3360d5 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Thu, 24 Jul 2025 13:28:25 -0700 Subject: [PATCH 25/42] fix: increase start delay for ASGI conformance tests to address flaky failures (#391) The ASGI conformance tests are failing sporadically with connection refused errors, where the server appears to not be ready when the test client attempts to connect. Hypothesis: The default 1-second start delay may be insufficient for Uvicorn/ASGI server startup in CI environments. This change increases startDelay to 5 seconds to match the buildpack integration tests, which have been running reliably. Note: The GitHub Action expects startDelay (camelCase) as an input parameter, which it then passes to the conformance test client as -start-delay flag. This is an attempt to diagnose and fix the intermittent failures - further investigation may be needed if the issue persists. --- .github/workflows/conformance-asgi.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/conformance-asgi.yml b/.github/workflows/conformance-asgi.yml index b3f3601d..69f2b215 100644 --- a/.github/workflows/conformance-asgi.yml +++ b/.github/workflows/conformance-asgi.yml @@ -54,6 +54,7 @@ jobs: useBuildpacks: false validateMapping: false cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http --signature-type http --asgi'" + startDelay: 5 - name: Run CloudEvents conformance tests uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 @@ -62,6 +63,7 @@ jobs: useBuildpacks: false validateMapping: false cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event --signature-type cloudevent --asgi'" + startDelay: 5 - name: Run HTTP conformance tests declarative uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 @@ -70,6 +72,7 @@ jobs: useBuildpacks: false validateMapping: false cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative --asgi'" + startDelay: 5 - name: Run CloudEvents conformance tests declarative uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 @@ -78,6 +81,7 @@ jobs: useBuildpacks: false validateMapping: false cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event_declarative --asgi'" + startDelay: 5 - name: Run HTTP concurrency tests declarative uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 @@ -86,6 +90,7 @@ jobs: useBuildpacks: false validateConcurrency: true cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative_concurrent --asgi'" + startDelay: 5 # Note: Event (legacy) and Typed tests are not supported in ASGI mode # Note: validateMapping is set to false for CloudEvent tests because ASGI mode From f1dc285f07f7f5daa398d1a510573b4d15117332 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Mon, 28 Jul 2025 10:55:45 -0700 Subject: [PATCH 26/42] chore(main): release 3.9.2 (#392) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7be45f0..73ed6d36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ 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.2](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.9.1...v3.9.2) (2025-07-24) + + +### Bug Fixes + +* increase start delay for ASGI conformance tests to address flaky failures ([#391](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/391)) ([1b6c428](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/1b6c428d559991134240473f6622f8759a3360d5)) + ## [3.9.1](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.9.0...v3.9.1) (2025-07-24) diff --git a/pyproject.toml b/pyproject.toml index 968ed607..19912786 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "functions-framework" -version = "3.9.1" +version = "3.9.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." readme = "README.md" requires-python = ">=3.7, <4" diff --git a/setup.py b/setup.py index e1467ced..3ba48c93 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="3.9.1", + version="3.9.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", From 9b37f85f6c37078119c2ea3cc91e6b3c00954a8c Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Mon, 4 Aug 2025 13:27:44 -0700 Subject: [PATCH 27/42] fix(ci): Add release-assets.githubusercontent.com to allowed endpoints (#394) The CodeQL workflow was failing because the `step-security/harden-runner` was blocking egress traffic to the endpoint used for downloading the CodeQL bundle. The download from `https://github.com/github/codeql-action/releases` redirects to `release-assets.githubusercontent.com`. This change adds `release-assets.githubusercontent.com` to the list of allowed endpoints to resolve the `ECONNREFUSED` error and removes previous incorrect attempts. --- .github/workflows/codeql.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 270f890c..e34c1f9b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -51,6 +51,7 @@ jobs: github.com:443 pypi.org:443 objects.githubusercontent.com:443 + release-assets.githubusercontent.com:443 - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 From a07b1e4f81d84424189488022e5f3d59c06014cc Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Mon, 4 Aug 2025 13:39:47 -0700 Subject: [PATCH 28/42] feat: Add async and streaming examples (#393) Adds two new examples to demonstrate asynchronous and streaming capabilities: - `cloud_run_async`: Shows how to create basic asynchronous HTTP and CloudEvent functions. - `cloud_run_streaming_http`: Shows how to create both synchronous and asynchronous streaming HTTP functions. The examples include instructions for running locally with the `functions-framework` CLI and deploying to Cloud Run with `gcloud`. --- examples/README.md | 2 + examples/cloud_run_async/README.md | 68 +++++++++++++++++++ examples/cloud_run_async/main.py | 36 ++++++++++ examples/cloud_run_async/requirements.txt | 4 ++ examples/cloud_run_async/send_cloud_event.py | 38 +++++++++++ examples/cloud_run_streaming_http/README.md | 65 ++++++++++++++++++ examples/cloud_run_streaming_http/main.py | 59 ++++++++++++++++ .../cloud_run_streaming_http/requirements.txt | 1 + 8 files changed, 273 insertions(+) create mode 100644 examples/cloud_run_async/README.md create mode 100644 examples/cloud_run_async/main.py create mode 100644 examples/cloud_run_async/requirements.txt create mode 100644 examples/cloud_run_async/send_cloud_event.py create mode 100644 examples/cloud_run_streaming_http/README.md create mode 100644 examples/cloud_run_streaming_http/main.py create mode 100644 examples/cloud_run_streaming_http/requirements.txt diff --git a/examples/README.md b/examples/README.md index 7960a743..47b7c398 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,6 +5,8 @@ * [`cloud_run_http`](./cloud_run_http/) - Deploying an HTTP function to [Cloud Run](http://cloud.google.com/run) with the Functions Framework * [`cloud_run_event`](./cloud_run_event/) - Deploying a CloudEvent function to [Cloud Run](http://cloud.google.com/run) with the Functions Framework * [`cloud_run_cloud_events`](cloud_run_cloud_events/) - Deploying a [CloudEvent](https://github.com/cloudevents/sdk-python) function to [Cloud Run](http://cloud.google.com/run) with the Functions Framework +* [`cloud_run_async`](./cloud_run_async/) - Deploying asynchronous HTTP and CloudEvent functions to [Cloud Run](http://cloud.google.com/run) with the Functions Framework +* [`cloud_run_streaming_http`](./cloud_run_streaming_http/) - Deploying streaming HTTP functions to [Cloud Run](http://cloud.google.com/run) with the Functions Framework ## Development Tools * [`docker-compose`](./docker-compose) - diff --git a/examples/cloud_run_async/README.md b/examples/cloud_run_async/README.md new file mode 100644 index 00000000..81de2747 --- /dev/null +++ b/examples/cloud_run_async/README.md @@ -0,0 +1,68 @@ +# Deploying async functions to Cloud Run + +This sample shows how to deploy asynchronous functions to [Cloud Run functions](https://cloud.google.com/functions) with the Functions Framework. It includes examples for both HTTP and CloudEvent functions, which can be found in the `main.py` file. + +## Dependencies + +Install the dependencies for this example: + +```sh +pip install -r requirements.txt +``` + +## Running locally + +### HTTP Function + +To run the HTTP function locally, use the `functions-framework` command: + +```sh +functions-framework --target=hello_async_http +``` + +Then, send a request to it from another terminal: + +```sh +curl localhost:8080 +# Output: Hello, async world! +``` + +### CloudEvent Function + +To run the CloudEvent function, specify the target and set the signature type: + +```sh +functions-framework --target=hello_async_cloudevent --signature-type=cloudevent +``` + +Then, in another terminal, send a sample CloudEvent using the provided script: + +```sh +python send_cloud_event.py +``` + +## Deploying to Cloud Run + +You can deploy these functions to Cloud Run using the `gcloud` CLI. + +### HTTP Function + +```sh +gcloud run deploy async-http-function \ + --source . \ + --function hello_async_http \ + --base-image python312 \ + --region +``` + +### CloudEvent Function + +```sh +gcloud run deploy async-cloudevent-function \ + --source . \ + --function hello_async_cloudevent \ + --base-image python312 \ + --region +``` + +After deploying, you can invoke the CloudEvent function by sending an HTTP POST request with a CloudEvent payload to its URL. diff --git a/examples/cloud_run_async/main.py b/examples/cloud_run_async/main.py new file mode 100644 index 00000000..40062a95 --- /dev/null +++ b/examples/cloud_run_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. + +import functions_framework.aio + +@functions_framework.aio.http +async def hello_async_http(request): + """ + An async HTTP function. + Args: + request (starlette.requests.Request): The request object. + Returns: + The response text, or an instance of any Starlette response class + (e.g. `starlette.responses.Response`). + """ + return "Hello, async world!" + +@functions_framework.aio.cloud_event +async def hello_async_cloudevent(cloud_event): + """ + An async CloudEvent function. + Args: + cloud_event (cloudevents.http.CloudEvent): The CloudEvent object. + """ + print(f"Received event with ID: {cloud_event['id']} and data {cloud_event.data}") diff --git a/examples/cloud_run_async/requirements.txt b/examples/cloud_run_async/requirements.txt new file mode 100644 index 00000000..b862cce2 --- /dev/null +++ b/examples/cloud_run_async/requirements.txt @@ -0,0 +1,4 @@ +functions-framework>=3.9.2,<4.0.0 + +# For testing +httpx<=0.28.1 diff --git a/examples/cloud_run_async/send_cloud_event.py b/examples/cloud_run_async/send_cloud_event.py new file mode 100644 index 00000000..714bc719 --- /dev/null +++ b/examples/cloud_run_async/send_cloud_event.py @@ -0,0 +1,38 @@ +#!/usr/local/bin/python + +# 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 httpx + +from cloudevents.http import CloudEvent, to_structured + + +async def main(): + attributes = { + "Content-Type": "application/json", + "source": "from-galaxy-far-far-away", + "type": "cloudevent.greet.you", + } + data = {"name": "john"} + + event = CloudEvent(attributes, data) + + headers, data = to_structured(event) + + async with httpx.AsyncClient() as client: + await client.post("http://localhost:8080/", headers=headers, data=data) + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/cloud_run_streaming_http/README.md b/examples/cloud_run_streaming_http/README.md new file mode 100644 index 00000000..839b09c7 --- /dev/null +++ b/examples/cloud_run_streaming_http/README.md @@ -0,0 +1,65 @@ +# Deploying streaming functions to Cloud Run + +This sample shows how to deploy streaming functions to [Cloud Run](http://cloud.google.com/run) with the Functions Framework. The `main.py` file contains examples for both synchronous and asynchronous streaming. + +## Dependencies + +Install the dependencies for this example: + +```sh +pip install -r requirements.txt +``` + +## Running locally + +### Synchronous Streaming + +To run the synchronous streaming function locally: + +```sh +functions-framework --target=hello_stream +``` + +Then, send a request to it from another terminal: + +```sh +curl localhost:8080 +``` + +### Asynchronous Streaming + +To run the asynchronous streaming function locally: + +```sh +functions-framework --target=hello_stream_async +``` + +Then, send a request to it from another terminal: + +```sh +curl localhost:8080 +``` + +## Deploying to Cloud Run + +You can deploy these functions to Cloud Run using the `gcloud` CLI. + +### Synchronous Streaming + +```sh +gcloud run deploy streaming-function \ + --source . \ + --function hello_stream \ + --base-image python312 \ + --region +``` + +### Asynchronous Streaming + +```sh +gcloud run deploy streaming-async-function \ + --source . \ + --function hello_stream_async \ + --base-image python312 \ + --region +``` diff --git a/examples/cloud_run_streaming_http/main.py b/examples/cloud_run_streaming_http/main.py new file mode 100644 index 00000000..5010b1a3 --- /dev/null +++ b/examples/cloud_run_streaming_http/main.py @@ -0,0 +1,59 @@ +# 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 time +import asyncio +import functions_framework +import functions_framework.aio +from starlette.responses import StreamingResponse + +# Helper function for the synchronous streaming example. +def slow_numbers(minimum, maximum): + yield '
    ' + for number in range(minimum, maximum + 1): + yield '
  • %d
  • ' % number + time.sleep(0.5) + yield '
' + +@functions_framework.http +def hello_stream(request): + """ + A synchronous HTTP function that streams a response. + Args: + request (flask.Request): The request object. + Returns: + A generator, which will be streamed as the response. + """ + generator = slow_numbers(1, 10) + return generator, {'Content-Type': 'text/html'} + +# Helper function for the asynchronous streaming example. +async def slow_numbers_async(minimum, maximum): + yield '
    ' + for number in range(minimum, maximum + 1): + yield '
  • %d
  • ' % number + await asyncio.sleep(0.5) + yield '
' + +@functions_framework.aio.http +async def hello_stream_async(request): + """ + An asynchronous HTTP function that streams a response. + Args: + request (starlette.requests.Request): The request object. + Returns: + A starlette.responses.StreamingResponse. + """ + generator = slow_numbers_async(1, 10) + return StreamingResponse(generator, media_type='text/html') diff --git a/examples/cloud_run_streaming_http/requirements.txt b/examples/cloud_run_streaming_http/requirements.txt new file mode 100644 index 00000000..83e77d02 --- /dev/null +++ b/examples/cloud_run_streaming_http/requirements.txt @@ -0,0 +1 @@ +functions-framework>=3.9.2,<4.0.0 From 3597a56e82958d97ee915f564d074e3faa4efb56 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Mon, 10 Nov 2025 16:20:51 +0000 Subject: [PATCH 29/42] chore(deps): update all non-major dependencies (#400) --- .github/workflows/codeql.yml | 10 +++++----- .github/workflows/conformance-asgi.yml | 6 +++--- .github/workflows/conformance.yml | 6 +++--- .github/workflows/dependency-review.yml | 6 +++--- .github/workflows/lint.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- .github/workflows/scorecard.yml | 8 ++++---- .github/workflows/unit.yml | 4 ++-- pyproject.toml | 2 +- 9 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e34c1f9b..e5e575fb 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@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: disable-sudo: true egress-policy: block @@ -54,11 +54,11 @@ jobs: release-assets.githubusercontent.com:443 - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4 + uses: github/codeql-action/init@5d5cd550d3e189c569da8f16ea8de2d821c9bf7a # v3.31.2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -68,7 +68,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@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4 + uses: github/codeql-action/autobuild@5d5cd550d3e189c569da8f16ea8de2d821c9bf7a # v3.31.2 # â„šī¸ 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 @@ -81,6 +81,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4 + uses: github/codeql-action/analyze@5d5cd550d3e189c569da8f16ea8de2d821c9bf7a # v3.31.2 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/conformance-asgi.yml b/.github/workflows/conformance-asgi.yml index 69f2b215..ee568faa 100644 --- a/.github/workflows/conformance-asgi.yml +++ b/.github/workflows/conformance-asgi.yml @@ -17,7 +17,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: disable-sudo: true egress-policy: block @@ -32,7 +32,7 @@ jobs: release-assets.githubusercontent.com:443 - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Setup Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 @@ -45,7 +45,7 @@ jobs: - name: Setup Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: - go-version: '1.24' + go-version: '1.25' - name: Run HTTP conformance tests uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 9eedb0a2..34f997a6 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@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: disable-sudo: true egress-policy: block @@ -37,7 +37,7 @@ jobs: release-assets.githubusercontent.com:443 - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Setup Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 @@ -50,7 +50,7 @@ jobs: - name: Setup Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: - go-version: '1.24' + go-version: '1.25' - name: Run HTTP conformance tests uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 02009c72..efa405f8 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@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: 'Dependency Review' - uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1 + uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1c99b0f7..e9088c6a 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@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: disable-sudo: true egress-policy: block @@ -21,7 +21,7 @@ jobs: github.com:443 pypi.org:443 - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Setup Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - name: Install tox diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6bb76655..c209a7ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,12 +13,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: ref: ${{ github.event.release.tag_name }} - name: Install Python diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 23b0c7c0..6fdac794 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@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: disable-sudo: true egress-policy: block @@ -47,12 +47,12 @@ jobs: - name: "Checkout code" - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 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@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4 + uses: github/codeql-action/upload-sarif@5d5cd550d3e189c569da8f16ea8de2d821c9bf7a # v3.31.2 with: sarif_file: results.sarif diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 90e9e915..20f24675 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@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: disable-sudo: true egress-policy: block @@ -57,7 +57,7 @@ jobs: release-assets.githubusercontent.com:443 - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Use Python ${{ matrix.python }} uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: diff --git a/pyproject.toml b/pyproject.toml index 19912786..27368e37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "click>=7.0,<9.0", "watchdog>=1.0.0", "gunicorn>=22.0.0; platform_system!='Windows'", - "cloudevents>=1.2.0,<=1.11.0", # Must support python 3.7 + "cloudevents>=1.12.0,<=1.12.0", # Must support python 3.7 "Werkzeug>=0.14,<4.0.0", "starlette>=0.37.0,<1.0.0; python_version>='3.8'", "uvicorn>=0.18.0,<1.0.0; python_version>='3.8'", From ef45fae46896b50a7a3e0e5c3cb4813519e3cb76 Mon Sep 17 00:00:00 2001 From: Maeve <167252720+maemayve@users.noreply.github.com> Date: Mon, 10 Nov 2025 10:13:59 -0800 Subject: [PATCH 30/42] fix: remove Python 3.7 test execution (#402) Python 3.7 is a decomissioned runtime. Tests are failing because of language features not supported by this python version. Disable these tests rather than backfixing. --- .coveragerc-py37 | 22 ---------------------- .github/workflows/conformance.yml | 2 -- .github/workflows/unit.yml | 10 +--------- tox.ini | 7 ------- 4 files changed, 1 insertion(+), 40 deletions(-) delete mode 100644 .coveragerc-py37 diff --git a/.coveragerc-py37 b/.coveragerc-py37 deleted file mode 100644 index efb63fec..00000000 --- a/.coveragerc-py37 +++ /dev/null @@ -1,22 +0,0 @@ -[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/* - */functions_framework/_http/asgi.py - */.tox/* - */tests/* - */venv/* - */.venv/* - -[report] -exclude_lines = - pragma: no cover - from functions_framework.aio import - from functions_framework._http.asgi import - from functions_framework._http.gunicorn import UvicornApplication - class AsgiMiddleware: - 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/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 34f997a6..6c14136e 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -17,8 +17,6 @@ jobs: include: - platform: ubuntu-22.04 python: '3.8' - - platform: ubuntu-22.04 - python: '3.7' runs-on: ${{ matrix.platform }} steps: - name: Harden Runner diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 20f24675..96a880d2 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -11,7 +11,7 @@ jobs: test: strategy: matrix: - python: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] + python: ['3.8', '3.9', '3.10', '3.11', '3.12'] platform: [ubuntu-latest, macos-latest, windows-latest] # Python <= 3.9 is not available on macos-latest # Workaround for https://github.com/actions/setup-python/issues/696 @@ -21,23 +21,15 @@ jobs: python: '3.9' - platform: macos-latest python: '3.8' - - platform: macos-latest - python: '3.7' - platform: ubuntu-latest python: '3.8' - - platform: ubuntu-latest - python: '3.7' include: - platform: macos-latest python: '3.9' - platform: macos-13 python: '3.8' - - platform: macos-13 - python: '3.7' - platform: ubuntu-22.04 python: '3.8' - - platform: ubuntu-22.04 - python: '3.7' runs-on: ${{ matrix.platform }} steps: - name: Harden Runner diff --git a/tox.ini b/tox.ini index cb0873b6..2e36e689 100644 --- a/tox.ini +++ b/tox.ini @@ -16,9 +16,6 @@ envlist = py38-ubuntu-22.04 py38-macos-13 py38-windows-latest - py37-ubuntu-22.04 - py37-macos-13 - py37-windows-latest [testenv] usedevelop = true @@ -31,10 +28,6 @@ deps = pretend 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} From 2cf966f037d83fe0d8e69cee8d41ff238db79164 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 10:20:01 -0800 Subject: [PATCH 31/42] chore(main): release 3.10.0 (#395) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 13 +++++++++++++ pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73ed6d36..b817ff60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ 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.10.0](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.9.2...v3.10.0) (2025-11-10) + + +### Features + +* Add async and streaming examples ([#393](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/393)) ([a07b1e4](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/a07b1e4f81d84424189488022e5f3d59c06014cc)) + + +### Bug Fixes + +* **ci:** Add release-assets.githubusercontent.com to allowed endpoints ([#394](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/394)) ([9b37f85](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/9b37f85f6c37078119c2ea3cc91e6b3c00954a8c)) +* remove Python 3.7 test execution ([#402](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/402)) ([ef45fae](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/ef45fae46896b50a7a3e0e5c3cb4813519e3cb76)) + ## [3.9.2](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.9.1...v3.9.2) (2025-07-24) diff --git a/pyproject.toml b/pyproject.toml index 27368e37..b187260f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "functions-framework" -version = "3.9.2" +version = "3.10.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 3ba48c93..28d0ea11 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="3.9.2", + version="3.10.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", From 8d74a7b9ecead58df77208d6e4717419a9aa9447 Mon Sep 17 00:00:00 2001 From: Maeve <167252720+maemayve@users.noreply.github.com> Date: Mon, 17 Nov 2025 14:39:44 -0800 Subject: [PATCH 32/42] fix: Correct cloudevents dependency to allow 1.11.0 (#405) Python 3.7 is no longer supported so don't allow versions prior to 1.11.0 but allow 1.11.0 as Python 3.8 is still supported by the framework. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b187260f..6d98287c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "click>=7.0,<9.0", "watchdog>=1.0.0", "gunicorn>=22.0.0; platform_system!='Windows'", - "cloudevents>=1.12.0,<=1.12.0", # Must support python 3.7 + "cloudevents>=1.11.0,<=1.12.0", # Must support python 3.8 "Werkzeug>=0.14,<4.0.0", "starlette>=0.37.0,<1.0.0; python_version>='3.8'", "uvicorn>=0.18.0,<1.0.0; python_version>='3.8'", From 43e63f6f847d89eeb018add028fb6222f2fac38c Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Fri, 13 Feb 2026 14:48:58 -0800 Subject: [PATCH 33/42] fix: pin lint tool versions to avoid surprise breakages (#413) --- tox.ini | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index 2e36e689..6b866c51 100644 --- a/tox.ini +++ b/tox.ini @@ -34,10 +34,10 @@ commands = pytest {env:PYTESTARGS} {posargs} [testenv:lint] basepython=python3 deps = - black + black>=25,<26 twine - isort - mypy + isort>=5,<6 + mypy>=1,<2 build commands = black --check src tests conftest.py --exclude tests/test_functions/background_load_error/main.py From 0a3f09e6695dbf9c350940fee1eaf15ea4632e84 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Fri, 13 Feb 2026 15:23:26 -0800 Subject: [PATCH 34/42] chore: configure dependabot to use fix commit type when updating depedencies (#411) Today, release-please doesn't trigger a release for commits with chore: commit type. We'd like to cut a release when updating dependencies that impacts the packaged artifact, so we'll instruct dependabot to use fix commit type instead of chore(deps) it uses today. --- .github/renovate.json | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/.github/renovate.json b/.github/renovate.json index 32ac90d6..f7ad311a 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,15 +1,29 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["group:allNonMajor", "schedule:monthly"], + "extends": [ + "group:allNonMajor", + "schedule:monthly" + ], + "semanticCommits": "enabled", "packageRules": [ { "description": "Create a PR whenever there is a new major version", - "matchUpdateTypes": [ - "major" - ] + "matchUpdateTypes": ["major"] + }, + { + "description": "Use releasable commit type for runtime dependency updates", + "matchManagers": ["pep621", "pip_setup"], + "matchDepTypes": ["dependencies"], + "semanticCommitType": "fix", + "semanticCommitScope": "deps" + }, + { + "description": "Keep development-only dependency updates non-releasable", + "matchManagers": ["pep621"], + "matchDepTypes": ["dependency-groups"], + "semanticCommitType": "chore", + "semanticCommitScope": "deps" } ], - "ignorePaths": [ - "examples/**" - ] + "ignorePaths": ["examples/**"] } From f5fd4ddd55bb4b11fd93abff389f3035261330fb Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Tue, 17 Feb 2026 18:41:29 +0000 Subject: [PATCH 35/42] chore(deps): update actions/setup-go action to v6 (#410) Co-authored-by: Daniel Lee --- .github/workflows/conformance-asgi.yml | 2 +- .github/workflows/conformance.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/conformance-asgi.yml b/.github/workflows/conformance-asgi.yml index ee568faa..a67d8c42 100644 --- a/.github/workflows/conformance-asgi.yml +++ b/.github/workflows/conformance-asgi.yml @@ -43,7 +43,7 @@ jobs: run: python -m pip install -e . - name: Setup Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: go-version: '1.25' diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 6c14136e..9604ebe9 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -46,7 +46,7 @@ jobs: run: python -m pip install -e . - name: Setup Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: go-version: '1.25' From e90a9890a0c9b9affc768b7e631e68fd05944c4f Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Tue, 17 Feb 2026 19:06:25 +0000 Subject: [PATCH 36/42] chore(deps): update actions/checkout action to v6 (#408) --- .github/workflows/codeql.yml | 2 +- .github/workflows/conformance-asgi.yml | 2 +- .github/workflows/conformance.yml | 2 +- .github/workflows/dependency-review.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/scorecard.yml | 2 +- .github/workflows/unit.yml | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e5e575fb..c3114a39 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -54,7 +54,7 @@ jobs: release-assets.githubusercontent.com:443 - name: Checkout repository - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/conformance-asgi.yml b/.github/workflows/conformance-asgi.yml index a67d8c42..453ec9e3 100644 --- a/.github/workflows/conformance-asgi.yml +++ b/.github/workflows/conformance-asgi.yml @@ -32,7 +32,7 @@ jobs: release-assets.githubusercontent.com:443 - name: Checkout code - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 9604ebe9..a40d7325 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -35,7 +35,7 @@ jobs: release-assets.githubusercontent.com:443 - name: Checkout code - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index efa405f8..0362f060 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -25,6 +25,6 @@ jobs: api.github.com:443 github.com:443 - name: 'Checkout Repository' - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: 'Dependency Review' uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e9088c6a..0704d917 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -21,7 +21,7 @@ jobs: github.com:443 pypi.org:443 - - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - name: Install tox diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c209a7ed..18a4bc6e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.release.tag_name }} - name: Install Python diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 6fdac794..3688db76 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -47,7 +47,7 @@ jobs: - name: "Checkout code" - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 96a880d2..37d8f81f 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -49,7 +49,7 @@ jobs: release-assets.githubusercontent.com:443 - name: Checkout - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Use Python ${{ matrix.python }} uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: From 80fdae75cdb2d4d389a17f690a1ab8401b8d3916 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:29:55 -0800 Subject: [PATCH 37/42] chore(main): release 3.10.1 (#406) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b817ff60..14e609c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ 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.10.1](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.10.0...v3.10.1) (2026-02-17) + + +### Bug Fixes + +* Correct cloudevents dependency to allow 1.11.0 ([#405](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/405)) ([8d74a7b](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/8d74a7b9ecead58df77208d6e4717419a9aa9447)) +* pin lint tool versions to avoid surprise breakages ([#413](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/413)) ([43e63f6](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/43e63f6f847d89eeb018add028fb6222f2fac38c)) + ## [3.10.0](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.9.2...v3.10.0) (2025-11-10) diff --git a/pyproject.toml b/pyproject.toml index 6d98287c..972e7e52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "functions-framework" -version = "3.10.0" +version = "3.10.1" 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 28d0ea11..db6479ea 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="3.10.0", + version="3.10.1", 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", From 6de54973227faab57429d3fcfa33ced4df6c7b65 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Tue, 17 Feb 2026 20:37:00 +0000 Subject: [PATCH 38/42] chore(deps): update all non-major dependencies (#407) --- .github/workflows/codeql.yml | 8 ++++---- .github/workflows/conformance-asgi.yml | 14 +++++++------- .github/workflows/conformance.yml | 18 +++++++++--------- .github/workflows/dependency-review.yml | 4 ++-- .github/workflows/lint.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/scorecard.yml | 4 ++-- .github/workflows/unit.yml | 2 +- 8 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c3114a39..fe4c7c54 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@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: disable-sudo: true egress-policy: block @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@5d5cd550d3e189c569da8f16ea8de2d821c9bf7a # v3.31.2 + uses: github/codeql-action/init@f5c2471be782132e47a6e6f9c725e56730d6e9a3 # v3.32.3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -68,7 +68,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@5d5cd550d3e189c569da8f16ea8de2d821c9bf7a # v3.31.2 + uses: github/codeql-action/autobuild@f5c2471be782132e47a6e6f9c725e56730d6e9a3 # v3.32.3 # â„šī¸ 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 @@ -81,6 +81,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@5d5cd550d3e189c569da8f16ea8de2d821c9bf7a # v3.31.2 + uses: github/codeql-action/analyze@f5c2471be782132e47a6e6f9c725e56730d6e9a3 # v3.32.3 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/conformance-asgi.yml b/.github/workflows/conformance-asgi.yml index 453ec9e3..ccb10a5c 100644 --- a/.github/workflows/conformance-asgi.yml +++ b/.github/workflows/conformance-asgi.yml @@ -17,7 +17,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: disable-sudo: true egress-policy: block @@ -45,10 +45,10 @@ jobs: - name: Setup Go uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: - go-version: '1.25' + go-version: '1.26' - name: Run HTTP conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: functionType: 'http' useBuildpacks: false @@ -57,7 +57,7 @@ jobs: startDelay: 5 - name: Run CloudEvents conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: functionType: 'cloudevent' useBuildpacks: false @@ -66,7 +66,7 @@ jobs: startDelay: 5 - name: Run HTTP conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: functionType: 'http' useBuildpacks: false @@ -75,7 +75,7 @@ jobs: startDelay: 5 - name: Run CloudEvents conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: functionType: 'cloudevent' useBuildpacks: false @@ -84,7 +84,7 @@ jobs: startDelay: 5 - name: Run HTTP concurrency tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: functionType: 'http' useBuildpacks: false diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index a40d7325..92f084c9 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -20,7 +20,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: disable-sudo: true egress-policy: block @@ -48,10 +48,10 @@ jobs: - name: Setup Go uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: - go-version: '1.25' + go-version: '1.26' - name: Run HTTP conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: functionType: 'http' useBuildpacks: false @@ -59,7 +59,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http --signature-type http'" - name: Run event conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: functionType: 'legacyevent' useBuildpacks: false @@ -67,7 +67,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_legacy_event --signature-type event'" - name: Run CloudEvents conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: functionType: 'cloudevent' useBuildpacks: false @@ -75,7 +75,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_cloud_event --signature-type cloudevent'" - name: Run HTTP conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: functionType: 'http' useBuildpacks: false @@ -83,7 +83,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative'" - name: Run CloudEvents conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: functionType: 'cloudevent' useBuildpacks: false @@ -91,7 +91,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_cloud_event_declarative'" - name: Run HTTP concurrency tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: functionType: 'http' useBuildpacks: false @@ -99,7 +99,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative_concurrent'" - name: Run Typed tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: functionType: 'http' declarativeType: 'typed' diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 0362f060..5dbfda76 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@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: disable-sudo: true egress-policy: block @@ -27,4 +27,4 @@ jobs: - name: 'Checkout Repository' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: 'Dependency Review' - uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1 + uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0704d917..6ca5a4ac 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@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: disable-sudo: true egress-policy: block diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 18a4bc6e..2caddcba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 3688db76..412aedc9 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@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: disable-sudo: true egress-policy: block @@ -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@5d5cd550d3e189c569da8f16ea8de2d821c9bf7a # v3.31.2 + uses: github/codeql-action/upload-sarif@f5c2471be782132e47a6e6f9c725e56730d6e9a3 # v3.32.3 with: sarif_file: results.sarif diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 37d8f81f..c94865e2 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -33,7 +33,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: disable-sudo: true egress-policy: block From b41ee77d6fb61a9e0a76f17d561a221e50fe788a Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 17 Feb 2026 15:23:16 -0800 Subject: [PATCH 39/42] fix: remove macos-13 from test matrix (runner retired) (#414) --- .github/workflows/unit.yml | 2 -- tox.ini | 1 - 2 files changed, 3 deletions(-) diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index c94865e2..adab381d 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -26,8 +26,6 @@ jobs: include: - platform: macos-latest python: '3.9' - - platform: macos-13 - python: '3.8' - platform: ubuntu-22.04 python: '3.8' runs-on: ${{ matrix.platform }} diff --git a/tox.ini b/tox.ini index 6b866c51..0e69c33d 100644 --- a/tox.ini +++ b/tox.ini @@ -14,7 +14,6 @@ envlist = py39-macos-13 py39-windows-latest py38-ubuntu-22.04 - py38-macos-13 py38-windows-latest [testenv] From 715ba9a4a8075eed73d78c28209763a7eb67f01f Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Wed, 17 Jun 2026 10:57:21 -0700 Subject: [PATCH 40/42] fix(ci): update allowed endpoints for harden-runner (#427) --- .github/workflows/dependency-review.yml | 2 ++ .github/workflows/unit.yml | 1 + 2 files changed, 3 insertions(+) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 5dbfda76..bcf29c0b 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -22,7 +22,9 @@ jobs: disable-sudo: true egress-policy: block allowed-endpoints: > + api.deps.dev:443 api.github.com:443 + api.securityscorecards.dev:443 github.com:443 - name: 'Checkout Repository' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index adab381d..2901f5f3 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -42,6 +42,7 @@ jobs: github.com:443 objects.githubusercontent.com:443 production.cloudflare.docker.com:443 + production.cloudfront.docker.com:443 pypi.org:443 registry-1.docker.io:443 release-assets.githubusercontent.com:443 From c6501715bb57348ead817ee90c18622b3c1c31ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christopher=20M=C3=BCller?= <63948181+SpielerNogard@users.noreply.github.com> Date: Wed, 17 Jun 2026 20:13:27 +0200 Subject: [PATCH 41/42] fix(deps): bump starlette to >=1.0.1 on Python 3.10+ to fix PYSEC-2026-161 (#423) Starlette <=1.0.0 is vulnerable to a missing Host header validation that poisons request.url.path and bypasses path-based security checks (GHSA-86qp-5c8j-p5mr / PYSEC-2026-161). The fix only landed in 1.0.1, which requires Python >=3.10. Constraint is split by interpreter version so Python 3.8/3.9 users keep the existing 0.x line (no upstream fix available) while Python 3.10+ pulls the patched 1.x line. Co-authored-by: Daniel Lee --- pyproject.toml | 3 ++- setup.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 972e7e52..04baad0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,8 @@ dependencies = [ "gunicorn>=22.0.0; platform_system!='Windows'", "cloudevents>=1.11.0,<=1.12.0", # Must support python 3.8 "Werkzeug>=0.14,<4.0.0", - "starlette>=0.37.0,<1.0.0; python_version>='3.8'", + "starlette>=0.37.0,<1.0.0; python_version>='3.8' and python_version<'3.10'", + "starlette>=1.0.1,<2.0.0; python_version>='3.10'", "uvicorn>=0.18.0,<1.0.0; python_version>='3.8'", "uvicorn-worker>=0.2.0,<1.0.0; python_version>='3.8'", ] diff --git a/setup.py b/setup.py index db6479ea..41244a6c 100644 --- a/setup.py +++ b/setup.py @@ -58,7 +58,10 @@ "Werkzeug>=0.14,<4.0.0", ], extras_require={ - "async": ["starlette>=0.37.0,<1.0.0"], + "async": [ + "starlette>=0.37.0,<1.0.0; python_version<'3.10'", + "starlette>=1.0.1,<2.0.0; python_version>='3.10'", + ], }, entry_points={ "console_scripts": [ From d13d9aa4666b553e0e435813ab799c6f43e96bd8 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:17:02 -0700 Subject: [PATCH 42/42] chore(main): release 3.10.2 (#417) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14e609c4..20d13326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ 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.10.2](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.10.1...v3.10.2) (2026-06-17) + + +### Bug Fixes + +* **ci:** update allowed endpoints for harden-runner ([#427](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/427)) ([715ba9a](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/715ba9a4a8075eed73d78c28209763a7eb67f01f)) +* **deps:** bump starlette to >=1.0.1 on Python 3.10+ to fix PYSEC-2026-161 ([#423](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/423)) ([c650171](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/c6501715bb57348ead817ee90c18622b3c1c31ab)) +* remove macos-13 from test matrix (runner retired) ([#414](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/414)) ([b41ee77](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/b41ee77d6fb61a9e0a76f17d561a221e50fe788a)) + ## [3.10.1](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.10.0...v3.10.1) (2026-02-17) diff --git a/pyproject.toml b/pyproject.toml index 04baad0e..cb6549fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "functions-framework" -version = "3.10.1" +version = "3.10.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." readme = "README.md" requires-python = ">=3.7, <4" diff --git a/setup.py b/setup.py index 41244a6c..10dfee0d 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="3.10.1", + version="3.10.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",