diff --git a/.github/blunderbuss.yml b/.github/blunderbuss.yml new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/.github/blunderbuss.yml @@ -0,0 +1 @@ + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..5bc98c6f --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ + diff --git a/.github/release-please.yml b/.github/release-please.yml new file mode 100644 index 00000000..30c96e19 --- /dev/null +++ b/.github/release-please.yml @@ -0,0 +1,2 @@ +releaseType: python +handleGHRelease: true \ No newline at end of file diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 00000000..f7ad311a --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "group:allNonMajor", + "schedule:monthly" + ], + "semanticCommits": "enabled", + "packageRules": [ + { + "description": "Create a PR whenever there is a new major version", + "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/**"] +} diff --git a/.github/workflows/buildpack-integration-test.yml b/.github/workflows/buildpack-integration-test.yml new file mode 100644 index 00000000..2c028fa9 --- /dev/null +++ b/.github/workflows/buildpack-integration-test.yml @@ -0,0 +1,75 @@ +# Validates Functions Framework with GCF buildpacks. +name: Buildpack Integration Test +on: + push: + branches: + - 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: + 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: 'python38' + builder-runtime-version: '3.8' + start-delay: 5 + 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: 'python39' + builder-runtime-version: '3.9' + start-delay: 5 + 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: 'python310' + builder-runtime-version: '3.10' + start-delay: 5 + python311: + uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@main + with: + http-builder-source: 'tests/conformance' + http-builder-target: 'write_http_declarative' + cloudevent-builder-source: 'tests/conformance' + cloudevent-builder-target: 'write_cloud_event_declarative' + prerun: 'tests/conformance/prerun.sh ${{ github.sha }}' + builder-runtime: 'python311' + builder-runtime-version: '3.11' + start-delay: 5 +# Python 3.12 conformance tests are disabled due to the buildpack defaulting to +# Ubuntu 18.04, which has no Python 3.12 version, and being unable to specify +# the OS/stack via the conformance test configuration +# +# python312: +# uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@main +# with: +# http-builder-source: 'tests/conformance' +# http-builder-target: 'write_http_declarative' +# cloudevent-builder-source: 'tests/conformance' +# cloudevent-builder-target: 'write_cloud_event_declarative' +# prerun: 'tests/conformance/prerun.sh ${{ github.sha }}' +# builder-runtime: 'python312' +# builder-runtime-version: '3.12' +# start-delay: 5 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..fe4c7c54 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,86 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: ["main"] + pull_request: + # The branches below must be a subset of the branches above + branches: ["main"] + schedule: + - cron: "0 0 * * 1" + +permissions: + contents: read + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: ["python"] + # CodeQL supports [ $supported-codeql-languages ] + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + 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 + pypi.org:443 + objects.githubusercontent.com:443 + release-assets.githubusercontent.com:443 + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + 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. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # 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@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 + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + 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 new file mode 100644 index 00000000..92f084c9 --- /dev/null +++ b/.github/workflows/conformance.yml @@ -0,0 +1,108 @@ +name: Python Conformance CI +on: + push: + branches: + - 'main' + pull_request: + +# Declare default permissions as read only. +permissions: read-all + +jobs: + build: + strategy: + matrix: + 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@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/main.py --target write_http --signature-type http'" + + - name: Run event conformance tests + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 + with: + 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@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 + with: + 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@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 + with: + 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@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 + with: + 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@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 + with: + 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@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 + with: + functionType: 'http' + declarativeType: 'typed' + useBuildpacks: false + validateMapping: false + 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 new file mode 100644 index 00000000..bcf29c0b --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,32 @@ +# Dependency Review Action +# +# This Action will scan dependency manifest files that change as part of a Pull Request, +# surfacing known-vulnerable versions of the packages declared or updated in the PR. +# Once installed, if the workflow run is marked as required, +# PRs introducing known-vulnerable packages will be blocked from merging. +# +# Source repository: https://github.com/actions/dependency-review-action +name: 'Dependency Review' +on: [pull_request] + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: Harden Runner + 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: 'Dependency Review' + uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..6ca5a4ac --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,30 @@ +name: Python Lint CI +on: + push: + branches: + - main + pull_request: +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + with: + disable-sudo: true + egress-policy: block + allowed-endpoints: > + files.pythonhosted.org:443 + github.com:443 + pypi.org:443 + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + - name: Install tox + run: python -m pip install tox + - name: Lint + run: python -m tox -e lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..2caddcba --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,34 @@ +name: Release to PyPI + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + build-and-pubish: + name: Build and Publish + runs-on: ubuntu-latest + steps: + - name: Harden Runner + 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.release.tag_name }} + - name: Install Python + 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@e9ccbe5a211ba3e8363f472cae362b56b104e796 # main + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 00000000..412aedc9 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,69 @@ +name: Scorecard supply-chain security +on: + # For Branch-Protection check. Only the default branch is supported. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection + branch_protection_rule: + # To guarantee Maintained check is occasionally updated. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained + schedule: + - cron: '0 */12 * * *' + push: + branches: [ "main" ] + workflow_dispatch: + +# Declare default permissions as read only. +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + # Needed to upload the results to code-scanning dashboard. + security-events: write + # Needed to publish results and get a badge (see publish_results below). + id-token: write + + 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 + api.osv.dev:443 + api.scorecard.dev:443 + api.securityscorecards.dev:443 + auth.docker.io:443 + bestpractices.coreinfrastructure.org: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 + *.sigstore.dev:443 + + + - name: "Checkout code" + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: "Run analysis" + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + # Public repositories: + # - Publish results to OpenSSF REST API for easy access by consumers + # - Allows the repository to include the Scorecard badge. + # - See https://github.com/ossf/scorecard-action#publishing-results. + publish_results: true + + # Upload the results to GitHub's code scanning dashboard. + - name: "Upload to code-scanning" + 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 new file mode 100644 index 00000000..2901f5f3 --- /dev/null +++ b/.github/workflows/unit.yml @@ -0,0 +1,63 @@ +name: Python Unit CI +on: + push: + branches: + - main + pull_request: +permissions: + contents: read + +jobs: + test: + strategy: + matrix: + 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@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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Use Python ${{ matrix.python }} + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: ${{ matrix.python }} + - name: Install tox + run: python -m pip install tox + - name: Test + 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 98e18281..967d4513 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,10 @@ __pycache__/ build/ dist/ +.coverage +.vscode/ +.idea/ +function_output.json +serverlog_stderr.txt +serverlog_stdout.txt +venv/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 82edfebe..00000000 --- a/.travis.yml +++ /dev/null @@ -1,18 +0,0 @@ -language: python - -matrix: - include: - - python: 3.5 - env: TOXENV=py35 - - python: 3.6 - env: TOXENV=py36 - - python: 3.7 - env: TOXENV=py37 - - python: 3.8 - env: TOXENV=py38 - - python: 3.8 - env: TOXENV=lint - -install: pip install tox - -script: tox diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..20d13326 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,396 @@ +# Changelog +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) + + +### Features + +* configure security score card action ([#216](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/216)) ([7868dc1](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/7868dc110c048d3e1acf082faf36b75c3770e3f3)) + + +### Bug Fixes + +* streaming requests cannot access request data ([#245](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/245)) ([c492b04](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/c492b04e87a55194b7709e471b0ec3e2c630f288)) + +## [3.3.0](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.2.1...v3.3.0) (2022-12-16) + + +### Features + +* Support strongly typed functions signature ([#208](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/208)) ([aa59a6b](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/aa59a6b6839820946dc72ceea7fd97b3dfd839c2)) + + +### Bug Fixes + +* remove DRY_RUN env var and --dry-run flag ([#210](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/210)) ([f013ab4](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/f013ab4e4c00acae827ad85e6e2ac5698859605f)) + +## [3.2.1](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.2.0...v3.2.1) (2022-11-09) + + +### Bug Fixes + +* Remove the 10MB limit ([#205](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/205)) ([34b8083](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/34b8083c53d88d378718916cc2c20de7665150d4)) + +## [3.2.0](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.1.0...v3.2.0) (2022-08-11) + + +### Features + +* Scale gunicorn server to serve 1000 concurrent requests ([#195](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/195)) ([91e2efa](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/91e2efa9356b120a07906023119219d99a6a0791)) + +## [3.1.0](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.0.0...v3.1.0) (2022-06-09) + + +### Features + +* Add more details to MissingTargetException error ([#189](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/189)) ([b7055ed](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/b7055ed838523cda71bf71bc0149f33271e60ebc)) +* allow for watchdog>=2.0.0 ([#186](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/186)) ([b4ed666](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/b4ed66673b3dd762c371f755c493a381c8241b50)) + + +### Bug Fixes + +* Add functools.wraps decorator ([#179](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/179)) ([f2285f9](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/f2285f96072d7ab8f00ada0d5f8c075e2b4ad364)) +* Change gunicorn request line limit to unlimited ([#173](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/173)) ([6f4a360](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/6f4a3608634debe0833d0ef5cd769050b5fecb01)) +* for issue [#170](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/170) gracefully handle pubsub messages without attributes in them ([#187](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/187)) ([a820fd4](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/a820fd4cdb4bef6cffe0ef68a2d03af922f13d7e)) +* Support relative imports for submodules ([#169](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/169)) ([9046388](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/9046388fe8c32e897b83315863ee57ccf7d0e8df)) + + +### Documentation + +* update README to use declarative function signatures ([#171](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/171)) ([efb0e84](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/efb0e84d3f8ada6ac305b216baa6632570c38495)) + +## [Unreleased] + +## [3.0.0] - 2021-11-10 +### Fixed +- refactor: change declarative function signature from `cloudevent` to `cloud_event` ([#167]) + +## [2.4.0-beta.2] - 2021-11-01 +### Fixed +- fix: remove debug statements + +## [2.4.0-beta.1] - 2021-10-29 +### Added +- feat: Support declarative function signatures: `http` and `cloudevent` ([#160]) + +## [2.3.0] - 2021-10-12 +### Added +- feat: add support for Python 3.10 ([#151]) +### Changed +- fix: update event conversion ([#154]) +- fix: Move backwards-compatible logic before function source load ([#152]) +- fix: Add a DummyErrorHandler ([#137]) + +## [2.2.1] - 2021-06-01 +### Changed +- Update GCF Python 3.7 backwards-compatible logging ([#131]) + +## [2.2.0] - 2021-05-24 +### Added +- Relax constraint to `flask<3.0` and `click<9.0` ([#129]) + +## [2.1.3] - 2021-04-23 +### Changed +- Change gunicorn loglevel to error ([#122]) + +### Added +- Add support for background to CloudEvent conversion ([#116]) + +## [2.1.2] - 2021-02-23 +### Added +- Add crash header to 500 responses ([#114]) + +## [2.1.1] - 2021-02-17 +### Fixed +- Add backwards-compatible logging for GCF Python 3.7 ([#107]) +- Document `--dry-run` flag ([#105]) + +## [2.1.0] - 2020-12-23 +### Added +- Support Python 3.9 + +### Fixed +- Execute the source module w/in the app context ([#76]) + +## [2.0.0] - 2020-07-01 +### Added +- Support `cloudevent` signature type ([#55], [#56]) + +## [1.6.0] - 2020-08-19 +### Changed +- Add legacy GCF Python 3.7 behavior ([#77]) + +### Added +- Improve documentation around Dockerfiles ([#70]) + +## [1.5.0] - 2020-07-06 +### Changed +- Framework will consume entire request before responding ([#66]) + +## [1.4.4] - 2020-06-19 +### Fixed +- Improve module loading ([#61]) + +## [1.4.3] - 2020-05-14 +### Fixed +- Load the source file into the correct module name ([#49]) + +## [1.4.2] - 2020-05-13 +### Fixed +- Fix handling of `--debug` flag when gunicorn is not present ([#44]) + +## [1.4.1] - 2020-05-07 +### Fixed +- Fix Windows support ([#38]) + +## [1.4.0] - 2020-05-06 +### Changed +- Use gunicorn as a production HTTP server + +## [1.3.0] - 2020-04-13 +### Added +- Add support for running `python -m functions_framework` ([#31]) + +### Changed +- Move `functions_framework.cli.cli` to `functions_framework._cli._cli` +- Adjust path handling for robots.txt and favicon.ico ([#33]) + +## [1.2.0] - 2020-02-20 +### Added +- Add support for `--host` flag ([#20]) + +## [1.1.1] - 2020-02-06 +### Added +- Add support for `--dry-run` flag ([#14]) + +### Changed +- Make `--debug` a flag instead of a boolean option + +### Fixed +- Better support for CloudEvent functions and error handling + +## [1.0.1] - 2020-01-30 +### Added +- Add Cloud Run Button ([#1]) +- Add README badges ([#2]) +- Add `debug` flag documentation ([#7]) +- Add `watchdog` dependency ([#8]) + +### Fixed +- Fix `--signature-type` typo ([#4]) +- Fix `install_requires` typo ([#12]) + +## [1.0.0] - 2020-01-09 +### Added +- Initial release + +[Unreleased]: https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.0.0...HEAD +[3.0.0]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v3.0.0 +[2.4.0-beta.2]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v2.4.0-beta.2 +[2.4.0-beta.1]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v2.4.0-beta.1 +[2.3.0]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v2.3.0 +[2.2.1]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v2.2.1 +[2.2.0]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v2.2.0 +[2.1.3]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v2.1.3 +[2.1.2]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v2.1.2 +[2.1.1]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v2.1.1 +[2.1.0]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v2.1.0 +[2.0.0]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v2.0.0 +[1.6.0]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v1.6.0 +[1.5.0]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v1.5.0 +[1.4.4]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v1.4.4 +[1.4.3]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v1.4.3 +[1.4.2]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v1.4.2 +[1.4.1]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v1.4.1 +[1.4.0]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v1.4.0 +[1.3.0]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v1.3.0 +[1.3.0]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v1.3.0 +[1.2.0]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v1.2.0 +[1.1.1]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v1.1.1 +[1.0.1]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v1.0.1 +[1.0.0]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v1.0.0 + +[#167]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/167 +[#160]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/160 +[#154]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/154 +[#152]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/152 +[#151]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/151 +[#137]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/137 +[#131]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/131 +[#129]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/129 +[#122]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/122 +[#116]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/116 +[#114]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/114 +[#107]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/107 +[#105]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/105 +[#77]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/77 +[#76]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/76 +[#70]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/70 +[#66]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/66 +[#61]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/61 +[#56]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/56 +[#55]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/55 +[#49]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/49 +[#44]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/44 +[#38]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/38 +[#33]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/33 +[#31]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/31 +[#20]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/20 +[#14]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/14 +[#12]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/12 +[#8]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/8 +[#7]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/7 +[#4]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/4 +[#2]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/2 +[#1]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/1 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 4976d0a3..d564ad92 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,17 @@ # Functions Framework for Python + +[![PyPI version](https://badge.fury.io/py/functions-framework.svg)](https://badge.fury.io/py/functions-framework) + +[![Python unit CI][ff_python_unit_img]][ff_python_unit_link] [![Python lint CI][ff_python_lint_img]][ff_python_lint_link] [![Python conformace CI][ff_python_conformance_img]][ff_python_conformance_link] ![Security Scorecard](https://api.securityscorecards.dev/projects/github.com/GoogleCloudPlatform/functions-framework-python/badge) + An open source FaaS (Function as a service) framework for writing portable 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: @@ -26,14 +30,14 @@ curl http://my-url All without needing to worry about writing an HTTP server or complicated request handling logic. -# Features +## Features * Spin up a local development server for quick testing * Invoke a function in response to a request * Automatically unmarshal events conforming to the [CloudEvents](https://cloudevents.io/) spec * Portable between serverless platforms -# Installation +## Installation Install the Functions Framework via `pip`: @@ -44,110 +48,311 @@ pip install functions-framework Or, for deployment, add the Functions Framework to your `requirements.txt` file: ``` -functions-framework==1.0.0 +functions-framework==3.* ``` -# Quickstart: Hello, World on your local machine +## Quickstarts + +### Quickstart: HTTP Function (Hello World) Create an `main.py` file with the following contents: ```python -def hello(request): +import flask +import functions_framework + +@functions_framework.http +def hello(request: flask.Request) -> flask.typing.ResponseReturnValue: return "Hello world!" ``` +> 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: ```sh -functions-framework --target=hello +functions-framework --target hello --debug + * Serving Flask app "hello" (lazy loading) + * Environment: production + WARNING: This is a development server. Do not use it in a production deployment. + Use a production WSGI server instead. + * Debug mode: on + * Running on http://0.0.0.0:8080/ (Press CTRL+C to quit) ``` +(You can also use `functions-framework-python` if you have multiple +language frameworks installed). + Open http://localhost:8080/ in your browser and see *Hello world!*. +Or send requests to this function using `curl` from another terminal window: + +```sh +curl localhost:8080 +# Output: Hello world! +``` -# Quickstart: Set up a new project +### Quickstart: CloudEvent Function -Create a `main.py` file with the following contents: +Create an `main.py` file with the following contents: ```python -def hello(request): - return "Hello world!" +import functions_framework +from cloudevents.http.event import CloudEvent + +@functions_framework.cloud_event +def hello_cloud_event(cloud_event: CloudEvent) -> None: + print(f"Received event with ID: {cloud_event['id']} and data {cloud_event.data}") ``` -Now install the Functions Framework: +> 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: ```sh -pip install functions-framework +functions-framework --target=hello_cloud_event ``` -Use the `functions-framework` command to start the built-in local development server: +In a different terminal, `curl` the Functions Framework server: ```sh -functions-framework --target hello - * Serving Flask app "hello" (lazy loading) - * Environment: production - WARNING: This is a development server. Do not use it in a production deployment. - Use a production WSGI server instead. - * Debug mode: off - * Running on http://0.0.0.0:8080/ (Press CTRL+C to quit) +curl -X POST localhost:8080 \ + -H "Content-Type: application/cloudevents+json" \ + -d '{ + "specversion" : "1.0", + "type" : "example.com.cloud.event", + "source" : "https://example.com/cloudevents/pull", + "subject" : "123", + "id" : "A234-1234-1234", + "time" : "2018-04-05T17:31:00Z", + "data" : "hello world" +}' ``` -Send requests to this function using `curl` from another terminal window: - -```sh -curl localhost:8080 -# Output: Hello world! +Output from the terminal running `functions-framework`: ``` +Received event with ID: A234-1234-1234 and data hello world +``` -# Run your function on serverless platforms +More info on sending [CloudEvents](http://cloudevents.io) payloads, see [`examples/cloud_run_cloud_events`](examples/cloud_run_cloud_events/) instruction. -## Google Cloud Functions -This Functions Framework is based on the [Python Runtime on Google Cloud Functions](https://cloud.google.com/functions/docs/concepts/python-runtime). +### Quickstart: Error handling -On Cloud Functions, using the Functions Framework is not necessary: you don't need to add it to your `requirements.txt` file. +The framework includes an error handler that is similar to the +[`flask.Flask.errorhandler`](https://flask.palletsprojects.com/en/1.1.x/api/#flask.Flask.errorhandler) +function, which allows you to handle specific error types with a decorator: -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). +```python +import functions_framework + + +@functions_framework.errorhandler(ZeroDivisionError) +def handle_zero_division(e): + return "I'm a teapot", 418 + + +def function(request): + 1 / 0 + return "Success", 200 +``` -## Cloud Run/Cloud Run on GKE +This function will catch the `ZeroDivisionError` and return a different +response instead. + +### Quickstart: Pub/Sub emulator +1. Create a `main.py` file with the following contents: + + ```python + def hello(event, context): + print("Received", context.event_id) + ``` + +1. Start the Functions Framework on port 8080: + + ```sh + functions-framework --target=hello --signature-type=event --debug --port=8080 + ``` + +1. In a second terminal, start the Pub/Sub emulator on port 8085. + + ```sh + export PUBSUB_PROJECT_ID=my-project + gcloud beta emulators pubsub start \ + --project=$PUBSUB_PROJECT_ID \ + --host-port=localhost:8085 + ``` + + You should see the following after the Pub/Sub emulator has started successfully: + + ```none + [pubsub] INFO: Server started, listening on 8085 + ``` + +1. In a third terminal, create a Pub/Sub topic and attach a push subscription to the topic, using `http://localhost:8080` as its push endpoint. [Publish](https://cloud.google.com/pubsub/docs/quickstart-client-libraries#publish_messages) some messages to the topic. Observe your function getting triggered by the Pub/Sub messages. + + ```sh + export PUBSUB_PROJECT_ID=my-project + export TOPIC_ID=my-topic + export PUSH_SUBSCRIPTION_ID=my-subscription + $(gcloud beta emulators pubsub env-init) + + git clone https://github.com/googleapis/python-pubsub.git + cd python-pubsub/samples/snippets/ + pip install -r requirements.txt + + python publisher.py $PUBSUB_PROJECT_ID create $TOPIC_ID + python subscriber.py $PUBSUB_PROJECT_ID create-push $TOPIC_ID $PUSH_SUBSCRIPTION_ID http://localhost:8080 + python publisher.py $PUBSUB_PROJECT_ID publish $TOPIC_ID + ``` + + You should see the following after the commands have run successfully: + + ```none + Created topic: projects/my-project/topics/my-topic + + topic: "projects/my-project/topics/my-topic" + push_config { + push_endpoint: "http://localhost:8080" + } + ack_deadline_seconds: 10 + message_retention_duration { + seconds: 604800 + } + . + Endpoint for subscription is: http://localhost:8080 + + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + Published messages to projects/my-project/topics/my-topic. + ``` + + And in the terminal where the Functions Framework is running: + + ```none + * Serving Flask app "hello" (lazy loading) + * Environment: production + WARNING: This is a development server. Do not use it in a production deployment. + Use a production WSGI server instead. + * Debug mode: on + * Running on http://0.0.0.0:8080/ (Press CTRL+C to quit) + * Restarting with fsevents reloader + * Debugger is active! + * Debugger PIN: 911-794-046 + Received 1 + 127.0.0.1 - - [11/Aug/2021 14:42:22] "POST / HTTP/1.1" 200 - + Received 2 + 127.0.0.1 - - [11/Aug/2021 14:42:22] "POST / HTTP/1.1" 200 - + Received 5 + 127.0.0.1 - - [11/Aug/2021 14:42:22] "POST / HTTP/1.1" 200 - + Received 6 + 127.0.0.1 - - [11/Aug/2021 14:42:22] "POST / HTTP/1.1" 200 - + Received 7 + 127.0.0.1 - - [11/Aug/2021 14:42:22] "POST / HTTP/1.1" 200 - + Received 8 + 127.0.0.1 - - [11/Aug/2021 14:42:22] "POST / HTTP/1.1" 200 - + Received 9 + 127.0.0.1 - - [11/Aug/2021 14:42:39] "POST / HTTP/1.1" 200 - + Received 3 + 127.0.0.1 - - [11/Aug/2021 14:42:39] "POST / HTTP/1.1" 200 - + Received 4 + 127.0.0.1 - - [11/Aug/2021 14:42:39] "POST / HTTP/1.1" 200 - + ``` + +For more details on extracting data from a Pub/Sub event, see +https://cloud.google.com/functions/docs/tutorials/pubsub#functions_helloworld_pubsub_tutorial-python + +### Quickstart: Build a Deployable Container + +1. Install [Docker](https://store.docker.com/search?type=edition&offering=community) and the [`pack` tool](https://buildpacks.io/docs/install-pack/). + +1. Build a container from your function using the Functions [buildpacks](https://github.com/GoogleCloudPlatform/buildpacks): + + pack build \ + --builder gcr.io/buildpacks/builder:v1 \ + --env GOOGLE_FUNCTION_SIGNATURE_TYPE=http \ + --env GOOGLE_FUNCTION_TARGET=hello \ + my-first-function + +1. Start the built container: + + docker run --rm -p 8080:8080 my-first-function + # Output: Serving function... + +1. Send requests to this function using `curl` from another terminal window: + + curl localhost:8080 + # Output: Hello World! + +## Run your function on serverless platforms + +### Google Cloud Run functions -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). +This Functions Framework is based on the [Python Runtime on Google Cloud Functions](https://cloud.google.com/functions/docs/concepts/python-runtime). + +On Cloud Functions, using the Functions Framework is not necessary: you don't need to add it to your `requirements.txt` file. -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). +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). -## Container environments based on Knative +### 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. -# Configure the Functions Framework +## Configure the Functions Framework You can configure the Functions Framework using command-line flags or environment variables. If you specify both, the environment variable will be ignored. -Command-line flag | Environment variable | Description -------------------------- | ------------------------- | ----------- -`--port` | `PORT` | The port on which the Functions Framework listens for requests. Default: `8080` -`--target` | `FUNCTION_TARGET` | The name of the exported function to be invoked in response to requests. Default: `function` -`--signature-type` | `FUNCTION_SIGNATURE_TYPE` | The signature used when writing your function. Controls unmarshalling rules and determines which arguments are used to invoke your function. Default: `http`; accepted values: `http` or `event` -`--source` | `FUNCTION_SOURCE` | The path to the file containing your function. Default: `main.py` (in the current working directory) +| Command-line flag | Environment variable | Description | +| ------------------ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--host` | `HOST` | The host on which the Functions Framework listens for requests. Default: `0.0.0.0` | +| `--port` | `PORT` | The port on which the Functions Framework listens for requests. Default: `8080` | +| `--target` | `FUNCTION_TARGET` | The name of the exported function to be invoked in response to requests. Default: `function` | +| `--signature-type` | `FUNCTION_SIGNATURE_TYPE` | The signature used when writing your function. Controls unmarshalling rules and determines which arguments are used to invoke your function. Default: `http`; accepted values: `http`, `event` or `cloudevent` | +| `--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 CloudEvents +## Enable Google Cloud Run function Events -The Functions Framework can unmarshall incoming [CloudEvents](http://cloudevents.io) payloads to `data` 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: +The Functions Framework can unmarshall incoming +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: ```python -def hello(data, context): - print(data) +def hello(event, context): + print(event) print(context) ``` -To enable automatic unmarshalling, set the function signature type to `event` using the `--signature-type` command-line flag or the `FUNCTION_SIGNATURE_TYPE` environment variable. By default, the HTTP signature type will be used and automatic event unmarshalling will be disabled. +To enable automatic unmarshalling, set the function signature type to `event` + using the `--signature-type` command-line flag or the `FUNCTION_SIGNATURE_TYPE` environment variable. By default, the HTTP +signature will be used and automatic event unmarshalling will be disabled. + +For more details on this signature type, see the Google Cloud Functions +documentation on +[background functions](https://cloud.google.com/functions/docs/writing/background#cloud_pubsub_example). -For more details on this signature type, check out the Google Cloud Functions documentation on [background functions](https://cloud.google.com/functions/docs/writing/background#cloud_pubsub_example). +See the [running example](examples/cloud_run_event). -# Advanced Examples +## Advanced Examples -More advanced guides can be found in the [`examples/`](./examples/) directory. +More advanced guides can be found in the [`examples/`](examples/) directory. +You can also find examples on using the CloudEvent Python SDK [here](https://github.com/cloudevents/sdk-python). -# Contributing +## Contributing Contributions to this library are welcome and encouraged. See [CONTRIBUTING](CONTRIBUTING.md) for more information on how to get started. + +[ff_python_unit_img]: https://github.com/GoogleCloudPlatform/functions-framework-python/workflows/Python%20Unit%20CI/badge.svg +[ff_python_unit_link]: https://github.com/GoogleCloudPlatform/functions-framework-python/actions?query=workflow%3A"Python+Unit+CI" +[ff_python_lint_img]: https://github.com/GoogleCloudPlatform/functions-framework-python/workflows/Python%20Lint%20CI/badge.svg +[ff_python_lint_link]: https://github.com/GoogleCloudPlatform/functions-framework-python/actions?query=workflow%3A"Python+Lint+CI" +[ff_python_conformance_img]: https://github.com/GoogleCloudPlatform/functions-framework-python/workflows/Python%20Conformance%20CI/badge.svg +[ff_python_conformance_link]: https://github.com/GoogleCloudPlatform/functions-framework-python/actions?query=workflow%3A"Python+Conformance+CI" diff --git a/conftest.py b/conftest.py index b8d44d43..257f60d4 100644 --- a/conftest.py +++ b/conftest.py @@ -12,7 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +import logging import os +import sys + +from importlib import reload import pytest @@ -26,3 +30,69 @@ def isolate_environment(): finally: os.environ.clear() os.environ.update(_environ) + + +@pytest.fixture(scope="function", autouse=True) +def isolate_logging(): + "Ensure any changes to logging are isolated to individual tests" "" + try: + yield + finally: + sys.stdout = sys.__stdout__ + 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 e6bc3e0f..47b7c398 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,3 +1,13 @@ # Python Functions Frameworks Examples -* [`cloud_run`](./cloud_run/) - Deploying a function to [Cloud Run](http://cloud.google.com/run) with the Functions Framework +## Deployment targets +### Cloud Run +* [`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) - +* [`skaffold`](./skaffold) - Developing multiple functions on the same host using Minikube and Skaffold diff --git a/examples/cloud_run/Dockerfile b/examples/cloud_run/Dockerfile deleted file mode 100644 index b211a229..00000000 --- a/examples/cloud_run/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -# Use the official Python image. -# https://hub.docker.com/_/python -FROM python:3.7-slim - -# Copy local code to the container image. -ENV APP_HOME /app -WORKDIR $APP_HOME -COPY . . - -# Install production dependencies. -RUN pip install gunicorn functions-framework -RUN pip install -r requirements.txt - -# Run the web service on container startup. Here we use the gunicorn -# webserver, with one worker process and 8 threads. -# For environments with multiple CPU cores, increase the number of workers -# to be equal to the cores available. -CMD exec gunicorn --bind :$PORT --workers 1 --threads 8 -e FUNCTION_TARGET=hello functions_framework:app diff --git a/examples/cloud_run/requirements.txt b/examples/cloud_run/requirements.txt deleted file mode 100644 index 33c5f99f..00000000 --- a/examples/cloud_run/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -# Optionally include additional dependencies here 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 new file mode 100644 index 00000000..08f45150 --- /dev/null +++ b/examples/cloud_run_cloud_events/Dockerfile @@ -0,0 +1,18 @@ +# Use the official Python image. +# https://hub.docker.com/_/python +FROM python:3.7-slim@sha256:adbcdfcd0511bab2d6db252e55b983da1b431598ed755c1620b291fbeb5f6f72 + +# Copy local code to the container image. +ENV APP_HOME /app +ENV PYTHONUNBUFFERED TRUE + +WORKDIR $APP_HOME +COPY . . + +# Install production dependencies. +RUN pip install gunicorn cloudevents functions-framework +RUN pip install -r requirements.txt +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/README.md b/examples/cloud_run_cloud_events/README.md new file mode 100644 index 00000000..4bf54528 --- /dev/null +++ b/examples/cloud_run_cloud_events/README.md @@ -0,0 +1,23 @@ +# Deploying a CloudEvent Function to Cloud Run with the Functions Framework + +This sample uses the [CloudEvents SDK](https://github.com/cloudevents/sdk-python) to send and receive a [CloudEvent](http://cloudevents.io) on Cloud Run. + +## How to run this locally + +Build the Docker image: + +```commandline +docker build -t cloud_event_example . +``` + +Run the image and bind the correct ports: + +```commandline +docker run --rm -p 8080:8080 -e PORT=8080 cloud_event_example +``` + +Send an event to the container: + +```python +docker run -t cloud_event_example send_cloud_event.py +``` diff --git a/examples/cloud_run_cloud_events/main.py b/examples/cloud_run_cloud_events/main.py new file mode 100644 index 00000000..eb6d6dc0 --- /dev/null +++ b/examples/cloud_run_cloud_events/main.py @@ -0,0 +1,20 @@ +# 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. + +# This sample creates a function using the CloudEvents SDK +# (https://github.com/cloudevents/sdk-python) + + +def hello(cloud_event): + print(f"Received event with ID: {cloud_event['id']} and data {cloud_event.data}") diff --git a/examples/cloud_run_cloud_events/requirements.txt b/examples/cloud_run_cloud_events/requirements.txt new file mode 100644 index 00000000..43d925f1 --- /dev/null +++ b/examples/cloud_run_cloud_events/requirements.txt @@ -0,0 +1,3 @@ +# Optionally include additional dependencies here +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 new file mode 100644 index 00000000..c08b8f93 --- /dev/null +++ b/examples/cloud_run_cloud_events/send_cloud_event.py @@ -0,0 +1,35 @@ +#!/usr/local/bin/python + +# 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 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 +# default will set "specversion" to the most recent cloudevent version (e.g. 1.0) +# and "id" to a generated uuid.uuid4 string. +attributes = { + "Content-Type": "application/json", + "source": "from-galaxy-far-far-away", + "type": "cloudevent.greet.you", +} +data = {"name": "john"} + +event = CloudEvent(attributes, data) + +# Send the event to our local docker container listening on port 8080 +headers, data = to_structured(event) +requests.post("http://localhost:8080/", headers=headers, data=data) diff --git a/examples/cloud_run_decorator/Dockerfile b/examples/cloud_run_decorator/Dockerfile new file mode 100644 index 00000000..cc3f44c8 --- /dev/null +++ b/examples/cloud_run_decorator/Dockerfile @@ -0,0 +1,17 @@ +# Use the official Python image. +# https://hub.docker.com/_/python +FROM python:3.7-slim@sha256:adbcdfcd0511bab2d6db252e55b983da1b431598ed755c1620b291fbeb5f6f72 + +# Copy local code to the container image. +ENV APP_HOME /app +ENV PYTHONUNBUFFERED TRUE + +WORKDIR $APP_HOME +COPY . . + +# Install production dependencies. +RUN pip install functions-framework +RUN pip install -r requirements.txt + +# Run the web service on container startup. +CMD exec functions-framework --target=hello_http diff --git a/examples/cloud_run_decorator/README.md b/examples/cloud_run_decorator/README.md new file mode 100644 index 00000000..ba560b6b --- /dev/null +++ b/examples/cloud_run_decorator/README.md @@ -0,0 +1,23 @@ +## How to run this locally + +This guide shows how to run `hello_http` target locally. +To test with `hello_cloud_event`, change the target accordingly in Dockerfile. + +Build the Docker image: + +```commandline +docker build -t decorator_example . +``` + +Run the image and bind the correct ports: + +```commandline +docker run --rm -p 8080:8080 -e PORT=8080 decorator_example +``` + +Send requests to this function using `curl` from another terminal window: + +```sh +curl localhost:8080 +# Output: Hello world! +``` \ No newline at end of file diff --git a/examples/cloud_run_decorator/main.py b/examples/cloud_run_decorator/main.py new file mode 100644 index 00000000..19f96ee0 --- /dev/null +++ b/examples/cloud_run_decorator/main.py @@ -0,0 +1,27 @@ +# Copyright 2021 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. + +# This sample creates a function using the CloudEvents SDK +# (https://github.com/cloudevents/sdk-python) +import functions_framework + + +@functions_framework.cloud_event +def hello_cloud_event(cloud_event): + return f"Received event with ID: {cloud_event['id']} and data {cloud_event.data}" + + +@functions_framework.http +def hello_http(request): + return "Hello world!" diff --git a/examples/cloud_run_decorator/requirements.txt b/examples/cloud_run_decorator/requirements.txt new file mode 100644 index 00000000..3f8c88a5 --- /dev/null +++ b/examples/cloud_run_decorator/requirements.txt @@ -0,0 +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/Dockerfile b/examples/cloud_run_event/Dockerfile new file mode 100644 index 00000000..d3b4c571 --- /dev/null +++ b/examples/cloud_run_event/Dockerfile @@ -0,0 +1,17 @@ +# Use the official Python image. +# https://hub.docker.com/_/python +FROM python:3.7-slim@sha256:adbcdfcd0511bab2d6db252e55b983da1b431598ed755c1620b291fbeb5f6f72 + +# Copy local code to the container image. +ENV APP_HOME /app +ENV PYTHONUNBUFFERED TRUE + +WORKDIR $APP_HOME +COPY . . + +# Install production dependencies. +RUN pip install gunicorn functions-framework +RUN pip install -r requirements.txt + +# Run the web service on container startup. +CMD exec functions-framework --target=hello --signature-type=event diff --git a/examples/cloud_run_event/main.py b/examples/cloud_run_event/main.py new file mode 100644 index 00000000..e5ca470d --- /dev/null +++ b/examples/cloud_run_event/main.py @@ -0,0 +1,17 @@ +# 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. + + +def hello(event, context): + pass diff --git a/examples/cloud_run_event/requirements.txt b/examples/cloud_run_event/requirements.txt new file mode 100644 index 00000000..3f8c88a5 --- /dev/null +++ b/examples/cloud_run_event/requirements.txt @@ -0,0 +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/Dockerfile b/examples/cloud_run_http/Dockerfile new file mode 100644 index 00000000..14f2b2e4 --- /dev/null +++ b/examples/cloud_run_http/Dockerfile @@ -0,0 +1,17 @@ +# Use the official Python image. +# https://hub.docker.com/_/python +FROM python:3.7-slim@sha256:adbcdfcd0511bab2d6db252e55b983da1b431598ed755c1620b291fbeb5f6f72 + +# Copy local code to the container image. +ENV APP_HOME /app +ENV PYTHONUNBUFFERED TRUE + +WORKDIR $APP_HOME +COPY . . + +# Install production dependencies. +RUN pip install functions-framework +RUN pip install -r requirements.txt + +# Run the web service on container startup. +CMD exec functions-framework --target=hello diff --git a/examples/cloud_run/README.md b/examples/cloud_run_http/README.md similarity index 84% rename from examples/cloud_run/README.md rename to examples/cloud_run_http/README.md index 2278f120..4cbe96d4 100644 --- a/examples/cloud_run/README.md +++ b/examples/cloud_run_http/README.md @@ -1,5 +1,7 @@ # Deploy a function to Cloud Run +[![Run on Google Cloud](https://deploy.cloud.run/button.svg)](https://deploy.cloud.run) + This guide will show you how to deploy the following example function to [Cloud Run](https://cloud.google.com/run): ```python @@ -24,14 +26,11 @@ WORKDIR $APP_HOME COPY . . # Install production dependencies. -RUN pip install gunicorn functions-framework +RUN pip install functions-framework RUN pip install -r requirements.txt -# Run the web service on container startup. Here we use the gunicorn -# webserver, with one worker process and 8 threads. -# For environments with multiple CPU cores, increase the number of workers -# to be equal to the cores available. -CMD exec gunicorn --bind :$PORT --workers 1 --threads 8 -e FUNCTION_TARGET=hello functions_framework:app +# Run the web service on container startup. +CMD exec functions-framework --target=hello ``` Start the container locally by running `docker build` and `docker run`: diff --git a/examples/cloud_run/main.py b/examples/cloud_run_http/main.py similarity index 99% rename from examples/cloud_run/main.py rename to examples/cloud_run_http/main.py index 03640226..5253627c 100644 --- a/examples/cloud_run/main.py +++ b/examples/cloud_run_http/main.py @@ -12,5 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. + def hello(request): return "Hello world!" diff --git a/examples/cloud_run_http/requirements.txt b/examples/cloud_run_http/requirements.txt new file mode 100644 index 00000000..3f8c88a5 --- /dev/null +++ b/examples/cloud_run_http/requirements.txt @@ -0,0 +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/Dockerfile b/examples/docker-compose/Dockerfile new file mode 100644 index 00000000..a7abbfbe --- /dev/null +++ b/examples/docker-compose/Dockerfile @@ -0,0 +1,17 @@ +# Use the Python base image +FROM python + +# Set a working directory +WORKDIR /func + +# Copy all the files from the local directory into the container +COPY . . + +# Install the Functions Framework +RUN pip install functions-framework + +# Install any dependencies of the function +RUN pip install -r requirements.txt + +# Run the function +CMD ["functions-framework", "--target=hello", "--debug"] diff --git a/examples/docker-compose/README.md b/examples/docker-compose/README.md new file mode 100644 index 00000000..68ce73b6 --- /dev/null +++ b/examples/docker-compose/README.md @@ -0,0 +1,67 @@ +# Developing functions with Docker Compose + +## Introduction + +This examples shows you how to develop a Cloud Function locally with Docker Compose, including live reloading. + +## Install `docker-compose`: +https://docs.docker.com/compose/install/ + +## Start the `docker-compose` environment: + +In this directory, bring up the `docker-compose` environment with: +``` +docker-compose up +``` + +You should see output similar to: + +``` +Building function +[+] Building 7.0s (10/10) FINISHED + => [internal] load build definition from Dockerfile 0.0s + => => transferring dockerfile: 431B 0.0s + => [internal] load .dockerignore 0.0s + => => transferring context: 2B 0.0s + => [internal] load metadata for docker.io/library/python:latest 0.6s + => [1/5] FROM docker.io/library/python@sha256:7a93befe45f3afb6b337 0.0s + => [internal] load build context 0.0s + => => transferring context: 2.11kB 0.0s + => CACHED [2/5] WORKDIR /func 0.0s + => [3/5] COPY . . 0.0s + => [4/5] RUN pip install functions-framework 4.7s + => [5/5] RUN pip install -r requirements.txt 1.1s + => exporting to image 0.4s + => => exporting layers 0.4s + => => writing image sha256:99962e5907e80856af6b032aa96a3130dde9ab6 0.0s + => => naming to docker.io/library/docker-compose_function 0.0s + +Use 'docker scan' to run Snyk tests against images to find vulnerabilities and learn how to fix them +Recreating docker-compose_function_1 ... done +Attaching to docker-compose_function_1 +function_1 | * Serving Flask app 'hello' (lazy loading) +function_1 | * Environment: production +function_1 | WARNING: This is a development server. Do not use it in a production deployment. +function_1 | Use a production WSGI server instead. +function_1 | * Debug mode: on +function_1 | * Running on all addresses. +function_1 | WARNING: This is a development server. Do not use it in a production deployment. +function_1 | * Running on http://172.21.0.2:8080/ (Press CTRL+C to quit) +function_1 | * Restarting with watchdog (inotify) +function_1 | * Debugger is active! +``` +function_1 | * Debugger PIN: 162-882-413 + +## Call your Cloud Function + +Leaving the previous command running, in a **new terminal**, call your functions. To call the `hello` function: + +```bash +curl localhost:8080/hello +``` + +You should see output similar to: + +```terminal +Hello, World! +``` diff --git a/examples/docker-compose/docker-compose.yml b/examples/docker-compose/docker-compose.yml new file mode 100644 index 00000000..b801ce17 --- /dev/null +++ b/examples/docker-compose/docker-compose.yml @@ -0,0 +1,8 @@ +version: "3.9" +services: + function: + build: . + ports: + - "8080:8080" + volumes: + - .:/func diff --git a/examples/docker-compose/main.py b/examples/docker-compose/main.py new file mode 100644 index 00000000..cf91e512 --- /dev/null +++ b/examples/docker-compose/main.py @@ -0,0 +1,18 @@ +# Copyright 2021 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. + + +def hello(request): + """Return a friendly HTTP greeting.""" + return "Hello, World!!!" diff --git a/examples/docker-compose/requirements.txt b/examples/docker-compose/requirements.txt new file mode 100644 index 00000000..c856b8d8 --- /dev/null +++ b/examples/docker-compose/requirements.txt @@ -0,0 +1,2 @@ +# Add any Python requirements here +cloudevents==1.11.0 # Pin version - last version compatible w/ python3.7 diff --git a/examples/skaffold/README.md b/examples/skaffold/README.md new file mode 100644 index 00000000..d115e30a --- /dev/null +++ b/examples/skaffold/README.md @@ -0,0 +1,139 @@ +# Developing multiple functions on the same host using Minikube and Skaffold + +## Introduction + +This example shows you how to develop multiple Cloud Functions to a single host +using Minikube and Skaffold. + +The example will focus on: +* taking two separate Cloud Functions (defined in the same file) +* building them each individually with Cloud Buildpacks and the Functions Framework +* deploying them to a local Kubernetes cluster with `minikube` and `skaffold` +* including live reloading! + +## Install `minikube` +*Note: If on Cloud Shell, `minikube` is pre-installed.* + +Install `minikube` via the instructions for your platform at + +Confirm that `minikube` is installed: + +```bash +minikube version +``` + +You should see output similar to: + +```terminal +minikube version: v1.15.1 +commit: 23f40a012abb52eff365ff99a709501a61ac5876 +``` + +## Start `minikube` + +This starts `minikube` using the default profile: + +```bash +minikube start +``` + +This may take a few minutes. + +*Note: If on Cloud Shell, you may be asked to enable Cloud Shell to make API calls* + +You should see output similar to: + +```terminal +😄 minikube v1.15.1 on Debian 10.6 + ▪ MINIKUBE_FORCE_SYSTEMD=true + ▪ MINIKUBE_HOME=/google/minikube + ▪ MINIKUBE_WANTUPDATENOTIFICATION=false +✨ Automatically selected the docker driver +👍 Starting control plane node minikube in cluster minikube +🚜 Pulling base image ... +💾 Downloading Kubernetes v1.19.4 preload ... +🔥 Creating docker container (CPUs=2, Memory=4000MB) ... +🐳 Preparing Kubernetes v1.19.4 on Docker 19.03.13 ... +🔎 Verifying Kubernetes components... +🌟 Enabled addons: storage-provisioner, default-storageclass +🏄 Done! kubectl is now configured to use "minikube" cluster and "default" namespace by default +``` + +## Install the `ingress` addon for `minikube` + +This allows `minikube` to handle external traffic: + +```bash +minikube addons enable ingress +``` + +You should see output similar to: + +```terminal +🔎 Verifying ingress addon... +🌟 The 'ingress' addon is enabled +``` + +## Install `skaffold` +*Note: If on Cloud Shell, `skaffold` is pre-installed.* + +Install `skaffold` via the instructions for your platform at + +Confirm that `skaffold` is installed: + +```bash +skaffold version +``` + +You should see output similar to: + +```terminal +v1.16.0 +``` + +## Start `skaffold` + +Start `skaffold` with: + +```bash +skaffold dev +``` + +You should see output similar to: + +```terminal +Starting deploy... +Waiting for deployments to stabilize... + - deployment/hello is ready. [1/2 deployment(s) still pending] + - deployment/goodbye is ready. +Deployments stabilized in 1.154162006s +Watching for changes... +``` + +This command will continue running indefinitely, watching for changes and redeploying as necessary. + +## Call your Cloud Functions + +Leaving the previous command running, in a **new terminal**, call your functions. To call the `hello` function: + +```bash +curl `minikube ip`/hello +``` + +You should see output similar to: + +```terminal +Hello, World! +``` + +To call the `goodbye` function: + +```bash +curl `minikube ip`/goodbye +``` + +You should see output similar to: + +```terminal +Goodbye, World! +``` diff --git a/examples/skaffold/k8s/goodbye.yaml b/examples/skaffold/k8s/goodbye.yaml new file mode 100644 index 00000000..6a891230 --- /dev/null +++ b/examples/skaffold/k8s/goodbye.yaml @@ -0,0 +1,47 @@ +# 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. + +apiVersion: v1 +kind: Service +metadata: + name: goodbye +spec: + ports: + - port: 8080 + name: http + type: LoadBalancer + selector: + app: goodbye +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: goodbye +spec: + selector: + matchLabels: + app: goodbye + template: + metadata: + labels: + app: goodbye + spec: + containers: + - name: goodbye + image: example-goodbye-image + env: + - name: PORT + value: "8080" + ports: + - containerPort: 8080 diff --git a/examples/skaffold/k8s/hello.yaml b/examples/skaffold/k8s/hello.yaml new file mode 100644 index 00000000..68570ff6 --- /dev/null +++ b/examples/skaffold/k8s/hello.yaml @@ -0,0 +1,47 @@ +# 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. + +apiVersion: v1 +kind: Service +metadata: + name: hello +spec: + ports: + - port: 8080 + name: http + type: LoadBalancer + selector: + app: hello +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: hello +spec: + selector: + matchLabels: + app: hello + template: + metadata: + labels: + app: hello + spec: + containers: + - name: hello + image: example-hello-image + env: + - name: PORT + value: "8080" + ports: + - containerPort: 8080 diff --git a/examples/skaffold/k8s/ingress.yaml b/examples/skaffold/k8s/ingress.yaml new file mode 100644 index 00000000..0abc2d6e --- /dev/null +++ b/examples/skaffold/k8s/ingress.yaml @@ -0,0 +1,30 @@ +# 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. + +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: scaffold-example-ingress +spec: + rules: + - http: + paths: + - path: /hello + backend: + serviceName: hello + servicePort: 8080 + - path: /goodbye + backend: + serviceName: goodbye + servicePort: 8080 diff --git a/examples/skaffold/main.py b/examples/skaffold/main.py new file mode 100644 index 00000000..298249be --- /dev/null +++ b/examples/skaffold/main.py @@ -0,0 +1,23 @@ +# 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. + + +def hello(request): + """Return a friendly HTTP greeting.""" + return "Hello, World!" + + +def goodbye(request): + """Return a friendly HTTP goodbye.""" + return "Goodbye, World!" diff --git a/examples/skaffold/requirements.txt b/examples/skaffold/requirements.txt new file mode 100644 index 00000000..c856b8d8 --- /dev/null +++ b/examples/skaffold/requirements.txt @@ -0,0 +1,2 @@ +# Add any Python requirements here +cloudevents==1.11.0 # Pin version - last version compatible w/ python3.7 diff --git a/examples/skaffold/skaffold.yaml b/examples/skaffold/skaffold.yaml new file mode 100644 index 00000000..ca668226 --- /dev/null +++ b/examples/skaffold/skaffold.yaml @@ -0,0 +1,28 @@ +# 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. + +apiVersion: skaffold/v2beta9 +kind: Config +build: + artifacts: + - image: example-hello-image + buildpacks: + builder: "gcr.io/buildpacks/builder:v1" + env: + - "GOOGLE_FUNCTION_TARGET=hello" + - image: example-goodbye-image + buildpacks: + builder: "gcr.io/buildpacks/builder:v1" + env: + - "GOOGLE_FUNCTION_TARGET=goodbye" 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.cfg b/setup.cfg index 26b05dba..4a639876 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,10 +1,10 @@ [isort] -multi_line_output=3 -include_trailing_comma=True -force_grid_wrap=0 -use_parentheses=True -line_length=88 -lines_between_types=1 -combine_as_imports=True -default_section=THIRDPARTY -known_first_party=functions_framework,google.cloud.functions +multi_line_output = 3 +include_trailing_comma = True +force_grid_wrap = 0 +use_parentheses = True +line_length = 88 +lines_between_types = 1 +combine_as_imports = True +default_section = THIRDPARTY +known_first_party = functions_framework, google.cloud.functions diff --git a/setup.py b/setup.py index 4965edb7..10dfee0d 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="1.0.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,27 +33,43 @@ 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<=2.0", "click>=7.0<=8.0"], - extras_require={"test": ["pytest", "tox"]}, + install_requires=[ + "flask>=1.0,<4.0", + "click>=7.0,<9.0", + "watchdog>=1.0.0", + "gunicorn>=22.0.0; platform_system!='Windows'", + "cloudevents>=1.2.0,<2.0.0", + "Werkzeug>=0.14,<4.0.0", + ], + extras_require={ + "async": [ + "starlette>=0.37.0,<1.0.0; python_version<'3.10'", + "starlette>=1.0.1,<2.0.0; python_version>='3.10'", + ], + }, entry_points={ "console_scripts": [ - "functions-framework=functions_framework.cli:cli", - "functions_framework=functions_framework.cli:cli", - "ff=functions_framework.cli:cli", + "ff=functions_framework._cli:_cli", + "functions-framework=functions_framework._cli:_cli", + "functions_framework=functions_framework._cli:_cli", + "functions-framework-python=functions_framework._cli:_cli", + "functions_framework_python=functions_framework._cli:_cli", ] }, ) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 52416f58..31169f4c 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -13,72 +13,208 @@ # limitations under the License. import functools -import importlib.util +import inspect +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 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, + execution_id, +) +from functions_framework.background_event import BackgroundEvent from functions_framework.exceptions import ( + EventConversionException, FunctionsFrameworkException, - InvalidConfigurationException, - InvalidTargetTypeException, MissingSourceException, - MissingTargetException, ) from google.cloud.functions.context import Context -DEFAULT_SOURCE = os.path.realpath("./main.py") -DEFAULT_SIGNATURE_TYPE = "http" - - -class _Event(object): - """Event passed to background functions.""" - - # Supports both v1beta1 and v1beta2 event formats. - def __init__( - self, - context=None, - data="", - eventId="", - timestamp="", - eventType="", - resource="", - **kwargs - ): - self.context = context - if not self.context: - self.context = { - "eventId": eventId, - "timestamp": timestamp, - "eventType": eventType, - "resource": resource, - } - self.data = data +_FUNCTION_STATUS_HEADER_FIELD = "X-Google-Status" +_CRASH = "crash" + +_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.""" + + def __init__(self, level, stderr=sys.stderr): + io.TextIOWrapper.__init__(self, io.StringIO(), encoding=stderr.encoding) + self.level = level + self.stderr = stderr + + def write(self, out): + payload = dict(severity=self.level, message=out.rstrip("\n")) + return self.stderr.write(json.dumps(payload) + "\n") + + +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 + ) + + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + +def typed(*args): + def _typed(func): + _typed_event.register_typed_event(input_type, func) + + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + # no input type provided as a parameter, we need to use reflection + # e.g function declaration: + # @typed + # def myfunc(x:input_type) + if len(args) == 1 and isinstance(args[0], types.FunctionType): + input_type = None + return _typed(args[0]) + + # input type provided as a parameter to the decorator + # e.g. function declaration + # @typed(input_type) + # def myfunc(x) + else: + input_type = args[0] + return _typed + + +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 + ) + + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + +def setup_logging(): + logging.getLogger().setLevel(logging.INFO) + info_handler = logging.StreamHandler(sys.stdout) + info_handler.setLevel(logging.NOTSET) + info_handler.addFilter(lambda record: record.levelno <= logging.INFO) + logging.getLogger().addHandler(info_handler) + + warn_handler = logging.StreamHandler(sys.stderr) + warn_handler.setLevel(logging.WARNING) + logging.getLogger().addHandler(warn_handler) 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()) return view_func -def _is_binary_cloud_event(request): - return ( - request.headers.get("ce-type") - and request.headers.get("ce-specversion") - and request.headers.get("ce-source") - and request.headers.get("ce-id") - ) +def _run_cloud_event(function, request): + data = request.get_data() + event = from_http(request.headers, data) + function(event) + + +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() + input = inputType.from_dict(data) + response = function(input) + if response is None: + return "", 200 + if response.__class__.__module__ == "builtins": + return response + _typed_event._validate_return_type(response) + return json.dumps(response.to_dict()) + except Exception as e: + raise FunctionsFrameworkException( + "Function execution failed with the error" + ) from e + + return view_func + + +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 + try: + event = from_http(request.headers, request.get_data()) + except ( + cloud_exceptions.MissingRequiredFields, + cloud_exceptions.InvalidRequiredFields, + ) as e: + ce_exception = e + + if not ce_exception: + function(event) + return "OK" + + # Not a CloudEvent. Try converting to a CloudEvent. + try: + function(event_conversion.background_event_to_cloud_event(request)) + except EventConversionException as e: + flask.abort( + 400, + description=( + "Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent but" + " parsing CloudEvent failed and converting from background event to" + f" CloudEvent also failed.\nGot HTTP headers: {request.headers}\nGot" + f" data: {request.get_data()}\nGot CloudEvent exception: {repr(ce_exception)}" + f"\nGot background event conversion exception: {repr(e)}" + ), + ) + return "OK" + + return view_func def _event_view_func_wrapper(function, request): + @execution_id.set_execution_context(request, _enable_execution_id_logging()) def view_func(path): - if _is_binary_cloud_event(request): + if event_conversion.is_convertable_cloud_event(request): + # Convert this CloudEvent to the equivalent background event data and context. + data, context = event_conversion.cloud_event_to_background_event(request) + function(data, context) + elif is_binary(request.headers): # Support CloudEvents in binary content mode, with data being the # whole request body and context attributes retrieved from request # headers. @@ -92,8 +228,10 @@ def view_func(path): function(data, context) else: # This is a regular CloudEvent - event_data = request.get_json() - event_object = _Event(**event_data) + event_data = event_conversion.marshal_background_event_data(request) + if not event_data: + flask.abort(400) + event_object = BackgroundEvent(**event_data) data = event_object.data context = Context(**event_object.context) function(data, context) @@ -103,78 +241,21 @@ def view_func(path): return view_func -def create_app(target=None, source=None, signature_type=None): - # Get the configured function target - target = target or os.environ.get("FUNCTION_TARGET", "") - # Set the environment variable if it wasn't already - os.environ["FUNCTION_TARGET"] = target - - if not target: - raise InvalidConfigurationException( - "Target is not specified (FUNCTION_TARGET environment variable not set)" - ) - - # Get the configured function source - source = source or os.environ.get("FUNCTION_SOURCE", DEFAULT_SOURCE) - - # Python 3.5: os.path.exist does not support PosixPath - source = str(source) - - # Set the template folder relative to the source path - # Python 3.5: join does not support PosixPath - template_folder = str(pathlib.Path(source).parent / "templates") - - if not os.path.exists(source): - raise MissingSourceException( - "File {source} that is expected to define function doesn't exist".format( - source=source - ) - ) - - # Get the configured function signature type - signature_type = signature_type or os.environ.get( - "FUNCTION_SIGNATURE_TYPE", DEFAULT_SIGNATURE_TYPE - ) - # Set the environment variable if it wasn't already - os.environ["FUNCTION_SIGNATURE_TYPE"] = signature_type - - # Load the source file - spec = importlib.util.spec_from_file_location("main", source) - source_module = importlib.util.module_from_spec(spec) - sys.path.append(os.path.dirname(os.path.realpath(source))) - spec.loader.exec_module(source_module) - - app = flask.Flask(target, template_folder=template_folder) - - # Extract the target function from the source file - try: - function = getattr(source_module, target) - except AttributeError: - raise MissingTargetException( - "File {source} is expected to contain a function named {target}".format( - source=source, target=target - ) - ) - - # Check that it is a function - if not isinstance(function, types.FunctionType): - raise InvalidTargetTypeException( - "The function defined in file {source} as {target} needs to be of " - "type function. Got: invalid type {target_type}".format( - source=source, target=target, target_type=type(function) - ) - ) - +def _configure_app(app, function, signature_type): # Mount the function at the root. Support GCF's default path behavior # Modify the url_map and view_functions directly here instead of using # add_url_rule in order to create endpoints that route all methods - if signature_type == "http": + if signature_type == _function_registry.HTTP_SIGNATURE_TYPE: app.url_map.add( werkzeug.routing.Rule("/", defaults={"path": ""}, endpoint="run") ) + app.url_map.add(werkzeug.routing.Rule("/robots.txt", endpoint="error")) + app.url_map.add(werkzeug.routing.Rule("/favicon.ico", endpoint="error")) app.url_map.add(werkzeug.routing.Rule("/", endpoint="run")) app.view_functions["run"] = _http_view_func_wrapper(function, flask.request) - elif signature_type == "event": + app.view_functions["error"] = lambda: flask.abort(404, description="Not Found") + app.after_request(read_request) + elif signature_type == _function_registry.BACKGROUNDEVENT_SIGNATURE_TYPE: app.url_map.add( werkzeug.routing.Rule( "/", defaults={"path": ""}, endpoint="run", methods=["POST"] @@ -184,6 +265,39 @@ def create_app(target=None, source=None, signature_type=None): werkzeug.routing.Rule("/", endpoint="run", methods=["POST"]) ) app.view_functions["run"] = _event_view_func_wrapper(function, flask.request) + # Add a dummy endpoint for GET / + app.url_map.add(werkzeug.routing.Rule("/", endpoint="get", methods=["GET"])) + app.view_functions["get"] = lambda: "" + elif signature_type == _function_registry.CLOUDEVENT_SIGNATURE_TYPE: + app.url_map.add( + werkzeug.routing.Rule( + "/", defaults={"path": ""}, endpoint=signature_type, methods=["POST"] + ) + ) + app.url_map.add( + werkzeug.routing.Rule( + "/", endpoint=signature_type, methods=["POST"] + ) + ) + + app.view_functions[signature_type] = _cloud_event_view_func_wrapper( + function, flask.request + ) + elif signature_type == _function_registry.TYPED_SIGNATURE_TYPE: + app.url_map.add( + werkzeug.routing.Rule( + "/", defaults={"path": ""}, endpoint=signature_type, methods=["POST"] + ) + ) + app.url_map.add( + werkzeug.routing.Rule( + "/", endpoint=signature_type, methods=["POST"] + ) + ) + input_type = _function_registry.get_func_input_type(function.__name__) + app.view_functions[signature_type] = _typed_event_func_wrapper( + function, flask.request, input_type + ) else: raise FunctionsFrameworkException( "Invalid signature type: {signature_type}".format( @@ -191,7 +305,129 @@ def create_app(target=None, source=None, signature_type=None): ) ) - return app + +def read_request(response): + """ + Force the framework to read the entire request before responding, to avoid + connection errors when returning prematurely. Skipped on streaming responses + as these may continue to operate on the request after they are returned. + """ + + if not response.is_streamed: + flask.request.get_data() + + return response + + +def crash_handler(e): + """ + Return crash header to allow logging 'crash' message in logs. + """ + return str(e), 500, {_FUNCTION_STATUS_HEADER_FIELD: _CRASH} + + +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) + + # Set the template folder relative to the source path + # Python 3.5: join does not support PosixPath + template_folder = str(pathlib.Path(source).parent / "templates") + + if not os.path.exists(source): + raise MissingSourceException( + "File {source} that is expected to define function doesn't exist".format( + source=source + ) + ) + + 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) + global errorhandler + errorhandler = _app.errorhandler + + # Handle legacy GCF Python 3.7 behavior + if os.environ.get("ENTRY_POINT"): + os.environ["FUNCTION_NAME"] = os.environ.get("K_SERVICE", target) + _app.make_response_original = _app.make_response + + def handle_none(rv): + if rv is None: + rv = "OK" + return _app.make_response_original(rv) + + _app.make_response = handle_none + + # Handle log severity backwards compatibility + sys.stdout = _LoggingHandler("INFO", sys.stderr) + 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(): + 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) + + _configure_app(_app, function, signature_type) + + return _app class LazyWSGIApp: @@ -218,4 +454,38 @@ 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() + + +class DummyErrorHandler: + def __init__(self): + pass + + def __call__(self, *args, **kwargs): + return self + + +errorhandler = DummyErrorHandler() diff --git a/src/functions_framework/__main__.py b/src/functions_framework/__main__.py new file mode 100644 index 00000000..5f2e710c --- /dev/null +++ b/src/functions_framework/__main__.py @@ -0,0 +1,17 @@ +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from functions_framework._cli import _cli + +_cli(prog_name="python -m functions_framework") diff --git a/src/functions_framework/cli.py b/src/functions_framework/_cli.py similarity index 54% rename from src/functions_framework/cli.py rename to src/functions_framework/_cli.py index 8fb295ba..48455ea6 100644 --- a/src/functions_framework/cli.py +++ b/src/functions_framework/_cli.py @@ -16,21 +16,33 @@ import click -from functions_framework import create_app +from functions_framework import _function_registry, create_app +from functions_framework._http import create_server @click.command() @click.option("--target", envvar="FUNCTION_TARGET", type=click.STRING, required=True) @click.option("--source", envvar="FUNCTION_SOURCE", type=click.Path(), default=None) @click.option( - "--signature_type", + "--signature-type", envvar="FUNCTION_SIGNATURE_TYPE", - type=click.Choice(["http", "event"]), + type=click.Choice(["http", "event", "cloudevent", "typed"]), default="http", ) +@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", type=click.BOOL, default=False) -def cli(target, source, signature_type, port, debug): - host = "0.0.0.0" - app = create_app(target, source, signature_type) - app.run(host, port, debug) +@click.option("--debug", envvar="DEBUG", is_flag=True) +@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 new file mode 100644 index 00000000..1f08c794 --- /dev/null +++ b/src/functions_framework/_function_registry.py @@ -0,0 +1,138 @@ +# Copyright 2021 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 importlib.util +import os +import sys +import types + +from typing import Type + +from functions_framework.exceptions import ( + InvalidConfigurationException, + InvalidTargetTypeException, + MissingTargetException, +) + +DEFAULT_SOURCE = os.path.realpath("./main.py") + +FUNCTION_SIGNATURE_TYPE = "FUNCTION_SIGNATURE_TYPE" +HTTP_SIGNATURE_TYPE = "http" +CLOUDEVENT_SIGNATURE_TYPE = "cloudevent" +BACKGROUNDEVENT_SIGNATURE_TYPE = "event" +TYPED_SIGNATURE_TYPE = "typed" + +# REGISTRY_MAP stores the registered functions. +# Keys are user function names, values are user function signature types. +REGISTRY_MAP = {} + +# INPUT_TYPE_MAP stores the input type of the typed functions. +# 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.""" + # Extract the target function from the source file + if not hasattr(source_module, target): + non_target_functions = ", ".join( + "'{attr}'".format(attr=attr) + for attr in dir(source_module) + if isinstance(getattr(source_module, attr), types.FunctionType) + ) + raise MissingTargetException( + "File {source} is expected to contain a function named '{target}'. Found: {non_target_functions} instead".format( + source=source, target=target, non_target_functions=non_target_functions + ) + ) + function = getattr(source_module, target) + # Check that it is a function + if not isinstance(function, types.FunctionType): + raise InvalidTargetTypeException( + "The function defined in file {source} as '{target}' needs to be of " + "type function. Got: invalid type {target_type}".format( + source=source, target=target, target_type=type(function) + ) + ) + return function + + +def load_function_module(source): + """Load user function source file.""" + # 1. Extract the module name from the source path + realpath = os.path.realpath(source) + directory, filename = os.path.split(realpath) + name, extension = os.path.splitext(filename) + # 2. Create a new module + spec = importlib.util.spec_from_file_location( + name, realpath, submodule_search_locations=[directory] + ) + source_module = importlib.util.module_from_spec(spec) + # 3. Add the directory of the source to sys.path to allow the function to + # load modules relative to its location + sys.path.append(directory) + # 4. Add the module to sys.modules + sys.modules[name] = source_module + return source_module, spec + + +def get_function_source(source): + """Get the configured function source.""" + source = source or os.environ.get("FUNCTION_SOURCE", DEFAULT_SOURCE) + # Python 3.5: os.path.exist does not support PosixPath + source = str(source) + return source + + +def get_function_target(target): + """Get the configured function target.""" + target = target or os.environ.get("FUNCTION_TARGET", "") + # Set the environment variable if it wasn't already + os.environ["FUNCTION_TARGET"] = target + if not target: + raise InvalidConfigurationException( + "Target is not specified (FUNCTION_TARGET environment variable not set)" + ) + return target + + +def get_func_signature_type(func_name: str, signature_type: str) -> str: + """Get user function's signature type. + + Signature type is searched in the following order: + 1. Decorator user used to register their function + 2. --signature-type flag + 3. environment variable FUNCTION_SIGNATURE_TYPE + If none of the above is set, signature type defaults to be "http". + """ + registered_type = REGISTRY_MAP[func_name] if func_name in REGISTRY_MAP else "" + sig_type = ( + registered_type + or signature_type + or os.environ.get(FUNCTION_SIGNATURE_TYPE, HTTP_SIGNATURE_TYPE) + ) + # Set the environment variable if it wasn't already + os.environ[FUNCTION_SIGNATURE_TYPE] = sig_type + # Update signature type for legacy GCF Python 3.7 + if os.environ.get("ENTRY_POINT"): + os.environ["FUNCTION_TRIGGER_TYPE"] = sig_type + return sig_type + + +def get_func_input_type(func_name: str) -> Type: + registered_type = INPUT_TYPE_MAP[func_name] if func_name in INPUT_TYPE_MAP else "" + return registered_type diff --git a/src/functions_framework/_http/__init__.py b/src/functions_framework/_http/__init__.py new file mode 100644 index 00000000..fa2cbc09 --- /dev/null +++ b/src/functions_framework/_http/__init__.py @@ -0,0 +1,59 @@ +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from flask import Flask + +from functions_framework._http.flask import FlaskApplication + + +class HTTPServer: + def __init__(self, app, debug, **options): + self.app = app + self.debug = debug + self.options = options + + 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( + self.app, host, port, self.debug, **self.options + ) + http_server.run() + + +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/flask.py b/src/functions_framework/_http/flask.py new file mode 100644 index 00000000..b2edf563 --- /dev/null +++ b/src/functions_framework/_http/flask.py @@ -0,0 +1,25 @@ +# 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. + + +class FlaskApplication: + def __init__(self, app, host, port, debug, **options): + self.app = app + self.host = host + self.port = port + self.debug = debug + self.options = options + + def run(self): + self.app.run(self.host, self.port, debug=self.debug, **self.options) diff --git a/src/functions_framework/_http/gunicorn.py b/src/functions_framework/_http/gunicorn.py new file mode 100644 index 00000000..745ce2f8 --- /dev/null +++ b/src/functions_framework/_http/gunicorn.py @@ -0,0 +1,97 @@ +# 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 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": int(os.environ.get("WORKERS", 1)), + "worker_class": "uvicorn_worker.UvicornWorker", + "timeout": int(os.environ.get("CLOUD_RUN_TIMEOUT_SECONDS", 0)), + "loglevel": os.environ.get("GUNICORN_LOG_LEVEL", "error"), + "limit_request_line": 0, + } + self.options.update(options) + self.app = app + + super().__init__() + + def load_config(self): + for key, value in self.options.items(): + self.cfg.set(key, value) + + def load(self): + return self.app diff --git a/src/functions_framework/_typed_event.py b/src/functions_framework/_typed_event.py new file mode 100644 index 00000000..413c8f05 --- /dev/null +++ b/src/functions_framework/_typed_event.py @@ -0,0 +1,105 @@ +# Copyright 2022 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 inspect + +from inspect import signature + +from functions_framework import _function_registry +from functions_framework.exceptions import FunctionsFrameworkException + +"""Registers user function in the REGISTRY_MAP and the INPUT_TYPE_MAP. +Also performs some validity checks for the input type of the function + +Args: + decorator_type: The type provided by the @typed(input_type) decorator + func: User function +""" + + +def register_typed_event(decorator_type, func): + try: + sig = signature(func) + annotation_type = list(sig.parameters.values())[0].annotation + input_type = _select_input_type(decorator_type, annotation_type) + _validate_input_type(input_type) + except IndexError: + raise FunctionsFrameworkException( + "Function signature is missing an input parameter." + "The function should be defined as 'def your_fn(in: inputType)'" + ) + except Exception as e: + raise FunctionsFrameworkException( + "Functions using the @typed decorator must provide " + "the type of the input parameter by specifying @typed(inputType) and/or using python " + "type annotations 'def your_fn(in: inputType)'" + ) + + _function_registry.INPUT_TYPE_MAP[func.__name__] = input_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""" + + +def _validate_return_type(response): + if not (hasattr(response, "to_dict") and callable(getattr(response, "to_dict"))): + raise AttributeError( + "The type {response} does not have the required method called " + " 'to_dict'.".format(response=type(response)) + ) + + +"""Selects the input type for the typed function provided through the @typed(input_type) +decorator or through the parameter annotation in the user function +""" + + +def _select_input_type(decorator_type, annotation_type): + if decorator_type == None and annotation_type is inspect._empty: + raise TypeError( + "The function defined does not contain Type of the input object." + ) + + if ( + decorator_type != None + and annotation_type is not inspect._empty + and decorator_type != annotation_type + ): + raise TypeError( + "The object type provided via 'typed' decorator: '{decorator_type}'" + "is different than the one specified by the function parameter's type annotation : '{annotation_type}'.".format( + decorator_type=decorator_type, annotation_type=annotation_type + ) + ) + + if decorator_type == None: + return annotation_type + return decorator_type + + +"""Checks for the from_dict method implementation in the input type class""" + + +def _validate_input_type(input_type): + if not ( + hasattr(input_type, "from_dict") and callable(getattr(input_type, "from_dict")) + ): + raise AttributeError( + "The type {decorator_type} does not have the required method called " + " 'from_dict'.".format(decorator_type=input_type) + ) 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/background_event.py b/src/functions_framework/background_event.py new file mode 100644 index 00000000..be01960b --- /dev/null +++ b/src/functions_framework/background_event.py @@ -0,0 +1,44 @@ +# Copyright 2021 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. + + +class BackgroundEvent(object): + """BackgroundEvent is an event passed to GCF background event functions. + + Background event functions take data and context as parameters, both of + which this class represents. By contrast, CloudEvent functions take a + single CloudEvent object as their parameter. This class does not represent + CloudEvents. + """ + + # Supports v1beta1, v1beta2, and v1 event formats. + def __init__( + self, + context=None, + data="", + eventId="", + timestamp="", + eventType="", + resource="", + **kwargs, + ): + self.context = context + if not self.context: + self.context = { + "eventId": eventId, + "timestamp": timestamp, + "eventType": eventType, + "resource": resource, + } + self.data = data diff --git a/src/functions_framework/event_conversion.py b/src/functions_framework/event_conversion.py new file mode 100644 index 00000000..0e67cdaa --- /dev/null +++ b/src/functions_framework/event_conversion.py @@ -0,0 +1,346 @@ +# Copyright 2021 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 re + +from datetime import datetime +from typing import Any, Optional, Tuple + +from cloudevents.exceptions import MissingRequiredFields +from cloudevents.http import CloudEvent, from_http, is_binary + +from functions_framework.background_event import BackgroundEvent +from functions_framework.exceptions import EventConversionException +from google.cloud.functions.context import Context + +_CLOUD_EVENT_SPEC_VERSION = "1.0" + +# 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/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", + "google.storage.object.finalize": "google.cloud.storage.object.v1.finalized", + "google.storage.object.delete": "google.cloud.storage.object.v1.deleted", + "google.storage.object.archive": "google.cloud.storage.object.v1.archived", + "google.storage.object.metadataUpdate": "google.cloud.storage.object.v1.metadataUpdated", + "providers/cloud.firestore/eventTypes/document.write": "google.cloud.firestore.document.v1.written", + "providers/cloud.firestore/eventTypes/document.create": "google.cloud.firestore.document.v1.created", + "providers/cloud.firestore/eventTypes/document.update": "google.cloud.firestore.document.v1.updated", + "providers/cloud.firestore/eventTypes/document.delete": "google.cloud.firestore.document.v1.deleted", + "providers/firebase.auth/eventTypes/user.create": "google.firebase.auth.user.v1.created", + "providers/firebase.auth/eventTypes/user.delete": "google.firebase.auth.user.v1.deleted", + "providers/google.firebase.analytics/eventTypes/event.log": "google.firebase.analytics.log.v1.written", + "providers/google.firebase.database/eventTypes/ref.create": "google.firebase.database.ref.v1.created", + "providers/google.firebase.database/eventTypes/ref.write": "google.firebase.database.ref.v1.written", + "providers/google.firebase.database/eventTypes/ref.update": "google.firebase.database.ref.v1.updated", + "providers/google.firebase.database/eventTypes/ref.delete": "google.firebase.database.ref.v1.deleted", + "providers/cloud.storage/eventTypes/object.change": "google.cloud.storage.object.v1.finalized", +} + +# _BACKGROUND_TO_CE_TYPE contains duplicate values for some keys. This set contains the duplicates +# that should be dropped when generating the inverse mapping _CE_TO_BACKGROUND_TYPE +_NONINVERTALBE_CE_TYPES = { + "providers/cloud.pubsub/eventTypes/topic.publish", + "providers/cloud.storage/eventTypes/object.change", +} + +# Maps CloudEvent types to the equivalent background/legacy event types (inverse +# of _BACKGROUND_TO_CE_TYPE) +_CE_TO_BACKGROUND_TYPE = { + v: k for k, v in _BACKGROUND_TO_CE_TYPE.items() if k not in _NONINVERTALBE_CE_TYPES +} + +# CloudEvent service names. +_FIREBASE_AUTH_CE_SERVICE = "firebaseauth.googleapis.com" +_FIREBASE_CE_SERVICE = "firebase.googleapis.com" +_FIREBASE_DB_CE_SERVICE = "firebasedatabase.googleapis.com" +_FIRESTORE_CE_SERVICE = "firestore.googleapis.com" +_PUBSUB_CE_SERVICE = "pubsub.googleapis.com" +_STORAGE_CE_SERVICE = "storage.googleapis.com" + +# Raw pubsub types +_PUBSUB_EVENT_TYPE = "google.pubsub.topic.publish" +_PUBSUB_MESSAGE_TYPE = "type.googleapis.com/google.pubsub.v1.PubsubMessage" + +_PUBSUB_TOPIC_REQUEST_PATH = re.compile(r"projects\/[^/?]+\/topics\/[^/?]+") + +# Maps background event services to their equivalent CloudEvent services. +_SERVICE_BACKGROUND_TO_CE = { + "providers/cloud.firestore/": _FIRESTORE_CE_SERVICE, + "providers/google.firebase.analytics/": _FIREBASE_CE_SERVICE, + "providers/firebase.auth/": _FIREBASE_AUTH_CE_SERVICE, + "providers/google.firebase.database/": _FIREBASE_DB_CE_SERVICE, + "providers/cloud.pubsub/": _PUBSUB_CE_SERVICE, + "providers/cloud.storage/": _STORAGE_CE_SERVICE, + "google.pubsub": _PUBSUB_CE_SERVICE, + "google.storage": _STORAGE_CE_SERVICE, +} + +# Maps CloudEvent service strings to regular expressions used to split a background +# event resource string into CloudEvent resource and subject strings. Each regex +# must have exactly two capture groups: the first for the resource and the second +# for the subject. +_CE_SERVICE_TO_RESOURCE_RE = { + _FIREBASE_CE_SERVICE: re.compile(r"^(projects/[^/]+)/(events/[^/]+)$"), + _FIREBASE_DB_CE_SERVICE: re.compile(r"^projects/_/(instances/[^/]+)/(refs/.+)$"), + _FIRESTORE_CE_SERVICE: re.compile( + r"^(projects/[^/]+/databases/\(default\))/(documents/.+)$" + ), + _STORAGE_CE_SERVICE: re.compile(r"^(projects/[^/]/buckets/[^/]+)/(objects/.+)$"), +} + +# Maps Firebase Auth background event metadata field names to their equivalent +# CloudEvent field names. +_FIREBASE_AUTH_METADATA_FIELDS_BACKGROUND_TO_CE = { + "createdAt": "createTime", + "lastSignedInAt": "lastSignInTime", +} +# Maps Firebase Auth CloudEvent metadata field names to their equivalent +# background event field names (inverse of _FIREBASE_AUTH_METADATA_FIELDS_BACKGROUND_TO_CE). +_FIREBASE_AUTH_METADATA_FIELDS_CE_TO_BACKGROUND = { + v: k for k, v in _FIREBASE_AUTH_METADATA_FIELDS_BACKGROUND_TO_CE.items() +} + + +def background_event_to_cloud_event(request) -> CloudEvent: + """Converts a background event represented by the given HTTP request into a CloudEvent.""" + event_data = marshal_background_event_data(request) + if not event_data: + raise EventConversionException("Failed to parse JSON") + + event_object = BackgroundEvent(**event_data) + data = event_object.data + context = Context(**event_object.context) + + if context.event_type not in _BACKGROUND_TO_CE_TYPE: + raise EventConversionException( + f'Unable to find CloudEvent equivalent type for "{context.event_type}"' + ) + new_type = _BACKGROUND_TO_CE_TYPE[context.event_type] + + service, resource, subject = _split_resource(context) + source = f"//{service}/{resource}" + + # Handle Pub/Sub events. + if service == _PUBSUB_CE_SERVICE: + if "messageId" not in data: + data["messageId"] = context.event_id + if "publishTime" not in data: + data["publishTime"] = context.timestamp + data = {"message": data} + + # Handle Firebase Auth events. + if service == _FIREBASE_AUTH_CE_SERVICE: + if "metadata" in data: + for old, new in _FIREBASE_AUTH_METADATA_FIELDS_BACKGROUND_TO_CE.items(): + if old in data["metadata"]: + data["metadata"][new] = data["metadata"][old] + del data["metadata"][old] + if "uid" in data: + uid = data["uid"] + subject = f"users/{uid}" + + # Handle Firebase DB events. + if service == _FIREBASE_DB_CE_SERVICE: + # The CE source of firebasedatabase CloudEvents includes location information + # that is inferred from the 'domain' field of legacy events. + if "domain" not in event_data: + raise EventConversionException( + "Invalid FirebaseDB event payload: missing 'domain'" + ) + + domain = event_data["domain"] + location = "us-central1" + if domain != "firebaseio.com": + location = domain.split(".")[0] + + resource = f"projects/_/locations/{location}/{resource}" + source = f"//{service}/{resource}" + + metadata = { + "id": context.event_id, + "time": context.timestamp, + "specversion": _CLOUD_EVENT_SPEC_VERSION, + "datacontenttype": "application/json", + "type": new_type, + "source": source, + } + + if subject: + metadata["subject"] = subject + + return CloudEvent(metadata, data) + + +def is_convertable_cloud_event(request) -> bool: + """Is the given request a known CloudEvent that can be converted to background event.""" + if is_binary(request.headers): + event_type = request.headers.get("ce-type") + event_source = request.headers.get("ce-source") + return ( + event_source is not None + and event_type is not None + and event_type in _CE_TO_BACKGROUND_TYPE + ) + return False + + +def _split_ce_source(source) -> Tuple[str, str]: + """Splits a CloudEvent source string into resource and subject components.""" + regex = re.compile(r"\/\/([^/]+)\/(.+)") + match = regex.fullmatch(source) + if not match: + raise EventConversionException("Unexpected CloudEvent source.") + + return match.group(1), match.group(2) + + +def cloud_event_to_background_event(request) -> Tuple[Any, Context]: + """Converts a background event represented by the given HTTP request into a CloudEvent.""" + try: + event = from_http(request.headers, request.get_data()) + data = event.data + service, name = _split_ce_source(event["source"]) + + if event["type"] not in _CE_TO_BACKGROUND_TYPE: + raise EventConversionException( + f'Unable to find background event equivalent type for "{event["type"]}"' + ) + + if service == _PUBSUB_CE_SERVICE: + resource = {"service": service, "name": name, "type": _PUBSUB_MESSAGE_TYPE} + if "message" in data: + data = data["message"] + if "messageId" in data: + del data["messageId"] + if "publishTime" in data: + del data["publishTime"] + elif service == _FIREBASE_AUTH_CE_SERVICE: + resource = name + if "metadata" in data: + for old, new in _FIREBASE_AUTH_METADATA_FIELDS_CE_TO_BACKGROUND.items(): + if old in data["metadata"]: + data["metadata"][new] = data["metadata"][old] + del data["metadata"][old] + elif service == _STORAGE_CE_SERVICE: + resource = { + "name": f"{name}/{event['subject']}", + "service": service, + "type": data["kind"], + } + elif service == _FIREBASE_DB_CE_SERVICE: + name = re.sub("/locations/[^/]+", "", name) + resource = f"{name}/{event['subject']}" + else: + resource = f"{name}/{event['subject']}" + + context = Context( + eventId=event["id"], + timestamp=event["time"], + eventType=_CE_TO_BACKGROUND_TYPE[event["type"]], + resource=resource, + ) + return (data, context) + except (AttributeError, KeyError, TypeError, MissingRequiredFields): + raise EventConversionException( + "Failed to convert CloudEvent to BackgroundEvent." + ) + + +def _split_resource(context: Context) -> Tuple[str, str, str]: + """Splits a background event's resource into a CloudEvent service, resource, and subject.""" + service = "" + resource = "" + if isinstance(context.resource, dict): + service = context.resource.get("service", "") + resource = context.resource["name"] + else: + resource = context.resource + + # If there's no service we'll choose an appropriate one based on the event type. + if not service: + for b_service, ce_service in _SERVICE_BACKGROUND_TO_CE.items(): + if context.event_type.startswith(b_service): + service = ce_service + break + if not service: + raise EventConversionException( + "Unable to find CloudEvent equivalent service " + f"for {context.event_type}" + ) + + # If we don't need to split the resource string then we're done. + if service not in _CE_SERVICE_TO_RESOURCE_RE: + return service, resource, "" + + # Split resource into resource and subject. + match = _CE_SERVICE_TO_RESOURCE_RE[service].fullmatch(resource) + if not match: + raise EventConversionException("Resource regex did not match") + + return service, match.group(1), match.group(2) + + +def marshal_background_event_data(request): + """Marshal the request body of a raw Pub/Sub HTTP request into the schema that is expected of + a background event""" + try: + request_data = request.get_json() + if not _is_raw_pubsub_payload(request_data): + # If this in not a raw Pub/Sub request, return the unaltered request data. + return request_data + return { + "context": { + "eventId": request_data["message"]["messageId"], + "timestamp": request_data["message"].get( + "publishTime", datetime.utcnow().isoformat() + "Z" + ), + "eventType": _PUBSUB_EVENT_TYPE, + "resource": { + "service": _PUBSUB_CE_SERVICE, + "type": _PUBSUB_MESSAGE_TYPE, + "name": _parse_pubsub_topic(request.path), + }, + }, + "data": { + "@type": _PUBSUB_MESSAGE_TYPE, + "data": request_data["message"]["data"], + "attributes": request_data["message"].get("attributes", {}), + }, + } + except (AttributeError, KeyError, TypeError): + raise EventConversionException("Failed to convert Pub/Sub payload to event") + + +def _is_raw_pubsub_payload(request_data) -> bool: + """Does the given request body match the schema of a unmarshalled Pub/Sub request""" + return ( + request_data is not None + and "context" not in request_data + and "subscription" in request_data + and "message" in request_data + and "data" in request_data["message"] + and "messageId" in request_data["message"] + ) + + +def _parse_pubsub_topic(request_path) -> Optional[str]: + match = _PUBSUB_TOPIC_REQUEST_PATH.search(request_path) + if match: + return match.group(0) + else: + # It is possible to configure a Pub/Sub subscription to push directly to this function + # without passing the topic name in the URL path. + return "" diff --git a/src/functions_framework/exceptions.py b/src/functions_framework/exceptions.py index 970da5f4..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. @@ -31,3 +31,11 @@ class MissingSourceException(FunctionsFrameworkException): class MissingTargetException(FunctionsFrameworkException): pass + + +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 new file mode 100644 index 00000000..4f164d62 --- /dev/null +++ b/tests/conformance/main.py @@ -0,0 +1,74 @@ +import json +import time + +from cloudevents.http import to_json + +import functions_framework + +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) + + +def write_http(request): + _write_output(json.dumps(request.json)) + return "OK", 200 + + +def write_legacy_event(data, context): + _write_output( + json.dumps( + { + "data": data, + "context": { + "eventId": context.event_id, + "timestamp": context.timestamp, + "eventType": context.event_type, + "resource": context.resource, + }, + } + ) + ) + + +def write_cloud_event(cloud_event): + _write_output(to_json(cloud_event).decode()) + + +@functions_framework.http +def write_http_declarative(request): + _write_output(json.dumps(request.json)) + return "OK", 200 + + +@functions_framework.cloud_event +def write_cloud_event_declarative(cloud_event): + _write_output(to_json(cloud_event).decode()) + + +@functions_framework.http +def write_http_declarative_concurrent(request): + time.sleep(1) + return "OK", 200 + + +@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 new file mode 100755 index 00000000..c37fe62b --- /dev/null +++ b/tests/conformance/prerun.sh @@ -0,0 +1,20 @@ +# prerun.sh sets up the test function to use the functions framework commit +# specified by generating a `requirements.txt`. This makes the function `pack` buildable +# with GCF buildpacks. +# +# `pack` command example: +# pack build python-test --builder us.gcr.io/fn-img/buildpacks/python310/builder:python310_20220320_3_10_2_RC00 --env GOOGLE_RUNTIME=python310 --env GOOGLE_FUNCTION_TARGET=write_http_declarative +set -e + +FRAMEWORK_VERSION=$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 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 f6fac8ff..75c93f20 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,66 +12,154 @@ # 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 -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 -def create_app(monkeypatch): - create_app = pretend.call_recorder( - lambda *a, **kw: pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None)) - ) - monkeypatch.setattr(functions_framework.cli, "create_app", create_app) - return create_app +@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() - result = runner.invoke(cli) + result = runner.invoke(_cli) assert result.exit_code == 2 - assert 'Missing option "--target"' in result.output + assert "Missing option '--target'" in result.output @pytest.mark.parametrize( - "args, env, call", + "args, env, create_app_calls, run_calls", [ - (["--target", "foo"], {}, pretend.call("foo", None, "http")), - ([], {"FUNCTION_TARGET": "foo"}, pretend.call("foo", None, "http")), + ( + ["--target", "foo"], + {}, + [pretend.call("foo", None, "http")], + [pretend.call("0.0.0.0", 8080)], + ), + ( + [], + {"FUNCTION_TARGET": "foo"}, + [pretend.call("foo", None, "http")], + [pretend.call("0.0.0.0", 8080)], + ), ( ["--target", "foo", "--source", "/path/to/source.py"], {}, - pretend.call("foo", "/path/to/source.py", "http"), + [pretend.call("foo", "/path/to/source.py", "http")], + [pretend.call("0.0.0.0", 8080)], ), ( [], {"FUNCTION_TARGET": "foo", "FUNCTION_SOURCE": "/path/to/source.py"}, - pretend.call("foo", "/path/to/source.py", "http"), + [pretend.call("foo", "/path/to/source.py", "http")], + [pretend.call("0.0.0.0", 8080)], ), ( - ["--target", "foo", "--signature_type", "event"], + ["--target", "foo", "--signature-type", "event"], {}, - pretend.call("foo", None, "event"), + [pretend.call("foo", None, "event")], + [pretend.call("0.0.0.0", 8080)], ), ( [], {"FUNCTION_TARGET": "foo", "FUNCTION_SIGNATURE_TYPE": "event"}, - pretend.call("foo", None, "event"), + [pretend.call("foo", None, "event")], + [pretend.call("0.0.0.0", 8080)], + ), + ( + ["--target", "foo", "--host", "127.0.0.1"], + {}, + [pretend.call("foo", None, "http")], + [pretend.call("127.0.0.1", 8080)], + ), + ( + ["--target", "foo", "--debug"], + {}, + [pretend.call("foo", None, "http")], + [pretend.call("0.0.0.0", 8080)], + ), + ( + [], + {"FUNCTION_TARGET": "foo", "DEBUG": "True"}, + [pretend.call("foo", None, "http")], + [pretend.call("0.0.0.0", 8080)], ), ], ) -def test_cli_arguments(create_app, args, env, call): +def test_cli(monkeypatch, args, env, create_app_calls, run_calls): + wsgi_server = pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None)) + wsgi_app = pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None)) + create_app = pretend.call_recorder(lambda *a, **kw: wsgi_app) + monkeypatch.setattr(functions_framework._cli, "create_app", create_app) + create_server = pretend.call_recorder(lambda *a, **kw: wsgi_server) + monkeypatch.setattr(functions_framework._cli, "create_server", create_server) + runner = CliRunner(env=env) - result = runner.invoke(cli, args) + result = runner.invoke(_cli, args) + + 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) - if result.output: - print(result.output) + 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_app.calls == [call] + 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 new file mode 100644 index 00000000..2e7c281d --- /dev/null +++ b/tests/test_cloud_event_functions.py @@ -0,0 +1,251 @@ +# 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 json +import pathlib +import sys + +import pytest + +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" + +# Python 3.5: ModuleNotFoundError does not exist +try: + _ModuleNotFoundError = ModuleNotFoundError +except: + _ModuleNotFoundError = ImportError + + +@pytest.fixture +def data_payload(): + return {"name": "john"} + + +@pytest.fixture +def cloud_event_1_0(): + attributes = { + "specversion": "1.0", + "id": "my-id", + "source": "from-galaxy-far-far-away", + "type": "cloud_event.greet.you", + "time": "2020-08-16T13:58:54.471765", + } + data = {"name": "john"} + return CloudEvent(attributes, data) + + +@pytest.fixture +def cloud_event_0_3(): + attributes = { + "id": "my-id", + "source": "from-galaxy-far-far-away", + "type": "cloud_event.greet.you", + "specversion": "0.3", + "time": "2020-08-16T13:58:54.471765", + } + data = {"name": "john"} + return CloudEvent(attributes, data) + + +@pytest.fixture +def create_headers_binary(): + return lambda specversion: { + "ce-id": "my-id", + "ce-source": "from-galaxy-far-far-away", + "ce-type": "cloud_event.greet.you", + "ce-specversion": specversion, + "time": "2020-08-16T13:58:54.471765", + } + + +@pytest.fixture +def create_structured_data(): + return lambda specversion: { + "id": "my-id", + "source": "from-galaxy-far-far-away", + "type": "cloud_event.greet.you", + "specversion": specversion, + "time": "2020-08-16T13:58:54.471765", + } + + +@pytest.fixture +def background_event(): + with open(TEST_DATA_DIR / "pubsub_text-legacy-input.json", "r") as f: + return json.load(f) + + +@pytest.fixture(params=["main.py", "async_main.py"]) +def client(request): + source = TEST_FUNCTIONS_DIR / "cloud_events" / request.param + target = "function" + 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(params=["empty_data.py", "async_empty_data.py"]) +def empty_client(request): + source = TEST_FUNCTIONS_DIR / "cloud_events" / request.param + target = "function" + 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(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 = 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_binary_event(client, 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.text == "OK" + + +def test_event_0_3(client, 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.text == "OK" + + +def test_binary_event_0_3(client, 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.text == "OK" + + +@pytest.mark.parametrize("specversion", ["0.3", "1.0"]) +def test_cloud_event_missing_required_binary_fields( + client, specversion, create_headers_binary, data_payload +): + headers = create_headers_binary(specversion) + + for remove_key in headers: + if remove_key == "time": + continue + + invalid_headers = {key: headers[key] for key in headers if key != remove_key} + resp = client.post("/", headers=invalid_headers, json=data_payload) + + assert resp.status_code == 400 + assert "MissingRequiredFields" in resp.text + + +@pytest.mark.parametrize("specversion", ["0.3", "1.0"]) +def test_cloud_event_missing_required_structured_fields( + client, specversion, create_structured_data +): + headers = {"Content-Type": "application/cloudevents+json"} + data = create_structured_data(specversion) + + for remove_key in data: + if remove_key == "time": + continue + + invalid_data = {key: data[key] for key in data if key != remove_key} + resp = client.post("/", headers=headers, json=invalid_data) + + assert resp.status_code == 400 + assert "MissingRequiredFields" in resp.text + + +def test_invalid_fields_binary(client, create_headers_binary, data_payload): + # Testing none specversion fails + headers = create_headers_binary("not a spec version") + resp = client.post("/", headers=headers, json=data_payload) + + assert resp.status_code == 400 + assert "InvalidRequiredFields" in resp.text + + +def test_unparsable_cloud_event(client): + headers = {"Content-Type": "application/cloudevents+json"} + resp = client.post("/", headers=headers, data="") + + assert resp.status_code == 400 + assert "Bad Request" in resp.text + + +@pytest.mark.parametrize("specversion", ["0.3", "1.0"]) +def test_empty_data_binary(empty_client, create_headers_binary, specversion): + headers = create_headers_binary(specversion) + resp = empty_client.post("/", headers=headers, json="") + + assert resp.status_code == 200 + assert resp.text == "OK" + + +@pytest.mark.parametrize("specversion", ["0.3", "1.0"]) +def test_empty_data_structured(empty_client, specversion, create_structured_data): + headers = {"Content-Type": "application/cloudevents+json"} + + data = create_structured_data(specversion) + resp = empty_client.post("/", headers=headers, json=data) + + assert resp.status_code == 200 + assert resp.text == "OK" + + +@pytest.mark.parametrize("specversion", ["0.3", "1.0"]) +def test_no_mime_type_structured(empty_client, specversion, create_structured_data): + data = create_structured_data(specversion) + resp = empty_client.post("/", headers={}, json=data) + + assert resp.status_code == 200 + assert resp.text == "OK" + + +def test_background_event(converted_background_event_client, background_event): + resp = converted_background_event_client.post( + "/", headers={}, json=background_event + ) + + print(resp.text) + assert resp.status_code == 200 + assert resp.text == "OK" diff --git a/tests/test_convert.py b/tests/test_convert.py new file mode 100644 index 00000000..592681e7 --- /dev/null +++ b/tests/test_convert.py @@ -0,0 +1,659 @@ +# Copyright 2021 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 json +import pathlib + +import flask +import pretend +import pytest + +from cloudevents.http import from_json, to_binary + +from functions_framework import event_conversion +from functions_framework.exceptions import EventConversionException +from google.cloud.functions.context import Context + +TEST_DATA_DIR = pathlib.Path(__file__).resolve().parent / "test_data" + + +PUBSUB_BACKGROUND_EVENT = { + "context": { + "eventId": "1215011316659232", + "timestamp": "2020-05-18T12:13:19Z", + "eventType": "google.pubsub.topic.publish", + "resource": { + "service": "pubsub.googleapis.com", + "name": "projects/sample-project/topics/gcf-test", + "type": "type.googleapis.com/google.pubsub.v1.PubsubMessage", + }, + }, + "data": { + "data": "10", + }, +} + +PUBSUB_BACKGROUND_EVENT_WITHOUT_CONTEXT = { + "eventId": "1215011316659232", + "timestamp": "2020-05-18T12:13:19Z", + "eventType": "providers/cloud.pubsub/eventTypes/topic.publish", + "resource": "projects/sample-project/topics/gcf-test", + "data": { + "data": "10", + }, +} + +BACKGROUND_RESOURCE = { + "service": "storage.googleapis.com", + "name": "projects/_/buckets/some-bucket/objects/folder/Test.cs", + "type": "storage#object", +} + +BACKGROUND_RESOURCE_WITHOUT_SERVICE = { + "name": "projects/_/buckets/some-bucket/objects/folder/Test.cs", + "type": "storage#object", +} + +BACKGROUND_RESOURCE_STRING = "projects/_/buckets/some-bucket/objects/folder/Test.cs" + +PUBSUB_CLOUD_EVENT = { + "specversion": "1.0", + "id": "1215011316659232", + "source": "//pubsub.googleapis.com/projects/sample-project/topics/gcf-test", + "time": "2020-05-18T12:13:19Z", + "type": "google.cloud.pubsub.topic.v1.messagePublished", + "datacontenttype": "application/json", + "data": { + "message": { + "data": "10", + "publishTime": "2020-05-18T12:13:19Z", + "messageId": "1215011316659232", + }, + }, +} + + +@pytest.fixture +def pubsub_cloud_event_output(): + return from_json(json.dumps(PUBSUB_CLOUD_EVENT)) + + +@pytest.fixture +def raw_pubsub_request(): + return { + "subscription": "projects/sample-project/subscriptions/gcf-test-sub", + "message": { + "data": "eyJmb28iOiJiYXIifQ==", + "messageId": "1215011316659232", + "attributes": {"test": "123"}, + }, + } + + +@pytest.fixture +def raw_pubsub_request_noattributes(): + return { + "subscription": "projects/sample-project/subscriptions/gcf-test-sub", + "message": {"data": "eyJmb28iOiJiYXIifQ==", "messageId": "1215011316659232"}, + } + + +@pytest.fixture +def marshalled_pubsub_request(): + return { + "data": { + "@type": "type.googleapis.com/google.pubsub.v1.PubsubMessage", + "data": "eyJmb28iOiJiYXIifQ==", + "attributes": {"test": "123"}, + }, + "context": { + "eventId": "1215011316659232", + "eventType": "google.pubsub.topic.publish", + "resource": { + "name": "projects/sample-project/topics/gcf-test", + "service": "pubsub.googleapis.com", + "type": "type.googleapis.com/google.pubsub.v1.PubsubMessage", + }, + "timestamp": "2021-04-17T07:21:18.249Z", + }, + } + + +@pytest.fixture +def marshalled_pubsub_request_noattr(): + return { + "data": { + "@type": "type.googleapis.com/google.pubsub.v1.PubsubMessage", + "data": "eyJmb28iOiJiYXIifQ==", + "attributes": {}, + }, + "context": { + "eventId": "1215011316659232", + "eventType": "google.pubsub.topic.publish", + "resource": { + "name": "projects/sample-project/topics/gcf-test", + "service": "pubsub.googleapis.com", + "type": "type.googleapis.com/google.pubsub.v1.PubsubMessage", + }, + "timestamp": "2021-04-17T07:21:18.249Z", + }, + } + + +@pytest.fixture +def raw_pubsub_cloud_event_output(marshalled_pubsub_request): + event = PUBSUB_CLOUD_EVENT.copy() + # the data payload is more complex for the raw pubsub request + data = marshalled_pubsub_request["data"] + data["messageId"] = event["id"] + data["publishTime"] = event["time"] + event["data"] = {"message": data} + return from_json(json.dumps(event)) + + +@pytest.fixture +def firebase_auth_background_input(): + with open(TEST_DATA_DIR / "firebase-auth-legacy-input.json", "r") as f: + return json.load(f) + + +@pytest.fixture +def firebase_auth_cloud_event_output(): + with open(TEST_DATA_DIR / "firebase-auth-cloud-event-output.json", "r") as f: + return from_json(f.read()) + + +@pytest.fixture +def firebase_db_background_input(): + with open(TEST_DATA_DIR / "firebase-db-legacy-input.json", "r") as f: + return json.load(f) + + +@pytest.fixture +def firebase_db_cloud_event_output(): + with open(TEST_DATA_DIR / "firebase-db-cloud-event-output.json", "r") as f: + return from_json(f.read()) + + +@pytest.fixture +def create_ce_headers(): + return lambda event_type, source: { + "ce-id": "my-id", + "ce-type": event_type, + "ce-source": source, + "ce-specversion": "1.0", + "ce-subject": "my/subject", + "ce-time": "2020-08-16T13:58:54.471765", + } + + +@pytest.mark.parametrize( + "event", [PUBSUB_BACKGROUND_EVENT, PUBSUB_BACKGROUND_EVENT_WITHOUT_CONTEXT] +) +def test_pubsub_event_to_cloud_event(event, pubsub_cloud_event_output): + req = flask.Request.from_values(json=event) + cloud_event = event_conversion.background_event_to_cloud_event(req) + assert cloud_event == pubsub_cloud_event_output + + +def test_firebase_auth_event_to_cloud_event( + firebase_auth_background_input, firebase_auth_cloud_event_output +): + req = flask.Request.from_values(json=firebase_auth_background_input) + cloud_event = event_conversion.background_event_to_cloud_event(req) + assert cloud_event == firebase_auth_cloud_event_output + + +def test_firebase_auth_event_to_cloud_event_no_metadata( + firebase_auth_background_input, firebase_auth_cloud_event_output +): + # Remove metadata from the events to verify conversion still works. + del firebase_auth_background_input["data"]["metadata"] + del firebase_auth_cloud_event_output.data["metadata"] + + req = flask.Request.from_values(json=firebase_auth_background_input) + cloud_event = event_conversion.background_event_to_cloud_event(req) + assert cloud_event == firebase_auth_cloud_event_output + + +def test_firebase_auth_event_to_cloud_event_no_metadata_timestamps( + firebase_auth_background_input, firebase_auth_cloud_event_output +): + # Remove metadata timestamps from the events to verify conversion still works. + del firebase_auth_background_input["data"]["metadata"]["createdAt"] + del firebase_auth_background_input["data"]["metadata"]["lastSignedInAt"] + del firebase_auth_cloud_event_output.data["metadata"]["createTime"] + del firebase_auth_cloud_event_output.data["metadata"]["lastSignInTime"] + + req = flask.Request.from_values(json=firebase_auth_background_input) + cloud_event = event_conversion.background_event_to_cloud_event(req) + assert cloud_event == firebase_auth_cloud_event_output + + +def test_firebase_auth_event_to_cloud_event_no_uid( + firebase_auth_background_input, firebase_auth_cloud_event_output +): + # Remove UIDs from the events to verify conversion still works. The UID is mapped + # to the subject in the CloudEvent so remove that from the expected CloudEvent. + del firebase_auth_background_input["data"]["uid"] + del firebase_auth_cloud_event_output.data["uid"] + del firebase_auth_cloud_event_output["subject"] + + req = flask.Request.from_values(json=firebase_auth_background_input) + cloud_event = event_conversion.background_event_to_cloud_event(req) + assert cloud_event == firebase_auth_cloud_event_output + + +def test_firebase_db_event_to_cloud_event_default_location( + firebase_db_background_input, firebase_db_cloud_event_output +): + req = flask.Request.from_values(json=firebase_db_background_input) + cloud_event = event_conversion.background_event_to_cloud_event(req) + assert cloud_event == firebase_db_cloud_event_output + + +def test_firebase_db_event_to_cloud_event_location_subdomain( + firebase_db_background_input, firebase_db_cloud_event_output +): + firebase_db_background_input["domain"] = "europe-west1.firebasedatabase.app" + firebase_db_cloud_event_output["source"] = firebase_db_cloud_event_output[ + "source" + ].replace("us-central1", "europe-west1") + + req = flask.Request.from_values(json=firebase_db_background_input) + cloud_event = event_conversion.background_event_to_cloud_event(req) + assert cloud_event == firebase_db_cloud_event_output + + +def test_firebase_db_event_to_cloud_event_missing_domain( + firebase_db_background_input, firebase_db_cloud_event_output +): + del firebase_db_background_input["domain"] + req = flask.Request.from_values(json=firebase_db_background_input) + + with pytest.raises(EventConversionException) as exc_info: + event_conversion.background_event_to_cloud_event(req) + + assert ( + "Invalid FirebaseDB event payload: missing 'domain'" in exc_info.value.args[0] + ) + + +def test_marshal_background_event_data_bad_request(): + req = pretend.stub(headers={}, get_json=lambda: None) + + with pytest.raises(EventConversionException): + event_conversion.background_event_to_cloud_event(req) + + +@pytest.mark.parametrize( + "background_resource", + [ + BACKGROUND_RESOURCE, + BACKGROUND_RESOURCE_WITHOUT_SERVICE, + BACKGROUND_RESOURCE_STRING, + ], +) +def test_split_resource(background_resource): + context = Context( + eventType="google.storage.object.finalize", resource=background_resource + ) + service, resource, subject = event_conversion._split_resource(context) + assert service == "storage.googleapis.com" + assert resource == "projects/_/buckets/some-bucket" + assert subject == "objects/folder/Test.cs" + + +def test_split_resource_unknown_service_and_event_type(): + # With both an unknown service and an unknown event type, we won't attempt any + # event type mapping or resource/subject splitting. + background_resource = { + "service": "not_a_known_service", + "name": "projects/_/my/stuff/at/test.txt", + "type": "storage#object", + } + context = Context(eventType="not_a_known_event_type", resource=background_resource) + service, resource, subject = event_conversion._split_resource(context) + assert service == "not_a_known_service" + assert resource == "projects/_/my/stuff/at/test.txt" + assert subject == "" + + +def test_split_resource_without_service_unknown_event_type(): + background_resource = { + "name": "projects/_/buckets/some-bucket/objects/folder/Test.cs", + "type": "storage#object", + } + # This event type cannot be mapped to an equivalent CloudEvent type. + context = Context(eventType="not_a_known_event_type", resource=background_resource) + with pytest.raises(EventConversionException) as exc_info: + event_conversion._split_resource(context) + assert "Unable to find CloudEvent equivalent service" in exc_info.value.args[0] + + +def test_split_resource_no_resource_regex_match(): + background_resource = { + "service": "storage.googleapis.com", + # This name will not match the regex associated with the service. + "name": "foo/bar/baz", + "type": "storage#object", + } + context = Context( + eventType="google.storage.object.finalize", resource=background_resource + ) + with pytest.raises(EventConversionException) as exc_info: + event_conversion._split_resource(context) + assert "Resource regex did not match" in exc_info.value.args[0] + + +def test_marshal_background_event_data_without_topic_in_path( + raw_pubsub_request, marshalled_pubsub_request +): + req = flask.Request.from_values(json=raw_pubsub_request, path="/myfunc/") + payload = event_conversion.marshal_background_event_data(req) + + # Remove timestamps as they get generates on the fly + del marshalled_pubsub_request["context"]["timestamp"] + del payload["context"]["timestamp"] + + # Resource name is set to empty string when it cannot be parsed from the request path + marshalled_pubsub_request["context"]["resource"]["name"] = "" + + assert payload == marshalled_pubsub_request + + +def test_marshal_background_event_data_without_topic_in_path_no_attr( + raw_pubsub_request_noattributes, marshalled_pubsub_request_noattr +): + req = flask.Request.from_values( + json=raw_pubsub_request_noattributes, path="/myfunc/" + ) + payload = event_conversion.marshal_background_event_data(req) + + # Remove timestamps as they get generates on the fly + del marshalled_pubsub_request_noattr["context"]["timestamp"] + del payload["context"]["timestamp"] + + # Resource name is set to empty string when it cannot be parsed from the request path + marshalled_pubsub_request_noattr["context"]["resource"]["name"] = "" + + assert payload == marshalled_pubsub_request_noattr + + +def test_marshal_background_event_data_with_topic_path( + raw_pubsub_request, marshalled_pubsub_request +): + req = flask.Request.from_values( + json=raw_pubsub_request, + path="x/projects/sample-project/topics/gcf-test?pubsub_trigger=true", + ) + payload = event_conversion.marshal_background_event_data(req) + + # Remove timestamps as they are generated on the fly. + del marshalled_pubsub_request["context"]["timestamp"] + del payload["context"]["timestamp"] + + assert payload == marshalled_pubsub_request + + +@pytest.mark.parametrize( + "request_fixture, overrides", + [ + ( + "raw_pubsub_request", + { + "request_path": "x/projects/sample-project/topics/gcf-test?pubsub_trigger=true", + }, + ), + ("raw_pubsub_request", {"source": "//pubsub.googleapis.com/"}), + ("marshalled_pubsub_request", {}), + ], +) +def test_pubsub_emulator_request_to_cloud_event( + raw_pubsub_cloud_event_output, request_fixture, overrides, request +): + request_path = overrides.get("request_path", "/") + payload = request.getfixturevalue(request_fixture) + req = flask.Request.from_values( + path=request_path, + json=payload, + ) + cloud_event = event_conversion.background_event_to_cloud_event(req) + + # Remove timestamps as they are generated on the fly. + del raw_pubsub_cloud_event_output["time"] + del raw_pubsub_cloud_event_output.data["message"]["publishTime"] + del cloud_event["time"] + del cloud_event.data["message"]["publishTime"] + + if "source" in overrides: + # Default to the service name, when the topic is not configured subscription's pushEndpoint. + raw_pubsub_cloud_event_output["source"] = overrides["source"] + + assert cloud_event == raw_pubsub_cloud_event_output + + +def test_pubsub_emulator_request_with_invalid_message( + raw_pubsub_request, raw_pubsub_cloud_event_output +): + # Create an invalid message payload + raw_pubsub_request["message"] = None + req = flask.Request.from_values(json=raw_pubsub_request, path="/") + + with pytest.raises(EventConversionException) as exc_info: + cloud_event = event_conversion.background_event_to_cloud_event(req) + assert "Failed to convert Pub/Sub payload to event" in exc_info.value.args[0] + + +@pytest.mark.parametrize( + "ce_event_type, ce_source, expected_type, expected_resource", + [ + ( + "google.firebase.database.ref.v1.written", + "//firebasedatabase.googleapis.com/projects/_/instances/my-project-id", + "providers/google.firebase.database/eventTypes/ref.write", + "projects/_/instances/my-project-id/my/subject", + ), + ( + "google.cloud.pubsub.topic.v1.messagePublished", + "//pubsub.googleapis.com/projects/sample-project/topics/gcf-test", + "google.pubsub.topic.publish", + { + "service": "pubsub.googleapis.com", + "name": "projects/sample-project/topics/gcf-test", + "type": "type.googleapis.com/google.pubsub.v1.PubsubMessage", + }, + ), + ( + "google.cloud.storage.object.v1.finalized", + "//storage.googleapis.com/projects/_/buckets/some-bucket", + "google.storage.object.finalize", + { + "service": "storage.googleapis.com", + "name": "projects/_/buckets/some-bucket/my/subject", + "type": "value", + }, + ), + ( + "google.firebase.auth.user.v1.created", + "//firebaseauth.googleapis.com/projects/my-project-id", + "providers/firebase.auth/eventTypes/user.create", + "projects/my-project-id", + ), + ( + "google.firebase.database.ref.v1.written", + "//firebasedatabase.googleapis.com/projects/_/locations/us-central1/instances/my-project-id", + "providers/google.firebase.database/eventTypes/ref.write", + "projects/_/instances/my-project-id/my/subject", + ), + ( + "google.cloud.firestore.document.v1.written", + "//firestore.googleapis.com/projects/project-id/databases/(default)", + "providers/cloud.firestore/eventTypes/document.write", + "projects/project-id/databases/(default)/my/subject", + ), + ], +) +def test_cloud_event_to_legacy_event( + create_ce_headers, + ce_event_type, + ce_source, + expected_type, + expected_resource, +): + headers = create_ce_headers(ce_event_type, ce_source) + req = flask.Request.from_values(headers=headers, json={"kind": "value"}) + + (res_data, res_context) = event_conversion.cloud_event_to_background_event(req) + + assert res_context.event_id == "my-id" + assert res_context.timestamp == "2020-08-16T13:58:54.471765" + assert res_context.event_type == expected_type + assert res_context.resource == expected_resource + assert res_data == {"kind": "value"} + + +def test_cloud_event_to_legacy_event_with_pubsub_message_payload( + create_ce_headers, +): + headers = create_ce_headers( + "google.cloud.pubsub.topic.v1.messagePublished", + "//pubsub.googleapis.com/projects/sample-project/topics/gcf-test", + ) + data = { + "message": { + "data": "fizzbuzz", + "messageId": "aaaaaa-1111-bbbb-2222-cccccccccccc", + "publishTime": "2020-09-29T11:32:00.000Z", + } + } + req = flask.Request.from_values(headers=headers, json=data) + + (res_data, res_context) = event_conversion.cloud_event_to_background_event(req) + + assert res_context.event_type == "google.pubsub.topic.publish" + assert res_data == {"data": "fizzbuzz"} + + +def test_cloud_event_to_legacy_event_with_firebase_auth_ce( + create_ce_headers, +): + headers = create_ce_headers( + "google.firebase.auth.user.v1.created", + "//firebaseauth.googleapis.com/projects/my-project-id", + ) + data = { + "metadata": { + "createTime": "2020-05-26T10:42:27Z", + "lastSignInTime": "2020-10-24T11:00:00Z", + }, + "uid": "my-id", + } + req = flask.Request.from_values(headers=headers, json=data) + + (res_data, res_context) = event_conversion.cloud_event_to_background_event(req) + + assert res_context.event_type == "providers/firebase.auth/eventTypes/user.create" + assert res_data == { + "metadata": { + "createdAt": "2020-05-26T10:42:27Z", + "lastSignedInAt": "2020-10-24T11:00:00Z", + }, + "uid": "my-id", + } + + +def test_cloud_event_to_legacy_event_with_firebase_auth_ce_empty_metadata( + create_ce_headers, +): + headers = create_ce_headers( + "google.firebase.auth.user.v1.created", + "//firebaseauth.googleapis.com/projects/my-project-id", + ) + data = {"metadata": {}, "uid": "my-id"} + req = flask.Request.from_values(headers=headers, json=data) + + (res_data, res_context) = event_conversion.cloud_event_to_background_event(req) + + assert res_context.event_type == "providers/firebase.auth/eventTypes/user.create" + assert res_data == data + + +@pytest.mark.parametrize( + "header_overrides, exception_message", + [ + ( + {"ce-source": "invalid-source-format"}, + "Unexpected CloudEvent source", + ), + ( + {"ce-source": None}, + "Failed to convert CloudEvent to BackgroundEvent", + ), + ( + {"ce-subject": None}, + "Failed to convert CloudEvent to BackgroundEvent", + ), + ( + {"ce-type": "unknown-type"}, + "Unable to find background event equivalent type for", + ), + ], +) +def test_cloud_event_to_legacy_event_with_invalid_event( + create_ce_headers, + header_overrides, + exception_message, +): + headers = create_ce_headers( + "google.firebase.database.ref.v1.written", + "//firebasedatabase.googleapis.com/projects/_/instances/my-project-id", + ) + for k, v in header_overrides.items(): + if v is None: + del headers[k] + else: + headers[k] = v + + req = flask.Request.from_values(headers=headers, json={"some": "val"}) + + with pytest.raises(EventConversionException) as exc_info: + event_conversion.cloud_event_to_background_event(req) + + assert exception_message in exc_info.value.args[0] + + +@pytest.mark.parametrize( + "source,expected_service,expected_name", + [ + ( + "//firebasedatabase.googleapis.com/projects/_/instances/my-project-id", + "firebasedatabase.googleapis.com", + "projects/_/instances/my-project-id", + ), + ( + "//firebaseauth.googleapis.com/projects/my-project-id", + "firebaseauth.googleapis.com", + "projects/my-project-id", + ), + ( + "//firestore.googleapis.com/projects/project-id/databases/(default)", + "firestore.googleapis.com", + "projects/project-id/databases/(default)", + ), + ], +) +def test_split_ce_source(source, expected_service, expected_name): + service, name = event_conversion._split_ce_source(source) + assert service == expected_service + assert name == expected_name diff --git a/tests/test_data/firebase-auth-cloud-event-output.json b/tests/test_data/firebase-auth-cloud-event-output.json new file mode 100644 index 00000000..329c483b --- /dev/null +++ b/tests/test_data/firebase-auth-cloud-event-output.json @@ -0,0 +1,24 @@ +{ + "specversion": "1.0", + "type": "google.firebase.auth.user.v1.created", + "source": "//firebaseauth.googleapis.com/projects/my-project-id", + "subject": "users/UUpby3s4spZre6kHsgVSPetzQ8l2", + "id": "aaaaaa-1111-bbbb-2222-cccccccccccc", + "time": "2020-09-29T11:32:00.000Z", + "datacontenttype": "application/json", + "data": { + "email": "test@nowhere.com", + "metadata": { + "createTime": "2020-05-26T10:42:27Z", + "lastSignInTime": "2020-10-24T11:00:00Z" + }, + "providerData": [ + { + "email": "test@nowhere.com", + "providerId": "password", + "uid": "test@nowhere.com" + } + ], + "uid": "UUpby3s4spZre6kHsgVSPetzQ8l2" + } +} diff --git a/tests/test_data/firebase-auth-legacy-input.json b/tests/test_data/firebase-auth-legacy-input.json new file mode 100644 index 00000000..1119ea7c --- /dev/null +++ b/tests/test_data/firebase-auth-legacy-input.json @@ -0,0 +1,23 @@ +{ + "data": { + "email": "test@nowhere.com", + "metadata": { + "createdAt": "2020-05-26T10:42:27Z", + "lastSignedInAt": "2020-10-24T11:00:00Z" + }, + "providerData": [ + { + "email": "test@nowhere.com", + "providerId": "password", + "uid": "test@nowhere.com" + } + ], + "uid": "UUpby3s4spZre6kHsgVSPetzQ8l2" + }, + "eventId": "aaaaaa-1111-bbbb-2222-cccccccccccc", + "eventType": "providers/firebase.auth/eventTypes/user.create", + "notSupported": { + }, + "resource": "projects/my-project-id", + "timestamp": "2020-09-29T11:32:00.000Z" +} diff --git a/tests/test_data/firebase-db-cloud-event-output.json b/tests/test_data/firebase-db-cloud-event-output.json new file mode 100644 index 00000000..25e7e8a6 --- /dev/null +++ b/tests/test_data/firebase-db-cloud-event-output.json @@ -0,0 +1,15 @@ +{ + "specversion": "1.0", + "type": "google.firebase.database.ref.v1.written", + "source": "//firebasedatabase.googleapis.com/projects/_/locations/us-central1/instances/my-project-id", + "subject": "refs/gcf-test/xyz", + "id": "aaaaaa-1111-bbbb-2222-cccccccccccc", + "time": "2020-09-29T11:32:00.000Z", + "datacontenttype": "application/json", + "data": { + "data": null, + "delta": { + "grandchild": "other" + } + } + } \ No newline at end of file diff --git a/tests/test_data/firebase-db-legacy-input.json b/tests/test_data/firebase-db-legacy-input.json new file mode 100644 index 00000000..8134d84d --- /dev/null +++ b/tests/test_data/firebase-db-legacy-input.json @@ -0,0 +1,19 @@ +{ + "eventType": "providers/google.firebase.database/eventTypes/ref.write", + "params": { + "child": "xyz" + }, + "auth": { + "admin": true + }, + "domain": "firebaseio.com", + "data": { + "data": null, + "delta": { + "grandchild": "other" + } + }, + "resource": "projects/_/instances/my-project-id/refs/gcf-test/xyz", + "timestamp": "2020-09-29T11:32:00.000Z", + "eventId": "aaaaaa-1111-bbbb-2222-cccccccccccc" + } \ No newline at end of file diff --git a/tests/test_data/pubsub_text-cloud-event-output.json b/tests/test_data/pubsub_text-cloud-event-output.json new file mode 100644 index 00000000..48204933 --- /dev/null +++ b/tests/test_data/pubsub_text-cloud-event-output.json @@ -0,0 +1,17 @@ +{ + "specversion": "1.0", + "type": "google.cloud.pubsub.topic.v1.messagePublished", + "source": "//pubsub.googleapis.com/projects/sample-project/topics/gcf-test", + "id": "aaaaaa-1111-bbbb-2222-cccccccccccc", + "time": "2020-09-29T11:32:00.000Z", + "datacontenttype": "application/json", + "data": { + "message": { + "@type": "type.googleapis.com/google.pubsub.v1.PubsubMessage", + "attributes": { + "attr1":"attr1-value" + }, + "data": "dGVzdCBtZXNzYWdlIDM=" + } + } +} diff --git a/tests/test_data/pubsub_text-legacy-input.json b/tests/test_data/pubsub_text-legacy-input.json new file mode 100644 index 00000000..6028d090 --- /dev/null +++ b/tests/test_data/pubsub_text-legacy-input.json @@ -0,0 +1,19 @@ +{ + "context": { + "eventId":"aaaaaa-1111-bbbb-2222-cccccccccccc", + "timestamp":"2020-09-29T11:32:00.000Z", + "eventType":"google.pubsub.topic.publish", + "resource":{ + "service":"pubsub.googleapis.com", + "name":"projects/sample-project/topics/gcf-test", + "type":"type.googleapis.com/google.pubsub.v1.PubsubMessage" + } + }, + "data": { + "@type": "type.googleapis.com/google.pubsub.v1.PubsubMessage", + "attributes": { + "attr1":"attr1-value" + }, + "data": "dGVzdCBtZXNzYWdlIDM=" + } +} diff --git a/tests/test_decorator_functions.py b/tests/test_decorator_functions.py new file mode 100644 index 00000000..3a6e5e99 --- /dev/null +++ b/tests/test_decorator_functions.py @@ -0,0 +1,177 @@ +# Copyright 2021 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 sys + +import pytest + +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 +except: + _ModuleNotFoundError = ImportError + + +@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" + if not request.param.startswith("async_"): + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app) + + +@pytest.fixture(params=["decorator.py", "async_decorator.py"]) +def http_decorator_client(request): + source = TEST_FUNCTIONS_DIR / "decorators" / request.param + target = "function_http" + 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 cloud_event_1_0(): + attributes = { + "specversion": "1.0", + "id": "my-id", + "source": "from-galaxy-far-far-away", + "type": "cloud_event.greet.you", + "time": "2020-08-16T13:58:54.471765", + } + data = {"name": "john"} + return CloudEvent(attributes, data) + + +def test_cloud_event_decorator(cloud_event_decorator_client, 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.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.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 new file mode 100644 index 00000000..5b517cdc --- /dev/null +++ b/tests/test_function_registry.py @@ -0,0 +1,78 @@ +# Copyright 2021 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 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 = [ + { + "name": "get decorator type", + "function": "my_func", + "registered_type": "http", + "flag_type": "event", + "env_type": "event", + "want_type": "http", + }, + { + "name": "get flag type", + "function": "my_func_1", + "registered_type": "", + "flag_type": "event", + "env_type": "http", + "want_type": "event", + }, + { + "name": "get env var", + "function": "my_func_2", + "registered_type": "", + "flag_type": "", + "env_type": "event", + "want_type": "event", + }, + ] + for case in test_cases: + _function_registry.REGISTRY_MAP[case["function"]] = case["registered_type"] + os.environ[_function_registry.FUNCTION_SIGNATURE_TYPE] = case["env_type"] + signature_type = _function_registry.get_func_signature_type( + case["function"], case["flag_type"] + ) + + assert signature_type == case["want_type"], case["name"] + + +def test_get_function_signature_default(): + _function_registry.REGISTRY_MAP["my_func"] = "" + if _function_registry.FUNCTION_SIGNATURE_TYPE in os.environ: + del os.environ[_function_registry.FUNCTION_SIGNATURE_TYPE] + signature_type = _function_registry.get_func_signature_type("my_func", None) + + assert signature_type == "http" diff --git a/tests/test_functions.py b/tests/test_functions.py index 4c964893..bafb5e8b 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -12,13 +12,28 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json import pathlib import re +import sys import time +import pretend import pytest -from functions_framework import create_app, exceptions +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" @@ -31,7 +46,12 @@ @pytest.fixture -def background_json(tmpdir): +def tempfile_payload(tmpdir): + return {"filename": str(tmpdir / "filename.txt"), "value": "some-value"} + + +@pytest.fixture +def background_json(tempfile_payload): return { "context": { "eventId": "some-eventId", @@ -39,140 +59,213 @@ def background_json(tmpdir): "eventType": "some-eventType", "resource": "some-resource", }, - "data": {"filename": str(tmpdir / "filename.txt"), "value": "some-value"}, + "data": tempfile_payload, } -def test_http_function_executes_success(): - source = TEST_FUNCTIONS_DIR / "http_trigger" / "main.py" +@pytest.fixture +def background_event_client(): + source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py" target = "function" + return create_app(target, source, "event").test_client() - 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" +@pytest.fixture +def create_ce_headers(): + return lambda event_type: { + "ce-id": "my-id", + "ce-source": "//firebasedatabase.googleapis.com/projects/_/instances/my-project-id", + "ce-type": event_type, + "ce-specversion": "1.0", + "ce-subject": "refs/gcf-test/xyz", + "ce-time": "2020-08-16T13:58:54.471765", + } -def test_http_function_executes_failure(): - source = TEST_FUNCTIONS_DIR / "http_trigger" / "main.py" +@pytest.fixture(params=["main.py", "async_main.py"]) +def http_trigger_client(request): + source = TEST_FUNCTIONS_DIR / "http_trigger" / request.param target = "function" + if not request.param.startswith("async_"): + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app, raise_server_exceptions=False) - client = create_app(target, source).test_client() - resp = client.get("/", json={"mode": "FAILURE"}) - assert resp.status_code == 400 - assert resp.data == b"failure" +@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" + if not request.param.startswith("async_"): + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient( + app, + # Override baseurl to use localhost instead of default http://testserver. + base_url="http://localhost", + ) -def test_http_function_executes_throw(): - source = TEST_FUNCTIONS_DIR / "http_trigger" / "main.py" +@pytest.fixture(params=["main.py", "async_main.py"]) +def http_check_env_client(request): + source = TEST_FUNCTIONS_DIR / "http_check_env" / request.param target = "function" + if not request.param.startswith("async_"): + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app) - client = create_app(target, source).test_client() - resp = client.put("/", json={"mode": "THROW"}) - assert resp.status_code == 500 +@pytest.fixture(params=["main.py", "async_main.py"]) +def http_trigger_sleep_client(request): + source = TEST_FUNCTIONS_DIR / "http_trigger_sleep" / request.param + target = "function" + if not request.param.startswith("async_"): + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app) -def test_http_function_request_url_empty_path(): - source = TEST_FUNCTIONS_DIR / "http_request_check" / "main.py" +@pytest.fixture(params=["main.py", "async_main.py"]) +def http_with_import_client(request): + source = TEST_FUNCTIONS_DIR / "http_with_import" / request.param target = "function" + if not request.param.startswith("async_"): + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app) - client = create_app(target, source).test_client() - resp = client.get("", json={"mode": "url"}) - assert resp.status_code == 308 - assert resp.location == "http://localhost/" +@pytest.fixture(params=["sync", "async"]) +def http_method_check_client(request): + source = TEST_FUNCTIONS_DIR / "http_method_check" / "main.py" + target = "function" + if not request.param == "async": + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app) -def test_http_function_request_url_slash(): - source = TEST_FUNCTIONS_DIR / "http_request_check" / "main.py" +@pytest.fixture(params=["sync", "async"]) +def module_is_correct_client(request): + source = TEST_FUNCTIONS_DIR / "module_is_correct" / "main.py" target = "function" + if not request.param == "async": + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app) - client = create_app(target, source).test_client() - resp = client.get("/", json={"mode": "url"}) - assert resp.status_code == 200 - assert resp.data == b"http://localhost/" +@pytest.fixture(params=["sync", "async"]) +def returns_none_client(request): + source = TEST_FUNCTIONS_DIR / "returns_none" / "main.py" + target = "function" + if not request.param == "async": + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app) -def test_http_function_rquest_url_path(): - source = TEST_FUNCTIONS_DIR / "http_request_check" / "main.py" +@pytest.fixture(params=["sync", "async"]) +def relative_imports_client(request): + source = TEST_FUNCTIONS_DIR / "relative_imports" / "main.py" target = "function" + if not request.param == "async": + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app) - client = create_app(target, source).test_client() - resp = client.get("/my_path", json={"mode": "url"}) +def test_http_function_executes_success(http_trigger_client): + resp = http_trigger_client.post("/my_path", json={"mode": "SUCCESS"}) assert resp.status_code == 200 - assert resp.data == b"http://localhost/my_path" - + assert resp.text == "success" -def test_http_function_request_path_slash(): - source = TEST_FUNCTIONS_DIR / "http_request_check" / "main.py" - target = "function" - - 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_json): - source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py" - target = "function" +def test_background_function_executes(background_event_client, background_json): + resp = background_event_client.post("/", json=background_json) + assert resp.status_code == 200 - client = create_app(target, source, "event").test_client() - resp = client.post("/", json=background_json) +def test_background_function_supports_get(background_event_client, background_json): + resp = background_event_client.get("/") assert resp.status_code == 200 @@ -210,14 +303,8 @@ def test_multiple_calls(background_json): assert resp.status_code == 200 -def test_pubsub_payload(background_json): - source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py" - target = "function" - - client = create_app(target, source, "event").test_client() - - resp = client.post("/", json=background_json) - +def test_pubsub_payload(background_event_client, background_json): + resp = background_event_client.post("/", json=background_json) assert resp.status_code == 200 assert resp.data == b"OK" @@ -227,6 +314,12 @@ def test_pubsub_payload(background_json): ) +def test_background_function_no_data(background_event_client, background_json): + headers = {"Content-Type": "application/json"} + resp = background_event_client.post("/", headers=headers) + assert resp.status_code == 400 + + def test_invalid_function_definition_missing_function_file(): source = TEST_FUNCTIONS_DIR / "missing_function_file" / "main.py" target = "functions" @@ -239,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" @@ -247,11 +341,13 @@ def test_invalid_function_definition_multiple_entry_points(): create_app(target, source, "event") assert re.match( - "File .* is expected to contain a function named function", str(excinfo.value) + "File .* is expected to contain a function named 'function'. Found: 'fun', 'myFunctionBar', 'myFunctionFoo' instead", + str(excinfo.value), ) -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" @@ -259,12 +355,13 @@ def test_invalid_function_definition_multiple_entry_points_invalid_function(): create_app(target, source, "event") assert re.match( - "File .* is expected to contain a function named invalidFunction", + "File .* is expected to contain a function named 'invalidFunction'. Found: 'fun', 'myFunctionBar', 'myFunctionFoo' instead", str(excinfo.value), ) -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" @@ -272,13 +369,14 @@ def test_invalid_function_definition_multiple_entry_points_not_a_function(): create_app(target, source, "event") assert re.match( - "The function defined in file .* as notAFunction needs to be of type " + "The function defined in file .* as 'notAFunction' needs to be of type " "function. Got: .*", str(excinfo.value), ) -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" @@ -293,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" @@ -303,6 +415,26 @@ def test_invalid_function_definition_missing_dependency(): assert "No module named 'nonexistentpackage'" in str(excinfo.value) +@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) + + assert ( + "Target is not specified (FUNCTION_TARGET environment variable not set)" + == str(excinfo.value) + ) + + +@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" + + with pytest.raises(exceptions.FunctionsFrameworkException) as excinfo: + create_app(target, source, "invalid_signature_type") + + def test_http_function_flask_render_template(): source = TEST_FUNCTIONS_DIR / "http_flask_render_template" / "main.py" target = "function" @@ -321,38 +453,249 @@ def test_http_function_flask_render_template(): ) -def test_http_function_with_import(): - source = TEST_FUNCTIONS_DIR / "http_with_import" / "main.py" +def test_http_function_with_import(http_with_import_client): + resp = http_with_import_client.get("/") + + assert resp.status_code == 200 + assert resp.text == "Hello" + + +@pytest.mark.parametrize( + "method, text", + [ + ("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(http_method_check_client, method, text): + resp = getattr(http_method_check_client, method)("/") + + assert resp.status_code == 200 + assert resp.text == text + + +@pytest.mark.parametrize("path", ["robots.txt", "favicon.ico"]) +def test_error_paths(http_trigger_client, path): + resp = http_trigger_client.get("/{}".format(path)) + + assert resp.status_code == 404 + assert "Not Found" in resp.text + + +@pytest.mark.parametrize( + "target, source, signature_type", + [(None, None, None), (pretend.stub(), pretend.stub(), pretend.stub())], +) +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, **kw: wsgi_app) + monkeypatch.setattr(functions_framework, "create_app", create_app) + + # Test that it's lazy + lazy_app = LazyWSGIApp(target, source, signature_type) + + assert lazy_app.app == None + + args = [pretend.stub(), pretend.stub()] + kwargs = {"a": pretend.stub(), "b": pretend.stub()} + + # Test that it's initialized when called + app = lazy_app(*args, **kwargs) + + assert app == actual_app_stub + assert create_app.calls == [pretend.call(target, source, signature_type)] + assert wsgi_app.calls == [pretend.call(*args, **kwargs)] + + # Test that it's only initialized once + app = lazy_app(*args, **kwargs) + + assert app == actual_app_stub + assert wsgi_app.calls == [ + pretend.call(*args, **kwargs), + pretend.call(*args, **kwargs), + ] + + +def test_dummy_error_handler(): + @errorhandler("foo", bar="baz") + def function(): + pass + + +def test_class_in_main_is_in_right_module(module_is_correct_client): + resp = module_is_correct_client.get("/") + + assert resp.status_code == 200 + + +def test_flask_current_app_is_available(): + source = TEST_FUNCTIONS_DIR / "flask_current_app" / "main.py" target = "function" client = create_app(target, source).test_client() - resp = client.get("/") assert resp.status_code == 200 - assert resp.data == b"Hello" + + +def test_function_returns_none(returns_none_client): + resp = returns_none_client.get("/") + + assert resp.status_code == 500 + + +def test_function_returns_stream(): + source = TEST_FUNCTIONS_DIR / "http_streaming" / "main.py" + target = "function" + + client = create_app(target, source).test_client() + resp = client.post("/", data="1\n2\n3\n4\n") + + assert resp.status_code == 200 + assert resp.is_streamed + 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" + + monkeypatch.setenv("ENTRY_POINT", target) + + client = create_app(target, source).test_client() + resp = client.post("/", json={"mode": "FUNCTION_TRIGGER_TYPE"}) + assert resp.status_code == 200 + assert resp.data == b"http" + + resp = client.post("/", json={"mode": "FUNCTION_NAME"}) + assert resp.status_code == 200 + assert resp.data.decode("utf-8") == target @pytest.mark.parametrize( - "method, data", + "mode, expected", [ - ("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"), + ("loginfo", '"severity": "INFO"'), + ("logwarn", '"severity": "ERROR"'), + ("logerr", '"severity": "ERROR"'), + ("logcrit", '"severity": "ERROR"'), + ("stdout", '"severity": "INFO"'), + ("stderr", '"severity": "ERROR"'), ], ) -def test_http_function_all_methods(method, data): - source = TEST_FUNCTIONS_DIR / "http_method_check" / "main.py" +def test_legacy_function_log_severity(monkeypatch, capfd, mode, expected): + source = TEST_FUNCTIONS_DIR / "http_check_severity" / "main.py" + target = "function" + + monkeypatch.setenv("ENTRY_POINT", target) + + client = create_app(target, source).test_client() + resp = client.post("/", json={"mode": mode}) + captured = capfd.readouterr().err + assert resp.status_code == 200 + assert expected in captured + + +def test_legacy_function_log_exception(monkeypatch, capfd): + source = TEST_FUNCTIONS_DIR / "http_log_exception" / "main.py" + target = "function" + severity = '"severity": "ERROR"' + traceback = "Traceback (most recent call last)" + + monkeypatch.setenv("ENTRY_POINT", target) + + client = create_app(target, source).test_client() + resp = client.post("/") + captured = capfd.readouterr().err + assert resp.status_code == 200 + assert severity in captured + assert traceback in captured + + +def test_legacy_function_returns_none(monkeypatch): + source = TEST_FUNCTIONS_DIR / "returns_none" / "main.py" + target = "function" + + monkeypatch.setenv("ENTRY_POINT", target) + + client = create_app(target, source).test_client() + resp = client.get("/") + + assert resp.status_code == 200 + assert resp.data == b"OK" + + +def test_errorhandler(monkeypatch): + source = TEST_FUNCTIONS_DIR / "errorhandler" / "main.py" target = "function" + monkeypatch.setenv("ENTRY_POINT", target) + client = create_app(target, source).test_client() + resp = client.get("/") + + assert resp.status_code == 418 + assert resp.data == b"I'm a teapot" + + +@pytest.mark.parametrize( + "event_type", + [ + "google.cloud.firestore.document.v1.written", + "google.cloud.pubsub.topic.v1.messagePublished", + "google.cloud.storage.object.v1.finalized", + "google.cloud.storage.object.v1.metadataUpdated", + "google.firebase.analytics.log.v1.written", + "google.firebase.auth.user.v1.created", + "google.firebase.auth.user.v1.deleted", + "google.firebase.database.ref.v1.written", + ], +) +def tests_cloud_to_background_event_client( + background_event_client, create_ce_headers, tempfile_payload, event_type +): + headers = create_ce_headers(event_type) + resp = background_event_client.post("/", headers=headers, json=tempfile_payload) + + assert resp.status_code == 200 + with open(tempfile_payload["filename"]) as json_file: + data = json.load(json_file) + assert data["value"] == "some-value" + + +def tests_cloud_to_background_event_client_invalid_source( + background_event_client, create_ce_headers, tempfile_payload +): + headers = create_ce_headers("google.cloud.firestore.document.v1.written") + headers["ce-source"] = "invalid" + + resp = background_event_client.post("/", headers=headers, json=tempfile_payload) + + assert resp.status_code == 500 - resp = getattr(client, method)("/") +def test_relative_imports(relative_imports_client): + resp = relative_imports_client.get("/") assert resp.status_code == 200 - assert resp.data == data + assert resp.text == "success" diff --git a/tests/test_functions/background_missing_dependency/main.py b/tests/test_functions/background_missing_dependency/main.py index 19857092..3050adfc 100644 --- a/tests/test_functions/background_missing_dependency/main.py +++ b/tests/test_functions/background_missing_dependency/main.py @@ -19,14 +19,14 @@ def function(event, context): """Test function which uses a package which has not been provided. - The packaged imported above does not exist. Therefore, this import should - fail, the Worker should detect this error, and return appropriate load - response. + The packaged imported above does not exist. Therefore, this import should + fail, the Worker should detect this error, and return appropriate load + response. - Args: - event: The event data which triggered this background function. - context (google.cloud.functions.Context): The Cloud Functions event context. - """ + Args: + event: The event data which triggered this background function. + context (google.cloud.functions.Context): The Cloud Functions event context. + """ del event del context nonexistentpackage.wontwork("This function isn't expected to work.") diff --git a/tests/test_functions/background_multiple_entry_points/main.py b/tests/test_functions/background_multiple_entry_points/main.py index 9d976570..56b1a73f 100644 --- a/tests/test_functions/background_multiple_entry_points/main.py +++ b/tests/test_functions/background_multiple_entry_points/main.py @@ -18,14 +18,14 @@ def fun(name, event): """Test function implementation. - It writes the expected output (entry point name and the given value) to the - given file, as a response from the background function, verified by the test. + It writes the expected output (entry point name and the given value) to the + given file, as a response from the background function, verified by the test. - Args: - name: Entry point function which called this helper function. - event: The event which triggered this background function. Must contain - entries for 'value' and 'filename' keys in the data dictionary. - """ + Args: + name: Entry point function which called this helper function. + event: The event which triggered this background function. Must contain + entries for 'value' and 'filename' keys in the data dictionary. + """ filename = event["filename"] value = event["value"] f = open(filename, "w") @@ -38,15 +38,15 @@ def myFunctionFoo( ): # Used in test, pylint: disable=invalid-name,unused-argument """Test function at entry point myFunctionFoo. - Loaded in a test which verifies entry point handling in a file with multiple - entry points. + Loaded in a test which verifies entry point handling in a file with multiple + entry points. - Args: - event: The event data (as dictionary) which triggered this background - function. Must contain entries for 'value' and 'filename' keys in the data - dictionary. - context (google.cloud.functions.Context): The Cloud Functions event context. - """ + Args: + event: The event data (as dictionary) which triggered this background + function. Must contain entries for 'value' and 'filename' keys in the data + dictionary. + context (google.cloud.functions.Context): The Cloud Functions event context. + """ fun("myFunctionFoo", event) @@ -55,15 +55,15 @@ def myFunctionBar( ): # Used in test, pylint: disable=invalid-name,unused-argument """Test function at entry point myFunctionBar. - Loaded in a test which verifies entry point handling in a file with multiple - entry points. + Loaded in a test which verifies entry point handling in a file with multiple + entry points. - Args: - event: The event data (as dictionary) which triggered this background - function. Must contain entries for 'value' and 'filename' keys in the data - dictionary. - context (google.cloud.functions.Context): The Cloud Functions event context. - """ + Args: + event: The event data (as dictionary) which triggered this background + function. Must contain entries for 'value' and 'filename' keys in the data + dictionary. + context (google.cloud.functions.Context): The Cloud Functions event context. + """ fun("myFunctionBar", event) diff --git a/tests/test_functions/background_trigger/main.py b/tests/test_functions/background_trigger/main.py index 75cc3303..842c4889 100644 --- a/tests/test_functions/background_trigger/main.py +++ b/tests/test_functions/background_trigger/main.py @@ -20,15 +20,15 @@ def function( ): # Required by function definition pylint: disable=unused-argument """Test background function. - It writes the expected output (entry point name and the given value) to the - given file, as a response from the background function, verified by the test. + It writes the expected output (entry point name and the given value) to the + given file, as a response from the background function, verified by the test. - Args: - event: The event data (as dictionary) which triggered this background - function. Must contain entries for 'value' and 'filename' keys in the - data dictionary. - context (google.cloud.functions.Context): The Cloud Functions event context. - """ + Args: + event: The event data (as dictionary) which triggered this background + function. Must contain entries for 'value' and 'filename' keys in the + data dictionary. + context (google.cloud.functions.Context): The Cloud Functions event context. + """ filename = event["filename"] value = event["value"] f = open(filename, "w") 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/cloud_events/converted_background_event.py b/tests/test_functions/cloud_events/converted_background_event.py new file mode 100644 index 00000000..9264251d --- /dev/null +++ b/tests/test_functions/cloud_events/converted_background_event.py @@ -0,0 +1,53 @@ +# Copyright 2021 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 functions.""" +import flask + + +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. + + """ + data = { + "message": { + "@type": "type.googleapis.com/google.pubsub.v1.PubsubMessage", + "attributes": { + "attr1": "attr1-value", + }, + "data": "dGVzdCBtZXNzYWdlIDM=", + "messageId": "aaaaaa-1111-bbbb-2222-cccccccccccc", + "publishTime": "2020-09-29T11:32:00.000Z", + }, + } + + valid_event = ( + cloud_event["id"] == "aaaaaa-1111-bbbb-2222-cccccccccccc" + and cloud_event.data == data + and cloud_event["source"] + == "//pubsub.googleapis.com/projects/sample-project/topics/gcf-test" + and cloud_event["type"] == "google.cloud.pubsub.topic.v1.messagePublished" + and cloud_event["time"] == "2020-09-29T11:32:00.000Z" + ) + + if not valid_event: + flask.abort(500) diff --git a/tests/test_functions/cloud_events/empty_data.py b/tests/test_functions/cloud_events/empty_data.py new file mode 100644 index 00000000..d9209667 --- /dev/null +++ b/tests/test_functions/cloud_events/empty_data.py @@ -0,0 +1,39 @@ +# 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. + +"""Function used to test handling CloudEvent functions.""" +import flask + + +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: + flask.abort(500) diff --git a/tests/test_functions/cloud_events/main.py b/tests/test_functions/cloud_events/main.py new file mode 100644 index 00000000..739d2a9d --- /dev/null +++ b/tests/test_functions/cloud_events/main.py @@ -0,0 +1,40 @@ +# 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. + +"""Function used to test handling CloudEvent functions.""" +import flask + + +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: + flask.abort(500) 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/decorators/decorator.py b/tests/test_functions/decorators/decorator.py new file mode 100644 index 00000000..3aae119d --- /dev/null +++ b/tests/test_functions/decorators/decorator.py @@ -0,0 +1,66 @@ +# Copyright 2021 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.""" +import flask + +import functions_framework + + +@functions_framework.cloud_event +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: + flask.abort(500) + + +@functions_framework.http +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. + """ + mode = request.get_json().get("mode") + if mode == "path": + return request.path + else: + return "invalid request", 400 diff --git a/tests/test_functions/errorhandler/main.py b/tests/test_functions/errorhandler/main.py new file mode 100644 index 00000000..588ef4ef --- /dev/null +++ b/tests/test_functions/errorhandler/main.py @@ -0,0 +1,25 @@ +# 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 functions_framework + + +@functions_framework.errorhandler(ZeroDivisionError) +def handle_zero_division(e): + return "I'm a teapot", 418 + + +def function(request): + 1 / 0 + return "Success", 200 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/flask_current_app/main.py b/tests/test_functions/flask_current_app/main.py new file mode 100644 index 00000000..faa19ab8 --- /dev/null +++ b/tests/test_functions/flask_current_app/main.py @@ -0,0 +1,24 @@ +# 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. + +"""Test that flask.current_app is importable and usable outside the function""" + +from flask import current_app + +with current_app.app_context(): + pass + + +def function(request): + return "OK" 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_check_env/main.py b/tests/test_functions/http_check_env/main.py index 84859634..9c68dee8 100644 --- a/tests/test_functions/http_check_env/main.py +++ b/tests/test_functions/http_check_env/main.py @@ -23,13 +23,13 @@ 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. + 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. - """ + Returns: + Value of the requested environment variable. + """ name = request.get_json().get("mode") return os.environ[name] diff --git a/tests/test_functions/http_check_severity/main.py b/tests/test_functions/http_check_severity/main.py new file mode 100644 index 00000000..be586d8d --- /dev/null +++ b/tests/test_functions/http_check_severity/main.py @@ -0,0 +1,49 @@ +# 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. + +"""Function used in Worker tests of legacy GCF Python 3.7 logging.""" +import logging +import os +import sys + +X_GOOGLE_FUNCTION_NAME = "gcf-function" +X_GOOGLE_ENTRY_POINT = "function" +HOME = "/tmp" + + +def function(request): + """Test function which logs to the appropriate output. + + Args: + request: The HTTP request which triggered this function. Must contain name + of the requested output in the 'mode' field in JSON document + in request body. + + Returns: + Value of the mode. + """ + name = request.get_json().get("mode") + if name == "stdout": + print("log") + elif name == "stderr": + print("log", file=sys.stderr) + elif name == "loginfo": + logging.info("log") + elif name == "logwarn": + logging.warning("log") + elif name == "logerr": + logging.error("log") + elif name == "logcrit": + logging.critical("log") + return name diff --git a/tests/test_functions/http_flask_render_template/main.py b/tests/test_functions/http_flask_render_template/main.py index 413f0c9c..58e40231 100644 --- a/tests/test_functions/http_flask_render_template/main.py +++ b/tests/test_functions/http_flask_render_template/main.py @@ -20,20 +20,20 @@ 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. + 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. + 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. + Returns: + Value and status code defined for the given mode. - Raises: - Exception: Thrown when requested in the incoming mode specification. - """ + Raises: + Exception: Thrown when requested in the incoming mode specification. + """ if request.args and "message" in request.args: message = request.args.get("message") elif request.get_json() and "message" in request.get_json(): diff --git a/tests/test_functions/http_log_exception/main.py b/tests/test_functions/http_log_exception/main.py new file mode 100644 index 00000000..50becd1a --- /dev/null +++ b/tests/test_functions/http_log_exception/main.py @@ -0,0 +1,33 @@ +# 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. + +"""Function used in Worker tests of legacy GCF Python 3.7 logging.""" +import logging + +X_GOOGLE_FUNCTION_NAME = "gcf-function" +X_GOOGLE_ENTRY_POINT = "function" +HOME = "/tmp" + + +def function(request): + """Test function which logs exceptions. + + Args: + request: The HTTP request which triggered this function. + """ + try: + raise Exception + except: + logging.exception("log") + return None diff --git a/tests/test_functions/http_method_check/main.py b/tests/test_functions/http_method_check/main.py index 0782b616..bbba6bc7 100644 --- a/tests/test_functions/http_method_check/main.py +++ b/tests/test_functions/http_method_check/main.py @@ -18,10 +18,10 @@ def function(request): """Test HTTP function which returns the method it was called with - Args: - request: The HTTP request which triggered this function. + Args: + request: The HTTP request which triggered this function. - Returns: - The HTTP method which was used to call this function - """ + Returns: + The HTTP method which was used to call this function + """ return request.method 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_request_check/main.py b/tests/test_functions/http_request_check/main.py index 636069d8..1f678960 100644 --- a/tests/test_functions/http_request_check/main.py +++ b/tests/test_functions/http_request_check/main.py @@ -18,18 +18,18 @@ 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. + 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. + 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. - """ + Returns: + Value of the requested HTTP request element, or 'Bad Request' status in case + of unrecognized incoming request. + """ mode = request.get_json().get("mode") if mode == "path": return request.path 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_streaming/main.py b/tests/test_functions/http_streaming/main.py new file mode 100644 index 00000000..4b249697 --- /dev/null +++ b/tests/test_functions/http_streaming/main.py @@ -0,0 +1,44 @@ +# 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. + +"""Function used in Worker tests of handling HTTP functions.""" + +import flask + +from flask import Response, stream_with_context + + +def function(request): + """Test 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: + Value and status code defined for the given mode. + + Raises: + Exception: Thrown when requested in the incoming mode specification. + """ + print("INVOKED THE STREAM FUNCTION!!!") + + def generate(): + sum_so_far = 0 + for line in request.stream: + sum_so_far += float(line) + yield (str(sum_so_far) + "\n").encode("utf-8") + + return Response(stream_with_context(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/main.py b/tests/test_functions/http_trigger/main.py index ca207a48..b80d85b6 100644 --- a/tests/test_functions/http_trigger/main.py +++ b/tests/test_functions/http_trigger/main.py @@ -20,20 +20,20 @@ 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. + 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. + 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. + Returns: + Value and status code defined for the given mode. - Raises: - Exception: Thrown when requested in the incoming mode specification. - """ + Raises: + Exception: Thrown when requested in the incoming mode specification. + """ mode = request.get_json().get("mode") print("Mode: " + mode) # pylint: disable=superfluous-parens if mode == "SUCCESS": 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_trigger_sleep/main.py b/tests/test_functions/http_trigger_sleep/main.py index 46203a73..fcccf9b2 100644 --- a/tests/test_functions/http_trigger_sleep/main.py +++ b/tests/test_functions/http_trigger_sleep/main.py @@ -19,14 +19,14 @@ def function(request): """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. + 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. - """ + 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. + """ sleep_sec = int(request.get_json().get("mode")) / 1000.0 time.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/http_with_import/main.py b/tests/test_functions/http_with_import/main.py index f65996da..a07d646b 100644 --- a/tests/test_functions/http_with_import/main.py +++ b/tests/test_functions/http_with_import/main.py @@ -20,10 +20,10 @@ def function(request): """Test HTTP function which imports from another file - Args: - request: The HTTP request which triggered this function. + Args: + request: The HTTP request which triggered this function. - Returns: - The imported return value and status code defined for the given mode. - """ + Returns: + The imported return value and status code defined for the given mode. + """ return bar diff --git a/tests/test_functions/module_is_correct/main.py b/tests/test_functions/module_is_correct/main.py new file mode 100644 index 00000000..06d2f971 --- /dev/null +++ b/tests/test_functions/module_is_correct/main.py @@ -0,0 +1,32 @@ +# 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 os.path +import typing + + +class TestClass: + pass + + +def function(request): + # Ensure that the module for any object in this file is set correctly + _, filename = os.path.split(__file__) + name, _ = os.path.splitext(filename) + assert TestClass.__mro__[0].__module__ == name + + # Ensure that calling `get_type_hints` on an object in this file succeeds + assert typing.get_type_hints(TestClass) == {} + + return "OK" diff --git a/tests/test_functions/relative_imports/main.py b/tests/test_functions/relative_imports/main.py new file mode 100644 index 00000000..3c28ff1a --- /dev/null +++ b/tests/test_functions/relative_imports/main.py @@ -0,0 +1,22 @@ +# 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. + +"""Function used in test for relative imports.""" + +from .test import foo + + +def function(request): + """Test HTTP function who returns a value from a relative import""" + return foo diff --git a/tests/test_functions/relative_imports/test.py b/tests/test_functions/relative_imports/test.py new file mode 100644 index 00000000..862c735d --- /dev/null +++ b/tests/test_functions/relative_imports/test.py @@ -0,0 +1 @@ +foo = "success" diff --git a/tests/test_functions/returns_none/main.py b/tests/test_functions/returns_none/main.py new file mode 100644 index 00000000..9bd68bdf --- /dev/null +++ b/tests/test_functions/returns_none/main.py @@ -0,0 +1,27 @@ +# 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. + + +def function(request): + """Test HTTP function when using legacy GCF behavior. + + The function returns None, which should be a 200 response. + + Args: + request: The HTTP request which triggered this function. + + Returns: + None. + """ + return None 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_functions/typed_events/mismatch_types.py b/tests/test_functions/typed_events/mismatch_types.py new file mode 100644 index 00000000..0f238d9c --- /dev/null +++ b/tests/test_functions/typed_events/mismatch_types.py @@ -0,0 +1,43 @@ +# Copyright 2022 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 typed decorators.""" + +import flask + +import functions_framework + + +class TestType1: + name: str + age: int + + def __init__(self, name: str, age: int) -> None: + self.name = name + self.age = age + + +class TestType2: + name: str + + def __init__(self, name: str) -> None: + self.name = name + + +@functions_framework.typed(TestType2) +def function_typed_mismatch_types(test_type: TestType1): + valid_event = test_type.name == "john" and test_type.age == 10 + if not valid_event: + raise Exception("Received invalid input") + return test_type diff --git a/tests/test_functions/typed_events/missing_from_dict.py b/tests/test_functions/typed_events/missing_from_dict.py new file mode 100644 index 00000000..73a2cf93 --- /dev/null +++ b/tests/test_functions/typed_events/missing_from_dict.py @@ -0,0 +1,55 @@ +# Copyright 2022 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 typed decorators.""" +from typing import Any, TypeVar + +import flask + +import functions_framework + +T = TypeVar("T") + + +def from_str(x: Any) -> str: + assert isinstance(x, str) + return x + + +def from_int(x: Any) -> int: + assert isinstance(x, int) and not isinstance(x, bool) + return x + + +class TestTypeMissingFromDict: + name: str + age: int + + def __init__(self, name: str, age: int) -> None: + self.name = name + self.age = age + + def to_dict(self) -> dict: + result: dict = {} + result["name"] = from_str(self.name) + result["age"] = from_int(self.age) + return result + + +@functions_framework.typed(TestTypeMissingFromDict) +def function_typed_missing_from_dict(test_type: TestTypeMissingFromDict): + valid_event = test_type.name == "john" and test_type.age == 10 + if not valid_event: + raise Exception("Received invalid input") + return test_type diff --git a/tests/test_functions/typed_events/missing_parameter.py b/tests/test_functions/typed_events/missing_parameter.py new file mode 100644 index 00000000..64681d8e --- /dev/null +++ b/tests/test_functions/typed_events/missing_parameter.py @@ -0,0 +1,23 @@ +# Copyright 2022 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 typed decorators.""" +import flask + +import functions_framework + + +@functions_framework.typed +def function_typed_missing_type_information(): + print("hello") diff --git a/tests/test_functions/typed_events/missing_to_dict.py b/tests/test_functions/typed_events/missing_to_dict.py new file mode 100644 index 00000000..76c95344 --- /dev/null +++ b/tests/test_functions/typed_events/missing_to_dict.py @@ -0,0 +1,55 @@ +# Copyright 2022 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 typed decorators.""" +from typing import Any, TypeVar + +import flask + +import functions_framework + +T = TypeVar("T") + + +def from_str(x: Any) -> str: + assert isinstance(x, str) + return x + + +def from_int(x: Any) -> int: + assert isinstance(x, int) and not isinstance(x, bool) + return x + + +class TestTypeMissingToDict: + name: str + age: int + + def __init__(self, name: str, age: int) -> None: + self.name = name + self.age = age + + @staticmethod + def from_dict(obj: dict) -> "TestTypeMissingToDict": + name = from_str(obj.get("name")) + age = from_int(obj.get("age")) + return TestTypeMissingToDict(name, age) + + +@functions_framework.typed(TestTypeMissingToDict) +def function_typed_missing_to_dict(testType: TestTypeMissingToDict): + valid_event = testType.name == "john" and testType.age == 10 + if not valid_event: + raise Exception("Received invalid input") + return testType diff --git a/tests/test_functions/typed_events/missing_type.py b/tests/test_functions/typed_events/missing_type.py new file mode 100644 index 00000000..1f35c0d6 --- /dev/null +++ b/tests/test_functions/typed_events/missing_type.py @@ -0,0 +1,26 @@ +# Copyright 2022 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 typed decorators.""" +import flask + +import functions_framework + + +@functions_framework.typed +def function_typed_missing_type_information(testType): + valid_event = testType.name == "john" and testType.age == 10 + if not valid_event: + raise Exception("Received invalid input") + return testType diff --git a/tests/test_functions/typed_events/typed_event.py b/tests/test_functions/typed_events/typed_event.py new file mode 100644 index 00000000..ac00d2fe --- /dev/null +++ b/tests/test_functions/typed_events/typed_event.py @@ -0,0 +1,141 @@ +# Copyright 2022 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 typed decorators.""" +from typing import Any, Type, TypeVar, cast + +import flask + +import functions_framework + +T = TypeVar("T") + + +def from_str(x: Any) -> str: + assert isinstance(x, str) + return x + + +def from_int(x: Any) -> int: + assert isinstance(x, int) and not isinstance(x, bool) + return x + + +def to_class(c: Type[T], x: Any) -> dict: + assert isinstance(x, c) + return cast(Any, x).to_dict() + + +class TestType: + name: str + age: int + + def __init__(self, name: str, age: int) -> None: + self.name = name + self.age = age + + @staticmethod + def from_dict(obj: dict) -> "TestType": + name = from_str(obj.get("name")) + age = from_int(obj.get("age")) + return TestType(name, age) + + def to_dict(self) -> dict: + result: dict = {} + result["name"] = from_str(self.name) + result["age"] = from_int(self.age) + return result + + +class SampleType: + country: str + population: int + + def __init__(self, country: str, population: int) -> None: + self.country = country + self.population = population + + @staticmethod + def from_dict(obj: dict) -> "SampleType": + country = from_str(obj.get("country")) + population = from_int(obj.get("population")) + return SampleType(country, population) + + def to_dict(self) -> dict: + result: dict = {} + result["country"] = from_str(self.country) + result["population"] = from_int(self.population) + return result + + +class FaultyType: + country: str + population: int + + def __init__(self, country: str, population: int) -> None: + self.country = country + self.population = population + + @staticmethod + def from_dict(obj: dict) -> "SampleType": + country = from_str(obj.get("country")) + population = from_int(obj.get("population")) + return SampleType(country, population / 0) + + +@functions_framework.typed(TestType) +def function_typed(testType: TestType): + valid_event = testType.name == "john" and testType.age == 10 + if not valid_event: + raise Exception("Received invalid input") + return testType + + +@functions_framework.typed +def function_typed_reflect(testType: TestType): + valid_event = testType.name == "jane" and testType.age == 20 + if not valid_event: + raise Exception("Received invalid input") + return testType + + +@functions_framework.typed +def function_typed_no_return(testType: TestType): + valid_event = testType.name == "jane" and testType.age == 20 + if not valid_event: + raise Exception("Received invalid input") + + +@functions_framework.typed +def function_typed_string_return(testType: TestType): + valid_event = testType.name == "jane" and testType.age == 20 + if not valid_event: + raise Exception("Received invalid input") + return "Hello " + testType.name + + +@functions_framework.typed(TestType) +def function_typed_different_types(testType: TestType) -> SampleType: + valid_event = testType.name == "jane" and testType.age == 20 + if not valid_event: + raise Exception("Received invalid input") + sampleType = SampleType("Monaco", 40000) + return sampleType + + +@functions_framework.typed +def function_typed_faulty_from_dict(input: FaultyType): + valid_event = input.country == "Monaco" and input.population == 40000 + if not valid_event: + raise Exception("Received invalid input") diff --git a/tests/test_http.py b/tests/test_http.py new file mode 100644 index 00000000..df9d4c6c --- /dev/null +++ b/tests/test_http.py @@ -0,0 +1,252 @@ +# 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 os +import platform +import sys + +import flask +import pretend +import pytest + +import functions_framework._http + + +@pytest.mark.parametrize("debug", [True, False]) +def test_create_server(monkeypatch, debug): + server_stub = pretend.stub() + httpserver = pretend.call_recorder(lambda *a, **kw: server_stub) + monkeypatch.setattr(functions_framework._http, "HTTPServer", httpserver) + wsgi_app = pretend.stub() + options = {"a": pretend.stub(), "b": pretend.stub()} + + functions_framework._http.create_server(wsgi_app, debug, **options) + + assert httpserver.calls == [pretend.call(wsgi_app, debug, **options)] + + +@pytest.mark.parametrize( + "debug, gunicorn_missing, expected", + [ + (True, False, "flask"), + (False, False, "flask" if platform.system() == "Windows" else "gunicorn"), + (True, True, "flask"), + (False, True, "flask"), + ], +) +def test_httpserver(monkeypatch, debug, gunicorn_missing, expected): + 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), + "gunicorn": pretend.call_recorder(lambda *a, **kw: http_server), + } + options = {"a": pretend.stub(), "b": pretend.stub()} + + monkeypatch.setattr( + functions_framework._http, "FlaskApplication", server_classes["flask"] + ) + if gunicorn_missing or platform.system() == "Windows": + monkeypatch.setitem(sys.modules, "functions_framework._http.gunicorn", None) + else: + from functions_framework._http import gunicorn + + monkeypatch.setattr(gunicorn, "GunicornApplication", server_classes["gunicorn"]) + + 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'") +@pytest.mark.parametrize("debug", [True, False]) +def test_gunicorn_application(debug): + app = pretend.stub() + host = "1.2.3.4" + port = "1234" + options = {} + + import functions_framework._http.gunicorn + + gunicorn_app = functions_framework._http.gunicorn.GunicornApplication( + app, host, port, debug, **options + ) + + assert gunicorn_app.app == app + assert gunicorn_app.options == { + "bind": "%s:%s" % (host, port), + "workers": 1, + "threads": os.cpu_count() * 4, + "timeout": 0, + "loglevel": "error", + "limit_request_line": 0, + } + + assert gunicorn_app.cfg.bind == ["1.2.3.4:1234"] + assert gunicorn_app.cfg.workers == 1 + assert gunicorn_app.cfg.threads == os.cpu_count() * 4 + assert gunicorn_app.cfg.timeout == 0 + assert gunicorn_app.load() == app + + +@pytest.mark.parametrize("debug", [True, False]) +def test_flask_application(debug): + app = pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None)) + host = pretend.stub() + port = pretend.stub() + options = {"a": pretend.stub(), "b": pretend.stub()} + + flask_app = functions_framework._http.flask.FlaskApplication( + app, host, port, debug, **options + ) + + assert flask_app.app == app + assert flask_app.host == host + assert flask_app.port == port + assert flask_app.debug == debug + assert flask_app.options == options + + flask_app.run() + + 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_main.py b/tests/test_main.py new file mode 100644 index 00000000..91df82cf --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,26 @@ +# 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 pretend + +import functions_framework._cli + + +def test_main(monkeypatch): + _cli = pretend.call_recorder(lambda prog_name: None) + monkeypatch.setattr(functions_framework._cli, "_cli", _cli) + + from functions_framework import __main__ + + assert _cli.calls == [pretend.call(prog_name="python -m functions_framework")] diff --git a/tests/test_samples.py b/tests/test_samples.py new file mode 100644 index 00000000..d76d7796 --- /dev/null +++ b/tests/test_samples.py @@ -0,0 +1,44 @@ +import pathlib +import sys +import time + +import docker +import pytest +import requests + +EXAMPLES_DIR = pathlib.Path(__file__).resolve().parent.parent / "examples" + + +@pytest.mark.skipif( + sys.platform != "linux", reason="docker only works on linux in GH actions" +) +class TestSamples: + def stop_all_containers(self, docker_client): + containers = docker_client.containers.list() + for container in containers: + container.stop() + + @pytest.mark.slow_integration_test + def test_cloud_run_http(self): + client = docker.from_env() + self.stop_all_containers(client) + + TAG = "cloud_run_http" + 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 + while success == False and timeout > 0: + try: + response = requests.get("http://localhost:8080") + if response.text == "Hello world!": + success = True + except: + pass + + time.sleep(1) + timeout -= 1 + + container.stop() + + assert success 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_typed_event_functions.py b/tests/test_typed_event_functions.py new file mode 100644 index 00000000..3b8d5da1 --- /dev/null +++ b/tests/test_typed_event_functions.py @@ -0,0 +1,123 @@ +# Copyright 2022 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 pytest + +from functions_framework import create_app +from functions_framework.exceptions import FunctionsFrameworkException + +TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" + +# Python 3.5: ModuleNotFoundError does not exist +try: + _ModuleNotFoundError = ModuleNotFoundError +except: + _ModuleNotFoundError = ImportError + + +@pytest.fixture +def typed_decorator_client(function_name): + source = TEST_FUNCTIONS_DIR / "typed_events" / "typed_event.py" + target = function_name + return create_app(target, source).test_client() + + +@pytest.fixture +def typed_decorator_missing_to_dict(): + source = TEST_FUNCTIONS_DIR / "typed_events" / "missing_to_dict.py" + target = "function_typed_missing_to_dict" + return create_app(target, source).test_client() + + +@pytest.mark.parametrize("function_name", ["function_typed"]) +def test_typed_decorator(typed_decorator_client): + resp = typed_decorator_client.post("/", json={"name": "john", "age": 10}) + assert resp.status_code == 200 + assert resp.data == b'{"name": "john", "age": 10}' + + +@pytest.mark.parametrize("function_name", ["function_typed"]) +def test_typed_malformed_json(typed_decorator_client): + resp = typed_decorator_client.post("/", data="abc", content_type="application/json") + assert resp.status_code == 500 + + +@pytest.mark.parametrize("function_name", ["function_typed_faulty_from_dict"]) +def test_typed_faulty_from_dict(typed_decorator_client): + resp = typed_decorator_client.post( + "/", json={"country": "Monaco", "population": 40000} + ) + assert resp.status_code == 500 + + +@pytest.mark.parametrize("function_name", ["function_typed_reflect"]) +def test_typed_reflect_decorator(typed_decorator_client): + resp = typed_decorator_client.post("/", json={"name": "jane", "age": 20}) + assert resp.status_code == 200 + assert resp.data == b'{"name": "jane", "age": 20}' + + +@pytest.mark.parametrize("function_name", ["function_typed_different_types"]) +def test_typed_different_types(typed_decorator_client): + resp = typed_decorator_client.post("/", json={"name": "jane", "age": 20}) + assert resp.status_code == 200 + assert resp.data == b'{"country": "Monaco", "population": 40000}' + + +@pytest.mark.parametrize("function_name", ["function_typed_no_return"]) +def test_typed_no_return(typed_decorator_client): + resp = typed_decorator_client.post("/", json={"name": "jane", "age": 20}) + assert resp.status_code == 200 + assert resp.data == b"" + + +@pytest.mark.parametrize("function_name", ["function_typed_string_return"]) +def test_typed_string_return(typed_decorator_client): + resp = typed_decorator_client.post("/", json={"name": "jane", "age": 20}) + assert resp.status_code == 200 + assert resp.data == b"Hello jane" + + +def test_missing_from_dict_typed_decorator(): + source = TEST_FUNCTIONS_DIR / "typed_events" / "missing_from_dict.py" + target = "function_typed_missing_from_dict" + with pytest.raises(FunctionsFrameworkException) as excinfo: + create_app(target, source).test_client() + + +def test_mismatch_types_typed_decorator(): + source = TEST_FUNCTIONS_DIR / "typed_events" / "mismatch_types.py" + target = "function_typed_mismatch_types" + with pytest.raises(FunctionsFrameworkException) as excinfo: + create_app(target, source).test_client() + + +def test_missing_type_information_typed_decorator(): + source = TEST_FUNCTIONS_DIR / "typed_events" / "missing_type.py" + target = "function_typed_missing_type_information" + with pytest.raises(FunctionsFrameworkException): + create_app(target, source).test_client() + + +def test_missing_parameter_typed_decorator(): + source = TEST_FUNCTIONS_DIR / "typed_events" / "missing_parameter.py" + target = "function_typed_missing_parameter" + with pytest.raises(FunctionsFrameworkException): + create_app(target, source).test_client() + + +def test_missing_to_dict_typed_decorator(typed_decorator_missing_to_dict): + resp = typed_decorator_missing_to_dict.post("/", json={"name": "john", "age": 10}) + assert resp.status_code == 500 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 51dad087..a32fe9e4 100644 --- a/tests/test_view_functions.py +++ b/tests/test_view_functions.py @@ -11,8 +11,13 @@ # 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 json import pretend +import pytest +import werkzeug + +from cloudevents.http import from_http import functions_framework @@ -20,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") @@ -28,6 +33,17 @@ def test_http_view_func_wrapper(): assert function.calls == [pretend.call(request_object)] +def test_http_view_func_wrapper_attribute_copied(): + def function(_): + pass + + function.attribute = "foo" + view_func = functions_framework._http_view_func_wrapper(function, pretend.stub()) + + assert view_func.__name__ == "function" + assert view_func.attribute == "foo" + + def test_event_view_func_wrapper(monkeypatch): data = pretend.stub() json = { @@ -60,6 +76,86 @@ def test_event_view_func_wrapper(monkeypatch): ] +def test_event_view_func_wrapper_bad_request(monkeypatch): + request = pretend.stub(headers={}, get_json=lambda: None) + + context_stub = pretend.stub() + context_class = pretend.call_recorder(lambda *a, **kw: context_stub) + monkeypatch.setattr(functions_framework, "Context", context_class) + function = pretend.call_recorder(lambda data, context: "Hello") + + view_func = functions_framework._event_view_func_wrapper(function, request) + + with pytest.raises(werkzeug.exceptions.BadRequest): + view_func("/some/path") + + +def test_run_cloud_event(): + headers = {"Content-Type": "application/cloudevents+json"} + data = json.dumps( + { + "source": "from-galaxy-far-far-away", + "type": "cloud_event.greet.you", + "specversion": "1.0", + "id": "f6a65fcd-eed2-429d-9f71-ec0663d83025", + "time": "2020-08-13T02:12:14.946587+00:00", + "data": {"name": "john"}, + } + ) + request = pretend.stub(headers=headers, get_data=lambda: data) + + function = pretend.call_recorder(lambda cloud_event: "hello") + functions_framework._run_cloud_event(function, request) + expected_cloud_event = from_http(request.headers, request.get_data()) + + assert function.calls == [pretend.call(expected_cloud_event)] + + +def test_cloud_event_view_func_wrapper(): + headers = {"Content-Type": "application/cloudevents+json"} + data = json.dumps( + { + "source": "from-galaxy-far-far-away", + "type": "cloud_event.greet.you", + "specversion": "1.0", + "id": "f6a65fcd-eed2-429d-9f71-ec0663d83025", + "time": "2020-08-13T02:12:14.946587+00:00", + "data": {"name": "john"}, + } + ) + + request = pretend.stub(headers=headers, get_data=lambda: data) + event = from_http(request.headers, request.get_data()) + + function = pretend.call_recorder(lambda cloud_event: cloud_event) + + view_func = functions_framework._cloud_event_view_func_wrapper(function, request) + view_func("/some/path") + + assert function.calls == [pretend.call(event)] + + +def test_binary_cloud_event_view_func_wrapper(): + headers = { + "ce-specversion": "1.0", + "ce-source": "from-galaxy-far-far-away", + "ce-type": "cloud_event.greet.you", + "ce-id": "f6a65fcd-eed2-429d-9f71-ec0663d83025", + "ce-time": "2020-08-13T02:12:14.946587+00:00", + } + data = json.dumps({"name": "john"}) + + request = pretend.stub(headers=headers, get_data=lambda: data) + event = from_http(request.headers, request.get_data()) + + function = pretend.call_recorder(lambda cloud_event: cloud_event) + + view_func = functions_framework._cloud_event_view_func_wrapper(function, request) + view_func("/some/path") + + assert function.calls == [pretend.call(event)] + + def test_binary_event_view_func_wrapper(monkeypatch): data = pretend.stub() request = pretend.stub( diff --git a/tox.ini b/tox.ini index dd616b03..0e69c33d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,26 +1,46 @@ [tox] -envlist = py{35,36,37,38},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] -basepython = - py35: python3.5 - py36: python3.6 - py37: python3.7 - py38: python3.8 +usedevelop = true deps = - pytest + docker + httpx + pytest-asyncio + pytest-cov + pytest-integration pretend -commands = - pytest tests {posargs} +setenv = + PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 + windows-latest: PYTESTARGS = +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 -rc -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/*