diff --git a/.github/blunderbuss.yml b/.github/blunderbuss.yml index 33581568..8b137891 100644 --- a/.github/blunderbuss.yml +++ b/.github/blunderbuss.yml @@ -1,9 +1 @@ -assign_prs: - - KaylaNguyen - - HKWinterhalter - - janell-chen -assign_issues: - - KaylaNguyen - - HKWinterhalter - - janell-chen 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/**"] } diff --git a/.github/workflows/buildpack-integration-test.yml b/.github/workflows/buildpack-integration-test.yml index 78c56996..2c028fa9 100644 --- a/.github/workflows/buildpack-integration-test.yml +++ b/.github/workflows/buildpack-integration-test.yml @@ -3,50 +3,73 @@ name: Buildpack Integration Test on: push: branches: - - master + - main + pull_request: workflow_dispatch: + # Runs every day on 12:00 AM PST + schedule: + - cron: "0 0 * * *" # Declare default permissions as read only. permissions: read-all jobs: - python37: - uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.0 + python38: + 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: 'python38' + builder-runtime-version: '3.8' start-delay: 5 - python38: - uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.0 + python39: + 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: 'python38' + builder-runtime: 'python39' + builder-runtime-version: '3.9' start-delay: 5 - python39: - uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.0 + python310: + 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: 'python39' + builder-runtime: 'python310' + builder-runtime-version: '3.10' start-delay: 5 - python310: - uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.0 + 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: 'python310' - start-delay: 5 \ No newline at end of file + 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/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 33614b00..fe4c7c54 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,10 +13,10 @@ name: "CodeQL" on: push: - branches: ["master"] + branches: ["main"] pull_request: # The branches below must be a subset of the branches above - branches: ["master"] + branches: ["main"] schedule: - cron: "0 0 * * 1" @@ -41,7 +41,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@128a63446a954579617e875aaab7d2978154e969 # v2.4.0 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: disable-sudo: true egress-policy: block @@ -51,13 +51,14 @@ jobs: github.com:443 pypi.org:443 objects.githubusercontent.com:443 + release-assets.githubusercontent.com:443 - name: Checkout repository - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@83f0fe6c4988d98a455712a27f0255212bba9bd4 # v2.3.6 + 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. @@ -67,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@83f0fe6c4988d98a455712a27f0255212bba9bd4 # v2.3.6 + 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 @@ -80,6 +81,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@83f0fe6c4988d98a455712a27f0255212bba9bd4 # v2.3.6 + 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 new file mode 100644 index 00000000..ccb10a5c --- /dev/null +++ b/.github/workflows/conformance-asgi.yml @@ -0,0 +1,97 @@ +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@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + 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 + release-assets.githubusercontent.com:443 + + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: ${{ matrix.python }} + + - name: Install the framework + run: python -m pip install -e . + + - name: Setup Go + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + with: + go-version: '1.26' + + - name: Run HTTP conformance tests + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 + with: + functionType: 'http' + 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@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 + with: + functionType: 'cloudevent' + 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@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 + with: + functionType: 'http' + 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@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 + with: + functionType: 'cloudevent' + 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@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 + with: + functionType: 'http' + 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 + # does not support automatic conversion from legacy events to CloudEvents \ No newline at end of file diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 5ea928c5..92f084c9 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -1,18 +1,26 @@ name: Python Conformance CI -on: [push, pull_request] +on: + push: + branches: + - 'main' + pull_request: # Declare default permissions as read only. permissions: read-all jobs: build: - runs-on: ubuntu-latest strategy: matrix: - python: ['3.7', '3.8', '3.9', '3.10', '3.11'] + python: ['3.9', '3.10', '3.11', '3.12'] + platform: [ubuntu-latest] + include: + - platform: ubuntu-22.04 + python: '3.8' + runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@128a63446a954579617e875aaab7d2978154e969 # v2.4.0 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: disable-sudo: true egress-policy: block @@ -24,12 +32,13 @@ jobs: proxy.golang.org:443 pypi.org:443 storage.googleapis.com:443 + release-assets.githubusercontent.com:443 - name: Checkout code - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Python - uses: actions/setup-python@bd6b4b6205c4dbad673328db7b31b7fab9e241c0 # v4.6.1 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python }} @@ -37,69 +46,63 @@ jobs: run: python -m pip install -e . - name: Setup Go - uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1 + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: - go-version: '1.16' + go-version: '1.26' - name: Run HTTP conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@1975792fb34ebbfa058d690666186d669d3a5977 # v1.8.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: - version: 'v1.6.0' functionType: 'http' useBuildpacks: false validateMapping: false 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@1975792fb34ebbfa058d690666186d669d3a5977 # v1.8.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: - version: 'v1.6.0' functionType: 'legacyevent' useBuildpacks: false validateMapping: true 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@1975792fb34ebbfa058d690666186d669d3a5977 # v1.8.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: - version: 'v1.6.0' functionType: 'cloudevent' useBuildpacks: false validateMapping: true 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@1975792fb34ebbfa058d690666186d669d3a5977 # v1.8.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: - version: 'v1.6.0' functionType: 'http' useBuildpacks: false validateMapping: false cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative'" - name: Run CloudEvents conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@1975792fb34ebbfa058d690666186d669d3a5977 # v1.8.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: - version: 'v1.6.0' functionType: 'cloudevent' useBuildpacks: false validateMapping: true 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@1975792fb34ebbfa058d690666186d669d3a5977 # v1.8.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: - version: 'v1.6.0' functionType: 'http' useBuildpacks: false validateConcurrency: true cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative_concurrent'" - name: Run Typed tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@1975792fb34ebbfa058d690666186d669d3a5977 # v1.8.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: - version: 'v1.6.0' functionType: 'http' + declarativeType: 'typed' useBuildpacks: false validateMapping: false - cmd: "'functions-framework --source tests/conformance/main.py --target write_typed_event_declarative'" + cmd: "'functions-framework --source tests/conformance/main.py --target typed_conformance_test'" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index ee6bdbec..bcf29c0b 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,14 +17,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@128a63446a954579617e875aaab7d2978154e969 # v2.4.0 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: 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@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: 'Dependency Review' - uses: actions/dependency-review-action@1360a344ccb0ab6e9475edef90ad2f46bf8003b1 # v3.0.6 + uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1c0abd0f..6ca5a4ac 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,5 +1,9 @@ name: Python Lint CI -on: [push, pull_request] +on: + push: + branches: + - main + pull_request: permissions: contents: read @@ -8,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@128a63446a954579617e875aaab7d2978154e969 # v2.4.0 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: disable-sudo: true egress-policy: block @@ -17,9 +21,9 @@ jobs: github.com:443 pypi.org:443 - - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Python - uses: actions/setup-python@bd6b4b6205c4dbad673328db7b31b7fab9e241c0 # v4.6.1 + 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 fd5ccc6a..2caddcba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,22 +13,22 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@128a63446a954579617e875aaab7d2978154e969 # v2.4.0 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.release.tag_name }} - name: Install Python - uses: actions/setup-python@bd6b4b6205c4dbad673328db7b31b7fab9e241c0 # v4.6.1 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - name: Install build dependencies run: python -m pip install -U setuptools build wheel - name: Build distributions run: python -m build - name: Publish - uses: pypa/gh-action-pypi-publish@110f54a3871763056757c3e203635d4c5711439f # master + uses: pypa/gh-action-pypi-publish@e9ccbe5a211ba3e8363f472cae362b56b104e796 # main with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index e8b1f04c..412aedc9 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -8,7 +8,7 @@ on: schedule: - cron: '0 */12 * * *' push: - branches: [ "master" ] + branches: [ "main" ] workflow_dispatch: # Declare default permissions as read only. @@ -26,30 +26,33 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@128a63446a954579617e875aaab7d2978154e969 # v2.4.0 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: disable-sudo: true egress-policy: block allowed-endpoints: > api.github.com:443 api.osv.dev:443 + api.scorecard.dev:443 api.securityscorecards.dev:443 auth.docker.io:443 bestpractices.coreinfrastructure.org:443 - fulcio.sigstore.dev:443 + bestpractices.dev:443 + www.bestpractices.dev:443 github.com:443 index.docker.io:443 oss-fuzz-build-logs.storage.googleapis.com:443 sigstore-tuf-root.storage.googleapis.com:443 - rekor.sigstore.dev:443 + *.sigstore.dev:443 + - name: "Checkout code" - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@80e868c13c90f172d68d1f4501dee99e2479f7af # v2.1.3 + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: results_file: results.sarif results_format: sarif @@ -61,6 +64,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@83f0fe6c4988d98a455712a27f0255212bba9bd4 # v2.3.6 + 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 0aeb0e7c..2901f5f3 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -1,5 +1,9 @@ name: Python Unit CI -on: [push, pull_request] +on: + push: + branches: + - main + pull_request: permissions: contents: read @@ -7,30 +11,53 @@ jobs: test: strategy: matrix: - python: ['3.7', '3.8', '3.9', '3.10', '3.11'] + 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 + # Python <= 3.8 is not available on ubuntu-latest + exclude: + - platform: macos-latest + python: '3.9' + - platform: macos-latest + python: '3.8' + - platform: ubuntu-latest + python: '3.8' + include: + - platform: macos-latest + python: '3.9' + - platform: ubuntu-22.04 + python: '3.8' runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@128a63446a954579617e875aaab7d2978154e969 # v2.4.0 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: disable-sudo: true egress-policy: block 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 + production.cloudfront.docker.com:443 pypi.org:443 registry-1.docker.io:443 + release-assets.githubusercontent.com:443 - name: Checkout - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Use Python ${{ matrix.python }} - uses: actions/setup-python@bd6b4b6205c4dbad673328db7b31b7fab9e241c0 # v4.6.1 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python }} - 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 }} 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/CHANGELOG.md b/CHANGELOG.md index 9016fc49..20d13326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,153 @@ 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) + + +### 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) + + +### 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) + + +### 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) + + +### 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) + + +### 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) + + +### 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) + + +### Bug Fixes + +* remove unused import ([#349](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/349)) ([d5ac3d8](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/d5ac3d8d01fdb71f7454a0433e586f1eb4a0f6fe)) + +## [3.8.1](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.8.0...v3.8.1) (2024-07-26) + + +### Bug Fixes + +* upgrade gunicorn to 22 to fix CVE-2024-1135 ([#341](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/341)) ([d622f13](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/d622f137e8a2419fc487c867d67e12d0204b586b)) + +## [3.8.0](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.7.0...v3.8.0) (2024-06-25) + + +### Features + +* Set default logging level to align with Flask's defaults ([#336](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/336)) ([d1d0753](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/d1d0753b6ea0dcc4222e28fc61002ac563b54cac)) + + +### Bug Fixes + +* add www.bestpractices.dev:443 to scorecard ([#330](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/330)) ([02472e7](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/02472e7315d0fd642db26441b3cb21f799906739)) + +## [3.7.0](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.6.0...v3.7.0) (2024-05-17) + + +### Features + +* (opt-in): terminate handling of work when the request has already timed out ([#328](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/328)) ([2601975](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/2601975285386fc573de8033381edc99527ef3c9)) +* Add execution id ([#320](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/320)) ([662bf4c](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/662bf4ced9aa52efe774662a0f0f496d3d3534fc)) +* restore gunicorn worker default configs from 3.5.0 ([#326](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/326)) ([f08757a](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/f08757a17267d768e4c3ca4c6979f2a7db25e83c)) +* support disabling execution id logging ([#325](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/325)) ([2e7de92](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/2e7de92e5f9cd83f01222eb06385d66fe0211777)) + + +### Bug Fixes + +* update scorecard.yml ([#327](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/327)) ([fff38ae](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/fff38ae6ecb1054bad676900216663050e6edf10)) +* Update scorecard.yml ([#329](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/329)) ([04c1fdc](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/04c1fdc4185b8a97eb46d72b6432d32d5d70dffc)) + +## [3.6.0](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.5.0...v3.6.0) (2024-04-29) + + +### Features + +* avoid long running process when request timeout ([#309](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/309)) ([3a0cdb8](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/3a0cdb8ba1065510d8ca0f2bcabd484cd3ffaaf6)) + +## [3.5.0](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.4.0...v3.5.0) (2023-11-28) + + +### Features + +* initial typing of the public API ([#248](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/248)) ([45aed53](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/45aed538b5e39655318c7841457399fa3376ceaf)) + + +### Bug Fixes + +* don't exit on reload if there is a syntax error ([#214](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/214)) ([46780da](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/46780dac88c8dfe715babe89f792d08e9ca482e7)) +* reduce gunicorn concurrency to at most 4 * maximum available cor… ([#259](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/259)) ([2e04cc2](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/2e04cc28ae028b8facc85dbdf738e2b8076dbbf7)) + + +### Documentation + +* Fix broken Flask Request link in README.md ([#286](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/286)) ([6b9e9b5](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/6b9e9b56f2f364e9a19fd434d88a0fbe22808515)) + +### Dependencies + +* Include support for Flask 3 + ## [3.4.0](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.3.0...v3.4.0) (2023-05-24) 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. diff --git a/README.md b/README.md index ce094471..d564ad92 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,8 @@ Python functions -- brought to you by the Google Cloud Functions team. The Functions Framework lets you write lightweight functions that run in many different environments, including: -* [Google Cloud Functions](https://cloud.google.com/functions/) +* [Google Cloud Run Functions](https://cloud.google.com/functions/) * Your local development machine -* [Cloud Run and Cloud Run for Anthos](https://cloud.google.com/run/) * [Knative](https://github.com/knative/)-based environments The framework allows you to go from: @@ -59,14 +58,15 @@ functions-framework==3.* Create an `main.py` file with the following contents: ```python +import flask import functions_framework @functions_framework.http -def hello(request): +def hello(request: flask.Request) -> flask.typing.ResponseReturnValue: return "Hello world!" ``` -> Your function is passed a single parameter, `(request)`, which is a Flask [`Request`](http://flask.pocoo.org/docs/1.0/api/#flask.Request) object. +> Your function is passed a single parameter, `(request)`, which is a Flask [`Request`](https://flask.palletsprojects.com/en/3.0.x/api/#flask.Request) object. Run the following command: @@ -98,13 +98,14 @@ Create an `main.py` file with the following contents: ```python import functions_framework +from cloudevents.http.event import CloudEvent @functions_framework.cloud_event -def hello_cloud_event(cloud_event): +def hello_cloud_event(cloud_event: CloudEvent) -> None: print(f"Received event with ID: {cloud_event['id']} and data {cloud_event.data}") ``` -> Your function is passed a single [CloudEvent](https://github.com/cloudevents/sdk-python/blob/master/cloudevents/sdk/event/v1.py) parameter. +> Your function is passed a single [CloudEvent](https://github.com/cloudevents/sdk-python/blob/main/cloudevents/sdk/event/v1.py) parameter. Run the following command to run `hello_cloud_event` target locally: @@ -292,7 +293,7 @@ https://cloud.google.com/functions/docs/tutorials/pubsub#functions_helloworld_pu ## Run your function on serverless platforms -### Google Cloud Functions +### Google Cloud Run functions This Functions Framework is based on the [Python Runtime on Google Cloud Functions](https://cloud.google.com/functions/docs/concepts/python-runtime). @@ -300,12 +301,6 @@ On Cloud Functions, using the Functions Framework is not necessary: you don't ne After you've written your function, you can simply deploy it from your local machine using the `gcloud` command-line tool. [Check out the Cloud Functions quickstart](https://cloud.google.com/functions/docs/quickstart). -### Cloud Run/Cloud Run on GKE - -Once you've written your function and added the Functions Framework to your `requirements.txt` file, all that's left is to create a container image. [Check out the Cloud Run quickstart](https://cloud.google.com/run/docs/quickstarts/build-and-deploy) for Python to create a container image and deploy it to Cloud Run. You'll write a `Dockerfile` when you build your container. This `Dockerfile` allows you to specify exactly what goes into your container (including custom binaries, a specific operating system, and more). [Here is an example `Dockerfile` that calls Functions Framework.](https://github.com/GoogleCloudPlatform/functions-framework-python/blob/master/examples/cloud_run_http) - -If you want even more control over the environment, you can [deploy your container image to Cloud Run on GKE](https://cloud.google.com/run/docs/quickstarts/prebuilt-deploy-gke). With Cloud Run on GKE, you can run your function on a GKE cluster, which gives you additional control over the environment (including use of GPU-based instances, longer timeouts and more). - ### Container environments based on Knative Cloud Run and Cloud Run on GKE both implement the [Knative Serving API](https://www.knative.dev/docs/). The Functions Framework is designed to be compatible with Knative environments. Just build and deploy your container to a Knative environment. @@ -323,10 +318,10 @@ You can configure the Functions Framework using command-line flags or environmen | `--source` | `FUNCTION_SOURCE` | The path to the file containing your function. Default: `main.py` (in the current working directory) | | `--debug` | `DEBUG` | A flag that allows to run functions-framework to run in debug mode, including live reloading. Default: `False` | -## Enable Google Cloud Function Events +## Enable Google Cloud Run function Events The Functions Framework can unmarshall incoming -Google Cloud Functions [event](https://cloud.google.com/functions/docs/concepts/events-triggers#events) payloads to `event` and `context` objects. +Google Cloud Run functions [event](https://cloud.google.com/functions/docs/concepts/events-triggers#events) payloads to `event` and `context` objects. These will be passed as arguments to your function when it receives a request. Note that your function must use the `event`-style function signature: diff --git a/conftest.py b/conftest.py index 21572fda..257f60d4 100644 --- a/conftest.py +++ b/conftest.py @@ -42,3 +42,57 @@ 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, 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 + + +# 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/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_cloud_events/Dockerfile b/examples/cloud_run_cloud_events/Dockerfile index 1bae67aa..08f45150 100644 --- a/examples/cloud_run_cloud_events/Dockerfile +++ b/examples/cloud_run_cloud_events/Dockerfile @@ -12,7 +12,7 @@ COPY . . # Install production dependencies. RUN pip install gunicorn cloudevents functions-framework RUN pip install -r requirements.txt -RUN chmod +x send_cloudevent.py +RUN chmod +x send_cloud_event.py # Run the web service on container startup. CMD ["functions-framework", "--target=hello", "--signature-type=cloudevent"] 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_cloud_events/send_cloud_event.py b/examples/cloud_run_cloud_events/send_cloud_event.py index b523c31a..c08b8f93 100644 --- a/examples/cloud_run_cloud_events/send_cloud_event.py +++ b/examples/cloud_run_cloud_events/send_cloud_event.py @@ -13,9 +13,9 @@ # 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 cloudevents.http import CloudEvent, to_structured import requests +from cloudevents.http import CloudEvent, to_structured # Create a cloudevent using https://github.com/cloudevents/sdk-python # Note we only need source and type because the cloudevents constructor by 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/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 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 new file mode 100644 index 00000000..cb6549fd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,69 @@ +[project] +name = "functions-framework" +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" +# 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.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' 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'", +] + +[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" + +[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/setup.py b/setup.py index 9e8aedab..10dfee0d 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="3.4.0", + 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", @@ -33,29 +33,36 @@ author="Google LLC", author_email="googleapis-packages@google.com", classifiers=[ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable ", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", "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,<3.0", + "flask>=1.0,<4.0", "click>=7.0,<9.0", "watchdog>=1.0.0", - "gunicorn>=19.2.0,<21.0; platform_system!='Windows'", + "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; python_version<'3.10'", + "starlette>=1.0.1,<2.0.0; python_version>='3.10'", + ], + }, entry_points={ "console_scripts": [ "ff=functions_framework._cli:_cli", diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index d4575b57..31169f4c 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -17,21 +17,29 @@ import io import json import logging +import logging.config +import os import os.path import pathlib import sys import types from inspect import signature -from typing import Type +from typing import Callable, Type import cloudevents.exceptions as cloud_exceptions import flask import werkzeug from cloudevents.http import from_http, is_binary +from cloudevents.http.event import CloudEvent -from functions_framework import _function_registry, _typed_event, event_conversion +from functions_framework import ( + _function_registry, + _typed_event, + event_conversion, + execution_id, +) from functions_framework.background_event import BackgroundEvent from functions_framework.exceptions import ( EventConversionException, @@ -45,6 +53,9 @@ _CLOUDEVENT_MIME_TYPE = "application/cloudevents+json" +CloudEventFunction = Callable[[CloudEvent], None] +HTTPFunction = Callable[[flask.Request], flask.typing.ResponseReturnValue] + class _LoggingHandler(io.TextIOWrapper): """Logging replacement for stdout and stderr in GCF Python 3.7.""" @@ -59,11 +70,11 @@ def write(self, out): return self.stderr.write(json.dumps(payload) + "\n") -def cloud_event(func): +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 + _function_registry.REGISTRY_MAP[func.__name__] = ( + _function_registry.CLOUDEVENT_SIGNATURE_TYPE + ) @functools.wraps(func) def wrapper(*args, **kwargs): @@ -99,11 +110,11 @@ def wrapper(*args, **kwargs): return _typed -def http(func): +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 + _function_registry.REGISTRY_MAP[func.__name__] = ( + _function_registry.HTTP_SIGNATURE_TYPE + ) @functools.wraps(func) def wrapper(*args, **kwargs): @@ -125,6 +136,7 @@ def setup_logging(): def _http_view_func_wrapper(function, request): + @execution_id.set_execution_context(request, _enable_execution_id_logging()) @functools.wraps(function) def view_func(path): return function(request._get_current_object()) @@ -139,6 +151,7 @@ def _run_cloud_event(function, request): def _typed_event_func_wrapper(function, request, inputType: Type): + @execution_id.set_execution_context(request, _enable_execution_id_logging()) def view_func(path): try: data = request.get_json() @@ -159,6 +172,7 @@ def view_func(path): def _cloud_event_view_func_wrapper(function, request): + @execution_id.set_execution_context(request, _enable_execution_id_logging()) def view_func(path): ce_exception = None event = None @@ -194,6 +208,7 @@ def view_func(path): def _event_view_func_wrapper(function, request): + @execution_id.set_execution_context(request, _enable_execution_id_logging()) def view_func(path): if event_conversion.is_convertable_cloud_event(request): # Convert this CloudEvent to the equivalent background event data and context. @@ -312,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) @@ -328,6 +353,9 @@ def create_app(target=None, source=None, signature_type=None): source_module, spec = _function_registry.load_function_module(source) + if _enable_execution_id_logging(): + _configure_app_execution_id_logging() + # Create the application _app = flask.Flask(target, template_folder=template_folder) _app.register_error_handler(500, crash_handler) @@ -351,13 +379,51 @@ def handle_none(rv): sys.stderr = _LoggingHandler("ERROR", sys.stderr) setup_logging() + _app.wsgi_app = execution_id.WsgiMiddleware(_app.wsgi_app) + # Execute the module, within the application context with _app.app_context(): - spec.loader.exec_module(source_module) + try: + spec.loader.exec_module(source_module) + function = _function_registry.get_user_function( + source, source_module, target + ) + except Exception as e: + if werkzeug.serving.is_running_from_reloader(): + # When reloading, print out the error immediately, but raise + # it later so the debugger or server can handle it. + import traceback + + traceback.print_exc() + err = e + + def function(*_args, **_kwargs): + raise err from None + + else: + # When not reloading, raise the error immediately so the + # 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) - function = _function_registry.get_user_function(source, source_module, target) _configure_app(_app, function, signature_type) @@ -388,6 +454,29 @@ def __call__(self, *args, **kwargs): return self.app(*args, **kwargs) +def _configure_app_execution_id_logging(): + # Logging needs to be configured before app logger is accessed + logging.config.dictConfig( + { + "version": 1, + "handlers": { + "wsgi": { + "class": "logging.StreamHandler", + "stream": "ext://functions_framework.execution_id.logging_stream", + }, + }, + "root": {"level": "WARNING", "handlers": ["wsgi"]}, + } + ) + + +def _enable_execution_id_logging(): + # Based on distutils.util.strtobool + truthy_values = ("y", "yes", "t", "true", "on", "1") + env_var_value = os.environ.get("LOG_EXECUTION_ID") + return env_var_value in truthy_values + + app = LazyWSGIApp() diff --git a/src/functions_framework/_cli.py b/src/functions_framework/_cli.py index 773dd4cd..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 @@ -32,6 +32,17 @@ @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( + "--asgi", + envvar="FUNCTION_USE_ASGI", + is_flag=True, + help="Use ASGI server for function execution", +) +def _cli(target, source, signature_type, host, port, debug, asgi): + 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 f266ee82..1f08c794 100644 --- a/src/functions_framework/_function_registry.py +++ b/src/functions_framework/_function_registry.py @@ -16,7 +16,6 @@ import sys import types -from re import T from typing import Type from functions_framework.exceptions import ( @@ -41,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/_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 f522b67f..745ce2f8 100644 --- a/src/functions_framework/_http/gunicorn.py +++ b/src/functions_framework/_http/gunicorn.py @@ -1,4 +1,4 @@ -# Copyright 2020 Google LLC +# Copyright 2024 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,21 +12,81 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os + import gunicorn.app.base +from gunicorn.workers.gthread import ThreadWorker + +from ..request_timeout import ThreadingTimeout + +# global for use in our custom gthread worker; the gunicorn arbiter spawns these +# and it's not possible to inject (and self.timeout means something different to +# async workers!) +# set/managed in gunicorn application init for test-friendliness +TIMEOUT_SECONDS = None + class GunicornApplication(gunicorn.app.base.BaseApplication): + def __init__(self, app, host, port, debug, **options): + threads = int(os.environ.get("THREADS", (os.cpu_count() or 1) * 4)) + + global TIMEOUT_SECONDS + TIMEOUT_SECONDS = int(os.environ.get("CLOUD_RUN_TIMEOUT_SECONDS", 0)) + + self.options = { + "bind": "%s:%s" % (host, port), + "workers": int(os.environ.get("WORKERS", 1)), + "threads": threads, + "loglevel": os.environ.get("GUNICORN_LOG_LEVEL", "error"), + "limit_request_line": 0, + } + + if ( + TIMEOUT_SECONDS > 0 + and threads > 1 + and (os.environ.get("THREADED_TIMEOUT_ENABLED", "False").lower() == "true") + ): # pragma: no cover + self.options["worker_class"] = ( + "functions_framework._http.gunicorn.GThreadWorkerWithTimeoutSupport" + ) + else: + self.options["timeout"] = TIMEOUT_SECONDS + + 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 + + +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": 1, - "threads": 1024, - "timeout": 0, - "loglevel": "error", + "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): diff --git a/src/functions_framework/_typed_event.py b/src/functions_framework/_typed_event.py index 40e715ae..413c8f05 100644 --- a/src/functions_framework/_typed_event.py +++ b/src/functions_framework/_typed_event.py @@ -48,9 +48,9 @@ def register_typed_event(decorator_type, func): ) _function_registry.INPUT_TYPE_MAP[func.__name__] = input_type - _function_registry.REGISTRY_MAP[ - func.__name__ - ] = _function_registry.TYPED_SIGNATURE_TYPE + _function_registry.REGISTRY_MAP[func.__name__] = ( + _function_registry.TYPED_SIGNATURE_TYPE + ) """ Checks whether the response type of the typed function has a to_dict method""" diff --git a/src/functions_framework/aio/__init__.py b/src/functions_framework/aio/__init__.py new file mode 100644 index 00000000..a56fe942 --- /dev/null +++ b/src/functions_framework/aio/__init__.py @@ -0,0 +1,352 @@ +# 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 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 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, + _function_registry, + execution_id, +) +from functions_framework.exceptions import ( + FunctionsFrameworkException, + MissingSourceException, +) + +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 + ) + _function_registry.ASGI_FUNCTIONS.add(func.__name__) + 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 + ) + _function_registry.ASGI_FUNCTIONS.add(func.__name__) + + 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_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 + loop = asyncio.get_event_loop() + 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: + content, status_code = result + 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: + return result + + return handler + + +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() + + 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 + loop = asyncio.get_event_loop() + ctx = contextvars.copy_context() + await loop.run_in_executor(None, ctx.run, function, event) + return Response("OK") + + return handler + + +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": "WARNING", "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_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. + + 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) + + 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) + + 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: + http_handler = _http_func_wrapper(function, is_async, enable_id_logging) + 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}", + 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, enable_id_logging + ) + 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}'). " + ) + 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}" + ) + + app = Starlette( + routes=routes, + middleware=[ + Middleware(ExceptionHandlerMiddleware), + Middleware(execution_id.AsgiMiddleware), + ], + ) + + 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/src/functions_framework/event_conversion.py b/src/functions_framework/event_conversion.py index 06e5a812..0e67cdaa 100644 --- a/src/functions_framework/event_conversion.py +++ b/src/functions_framework/event_conversion.py @@ -27,7 +27,7 @@ # Maps background/legacy event types to their equivalent CloudEvent types. # For more info on event mappings see -# https://github.com/GoogleCloudPlatform/functions-framework-conformance/blob/master/docs/mapping.md +# https://github.com/GoogleCloudPlatform/functions-framework-conformance/blob/main/docs/mapping.md _BACKGROUND_TO_CE_TYPE = { "google.pubsub.topic.publish": "google.cloud.pubsub.topic.v1.messagePublished", "providers/cloud.pubsub/eventTypes/topic.publish": "google.cloud.pubsub.topic.v1.messagePublished", diff --git a/src/functions_framework/exceptions.py b/src/functions_framework/exceptions.py index 671a28a4..81b9f8f0 100644 --- a/src/functions_framework/exceptions.py +++ b/src/functions_framework/exceptions.py @@ -1,4 +1,4 @@ -# Copyright 2020 Google LLC +# Copyright 2024 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -35,3 +35,7 @@ class MissingTargetException(FunctionsFrameworkException): class EventConversionException(FunctionsFrameworkException): pass + + +class RequestTimeoutException(FunctionsFrameworkException): + pass diff --git a/src/functions_framework/execution_id.py b/src/functions_framework/execution_id.py new file mode 100644 index 00000000..df412187 --- /dev/null +++ b/src/functions_framework/execution_id.py @@ -0,0 +1,261 @@ +# 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. + +import contextlib +import contextvars +import functools +import inspect +import io +import json +import logging +import random +import re +import string +import sys + +import flask + +from werkzeug.local import LocalProxy + +_EXECUTION_ID_LENGTH = 12 +_EXECUTION_ID_CHARSET = string.digits + string.ascii_letters +_LOGGING_API_LABELS_FIELD = "logging.googleapis.com/labels" +_LOGGING_API_SPAN_ID_FIELD = "logging.googleapis.com/spanId" +_TRACE_CONTEXT_REGEX_PATTERN = re.compile( + r"^(?P[\w\d]+)/(?P\d+);o=(?P[01])$" +) +EXECUTION_ID_REQUEST_HEADER = "Function-Execution-Id" +TRACE_CONTEXT_REQUEST_HEADER = "X-Cloud-Trace-Context" + +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): + self.execution_id = execution_id + self.span_id = span_id + + +def _get_current_context(): + 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 + ) + + +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 + + +def _generate_execution_id(): + return "".join( + _EXECUTION_ID_CHARSET[random.randrange(len(_EXECUTION_ID_CHARSET))] + for _ in range(_EXECUTION_ID_LENGTH) + ) + + +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): + self.wsgi_app = wsgi_app + + def __call__(self, environ, start_response): + execution_id = ( + environ.get("HTTP_FUNCTION_EXECUTION_ID") or _generate_execution_id() + ) + environ["HTTP_FUNCTION_EXECUTION_ID"] = execution_id + return self.wsgi_app(environ, start_response) + + +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) + ) + stderr_redirect = contextlib.redirect_stderr( + LoggingHandlerAddExecutionId(sys.stderr) + ) + else: + stdout_redirect = contextlib.nullcontext() + stderr_redirect = contextlib.nullcontext() + + def decorator(view_function): + @functools.wraps(view_function) + def wrapper(*args, **kwargs): + context = _extract_context_from_headers(request.headers) + _set_current_context(context) + + with stderr_redirect, stdout_redirect: + 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) + + +class LoggingHandlerAddExecutionId(io.TextIOWrapper): + def __new__(cls, stream=sys.stdout): + if isinstance(stream, LoggingHandlerAddExecutionId): + return stream + else: + return super(LoggingHandlerAddExecutionId, cls).__new__(cls) + + def __init__(self, stream=sys.stdout): + io.TextIOWrapper.__init__(self, io.StringIO()) + self.stream = stream + + def write(self, contents): + if contents == "\n": + return + current_context = _get_current_context() + if current_context is None: + self.stream.write(contents + "\n") + self.stream.flush() + return + try: + execution_id = current_context.execution_id + span_id = current_context.span_id + payload = json.loads(contents) + if not isinstance(payload, dict): + payload = {"message": contents} + except json.JSONDecodeError: + if len(contents) > 0 and contents[-1] == "\n": + contents = contents[:-1] + payload = {"message": contents} + if execution_id: + payload[_LOGGING_API_LABELS_FIELD] = payload.get( + _LOGGING_API_LABELS_FIELD, {} + ) + payload[_LOGGING_API_LABELS_FIELD]["execution_id"] = execution_id + if span_id: + payload[_LOGGING_API_SPAN_ID_FIELD] = span_id + self.stream.write(json.dumps(payload)) + self.stream.write("\n") + self.stream.flush() diff --git a/src/functions_framework/py.typed b/src/functions_framework/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/functions_framework/request_timeout.py b/src/functions_framework/request_timeout.py new file mode 100644 index 00000000..ed34f66e --- /dev/null +++ b/src/functions_framework/request_timeout.py @@ -0,0 +1,42 @@ +import ctypes +import logging +import threading + +from .exceptions import RequestTimeoutException + +logger = logging.getLogger(__name__) + + +class ThreadingTimeout(object): # pragma: no cover + def __init__(self, seconds): + self.seconds = seconds + self.target_tid = threading.current_thread().ident + self.timer = None + + def __enter__(self): + self.timer = threading.Timer(self.seconds, self._raise_exc) + self.timer.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.timer.cancel() + if exc_type is RequestTimeoutException: + logger.warning( + "Request handling exceeded {0} seconds timeout; terminating request handling...".format( + self.seconds + ), + exc_info=(exc_type, exc_val, exc_tb), + ) + return False + + def _raise_exc(self): + ret = ctypes.pythonapi.PyThreadState_SetAsyncExc( + ctypes.c_long(self.target_tid), ctypes.py_object(RequestTimeoutException) + ) + if ret == 0: + raise ValueError("Invalid thread ID {}".format(self.target_tid)) + elif ret > 1: + ctypes.pythonapi.PyThreadState_SetAsyncExc( + ctypes.c_long(self.target_tid), None + ) + raise SystemError("PyThreadState_SetAsyncExc failed") 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/conformance/main.py b/tests/conformance/main.py index 057edaa7..4f164d62 100644 --- a/tests/conformance/main.py +++ b/tests/conformance/main.py @@ -8,15 +8,18 @@ filename = "function_output.json" -class ConformanceType: - json_request: str +class RawJson: + data: dict - def __init__(self, json_request: str) -> None: - self.json_request = json_request + def __init__(self, data): + self.data = data @staticmethod - def from_dict(obj: dict) -> "ConformanceType": - return ConformanceType(json.dumps(obj)) + def from_dict(obj: dict) -> "RawJson": + return RawJson(obj) + + def to_dict(self) -> dict: + return self.data def _write_output(content): @@ -66,7 +69,6 @@ def write_http_declarative_concurrent(request): return "OK", 200 -@functions_framework.typed(ConformanceType) -def write_typed_event_declarative(x): - _write_output(x.json_request) - return "OK" +@functions_framework.typed(RawJson) +def typed_conformance_test(x): + return RawJson({"payload": x.data}) diff --git a/tests/conformance/prerun.sh b/tests/conformance/prerun.sh index b46f0b51..c37fe62b 100755 --- a/tests/conformance/prerun.sh +++ b/tests/conformance/prerun.sh @@ -7,15 +7,14 @@ set -e FRAMEWORK_VERSION=$1 -if [ -z "${FRAMEWORK_VERSION}" ] - then - echo "Functions Framework version required as first parameter" - exit 1 +if [ -z "${FRAMEWORK_VERSION}" ]; then + echo "Functions Framework version required as first parameter" + exit 1 fi SCRIPT_DIR=$(realpath $(dirname $0)) cd $SCRIPT_DIR -echo "git+https://github.com/GoogleCloudPlatform/functions-framework-python@$FRAMEWORK_VERSION#egg=functions-framework" > requirements.txt -cat requirements.txt \ No newline at end of file +echo "git+https://github.com/GoogleCloudPlatform/functions-framework-python@$FRAMEWORK_VERSION#egg=functions-framework" >requirements.txt +cat requirements.txt diff --git a/tests/test_aio.py b/tests/test_aio.py new file mode 100644 index 00000000..e7533b1d --- /dev/null +++ b/tests/test_aio.py @@ -0,0 +1,171 @@ +# 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_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() + request.headers = Mock() + request.headers.get = Mock(return_value="") + 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() + request.headers = Mock() + request.headers.get = Mock(return_value="") + 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_asgi.py b/tests/test_asgi.py new file mode 100644 index 00000000..e5b97e60 --- /dev/null +++ b/tests/test_asgi.py @@ -0,0 +1,117 @@ +# 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 + +from starlette.applications import Starlette + +import functions_framework._http + + +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..75c93f20 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,15 +12,40 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os +import pathlib +import sys + import pretend import pytest 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() @@ -103,3 +128,38 @@ 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", "--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)] + + +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_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..3a6e5e99 100644 --- a/tests/test_decorator_functions.py +++ b/tests/test_decorator_functions.py @@ -12,15 +12,46 @@ # 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 + +import functions_framework._function_registry as registry + +# 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" + +@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 @@ -28,18 +59,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 +93,85 @@ 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} + + +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_execution_id.py b/tests/test_execution_id.py new file mode 100644 index 00000000..b8c5b9f0 --- /dev/null +++ b/tests/test_execution_id.py @@ -0,0 +1,383 @@ +# Copyright 2024 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 +import sys + +from functools import partial +from unittest.mock import Mock + +import pretend +import pytest + +from functions_framework import create_app, execution_id + +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" / "main.py" + target = "function" + client = create_app(target, source).test_client() + resp = client.post( + "/", + headers={ + "Function-Execution-Id": TEST_EXECUTION_ID, + "Content-Type": "application/json", + }, + ) + + assert resp.get_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" / "main.py" + target = "error" + app = create_app(target, source) + client = app.test_client() + 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 + + +def test_print_from_user_function_sets_execution_id(capsys, monkeypatch): + monkeypatch.setenv("LOG_EXECUTION_ID", "true") + source = TEST_FUNCTIONS_DIR / "execution_id" / "main.py" + target = "print_message" + app = create_app(target, source) + client = app.test_client() + 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" / "main.py" + target = "log_message" + app = create_app(target, source) + client = app.test_client() + 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 + + +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" / "main.py" + target = "function" + client = create_app(target, source).test_client() + resp = client.post( + "/", + headers={ + "Content-Type": "application/json", + }, + ) + + assert resp.get_json()["execution_id"] == TEST_EXECUTION_ID + + +def test_does_not_set_execution_id_when_not_enabled(capsys): + source = TEST_FUNCTIONS_DIR / "execution_id" / "main.py" + target = "print_message" + app = create_app(target, source) + client = app.test_client() + 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" / "main.py" + target = "print_message" + app = create_app(target, source) + client = app.test_client() + 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" / "main.py" + target = "print_message" + app = create_app(target, source) + client = app.test_client() + 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", + [ + ( + { + "X-Cloud-Trace-Context": f"TRACE_ID/{TEST_SPAN_ID};o=1", + "Function-Execution-Id": TEST_EXECUTION_ID, + }, + TEST_EXECUTION_ID, + TEST_SPAN_ID, + ), + ( + { + "X-Cloud-Trace-Context": f"TRACE_ID/{TEST_SPAN_ID};o=1", + "Function-Execution-Id": TEST_EXECUTION_ID, + }, + TEST_EXECUTION_ID, + TEST_SPAN_ID, + ), + ({}, None, None), + ( + { + "X-Cloud-Trace-Context": "malformed trace context string", + "Function-Execution-Id": TEST_EXECUTION_ID, + }, + TEST_EXECUTION_ID, + None, + ), + ], +) +def test_set_execution_context( + headers, expected_execution_id, expected_span_id, monkeypatch +): + request = pretend.stub(headers=headers) + + def view_func(): + pass + + 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) + execution_id.set_execution_context(request)(view_func)() + + assert mock_g.execution_id_context.span_id == expected_span_id + assert mock_g.execution_id_context.execution_id == expected_execution_id + + +@pytest.mark.parametrize( + "log_message,expected_log_json", + [ + ("text message", {"message": "text message"}), + ( + json.dumps({"custom-field1": "value1", "custom-field2": "value2"}), + {"custom-field1": "value1", "custom-field2": "value2"}, + ), + ("[]", {"message": "[]"}), + ], +) +def test_log_handler(monkeypatch, log_message, expected_log_json, capsys): + log_handler = execution_id.LoggingHandlerAddExecutionId(stream=sys.stdout) + monkeypatch.setattr( + execution_id, + "_get_current_context", + lambda: execution_id.ExecutionContext( + span_id=TEST_SPAN_ID, execution_id=TEST_EXECUTION_ID + ), + ) + expected_log_json.update( + { + "logging.googleapis.com/labels": { + "execution_id": TEST_EXECUTION_ID, + }, + "logging.googleapis.com/spanId": TEST_SPAN_ID, + } + ) + + log_handler.write(log_message) + record = capsys.readouterr() + assert json.loads(record.out) == expected_log_json + assert json.loads(record.out) == expected_log_json + + +def test_log_handler_without_context_logs_unmodified(monkeypatch, capsys): + log_handler = execution_id.LoggingHandlerAddExecutionId(stream=sys.stdout) + monkeypatch.setattr( + execution_id, + "_get_current_context", + lambda: None, + ) + expected_message = "log message\n" + + log_handler.write("log message") + record = capsys.readouterr() + assert record.out == expected_message + + +def test_log_handler_ignores_newlines(monkeypatch, capsys): + log_handler = execution_id.LoggingHandlerAddExecutionId(stream=sys.stdout) + monkeypatch.setattr( + execution_id, + "_get_current_context", + lambda: execution_id.ExecutionContext( + span_id=TEST_SPAN_ID, execution_id=TEST_EXECUTION_ID + ), + ) + + log_handler.write("\n") + record = capsys.readouterr() + assert record.out == "" + + +def test_log_handler_does_not_nest(): + log_handler_1 = execution_id.LoggingHandlerAddExecutionId(stream=sys.stdout) + log_handler_2 = execution_id.LoggingHandlerAddExecutionId(log_handler_1) + + assert log_handler_1 == log_handler_2 + + +def test_log_handler_omits_empty_execution_context(monkeypatch, capsys): + log_handler = execution_id.LoggingHandlerAddExecutionId(stream=sys.stdout) + monkeypatch.setattr( + execution_id, + "_get_current_context", + lambda: execution_id.ExecutionContext(span_id=None, execution_id=None), + ) + expected_json = { + "message": "some message", + } + + log_handler.write("some message") + record = capsys.readouterr() + assert json.loads(record.out) == expected_json + + +@pytest.mark.asyncio +async def test_maintains_execution_id_for_concurrent_requests(monkeypatch, capsys): + monkeypatch.setenv("LOG_EXECUTION_ID", "true") + monkeypatch.setattr( + execution_id, + "_generate_execution_id", + Mock(side_effect=("test-execution-id-1", "test-execution-id-2")), + ) + + 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" / "main.py" + target = "sleep" + client = create_app(target, source).test_client() + loop = asyncio.get_event_loop() + response1 = loop.run_in_executor( + None, + partial( + client.post, + "/", + headers={ + "Content-Type": "application/json", + }, + json={"message": "message1"}, + ), + ) + response2 = loop.run_in_executor( + None, + partial( + client.post, + "/", + headers={ + "Content-Type": "application/json", + }, + 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) 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_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 81860cae..bafb5e8b 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -12,19 +12,29 @@ # 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 +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 +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 +82,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" - - 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" + 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) -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" - - -def test_http_function_request_path_slash(): - source = TEST_FUNCTIONS_DIR / "http_request_check" / "main.py" - target = "function" + assert resp.text == "success" - 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_failure(http_trigger_client): + resp = http_trigger_client.post("/", json={"mode": "FAILURE"}) + assert resp.status_code == 400 + assert resp.text == "failure" -def test_http_function_request_path_path(): - source = TEST_FUNCTIONS_DIR / "http_request_check" / "main.py" - target = "function" +def test_http_function_executes_throw(http_trigger_client): + resp = http_trigger_client.put("/", json={"mode": "THROW"}) + assert resp.status_code == 500 - client = create_app(target, source).test_client() - resp = client.get("/my_path", json={"mode": "path"}) +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.data == b"/my_path" + assert resp.text == "http://localhost/" -def test_http_function_check_env_function_target(): - source = TEST_FUNCTIONS_DIR / "http_check_env" / "main.py" - target = "function" +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.text == "http://localhost/my_path" - client = create_app(target, source).test_client() - resp = client.post("/", json={"mode": "FUNCTION_TARGET"}) +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.data == b"function" + assert resp.text == "/" -def test_http_function_check_env_function_signature_type(): - source = TEST_FUNCTIONS_DIR / "http_check_env" / "main.py" - target = "function" +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.text == "/my_path" - 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 +332,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 +346,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 +360,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 +375,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" @@ -323,7 +391,21 @@ def test_invalid_function_definition_function_syntax_error(): ) -def test_invalid_function_definition_missing_dependency(): +def test_invalid_function_definition_function_syntax_robustness_with_debug(monkeypatch): + monkeypatch.setattr( + functions_framework.werkzeug.serving, "is_running_from_reloader", lambda: True + ) + source = TEST_FUNCTIONS_DIR / "background_load_error" / "main.py" + target = "function" + + client = create_app(target, source).test_client() + + resp = client.get("/") + assert resp.status_code == 500 + + +@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" @@ -333,7 +415,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) @@ -343,7 +426,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" @@ -369,54 +453,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( @@ -426,7 +495,7 @@ def test_error_paths(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 @@ -460,12 +529,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 @@ -480,12 +545,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 @@ -502,6 +563,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" @@ -620,12 +695,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/execution_id/async_main.py b/tests/test_functions/execution_id/async_main.py new file mode 100644 index 00000000..4485e3f4 --- /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.warning(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") diff --git a/tests/test_functions/execution_id/main.py b/tests/test_functions/execution_id/main.py new file mode 100644 index 00000000..f6677603 --- /dev/null +++ b/tests/test_functions/execution_id/main.py @@ -0,0 +1,33 @@ +import logging +import time + +logger = logging.getLogger(__name__) + + +def print_message(request): + json = request.get_json(silent=True) + print(json.get("message")) + return "success", 200 + + +def log_message(request): + json = request.get_json(silent=True) + logger.warning(json.get("message")) + return "success", 200 + + +def function(request): + return {"execution_id": request.headers.get("Function-Execution-Id")} + + +def error(request): + return 1 / 0 + + +def sleep(request): + json = request.get_json(silent=True) + message = json.get("message") + logger.warning(message) + time.sleep(1) + logger.warning(message) + return "success", 200 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_functions/timeout/main.py b/tests/test_functions/timeout/main.py new file mode 100644 index 00000000..09efeb88 --- /dev/null +++ b/tests/test_functions/timeout/main.py @@ -0,0 +1,12 @@ +import logging +import time + +logger = logging.getLogger(__name__) + + +def function(request): + # sleep for 1200 total ms (1.2 sec) + for _ in range(12): + time.sleep(0.1) + logger.info("some extra logging message") + return "success", 200 diff --git a/tests/test_http.py b/tests/test_http.py index 3414aaf6..df9d4c6c 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -12,9 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os import platform import sys +import flask import pretend import pytest @@ -44,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), @@ -97,7 +99,7 @@ def test_gunicorn_application(debug): assert gunicorn_app.options == { "bind": "%s:%s" % (host, port), "workers": 1, - "threads": 1024, + "threads": os.cpu_count() * 4, "timeout": 0, "loglevel": "error", "limit_request_line": 0, @@ -105,7 +107,7 @@ def test_gunicorn_application(debug): assert gunicorn_app.cfg.bind == ["1.2.3.4:1234"] assert gunicorn_app.cfg.workers == 1 - assert gunicorn_app.cfg.threads == 1024 + assert gunicorn_app.cfg.threads == os.cpu_count() * 4 assert gunicorn_app.cfg.timeout == 0 assert gunicorn_app.load() == app @@ -132,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", + ) + ] diff --git a/tests/test_samples.py b/tests/test_samples.py index 65cee7d0..d76d7796 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -24,7 +24,7 @@ def test_cloud_run_http(self): self.stop_all_containers(client) TAG = "cloud_run_http" - client.images.build(path=str(EXAMPLES_DIR / "cloud_run_http"), tag={TAG}) + client.images.build(path=str(EXAMPLES_DIR / "cloud_run_http"), tag=TAG) container = client.containers.run(image=TAG, detach=True, ports={8080: 8080}) timeout = 10 success = False diff --git a/tests/test_timeouts.py b/tests/test_timeouts.py new file mode 100644 index 00000000..9637de67 --- /dev/null +++ b/tests/test_timeouts.py @@ -0,0 +1,265 @@ +# Copyright 2024 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 socket +import time + +from multiprocessing import Process + +import pytest +import requests + +ff_gunicorn = pytest.importorskip("functions_framework._http.gunicorn") + + +from functions_framework import create_app + +TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" +TEST_HOST = "0.0.0.0" +TEST_PORT = "8080" + + +@pytest.fixture(autouse=True) +def run_around_tests(): + # the test samples test also listens on 8080, so let's be good stewards of + # the port and make sure it's free + _wait_for_no_listen(TEST_HOST, TEST_PORT) + yield + _wait_for_no_listen(TEST_HOST, TEST_PORT) + + +@pytest.mark.skipif("platform.system() == 'Windows'") +@pytest.mark.skipif("platform.system() == 'Darwin'") +@pytest.mark.slow_integration_test +def test_no_timeout_allows_request_processing_to_finish(): + source = TEST_FUNCTIONS_DIR / "timeout" / "main.py" + target = "function" + + app = create_app(target, source) + + options = {} + + gunicorn_app = ff_gunicorn.GunicornApplication( + app, TEST_HOST, TEST_PORT, False, **options + ) + + gunicorn_p = Process(target=gunicorn_app.run) + gunicorn_p.start() + + _wait_for_listen(TEST_HOST, TEST_PORT) + + result = requests.get("http://{}:{}/".format(TEST_HOST, TEST_PORT)) + + gunicorn_p.terminate() + gunicorn_p.join() + + assert result.status_code == 200 + + +@pytest.mark.skipif("platform.system() == 'Windows'") +@pytest.mark.skipif("platform.system() == 'Darwin'") +@pytest.mark.slow_integration_test +def test_timeout_but_not_threaded_timeout_enabled_does_not_kill(monkeypatch): + monkeypatch.setenv("CLOUD_RUN_TIMEOUT_SECONDS", "1") + monkeypatch.setenv("THREADED_TIMEOUT_ENABLED", "false") + source = TEST_FUNCTIONS_DIR / "timeout" / "main.py" + target = "function" + + app = create_app(target, source) + + options = {} + + gunicorn_app = ff_gunicorn.GunicornApplication( + app, TEST_HOST, TEST_PORT, False, **options + ) + + gunicorn_p = Process(target=gunicorn_app.run) + gunicorn_p.start() + + _wait_for_listen(TEST_HOST, TEST_PORT) + + result = requests.get("http://{}:{}/".format(TEST_HOST, TEST_PORT)) + + gunicorn_p.terminate() + gunicorn_p.join() + + assert result.status_code == 200 + + +@pytest.mark.skipif("platform.system() == 'Windows'") +@pytest.mark.skipif("platform.system() == 'Darwin'") +@pytest.mark.slow_integration_test +def test_timeout_and_threaded_timeout_enabled_kills(monkeypatch): + monkeypatch.setenv("CLOUD_RUN_TIMEOUT_SECONDS", "1") + monkeypatch.setenv("THREADED_TIMEOUT_ENABLED", "true") + source = TEST_FUNCTIONS_DIR / "timeout" / "main.py" + target = "function" + + app = create_app(target, source) + + options = {} + + gunicorn_app = ff_gunicorn.GunicornApplication( + app, TEST_HOST, TEST_PORT, False, **options + ) + + gunicorn_p = Process(target=gunicorn_app.run) + gunicorn_p.start() + + _wait_for_listen(TEST_HOST, TEST_PORT) + + result = requests.get("http://{}:{}/".format(TEST_HOST, TEST_PORT)) + + gunicorn_p.terminate() + gunicorn_p.join() + + # Any exception raised in execution is a 500 error. Cloud Functions 1st gen and + # 2nd gen/Cloud Run infrastructure doing the timeout will return a 408 (gen 1) + # or 504 (gen 2/CR) at the infrastructure layer when request timeouts happen, + # and this code will only be available to the user in logs. + assert result.status_code == 500 + + +@pytest.mark.skipif("platform.system() == 'Windows'") +@pytest.mark.skipif("platform.system() == 'Darwin'") +@pytest.mark.slow_integration_test +def test_timeout_and_threaded_timeout_enabled_but_timeout_not_exceeded_doesnt_kill( + monkeypatch, +): + monkeypatch.setenv("CLOUD_RUN_TIMEOUT_SECONDS", "2") + monkeypatch.setenv("THREADED_TIMEOUT_ENABLED", "true") + source = TEST_FUNCTIONS_DIR / "timeout" / "main.py" + target = "function" + + app = create_app(target, source) + + options = {} + + gunicorn_app = ff_gunicorn.GunicornApplication( + app, TEST_HOST, TEST_PORT, False, **options + ) + + gunicorn_p = Process(target=gunicorn_app.run) + gunicorn_p.start() + + _wait_for_listen(TEST_HOST, TEST_PORT) + + result = requests.get("http://{}:{}/".format(TEST_HOST, TEST_PORT)) + + gunicorn_p.terminate() + gunicorn_p.join() + + assert result.status_code == 200 + + +@pytest.mark.skipif("platform.system() == 'Windows'") +@pytest.mark.skipif("platform.system() == 'Darwin'") +@pytest.mark.slow_integration_test +def test_timeout_sync_worker_kills_on_timeout( + monkeypatch, +): + monkeypatch.setenv("CLOUD_RUN_TIMEOUT_SECONDS", "1") + monkeypatch.setenv("WORKERS", 2) + monkeypatch.setenv("THREADS", 1) + source = TEST_FUNCTIONS_DIR / "timeout" / "main.py" + target = "function" + + app = create_app(target, source) + + options = {} + + gunicorn_app = ff_gunicorn.GunicornApplication( + app, TEST_HOST, TEST_PORT, False, **options + ) + + gunicorn_p = Process(target=gunicorn_app.run) + gunicorn_p.start() + + _wait_for_listen(TEST_HOST, TEST_PORT) + + result = requests.get("http://{}:{}/".format(TEST_HOST, TEST_PORT)) + + gunicorn_p.terminate() + gunicorn_p.join() + + assert result.status_code == 500 + + +@pytest.mark.skipif("platform.system() == 'Windows'") +@pytest.mark.skipif("platform.system() == 'Darwin'") +@pytest.mark.slow_integration_test +def test_timeout_sync_worker_does_not_kill_if_less_than_timeout( + monkeypatch, +): + monkeypatch.setenv("CLOUD_RUN_TIMEOUT_SECONDS", "2") + monkeypatch.setenv("WORKERS", 2) + monkeypatch.setenv("THREADS", 1) + source = TEST_FUNCTIONS_DIR / "timeout" / "main.py" + target = "function" + + app = create_app(target, source) + + options = {} + + gunicorn_app = ff_gunicorn.GunicornApplication( + app, TEST_HOST, TEST_PORT, False, **options + ) + + gunicorn_p = Process(target=gunicorn_app.run) + gunicorn_p.start() + + _wait_for_listen(TEST_HOST, TEST_PORT) + + result = requests.get("http://{}:{}/".format(TEST_HOST, TEST_PORT)) + + gunicorn_p.terminate() + gunicorn_p.join() + + assert result.status_code == 200 + + +@pytest.mark.skip +def _wait_for_listen(host, port, timeout=10): + # Used in tests to make sure that the gunicorn app has booted and is + # listening before sending a test request + start_time = time.perf_counter() + while True: + try: + with socket.create_connection((host, port), timeout=timeout): + break + except OSError as ex: + time.sleep(0.01) + if time.perf_counter() - start_time >= timeout: + raise TimeoutError( + "Waited too long for port {} on host {} to start accepting " + "connections.".format(port, host) + ) from ex + + +@pytest.mark.skip +def _wait_for_no_listen(host, port, timeout=10): + # Used in tests to make sure that the port is actually free after + # the process binding to it should have been killed + start_time = time.perf_counter() + while True: + try: + with socket.create_connection((host, port), timeout=timeout): + time.sleep(0.01) + if time.perf_counter() - start_time >= timeout: + raise TimeoutError( + "Waited too long for port {} on host {} to stop accepting " + "connections.".format(port, host) + ) + except OSError as ex: + break diff --git a/tests/test_typing.py b/tests/test_typing.py new file mode 100644 index 00000000..0ca90b47 --- /dev/null +++ b/tests/test_typing.py @@ -0,0 +1,28 @@ +import typing + +if typing.TYPE_CHECKING: # pragma: no cover + import flask + + from cloudevents.http.event import CloudEvent + + import functions_framework + + @functions_framework.http + def hello(request: flask.Request) -> flask.typing.ResponseReturnValue: + return "Hello world!" + + @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/tests/test_view_functions.py b/tests/test_view_functions.py index f69b2155..a32fe9e4 100644 --- a/tests/test_view_functions.py +++ b/tests/test_view_functions.py @@ -25,7 +25,7 @@ def test_http_view_func_wrapper(): function = pretend.call_recorder(lambda request: "Hello") request_object = pretend.stub() - local_proxy = pretend.stub(_get_current_object=lambda: request_object) + local_proxy = pretend.stub(_get_current_object=lambda: request_object, headers={}) view_func = functions_framework._http_view_func_wrapper(function, local_proxy) view_func("/some/path") diff --git a/tox.ini b/tox.ini index 0fe3dba6..0e69c33d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,10 +1,27 @@ [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-windows-latest [testenv] usedevelop = true deps = docker + httpx + pytest-asyncio pytest-cov pytest-integration pretend @@ -16,11 +33,14 @@ commands = pytest {env:PYTESTARGS} {posargs} [testenv:lint] basepython=python3 deps = - black + black>=25,<26 twine - isort + isort>=5,<6 + mypy>=1,<2 + 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 - python setup.py --quiet sdist bdist_wheel + 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/*