From 9fbc2f6f8b39a87abac0d119b27daf5fe2d39894 Mon Sep 17 00:00:00 2001 From: Matthew Robertson Date: Fri, 30 Apr 2021 13:05:04 -0700 Subject: [PATCH 001/181] convert cloud events to background events (#124) * convert cloud events to background events * review feedback --- .gitignore | 1 + src/functions_framework/__init__.py | 6 +- src/functions_framework/event_conversion.py | 91 +++++++- tests/test_convert.py | 200 +++++++++++++++++- tests/test_functions.py | 102 ++++++--- .../test_functions/cloudevents/empty_data.py | 4 +- tests/test_functions/cloudevents/main.py | 4 +- 7 files changed, 369 insertions(+), 39 deletions(-) diff --git a/.gitignore b/.gitignore index c0305632..e8b2bffb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ build/ dist/ .coverage +.vscode/ diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 9f5cf34e..bc19b185 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -112,7 +112,11 @@ def view_func(path): def _event_view_func_wrapper(function, request): def view_func(path): - if is_binary(request.headers): + if event_conversion.is_convertable_cloudevent(request): + # Convert this CloudEvent to the equivalent background event data and context. + data, context = event_conversion.cloudevent_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. diff --git a/src/functions_framework/event_conversion.py b/src/functions_framework/event_conversion.py index 596bee2c..6559f9fa 100644 --- a/src/functions_framework/event_conversion.py +++ b/src/functions_framework/event_conversion.py @@ -14,9 +14,10 @@ import re from datetime import datetime -from typing import Optional, Tuple +from typing import Any, Optional, Tuple -from cloudevents.http import CloudEvent +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 @@ -48,6 +49,19 @@ "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" @@ -93,6 +107,11 @@ "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_cloudevent(request) -> CloudEvent: @@ -143,6 +162,74 @@ def background_event_to_cloudevent(request) -> CloudEvent: return CloudEvent(metadata, data) +def is_convertable_cloudevent(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 cloudevent_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"] + 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"], + } + 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 = "" diff --git a/tests/test_convert.py b/tests/test_convert.py index 07580991..c93f6c69 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -17,7 +17,7 @@ import flask import pytest -from cloudevents.http import from_json +from cloudevents.http import from_json, to_binary from functions_framework import event_conversion from functions_framework.exceptions import EventConversionException @@ -138,6 +138,18 @@ def firebase_auth_cloudevent_output(): 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] ) @@ -329,3 +341,189 @@ def test_pubsub_emulator_request_with_invalid_message( with pytest.raises(EventConversionException) as exc_info: cloudevent = event_conversion.background_event_to_cloudevent(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.document.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", + ), + ], +) +def test_cloudevent_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.cloudevent_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_cloudevent_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"}} + req = flask.Request.from_values(headers=headers, json=data) + + (res_data, res_context) = event_conversion.cloudevent_to_background_event(req) + + assert res_context.event_type == "google.pubsub.topic.publish" + assert res_data == {"data": "fizzbuzz"} + + +def test_cloudevent_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.cloudevent_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_cloudevent_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.cloudevent_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_cloudevent_to_legacy_event_with_invalid_event( + create_ce_headers, + header_overrides, + exception_message, +): + headers = create_ce_headers( + "google.firebase.database.document.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.cloudevent_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_functions.py b/tests/test_functions.py index 5f746931..9fcee69f 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -13,6 +13,7 @@ # limitations under the License. +import json import os import pathlib import re @@ -37,7 +38,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", @@ -45,7 +51,26 @@ def background_json(tmpdir): "eventType": "some-eventType", "resource": "some-resource", }, - "data": {"filename": str(tmpdir / "filename.txt"), "value": "some-value"}, + "data": tempfile_payload, + } + + +@pytest.fixture +def background_event_client(): + source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py" + target = "function" + return create_app(target, source, "event").test_client() + + +@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", } @@ -172,23 +197,13 @@ def test_http_function_execution_time(): assert resp.data == b"OK" -def test_background_function_executes(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_background_function_executes(background_event_client, background_json): + resp = background_event_client.post("/", json=background_json) assert resp.status_code == 200 -def test_background_function_supports_get(background_json): - source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py" - target = "function" - - client = create_app(target, source, "event").test_client() - - resp = client.get("/") +def test_background_function_supports_get(background_event_client, background_json): + resp = background_event_client.get("/") assert resp.status_code == 200 @@ -226,14 +241,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" @@ -243,13 +252,8 @@ def test_pubsub_payload(background_json): ) -def test_background_function_no_data(background_json): - source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py" - target = "function" - - client = create_app(target, source, "event").test_client() - - resp = client.post("/") +def test_background_function_no_data(background_event_client, background_json): + resp = background_event_client.post("/") assert resp.status_code == 400 @@ -544,3 +548,39 @@ def test_errorhandler(monkeypatch): 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.document.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 diff --git a/tests/test_functions/cloudevents/empty_data.py b/tests/test_functions/cloudevents/empty_data.py index 1d7b4751..c1000265 100644 --- a/tests/test_functions/cloudevents/empty_data.py +++ b/tests/test_functions/cloudevents/empty_data.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Function used to test handling Cloud Event functions.""" +"""Function used to test handling CloudEvent functions.""" import flask @@ -22,7 +22,7 @@ def function(cloudevent): The function returns 200 if it received the expected event, otherwise 500. Args: - cloudevent: A Cloud event as defined by https://github.com/cloudevents/sdk-python. + cloudevent: A CloudEvent as defined by https://github.com/cloudevents/sdk-python. Returns: HTTP status code indicating whether valid event was sent or not. diff --git a/tests/test_functions/cloudevents/main.py b/tests/test_functions/cloudevents/main.py index e9fb1c9c..aa480840 100644 --- a/tests/test_functions/cloudevents/main.py +++ b/tests/test_functions/cloudevents/main.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Function used to test handling Cloud Event functions.""" +"""Function used to test handling CloudEvent functions.""" import flask @@ -22,7 +22,7 @@ def function(cloudevent): The function returns 200 if it received the expected event, otherwise 500. Args: - cloudevent: A Cloud event as defined by https://github.com/cloudevents/sdk-python. + cloudevent: A CloudEvent as defined by https://github.com/cloudevents/sdk-python. Returns: HTTP status code indicating whether valid event was sent or not. From 3f9e0659e02257098e21e9af62dc4ccf31b05856 Mon Sep 17 00:00:00 2001 From: Matthew Robertson Date: Fri, 30 Apr 2021 16:07:54 -0700 Subject: [PATCH 002/181] enable validateMapping for event conformance tests (#125) --- .github/workflows/conformance.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index c905055f..a2611772 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -24,7 +24,7 @@ jobs: go-version: '1.15' - name: Run HTTP conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@v0.3.9 + uses: GoogleCloudPlatform/functions-framework-conformance/action@v0.3.10 with: functionType: 'http' useBuildpacks: false @@ -32,15 +32,15 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http --signature-type http'" - name: Run event conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@v0.3.9 + uses: GoogleCloudPlatform/functions-framework-conformance/action@v0.3.10 with: functionType: 'legacyevent' useBuildpacks: false - validateMapping: false + validateMapping: true cmd: "'functions-framework --source tests/conformance/main.py --target write_legacy_event --signature-type event'" - name: Run cloudevent conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@v0.3.9 + uses: GoogleCloudPlatform/functions-framework-conformance/action@v0.3.10 with: functionType: 'cloudevent' useBuildpacks: false From 0765875157f296af7a051163a73a645e20b51514 Mon Sep 17 00:00:00 2001 From: Tianzi Cai Date: Fri, 14 May 2021 11:26:57 -0700 Subject: [PATCH 003/181] docs: add quickstart for Pub/Sub emulator (#126) * docs: add quickstart for Pub/Sub emulator * nits * nits * address grant's comments * address grant's comment --- README.md | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/README.md b/README.md index 4c139eae..8feed774 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,78 @@ def function(request): 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(request): + return "Hello world!" + ``` + +1. Start the Functions Framework on port 8080: + + ```sh + functions-framework --target=hello --debug --port=8080 + ``` + +1. 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. Create a Pub/Sub topic and attach a push subscription to the topic, using `http://localhost:8085` 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 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/ + + python publisher.py $PUBSUB_PROJECT_ID create $TOPIC_ID + python subscriber.py $PUBSUB_PROJECT_ID create-push $TOPIC_ID $PUSH_SUBSCRIPTION_ID http://localhost:8085 + 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:8085" + } + ack_deadline_seconds: 10 + message_retention_duration { + seconds: 604800 + } + . + Endpoint for subscription is: http://localhost:8085 + + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + Published messages to projects/my-project/topics/my-topic. + ``` + ### 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/). From 9ceb1e00b742a68ed3e18e396f835fbe92e09a97 Mon Sep 17 00:00:00 2001 From: Tianzi Cai Date: Fri, 14 May 2021 13:54:07 -0700 Subject: [PATCH 004/181] docs: re-init an env var in the third terminal (#128) - To make sure that the commands can be run if copied and pasted, need to reinitialize an environment variable in the third terminal. - Add command to install `google-cloud-python` Confirmed that it works when copied and pasted! --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8feed774..711c1f53 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ response instead. functions-framework --target=hello --debug --port=8080 ``` -1. Start the Pub/Sub emulator on port 8085. +1. In a second terminal, start the Pub/Sub emulator on port 8085. ```sh export PUBSUB_PROJECT_ID=my-project @@ -157,15 +157,17 @@ response instead. [pubsub] INFO: Server started, listening on 8085 ``` -1. Create a Pub/Sub topic and attach a push subscription to the topic, using `http://localhost:8085` 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. +1. In a third terminal, create a Pub/Sub topic and attach a push subscription to the topic, using `http://localhost:8085` 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:8085 From 52264bcc770a3543e3256d36cefc69977d9fc143 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Mon, 17 May 2021 19:26:58 -0400 Subject: [PATCH 005/181] Relax constraint to flask<3.0 and click<9.0 (#129) --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index fb39268d..6564bfec 100644 --- a/setup.py +++ b/setup.py @@ -49,8 +49,8 @@ package_dir={"": "src"}, python_requires=">=3.5, <4", install_requires=[ - "flask>=1.0,<2.0", - "click>=7.0,<8.0", + "flask>=1.0,<3.0", + "click>=7.0,<9.0", "watchdog>=1.0.0,<2.0.0", "gunicorn>=19.2.0,<21.0; platform_system!='Windows'", "cloudevents>=1.2.0,<2.0.0", From 60ac588db70a3bf7224f43fae713272cbd649256 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Mon, 24 May 2021 14:22:10 -0400 Subject: [PATCH 006/181] Version 2.2.0 (#130) * Version 2.2.0 * Pin back version of docker-py used in tests --- CHANGELOG.md | 8 +++++++- README.md | 10 +++++----- setup.py | 2 +- tox.ini | 2 +- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a533ca4e..f0a04b82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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]) @@ -101,7 +105,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release -[Unreleased]: https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v2.1.3...HEAD +[Unreleased]: https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v2.2.0...HEAD +[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 @@ -121,6 +126,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [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 +[#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 diff --git a/README.md b/README.md index 711c1f53..8ed1151b 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ pip install functions-framework Or, for deployment, add the Functions Framework to your `requirements.txt` file: ``` -functions-framework==2.1.3 +functions-framework==2.2.0 ``` ## Quickstarts @@ -168,8 +168,8 @@ response instead. 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 publisher.py $PUBSUB_PROJECT_ID create $TOPIC_ID python subscriber.py $PUBSUB_PROJECT_ID create-push $TOPIC_ID $PUSH_SUBSCRIPTION_ID http://localhost:8085 python publisher.py $PUBSUB_PROJECT_ID publish $TOPIC_ID ``` @@ -178,7 +178,7 @@ response instead. ```none Created topic: projects/my-project/topics/my-topic - + topic: "projects/my-project/topics/my-topic" push_config { push_endpoint: "http://localhost:8085" @@ -189,7 +189,7 @@ response instead. } . Endpoint for subscription is: http://localhost:8085 - + 1 2 3 diff --git a/setup.py b/setup.py index 6564bfec..b2f933b9 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="2.1.3", + version="2.2.0", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown", diff --git a/tox.ini b/tox.ini index b9ba72f9..92483e7c 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ envlist = py{35,36,37,38,39}-{ubuntu-latest,macos-latest,windows-latest},lint [testenv] usedevelop = true deps = - docker + docker<5 # https://github.com/docker/docker-py/issues/2807 pytest-cov pytest-integration pretend From b46c12b2d686395b554c0a91d48459edc36b6b76 Mon Sep 17 00:00:00 2001 From: Arjun Srinivasan <69502+asriniva@users.noreply.github.com> Date: Tue, 1 Jun 2021 15:50:44 -0700 Subject: [PATCH 007/181] Update GCF Python 3.7 backwards-compatible logging (#131) * Fix logging monkeypatching * Add logging exception test --- src/functions_framework/__init__.py | 21 ++++++++---- tests/test_functions.py | 16 +++++++++ .../test_functions/http_log_exception/main.py | 33 +++++++++++++++++++ 3 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 tests/test_functions/http_log_exception/main.py diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index bc19b185..36cde34b 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -16,6 +16,7 @@ import importlib.util import io import json +import logging import os.path import pathlib import sys @@ -62,6 +63,18 @@ def write(self, out): return self.stderr.write(json.dumps(payload) + "\n") +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): def view_func(path): return function(request._get_current_object()) @@ -237,15 +250,9 @@ def handle_none(rv): app.make_response = handle_none # Handle log severity backwards compatibility - import logging # isort:skip - - logging.info = _LoggingHandler("INFO", sys.stderr).write - logging.warn = _LoggingHandler("ERROR", sys.stderr).write - logging.warning = _LoggingHandler("ERROR", sys.stderr).write - logging.error = _LoggingHandler("ERROR", sys.stderr).write - logging.critical = _LoggingHandler("ERROR", sys.stderr).write sys.stdout = _LoggingHandler("INFO", sys.stderr) sys.stderr = _LoggingHandler("ERROR", sys.stderr) + setup_logging() # Extract the target function from the source file if not hasattr(source_module, target): diff --git a/tests/test_functions.py b/tests/test_functions.py index 9fcee69f..ad16c15b 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -524,6 +524,22 @@ def test_legacy_function_log_severity(monkeypatch, capfd, mode, expected): 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" 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 From c7f66fd5ed9856bdf4e484774742d7c158ab7b43 Mon Sep 17 00:00:00 2001 From: Arjun Srinivasan <69502+asriniva@users.noreply.github.com> Date: Tue, 1 Jun 2021 16:25:09 -0700 Subject: [PATCH 008/181] Version 2.2.1 (#133) --- CHANGELOG.md | 8 +++++++- README.md | 2 +- setup.py | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0a04b82..a52fe500 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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]) @@ -105,7 +109,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release -[Unreleased]: https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v2.2.0...HEAD +[Unreleased]: https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v2.2.1...HEAD +[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 @@ -126,6 +131,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [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 +[#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 diff --git a/README.md b/README.md index 8ed1151b..e9399ace 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ pip install functions-framework Or, for deployment, add the Functions Framework to your `requirements.txt` file: ``` -functions-framework==2.2.0 +functions-framework==2.2.1 ``` ## Quickstarts diff --git a/setup.py b/setup.py index b2f933b9..2e7109d5 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="2.2.0", + version="2.2.1", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown", From 4900780dd525738eb28d1be3cffda08d447bb8f2 Mon Sep 17 00:00:00 2001 From: Matthew Robertson Date: Tue, 20 Jul 2021 13:44:17 -0700 Subject: [PATCH 009/181] fix: cloudevent conversion conformance tests (#139) This commit updates the legacy event to cloud event conversion logic to: - include the `messageId` and `publishTime` fields in the data payload of Pub/Sub events - include the location in the source field of firebasedatabase events fixes #139 --- .github/workflows/conformance.yml | 6 +- .gitignore | 3 + src/functions_framework/event_conversion.py | 33 ++++- tests/test_convert.py | 115 +++++++++++++++--- .../firebase-db-cloudevent-output.json | 15 +++ tests/test_data/firebase-db-legacy-input.json | 19 +++ .../cloudevents/converted_background_event.py | 2 + 7 files changed, 168 insertions(+), 25 deletions(-) create mode 100644 tests/test_data/firebase-db-cloudevent-output.json create mode 100644 tests/test_data/firebase-db-legacy-input.json diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index a2611772..32a1523b 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -24,7 +24,7 @@ jobs: go-version: '1.15' - name: Run HTTP conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@v0.3.10 + uses: GoogleCloudPlatform/functions-framework-conformance/action@v0.3.12 with: functionType: 'http' useBuildpacks: false @@ -32,7 +32,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http --signature-type http'" - name: Run event conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@v0.3.10 + uses: GoogleCloudPlatform/functions-framework-conformance/action@v0.3.12 with: functionType: 'legacyevent' useBuildpacks: false @@ -40,7 +40,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_legacy_event --signature-type event'" - name: Run cloudevent conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@v0.3.10 + uses: GoogleCloudPlatform/functions-framework-conformance/action@v0.3.12 with: functionType: 'cloudevent' useBuildpacks: false diff --git a/.gitignore b/.gitignore index e8b2bffb..50f3f15a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ build/ dist/ .coverage .vscode/ +function_output.json +serverlog_stderr.txt +serverlog_stdout.txt diff --git a/src/functions_framework/event_conversion.py b/src/functions_framework/event_conversion.py index 6559f9fa..4ac85606 100644 --- a/src/functions_framework/event_conversion.py +++ b/src/functions_framework/event_conversion.py @@ -94,7 +94,7 @@ # 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/.+)$"), + _FIREBASE_DB_CE_SERVICE: re.compile(r"^projects/_/(instances/[^/]+)/(refs/.+)$"), _FIRESTORE_CE_SERVICE: re.compile( r"^(projects/[^/]+/databases/\(default\))/(documents/.+)$" ), @@ -131,9 +131,14 @@ def background_event_to_cloudevent(request) -> CloudEvent: 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. @@ -147,13 +152,30 @@ def background_event_to_cloudevent(request) -> CloudEvent: 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": _CLOUDEVENT_SPEC_VERSION, "datacontenttype": "application/json", "type": new_type, - "source": f"//{service}/{resource}", + "source": source, } if subject: @@ -201,6 +223,10 @@ def cloudevent_to_background_event(request) -> Tuple[Any, Context]: 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: @@ -214,6 +240,9 @@ def cloudevent_to_background_event(request) -> Tuple[Any, Context]: "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']}" diff --git a/tests/test_convert.py b/tests/test_convert.py index c93f6c69..a4de5a75 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -75,6 +75,8 @@ "data": { "message": { "data": "10", + "publishTime": "2020-05-18T12:13:19Z", + "messageId": "1215011316659232", }, }, } @@ -122,7 +124,10 @@ def marshalled_pubsub_request(): def raw_pubsub_cloudevent_output(marshalled_pubsub_request): event = PUBSUB_CLOUD_EVENT.copy() # the data payload is more complex for the raw pubsub request - event["data"] = {"message": marshalled_pubsub_request["data"]} + data = marshalled_pubsub_request["data"] + data["messageId"] = event["id"] + data["publishTime"] = event["time"] + event["data"] = {"message": data} return from_json(json.dumps(event)) @@ -138,6 +143,18 @@ def firebase_auth_cloudevent_output(): 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_cloudevent_output(): + with open(TEST_DATA_DIR / "firebase-db-cloudevent-output.json", "r") as f: + return from_json(f.read()) + + @pytest.fixture def create_ce_headers(): return lambda event_type, source: { @@ -207,6 +224,41 @@ def test_firebase_auth_event_to_cloudevent_no_uid( assert cloudevent == firebase_auth_cloudevent_output +def test_firebase_db_event_to_cloudevent_default_location( + firebase_db_background_input, firebase_db_cloudevent_output +): + req = flask.Request.from_values(json=firebase_db_background_input) + cloudevent = event_conversion.background_event_to_cloudevent(req) + assert cloudevent == firebase_db_cloudevent_output + + +def test_firebase_db_event_to_cloudevent_location_subdomain( + firebase_db_background_input, firebase_db_cloudevent_output +): + firebase_db_background_input["domain"] = "europe-west1.firebasedatabase.app" + firebase_db_cloudevent_output["source"] = firebase_db_cloudevent_output[ + "source" + ].replace("us-central1", "europe-west1") + + req = flask.Request.from_values(json=firebase_db_background_input) + cloudevent = event_conversion.background_event_to_cloudevent(req) + assert cloudevent == firebase_db_cloudevent_output + + +def test_firebase_db_event_to_cloudevent_missing_domain( + firebase_db_background_input, firebase_db_cloudevent_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_cloudevent(req) + + assert ( + "Invalid FirebaseDB event payload: missing 'domain'" in exc_info.value.args[0] + ) + + @pytest.mark.parametrize( "background_resource", [ @@ -299,34 +351,39 @@ def test_marshal_background_event_data_with_topic_path( 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_cloudevent( - raw_pubsub_request, raw_pubsub_cloudevent_output + raw_pubsub_cloudevent_output, request_fixture, overrides, request ): + request_path = overrides.get("request_path", "/") + payload = request.getfixturevalue(request_fixture) req = flask.Request.from_values( - json=raw_pubsub_request, - path="x/projects/sample-project/topics/gcf-test?pubsub_trigger=true", + path=request_path, + json=payload, ) cloudevent = event_conversion.background_event_to_cloudevent(req) # Remove timestamps as they are generated on the fly. del raw_pubsub_cloudevent_output["time"] + del raw_pubsub_cloudevent_output.data["message"]["publishTime"] del cloudevent["time"] + del cloudevent.data["message"]["publishTime"] - assert cloudevent == raw_pubsub_cloudevent_output - - -def test_pubsub_emulator_request_to_cloudevent_without_topic_path( - raw_pubsub_request, raw_pubsub_cloudevent_output -): - req = flask.Request.from_values(json=raw_pubsub_request, path="/") - cloudevent = event_conversion.background_event_to_cloudevent(req) - - # Remove timestamps as they are generated on the fly. - del raw_pubsub_cloudevent_output["time"] - del cloudevent["time"] - - # Default to the service name, when the topic is not configured subscription's pushEndpoint. - raw_pubsub_cloudevent_output["source"] = "//pubsub.googleapis.com/" + if "source" in overrides: + # Default to the service name, when the topic is not configured subscription's pushEndpoint. + raw_pubsub_cloudevent_output["source"] = overrides["source"] assert cloudevent == raw_pubsub_cloudevent_output @@ -378,6 +435,18 @@ def test_pubsub_emulator_request_with_invalid_message( "providers/firebase.auth/eventTypes/user.create", "projects/my-project-id", ), + ( + "google.firebase.database.document.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_cloudevent_to_legacy_event( @@ -406,7 +475,13 @@ def test_cloudevent_to_legacy_event_with_pubsub_message_payload( "google.cloud.pubsub.topic.v1.messagePublished", "//pubsub.googleapis.com/projects/sample-project/topics/gcf-test", ) - data = {"message": {"data": "fizzbuzz"}} + 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.cloudevent_to_background_event(req) diff --git a/tests/test_data/firebase-db-cloudevent-output.json b/tests/test_data/firebase-db-cloudevent-output.json new file mode 100644 index 00000000..c1120926 --- /dev/null +++ b/tests/test_data/firebase-db-cloudevent-output.json @@ -0,0 +1,15 @@ +{ + "specversion": "1.0", + "type": "google.firebase.database.document.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_functions/cloudevents/converted_background_event.py b/tests/test_functions/cloudevents/converted_background_event.py index be0d6c52..44b69556 100644 --- a/tests/test_functions/cloudevents/converted_background_event.py +++ b/tests/test_functions/cloudevents/converted_background_event.py @@ -35,6 +35,8 @@ def function(cloudevent): "attr1": "attr1-value", }, "data": "dGVzdCBtZXNzYWdlIDM=", + "messageId": "aaaaaa-1111-bbbb-2222-cccccccccccc", + "publishTime": "2020-09-29T11:32:00.000Z", }, } From 54d87d0ce79f61f7b1a02e5d9feff028268de197 Mon Sep 17 00:00:00 2001 From: Grant Timmerman <744973+grant@users.noreply.github.com> Date: Tue, 20 Jul 2021 15:50:14 -0500 Subject: [PATCH 010/181] docs: mention flask as the base framework (#134) * docs: mention flask as the base framework Signed-off-by: Grant Timmerman * docs: move flask doc note Signed-off-by: Grant Timmerman Co-authored-by: Dustin Ingram --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index e9399ace..3bc4b297 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ def hello(request): return "Hello world!" ``` +> Your function is passed a single parameter, `(request)`, which is a Flask [`Request`](http://flask.pocoo.org/docs/1.0/api/#flask.Request) object. + Run the following command: ```sh From 9ea8dd2d7ccc88db6ab2c023859f74e7ead7f7f7 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Wed, 21 Jul 2021 11:34:21 -0500 Subject: [PATCH 011/181] fix: Add a DummyErrorHandler (#137) * Remove unused imports * Add failing test * Add DummyErrorHandler --- src/functions_framework/__init__.py | 11 +++++++++++ tests/test_cloudevent_functions.py | 4 ++-- tests/test_functions.py | 8 +++++++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 36cde34b..246dcbb2 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -348,3 +348,14 @@ def __call__(self, *args, **kwargs): app = LazyWSGIApp() + + +class DummyErrorHandler: + def __init__(self): + pass + + def __call__(self, *args, **kwargs): + return self + + +errorhandler = DummyErrorHandler() diff --git a/tests/test_cloudevent_functions.py b/tests/test_cloudevent_functions.py index 061bb945..237225cd 100644 --- a/tests/test_cloudevent_functions.py +++ b/tests/test_cloudevent_functions.py @@ -16,9 +16,9 @@ import pytest -from cloudevents.http import CloudEvent, from_http, to_binary, to_structured +from cloudevents.http import CloudEvent, to_binary, to_structured -from functions_framework import LazyWSGIApp, create_app, exceptions +from functions_framework import create_app TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" TEST_DATA_DIR = pathlib.Path(__file__).resolve().parent / "test_data" diff --git a/tests/test_functions.py b/tests/test_functions.py index ad16c15b..e17df452 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -25,7 +25,7 @@ import functions_framework -from functions_framework import LazyWSGIApp, create_app, exceptions +from functions_framework import LazyWSGIApp, create_app, errorhandler, exceptions TEST_FUNCTIONS_DIR = pathlib.Path.cwd() / "tests" / "test_functions" @@ -454,6 +454,12 @@ def test_lazy_wsgi_app(monkeypatch, target, source, signature_type): ] +def test_dummy_error_handler(): + @errorhandler("foo", bar="baz") + def function(): + pass + + def test_class_in_main_is_in_right_module(): source = TEST_FUNCTIONS_DIR / "module_is_correct" / "main.py" target = "function" From 0205c85886549f6cdba90d75b8468b423750b1e4 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Tue, 10 Aug 2021 12:52:07 -0500 Subject: [PATCH 012/181] docs: Add docker-compose example (#143) --- examples/README.md | 8 ++- examples/docker-compose/Dockerfile | 17 ++++++ examples/docker-compose/README.md | 67 ++++++++++++++++++++++ examples/docker-compose/docker-compose.yml | 8 +++ examples/docker-compose/main.py | 18 ++++++ examples/docker-compose/requirements.txt | 1 + 6 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 examples/docker-compose/Dockerfile create mode 100644 examples/docker-compose/README.md create mode 100644 examples/docker-compose/docker-compose.yml create mode 100644 examples/docker-compose/main.py create mode 100644 examples/docker-compose/requirements.txt diff --git a/examples/README.md b/examples/README.md index 343e19c9..dfc93131 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,5 +1,11 @@ # Python Functions Frameworks Examples +## 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_cloudevents`](./cloud_run_cloudevents/) - Deploying a [CloudEvent](https://github.com/cloudevents/sdk-python) function to [Cloud Run](http://cloud.google.com/run) with the Functions Framework \ No newline at end of file +* [`cloud_run_cloudevents`](./cloud_run_cloudevents/) - Deploying a [CloudEvent](https://github.com/cloudevents/sdk-python) function 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/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..3601409f --- /dev/null +++ b/examples/docker-compose/requirements.txt @@ -0,0 +1 @@ +# Add any Python requirements here From e79fc74703f96503aad29a6bdf4d1d8cf29c0b66 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Tue, 24 Aug 2021 12:48:10 -0500 Subject: [PATCH 013/181] docs: Update pub/sub quickstart to use background event (#144) * docs: Update pub/sub quickstart to use events * Use (event, context) in examples * Add link to Pub/Sub docs * Update ports elsewhere as well --- README.md | 55 ++++++++++++++++++++++++++------ examples/cloud_run_event/main.py | 2 +- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 3bc4b297..d2f90621 100644 --- a/README.md +++ b/README.md @@ -134,14 +134,14 @@ response instead. 1. Create a `main.py` file with the following contents: ```python - def hello(request): - return "Hello world!" + def hello(event, context): + print("Received", context.event_id) ``` 1. Start the Functions Framework on port 8080: ```sh - functions-framework --target=hello --debug --port=8080 + functions-framework --target=hello --signature-type=event --debug --port=8080 ``` 1. In a second terminal, start the Pub/Sub emulator on port 8085. @@ -159,7 +159,7 @@ response instead. [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:8085` 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. +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 @@ -172,7 +172,7 @@ response instead. 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:8085 + 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 ``` @@ -183,14 +183,14 @@ response instead. topic: "projects/my-project/topics/my-topic" push_config { - push_endpoint: "http://localhost:8085" + push_endpoint: "http://localhost:8080" } ack_deadline_seconds: 10 message_retention_duration { seconds: 604800 } . - Endpoint for subscription is: http://localhost:8085 + Endpoint for subscription is: http://localhost:8080 1 2 @@ -204,6 +204,41 @@ response instead. 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/). @@ -263,13 +298,13 @@ You can configure the Functions Framework using command-line flags or environmen ## Enable Google Cloud Functions Events The Functions Framework can unmarshall incoming -Google Cloud Functions [event](https://cloud.google.com/functions/docs/concepts/events-triggers#events) payloads to `data` and `context` objects. +Google Cloud 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) ``` diff --git a/examples/cloud_run_event/main.py b/examples/cloud_run_event/main.py index 7ae454c4..e5ca470d 100644 --- a/examples/cloud_run_event/main.py +++ b/examples/cloud_run_event/main.py @@ -13,5 +13,5 @@ # limitations under the License. -def hello(data, context): +def hello(event, context): pass From e7afc7d09115e38e3bb0cdfa7a4f5721971d0385 Mon Sep 17 00:00:00 2001 From: Annie Fu <16651409+anniefu@users.noreply.github.com> Date: Mon, 11 Oct 2021 16:28:27 -0700 Subject: [PATCH 014/181] fix: update to newest conformance test Action (#155) --- .github/workflows/conformance.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 32a1523b..b6a3b430 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -21,27 +21,30 @@ jobs: - name: Setup Go uses: actions/setup-go@v2 with: - go-version: '1.15' + go-version: '1.16' - name: Run HTTP conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@v0.3.12 + uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.1.0 with: + version: 'v0.3.12' 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@v0.3.12 + uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.1.0 with: + version: 'v0.3.12' functionType: 'legacyevent' useBuildpacks: false validateMapping: true cmd: "'functions-framework --source tests/conformance/main.py --target write_legacy_event --signature-type event'" - name: Run cloudevent conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@v0.3.12 + uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.1.0 with: + version: 'v0.3.12' functionType: 'cloudevent' useBuildpacks: false validateMapping: true From 135f32801d1eb0e3ad88e58b463e3ed6b80efbdf Mon Sep 17 00:00:00 2001 From: Matthew Robertson Date: Mon, 11 Oct 2021 16:39:45 -0700 Subject: [PATCH 015/181] fix: update event conversion (#154) This commit fixes the failing conformance tests. --- .github/workflows/conformance.yml | 6 +++--- src/functions_framework/event_conversion.py | 8 ++++---- tests/test_convert.py | 6 +++--- tests/test_data/firebase-db-cloudevent-output.json | 2 +- tests/test_functions.py | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index b6a3b430..fae29873 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -26,7 +26,7 @@ jobs: - name: Run HTTP conformance tests uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.1.0 with: - version: 'v0.3.12' + version: 'v1.1.0' functionType: 'http' useBuildpacks: false validateMapping: false @@ -35,7 +35,7 @@ jobs: - name: Run event conformance tests uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.1.0 with: - version: 'v0.3.12' + version: 'v1.1.0' functionType: 'legacyevent' useBuildpacks: false validateMapping: true @@ -44,7 +44,7 @@ jobs: - name: Run cloudevent conformance tests uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.1.0 with: - version: 'v0.3.12' + version: 'v1.1.0' functionType: 'cloudevent' useBuildpacks: false validateMapping: true diff --git a/src/functions_framework/event_conversion.py b/src/functions_framework/event_conversion.py index 4ac85606..0605d940 100644 --- a/src/functions_framework/event_conversion.py +++ b/src/functions_framework/event_conversion.py @@ -42,10 +42,10 @@ "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.document.v1.created", - "providers/google.firebase.database/eventTypes/ref.write": "google.firebase.database.document.v1.written", - "providers/google.firebase.database/eventTypes/ref.update": "google.firebase.database.document.v1.updated", - "providers/google.firebase.database/eventTypes/ref.delete": "google.firebase.database.document.v1.deleted", + "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", } diff --git a/tests/test_convert.py b/tests/test_convert.py index a4de5a75..ae227c83 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -404,7 +404,7 @@ def test_pubsub_emulator_request_with_invalid_message( "ce_event_type, ce_source, expected_type, expected_resource", [ ( - "google.firebase.database.document.v1.written", + "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", @@ -436,7 +436,7 @@ def test_pubsub_emulator_request_with_invalid_message( "projects/my-project-id", ), ( - "google.firebase.database.document.v1.written", + "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", @@ -561,7 +561,7 @@ def test_cloudevent_to_legacy_event_with_invalid_event( exception_message, ): headers = create_ce_headers( - "google.firebase.database.document.v1.written", + "google.firebase.database.ref.v1.written", "//firebasedatabase.googleapis.com/projects/_/instances/my-project-id", ) for k, v in header_overrides.items(): diff --git a/tests/test_data/firebase-db-cloudevent-output.json b/tests/test_data/firebase-db-cloudevent-output.json index c1120926..25e7e8a6 100644 --- a/tests/test_data/firebase-db-cloudevent-output.json +++ b/tests/test_data/firebase-db-cloudevent-output.json @@ -1,6 +1,6 @@ { "specversion": "1.0", - "type": "google.firebase.database.document.v1.written", + "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", diff --git a/tests/test_functions.py b/tests/test_functions.py index e17df452..176e2bb1 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -582,7 +582,7 @@ def test_errorhandler(monkeypatch): "google.firebase.analytics.log.v1.written", "google.firebase.auth.user.v1.created", "google.firebase.auth.user.v1.deleted", - "google.firebase.database.document.v1.written", + "google.firebase.database.ref.v1.written", ], ) def tests_cloud_to_background_event_client( From f0749e0d858f089c182f03dc1af4ac5c44d2e8b4 Mon Sep 17 00:00:00 2001 From: Laurent Picard Date: Tue, 12 Oct 2021 10:02:36 +0200 Subject: [PATCH 016/181] feat: add support for Python 3.10 (#151) --- .github/workflows/conformance.yml | 2 +- .github/workflows/unit.yml | 2 +- CHANGELOG.md | 4 ++++ setup.py | 1 + tox.ini | 2 +- 5 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index fae29873..31883ec3 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -5,7 +5,7 @@ jobs: runs-on: ubuntu-18.04 strategy: matrix: - python-version: [3.8, 3.9] + python-version: ['3.8', '3.9', '3.10'] steps: - name: Checkout code uses: actions/checkout@v2 diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index fbb36585..ea147493 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -4,7 +4,7 @@ jobs: test: strategy: matrix: - python: [3.6, 3.7, 3.8, 3.9] + python: ['3.6', '3.7', '3.8', '3.9', '3.10'] platform: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.platform }} steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index a52fe500..90b1501e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.3.0] - 2021-10-08 +### Added +- Support Python 3.10 + ## [2.2.1] - 2021-06-01 ### Changed - Update GCF Python 3.7 backwards-compatible logging ([#131]) diff --git a/setup.py b/setup.py index 2e7109d5..763f0a15 100644 --- a/setup.py +++ b/setup.py @@ -42,6 +42,7 @@ "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", ], keywords="functions-framework", packages=find_packages(where="src"), diff --git a/tox.ini b/tox.ini index 92483e7c..fb76a0e8 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py{35,36,37,38,39}-{ubuntu-latest,macos-latest,windows-latest},lint +envlist = py{35,36,37,38,39,310}-{ubuntu-latest,macos-latest,windows-latest},lint [testenv] usedevelop = true From d5651bb37a9c08aa48b809c666978af177bd3034 Mon Sep 17 00:00:00 2001 From: Arjun Srinivasan <69502+asriniva@users.noreply.github.com> Date: Tue, 12 Oct 2021 01:09:41 -0700 Subject: [PATCH 017/181] fix: Move backwards-compatible logic before function source load (#152) * Move backwards-compatible logic before function source load * Fix formatting Co-authored-by: Dustin Ingram --- src/functions_framework/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 246dcbb2..74329dba 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -232,11 +232,7 @@ def create_app(target=None, source=None, signature_type=None): global errorhandler errorhandler = app.errorhandler - # 6. Execute the module, within the application context - with app.app_context(): - spec.loader.exec_module(source_module) - - # Handle legacy GCF Python 3.7 behavior + # 6. Handle legacy GCF Python 3.7 behavior if os.environ.get("ENTRY_POINT"): os.environ["FUNCTION_TRIGGER_TYPE"] = signature_type os.environ["FUNCTION_NAME"] = os.environ.get("K_SERVICE", target) @@ -254,6 +250,10 @@ def handle_none(rv): sys.stderr = _LoggingHandler("ERROR", sys.stderr) setup_logging() + # 7. Execute the module, within the application context + with app.app_context(): + spec.loader.exec_module(source_module) + # Extract the target function from the source file if not hasattr(source_module, target): raise MissingTargetException( From 6c69bb8d6a100c1ee5ebd07f6164e8f0e929ba18 Mon Sep 17 00:00:00 2001 From: Arjun Srinivasan <69502+asriniva@users.noreply.github.com> Date: Tue, 12 Oct 2021 11:50:11 -0700 Subject: [PATCH 018/181] feat: Version 2.3.0 (#156) --- CHANGELOG.md | 15 ++++++++++++--- README.md | 2 +- setup.py | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90b1501e..d8ceffaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [2.3.0] - 2021-10-08 +## [2.3.0] - 2021-10-12 ### Added -- Support Python 3.10 +- 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 @@ -113,7 +117,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release -[Unreleased]: https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v2.2.1...HEAD +[Unreleased]: https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v2.3.0...HEAD +[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 @@ -135,6 +140,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [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 +[#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 diff --git a/README.md b/README.md index d2f90621..bc8fef80 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ pip install functions-framework Or, for deployment, add the Functions Framework to your `requirements.txt` file: ``` -functions-framework==2.2.1 +functions-framework==2.3.0 ``` ## Quickstarts diff --git a/setup.py b/setup.py index 763f0a15..a80961aa 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="2.2.1", + version="2.3.0", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown", From 455affdd144b90cf9c0a8cb52bdb345492e2c89c Mon Sep 17 00:00:00 2001 From: Grant Timmerman <744973+grant@users.noreply.github.com> Date: Tue, 12 Oct 2021 11:59:19 -0700 Subject: [PATCH 019/181] docs: use GitHub badges (#153) Signed-off-by: Grant Timmerman --- README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bc8fef80..2a27ddd7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ -# Functions Framework for Python [![Build Status](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2FGoogleCloudPlatform%2Ffunctions-framework-python%2Fbadge&style=flat)](https://actions-badge.atrox.dev/GoogleCloudPlatform/functions-framework-python/goto) [![PyPI version](https://badge.fury.io/py/functions-framework.svg)](https://badge.fury.io/py/functions-framework) +# 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] An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team. @@ -339,3 +343,10 @@ You can also find examples on using the CloudEvent Python SDK [here](https://git ## 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" From 52b2a28be91845e2ca040aa374d5486965e5f2c3 Mon Sep 17 00:00:00 2001 From: Kayla Nguyen Date: Tue, 26 Oct 2021 15:21:35 -0700 Subject: [PATCH 020/181] feat!: Declarative function signatures for python (#160) --- .gitignore | 1 + README.md | 36 ++- examples/cloud_run_cloudevents/main.py | 1 - .../cloud_run_cloudevents/send_cloudevent.py | 11 +- examples/cloud_run_decorator/Dockerfile | 17 ++ examples/cloud_run_decorator/README.md | 23 ++ examples/cloud_run_decorator/main.py | 27 +++ examples/cloud_run_decorator/requirements.txt | 1 + examples/cloud_run_http/main.py | 1 + setup.cfg | 18 +- src/functions_framework/__init__.py | 221 ++++++++---------- src/functions_framework/_function_registry.py | 118 ++++++++++ tests/test_decorator_functions.py | 69 ++++++ tests/test_function_registry.py | 62 +++++ tests/test_functions.py | 2 - tests/test_functions/decorators/decorator.py | 66 ++++++ 16 files changed, 531 insertions(+), 143 deletions(-) create mode 100644 examples/cloud_run_decorator/Dockerfile create mode 100644 examples/cloud_run_decorator/README.md create mode 100644 examples/cloud_run_decorator/main.py create mode 100644 examples/cloud_run_decorator/requirements.txt create mode 100644 src/functions_framework/_function_registry.py create mode 100644 tests/test_decorator_functions.py create mode 100644 tests/test_function_registry.py create mode 100644 tests/test_functions/decorators/decorator.py diff --git a/.gitignore b/.gitignore index 50f3f15a..8b5379fe 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ build/ dist/ .coverage .vscode/ +.idea/ function_output.json serverlog_stderr.txt serverlog_stdout.txt diff --git a/README.md b/README.md index 2a27ddd7..7d5c1e3d 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,40 @@ curl localhost:8080 # Output: Hello world! ``` +### Quickstart: Register your function using decorator + +Create an `main.py` file with the following contents: + +```python +import functions_framework + +@functions_framework.cloudevent +def hello_cloudevent(cloudevent): + return f"Received event with ID: {cloudevent['id']} and data {cloudevent.data}" + +@functions_framework.http +def hello_http(request): + return "Hello world!" + +``` + +Run the following command to run `hello_http` target locally: + +```sh +functions-framework --target=hello_http +``` + +Open http://localhost:8080/ in your browser and see *Hello world!*. + +Run the following command to run `hello_cloudevent` target locally: + +```sh +functions-framework --target=hello_cloudevent +``` + +More info on sending [CloudEvents](http://cloudevents.io) payloads, see [`examples/cloud_run_cloudevents`](examples/cloud_run_cloudevents/) instruction. + + ### Quickstart: Error handling The framework includes an error handler that is similar to the @@ -337,7 +371,7 @@ For more details on this signature type, check out the Google Cloud Functions do ## Advanced Examples -More advanced guides can be found in the [`examples/`](https://github.com/GoogleCloudPlatform/functions-framework-python/blob/master/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 diff --git a/examples/cloud_run_cloudevents/main.py b/examples/cloud_run_cloudevents/main.py index 6c7bdc5b..da77322f 100644 --- a/examples/cloud_run_cloudevents/main.py +++ b/examples/cloud_run_cloudevents/main.py @@ -14,7 +14,6 @@ # This sample creates a function using the CloudEvents SDK # (https://github.com/cloudevents/sdk-python) -import sys def hello(cloudevent): diff --git a/examples/cloud_run_cloudevents/send_cloudevent.py b/examples/cloud_run_cloudevents/send_cloudevent.py index f30909ca..b523c31a 100644 --- a/examples/cloud_run_cloudevents/send_cloudevent.py +++ b/examples/cloud_run_cloudevents/send_cloudevent.py @@ -15,19 +15,18 @@ # limitations under the License. from cloudevents.http import CloudEvent, to_structured import requests -import json # 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. +# 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" + "type": "cloudevent.greet.you", } -data = {"name":"john"} +data = {"name": "john"} event = CloudEvent(attributes, data) diff --git a/examples/cloud_run_decorator/Dockerfile b/examples/cloud_run_decorator/Dockerfile new file mode 100644 index 00000000..717e5a91 --- /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 + +# 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..92c33b6c --- /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_cloudevent`, 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..291280f3 --- /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.cloudevent +def hello_cloudevent(cloudevent): + return f"Received event with ID: {cloudevent['id']} and data {cloudevent.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..33c5f99f --- /dev/null +++ b/examples/cloud_run_decorator/requirements.txt @@ -0,0 +1 @@ +# Optionally include additional dependencies here diff --git a/examples/cloud_run_http/main.py b/examples/cloud_run_http/main.py index 03640226..5253627c 100644 --- a/examples/cloud_run_http/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/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/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 74329dba..c67cb7cd 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -13,14 +13,12 @@ # limitations under the License. import functools -import importlib.util import io import json import logging import os.path import pathlib import sys -import types import cloudevents.exceptions as cloud_exceptions import flask @@ -28,20 +26,15 @@ from cloudevents.http import from_http, is_binary -from functions_framework import event_conversion +from functions_framework import _function_registry, event_conversion 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" MAX_CONTENT_LENGTH = 10 * 1024 * 1024 _FUNCTION_STATUS_HEADER_FIELD = "X-Google-Status" @@ -63,6 +56,32 @@ def write(self, out): return self.stderr.write(json.dumps(payload) + "\n") +def cloudevent(func): + """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 http(func): + """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) @@ -156,6 +175,56 @@ def view_func(path): return view_func +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 == _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) + 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"] + ) + ) + app.url_map.add( + 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] = _cloudevent_view_func_wrapper( + function, flask.request + ) + else: + raise FunctionsFrameworkException( + "Invalid signature type: {signature_type}".format( + signature_type=signature_type + ) + ) + + def read_request(response): """ Force the framework to read the entire request before responding, to avoid @@ -174,21 +243,8 @@ def crash_handler(e): 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) + 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 @@ -201,126 +257,43 @@ def create_app(target=None, source=None, signature_type=None): ) ) - # 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: - # 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) - 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 - - # 5. Create the application - app = flask.Flask(target, template_folder=template_folder) - app.config["MAX_CONTENT_LENGTH"] = MAX_CONTENT_LENGTH - app.register_error_handler(500, crash_handler) + source_module, spec = _function_registry.load_function_module(source) + + # Create the application + _app = flask.Flask(target, template_folder=template_folder) + _app.config["MAX_CONTENT_LENGTH"] = MAX_CONTENT_LENGTH + _app.register_error_handler(500, crash_handler) global errorhandler - errorhandler = app.errorhandler + errorhandler = _app.errorhandler - # 6. Handle legacy GCF Python 3.7 behavior + # Handle legacy GCF Python 3.7 behavior if os.environ.get("ENTRY_POINT"): - os.environ["FUNCTION_TRIGGER_TYPE"] = signature_type os.environ["FUNCTION_NAME"] = os.environ.get("K_SERVICE", target) - app.make_response_original = app.make_response + _app.make_response_original = _app.make_response def handle_none(rv): if rv is None: rv = "OK" - return app.make_response_original(rv) + return _app.make_response_original(rv) - app.make_response = handle_none + _app.make_response = handle_none # Handle log severity backwards compatibility sys.stdout = _LoggingHandler("INFO", sys.stderr) sys.stderr = _LoggingHandler("ERROR", sys.stderr) setup_logging() - # 7. Execute the module, within the application context - with app.app_context(): + # Execute the module, within the application context + with _app.app_context(): spec.loader.exec_module(source_module) - # Extract the target function from the source file - if not hasattr(source_module, target): - raise MissingTargetException( - "File {source} is expected to contain a function named {target}".format( - source=source, target=target - ) - ) - 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) - ) - ) - - # 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": - 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) - app.view_functions["error"] = lambda: flask.abort(404, description="Not Found") - app.after_request(read_request) - elif signature_type == "event": - app.url_map.add( - werkzeug.routing.Rule( - "/", defaults={"path": ""}, endpoint="run", methods=["POST"] - ) - ) - app.url_map.add( - 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 == "cloudevent": - 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"] - ) - ) + # Get the configured function signature type + signature_type = _function_registry.get_func_signature_type(target, signature_type) + function = _function_registry.get_user_function(source, source_module, target) - app.view_functions[signature_type] = _cloudevent_view_func_wrapper( - function, flask.request - ) - else: - raise FunctionsFrameworkException( - "Invalid signature type: {signature_type}".format( - signature_type=signature_type - ) - ) + _configure_app(_app, function, signature_type) - return app + return _app class LazyWSGIApp: diff --git a/src/functions_framework/_function_registry.py b/src/functions_framework/_function_registry.py new file mode 100644 index 00000000..73f3d5f8 --- /dev/null +++ b/src/functions_framework/_function_registry.py @@ -0,0 +1,118 @@ +# 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 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" + +# REGISTRY_MAP stores the registered functions. +# Keys are user function names, values are user function signature types. +REGISTRY_MAP = {} + + +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): + raise MissingTargetException( + "File {source} is expected to contain a function named {target}".format( + source=source, target=target + ) + ) + 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) + 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) + ) + print("registered_type ", registered_type) + print("flag ", signature_type) + print("env ", 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 diff --git a/tests/test_decorator_functions.py b/tests/test_decorator_functions.py new file mode 100644 index 00000000..b90003cf --- /dev/null +++ b/tests/test_decorator_functions.py @@ -0,0 +1,69 @@ +# 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 pytest + +from cloudevents.http import CloudEvent, to_binary, to_structured + +from functions_framework import create_app + +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 cloudevent_decorator_client(): + source = TEST_FUNCTIONS_DIR / "decorators" / "decorator.py" + target = "function_cloudevent" + return create_app(target, source).test_client() + + +@pytest.fixture +def http_decorator_client(): + source = TEST_FUNCTIONS_DIR / "decorators" / "decorator.py" + target = "function_http" + return create_app(target, source).test_client() + + +@pytest.fixture +def cloudevent_1_0(): + attributes = { + "specversion": "1.0", + "id": "my-id", + "source": "from-galaxy-far-far-away", + "type": "cloudevent.greet.you", + "time": "2020-08-16T13:58:54.471765", + } + data = {"name": "john"} + return CloudEvent(attributes, data) + + +def test_cloudevent_decorator(cloudevent_decorator_client, cloudevent_1_0): + headers, data = to_structured(cloudevent_1_0) + resp = cloudevent_decorator_client.post("/", headers=headers, data=data) + + assert resp.status_code == 200 + assert resp.data == b"OK" + + +def test_http_decorator(http_decorator_client): + resp = http_decorator_client.post("/my_path", json={"mode": "path"}) + assert resp.status_code == 200 + assert resp.data == b"/my_path" diff --git a/tests/test_function_registry.py b/tests/test_function_registry.py new file mode 100644 index 00000000..e3ae3c7e --- /dev/null +++ b/tests/test_function_registry.py @@ -0,0 +1,62 @@ +# 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 + +from functions_framework import _function_registry + + +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 176e2bb1..64ecc794 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -14,10 +14,8 @@ import json -import os import pathlib import re -import sys import time import pretend diff --git a/tests/test_functions/decorators/decorator.py b/tests/test_functions/decorators/decorator.py new file mode 100644 index 00000000..0c32423c --- /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.cloudevent +def function_cloudevent(cloudevent): + """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: + cloudevent: 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 = ( + cloudevent["id"] == "my-id" + and cloudevent.data == {"name": "john"} + and cloudevent["source"] == "from-galaxy-far-far-away" + and cloudevent["type"] == "cloudevent.greet.you" + and cloudevent["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 From 5a9ef41e80ae084bc7157902e89fd900117de8a1 Mon Sep 17 00:00:00 2001 From: Kayla Nguyen Date: Fri, 29 Oct 2021 15:16:21 -0700 Subject: [PATCH 021/181] feat: Pre-release version 2.4.0-beta.1 (#162) --- CHANGELOG.md | 8 +++++++- setup.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8ceffaf..43ee60c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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]) @@ -117,7 +121,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release -[Unreleased]: https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v2.3.0...HEAD +[Unreleased]: https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v2.4.0-beta.1...HEAD +[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 @@ -140,6 +145,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [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 +[#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 diff --git a/setup.py b/setup.py index a80961aa..db653084 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="2.3.0", + version="2.4.0-beta.1", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown", From a57bac683f64aef37051fc87132fd693e919780c Mon Sep 17 00:00:00 2001 From: Kayla Nguyen Date: Fri, 29 Oct 2021 18:27:33 -0700 Subject: [PATCH 022/181] fix: remove debuggng code (#163) --- src/functions_framework/_function_registry.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/functions_framework/_function_registry.py b/src/functions_framework/_function_registry.py index 73f3d5f8..f7869b24 100644 --- a/src/functions_framework/_function_registry.py +++ b/src/functions_framework/_function_registry.py @@ -107,9 +107,6 @@ def get_func_signature_type(func_name: str, signature_type: str) -> str: or signature_type or os.environ.get(FUNCTION_SIGNATURE_TYPE, HTTP_SIGNATURE_TYPE) ) - print("registered_type ", registered_type) - print("flag ", signature_type) - print("env ", 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 From 75cc22c1424cb175ae0828a6fcabc7f867a2a45a Mon Sep 17 00:00:00 2001 From: Kayla Nguyen Date: Mon, 1 Nov 2021 15:26:35 -0700 Subject: [PATCH 023/181] chore: Pre-release version 2.4.0-beta.2 (#164) --- CHANGELOG.md | 7 ++++++- setup.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43ee60c2..bdd38d66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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]) @@ -121,7 +125,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release -[Unreleased]: https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v2.4.0-beta.1...HEAD +[Unreleased]: https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v2.4.0-beta.2...HEAD +[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 diff --git a/setup.py b/setup.py index db653084..83ec9ccd 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="2.4.0-beta.1", + version="2.4.0-beta.2", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown", From 85b7e436992600cf7f4add86f0cc4ad80804a994 Mon Sep 17 00:00:00 2001 From: Kayla Nguyen Date: Tue, 2 Nov 2021 12:15:44 -0700 Subject: [PATCH 024/181] chore: Add conformance tests for declarative functions (#165) --- .github/workflows/conformance.yml | 18 ++++++++++++++++++ tests/conformance/main.py | 13 +++++++++++++ 2 files changed, 31 insertions(+) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 31883ec3..93a55b2f 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -49,3 +49,21 @@ jobs: 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@v1.1.0 + with: + version: 'v1.1.0' + functionType: 'http' + useBuildpacks: false + validateMapping: false + cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative'" + + - name: Run cloudevent conformance tests declarative + uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.1.0 + with: + version: 'v1.1.0' + functionType: 'cloudevent' + useBuildpacks: false + validateMapping: true + cmd: "'functions-framework --source tests/conformance/main.py --target write_cloudevent_declarative'" diff --git a/tests/conformance/main.py b/tests/conformance/main.py index 345fccce..72fa1633 100644 --- a/tests/conformance/main.py +++ b/tests/conformance/main.py @@ -2,6 +2,8 @@ from cloudevents.http import to_json +import functions_framework + filename = "function_output.json" @@ -33,3 +35,14 @@ def write_legacy_event(data, context): def write_cloud_event(cloudevent): _write_output(to_json(cloudevent).decode()) + + +@functions_framework.http +def write_http_declarative(request): + _write_output(json.dumps(request.json)) + return "OK", 200 + + +@functions_framework.cloudevent +def write_cloudevent_declarative(cloudevent): + _write_output(to_json(cloudevent).decode()) From 99a9df6500c620594dd9d17bd32798283a20aa3e Mon Sep 17 00:00:00 2001 From: Kayla Nguyen Date: Wed, 10 Nov 2021 12:17:15 -0800 Subject: [PATCH 025/181] refactor!: rename cloudevent to cloud_event (#167) --- .github/workflows/conformance.yml | 6 +- README.md | 18 +-- examples/README.md | 2 +- .../Dockerfile | 0 .../README.md | 6 +- .../main.py | 4 +- .../requirements.txt | 0 .../send_cloud_event.py} | 0 examples/cloud_run_decorator/README.md | 2 +- examples/cloud_run_decorator/main.py | 6 +- src/functions_framework/__init__.py | 14 +- src/functions_framework/event_conversion.py | 12 +- tests/conformance/main.py | 10 +- ...tions.py => test_cloud_event_functions.py} | 40 +++--- tests/test_convert.py | 126 +++++++++--------- ... => firebase-auth-cloud-event-output.json} | 0 ...on => firebase-db-cloud-event-output.json} | 0 ...on => pubsub_text-cloud-event-output.json} | 0 tests/test_decorator_functions.py | 14 +- .../converted_background_event.py | 14 +- .../empty_data.py | 10 +- .../{cloudevents => cloud_events}/main.py | 14 +- tests/test_functions/decorators/decorator.py | 16 +-- tests/test_view_functions.py | 28 ++-- 24 files changed, 171 insertions(+), 171 deletions(-) rename examples/{cloud_run_cloudevents => cloud_run_cloud_events}/Dockerfile (100%) rename examples/{cloud_run_cloudevents => cloud_run_cloud_events}/README.md (72%) rename examples/{cloud_run_cloudevents => cloud_run_cloud_events}/main.py (86%) rename examples/{cloud_run_cloudevents => cloud_run_cloud_events}/requirements.txt (100%) rename examples/{cloud_run_cloudevents/send_cloudevent.py => cloud_run_cloud_events/send_cloud_event.py} (100%) rename tests/{test_cloudevent_functions.py => test_cloud_event_functions.py} (86%) rename tests/test_data/{firebase-auth-cloudevent-output.json => firebase-auth-cloud-event-output.json} (100%) rename tests/test_data/{firebase-db-cloudevent-output.json => firebase-db-cloud-event-output.json} (100%) rename tests/test_data/{pubsub_text-cloudevent-output.json => pubsub_text-cloud-event-output.json} (100%) rename tests/test_functions/{cloudevents => cloud_events}/converted_background_event.py (78%) rename tests/test_functions/{cloudevents => cloud_events}/empty_data.py (78%) rename tests/test_functions/{cloudevents => cloud_events}/main.py (71%) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 93a55b2f..940bdf58 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -41,7 +41,7 @@ jobs: validateMapping: true cmd: "'functions-framework --source tests/conformance/main.py --target write_legacy_event --signature-type event'" - - name: Run cloudevent conformance tests + - name: Run CloudEvents conformance tests uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.1.0 with: version: 'v1.1.0' @@ -59,11 +59,11 @@ jobs: validateMapping: false cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative'" - - name: Run cloudevent conformance tests declarative + - name: Run CloudEvents conformance tests declarative uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.1.0 with: version: 'v1.1.0' functionType: 'cloudevent' useBuildpacks: false validateMapping: true - cmd: "'functions-framework --source tests/conformance/main.py --target write_cloudevent_declarative'" + cmd: "'functions-framework --source tests/conformance/main.py --target write_cloud_event_declarative'" diff --git a/README.md b/README.md index 7d5c1e3d..029620ce 100644 --- a/README.md +++ b/README.md @@ -118,9 +118,9 @@ Create an `main.py` file with the following contents: ```python import functions_framework -@functions_framework.cloudevent -def hello_cloudevent(cloudevent): - return f"Received event with ID: {cloudevent['id']} and data {cloudevent.data}" +@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): @@ -136,13 +136,13 @@ functions-framework --target=hello_http Open http://localhost:8080/ in your browser and see *Hello world!*. -Run the following command to run `hello_cloudevent` target locally: +Run the following command to run `hello_cloud_event` target locally: ```sh -functions-framework --target=hello_cloudevent +functions-framework --target=hello_cloud_event ``` -More info on sending [CloudEvents](http://cloudevents.io) payloads, see [`examples/cloud_run_cloudevents`](examples/cloud_run_cloudevents/) instruction. +More info on sending [CloudEvents](http://cloudevents.io) payloads, see [`examples/cloud_run_cloud_events`](examples/cloud_run_cloud_events/) instruction. ### Quickstart: Error handling @@ -358,11 +358,11 @@ See the [running example](examples/cloud_run_event). ## Enable CloudEvents -The Functions framework can also unmarshall incoming [CloudEvents](http://cloudevents.io) payloads to the `cloudevent` object. This will be passed as a [cloudevent](https://github.com/cloudevents/sdk-python) to your function when it receives a request. Note that your function must use the `cloudevents`-style function signature: +The Functions framework can also unmarshall incoming [CloudEvents](http://cloudevents.io) payloads to the `cloud_event` object. This will be passed as a [CloudEvent](https://github.com/cloudevents/sdk-python) to your function when it receives a request. Note that your function must use the `CloudEvents`-style function signature: ```python -def hello(cloudevent): - print(f"Received event with ID: {cloudevent['id']}") +def hello(cloud_event): + print(f"Received event with ID: {cloud_event['id']}") ``` To enable automatic unmarshalling, set the function signature type to `cloudevent` 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. diff --git a/examples/README.md b/examples/README.md index dfc93131..7960a743 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,7 +4,7 @@ ### 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_cloudevents`](./cloud_run_cloudevents/) - Deploying a [CloudEvent](https://github.com/cloudevents/sdk-python) 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 ## Development Tools * [`docker-compose`](./docker-compose) - diff --git a/examples/cloud_run_cloudevents/Dockerfile b/examples/cloud_run_cloud_events/Dockerfile similarity index 100% rename from examples/cloud_run_cloudevents/Dockerfile rename to examples/cloud_run_cloud_events/Dockerfile diff --git a/examples/cloud_run_cloudevents/README.md b/examples/cloud_run_cloud_events/README.md similarity index 72% rename from examples/cloud_run_cloudevents/README.md rename to examples/cloud_run_cloud_events/README.md index 03a9931a..4bf54528 100644 --- a/examples/cloud_run_cloudevents/README.md +++ b/examples/cloud_run_cloud_events/README.md @@ -7,17 +7,17 @@ This sample uses the [CloudEvents SDK](https://github.com/cloudevents/sdk-python Build the Docker image: ```commandline -docker build -t cloudevent_example . +docker build -t cloud_event_example . ``` Run the image and bind the correct ports: ```commandline -docker run --rm -p 8080:8080 -e PORT=8080 cloudevent_example +docker run --rm -p 8080:8080 -e PORT=8080 cloud_event_example ``` Send an event to the container: ```python -docker run -t cloudevent_example send_cloudevent.py +docker run -t cloud_event_example send_cloud_event.py ``` diff --git a/examples/cloud_run_cloudevents/main.py b/examples/cloud_run_cloud_events/main.py similarity index 86% rename from examples/cloud_run_cloudevents/main.py rename to examples/cloud_run_cloud_events/main.py index da77322f..eb6d6dc0 100644 --- a/examples/cloud_run_cloudevents/main.py +++ b/examples/cloud_run_cloud_events/main.py @@ -16,5 +16,5 @@ # (https://github.com/cloudevents/sdk-python) -def hello(cloudevent): - print(f"Received event with ID: {cloudevent['id']} and data {cloudevent.data}") +def hello(cloud_event): + print(f"Received event with ID: {cloud_event['id']} and data {cloud_event.data}") diff --git a/examples/cloud_run_cloudevents/requirements.txt b/examples/cloud_run_cloud_events/requirements.txt similarity index 100% rename from examples/cloud_run_cloudevents/requirements.txt rename to examples/cloud_run_cloud_events/requirements.txt diff --git a/examples/cloud_run_cloudevents/send_cloudevent.py b/examples/cloud_run_cloud_events/send_cloud_event.py similarity index 100% rename from examples/cloud_run_cloudevents/send_cloudevent.py rename to examples/cloud_run_cloud_events/send_cloud_event.py diff --git a/examples/cloud_run_decorator/README.md b/examples/cloud_run_decorator/README.md index 92c33b6c..ba560b6b 100644 --- a/examples/cloud_run_decorator/README.md +++ b/examples/cloud_run_decorator/README.md @@ -1,7 +1,7 @@ ## How to run this locally This guide shows how to run `hello_http` target locally. -To test with `hello_cloudevent`, change the target accordingly in Dockerfile. +To test with `hello_cloud_event`, change the target accordingly in Dockerfile. Build the Docker image: diff --git a/examples/cloud_run_decorator/main.py b/examples/cloud_run_decorator/main.py index 291280f3..19f96ee0 100644 --- a/examples/cloud_run_decorator/main.py +++ b/examples/cloud_run_decorator/main.py @@ -17,9 +17,9 @@ import functions_framework -@functions_framework.cloudevent -def hello_cloudevent(cloudevent): - return f"Received event with ID: {cloudevent['id']} and data {cloudevent.data}" +@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 diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index c67cb7cd..46c8882b 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -56,7 +56,7 @@ def write(self, out): return self.stderr.write(json.dumps(payload) + "\n") -def cloudevent(func): +def cloud_event(func): """Decorator that registers cloudevent as user function signature type.""" _function_registry.REGISTRY_MAP[ func.__name__ @@ -101,13 +101,13 @@ def view_func(path): return view_func -def _run_cloudevent(function, request): +def _run_cloud_event(function, request): data = request.get_data() event = from_http(request.headers, data) function(event) -def _cloudevent_view_func_wrapper(function, request): +def _cloud_event_view_func_wrapper(function, request): def view_func(path): ce_exception = None event = None @@ -125,7 +125,7 @@ def view_func(path): # Not a CloudEvent. Try converting to a CloudEvent. try: - function(event_conversion.background_event_to_cloudevent(request)) + function(event_conversion.background_event_to_cloud_event(request)) except EventConversionException as e: flask.abort( 400, @@ -144,9 +144,9 @@ def view_func(path): def _event_view_func_wrapper(function, request): def view_func(path): - if event_conversion.is_convertable_cloudevent(request): + if event_conversion.is_convertable_cloud_event(request): # Convert this CloudEvent to the equivalent background event data and context. - data, context = event_conversion.cloudevent_to_background_event(request) + 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 @@ -214,7 +214,7 @@ def _configure_app(app, function, signature_type): ) ) - app.view_functions[signature_type] = _cloudevent_view_func_wrapper( + app.view_functions[signature_type] = _cloud_event_view_func_wrapper( function, flask.request ) else: diff --git a/src/functions_framework/event_conversion.py b/src/functions_framework/event_conversion.py index 0605d940..28cf2a1b 100644 --- a/src/functions_framework/event_conversion.py +++ b/src/functions_framework/event_conversion.py @@ -23,7 +23,7 @@ from functions_framework.exceptions import EventConversionException from google.cloud.functions.context import Context -_CLOUDEVENT_SPEC_VERSION = "1.0" +_CLOUD_EVENT_SPEC_VERSION = "1.0" # Maps background/legacy event types to their equivalent CloudEvent types. # For more info on event mappings see @@ -114,7 +114,7 @@ } -def background_event_to_cloudevent(request) -> CloudEvent: +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: @@ -154,7 +154,7 @@ def background_event_to_cloudevent(request) -> CloudEvent: # Handle Firebase DB events. if service == _FIREBASE_DB_CE_SERVICE: - # The CE source of firebasedatabase cloudevents includes location information + # 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( @@ -172,7 +172,7 @@ def background_event_to_cloudevent(request) -> CloudEvent: metadata = { "id": context.event_id, "time": context.timestamp, - "specversion": _CLOUDEVENT_SPEC_VERSION, + "specversion": _CLOUD_EVENT_SPEC_VERSION, "datacontenttype": "application/json", "type": new_type, "source": source, @@ -184,7 +184,7 @@ def background_event_to_cloudevent(request) -> CloudEvent: return CloudEvent(metadata, data) -def is_convertable_cloudevent(request) -> bool: +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") @@ -207,7 +207,7 @@ def _split_ce_source(source) -> Tuple[str, str]: return match.group(1), match.group(2) -def cloudevent_to_background_event(request) -> Tuple[Any, Context]: +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()) diff --git a/tests/conformance/main.py b/tests/conformance/main.py index 72fa1633..e790f626 100644 --- a/tests/conformance/main.py +++ b/tests/conformance/main.py @@ -33,8 +33,8 @@ def write_legacy_event(data, context): ) -def write_cloud_event(cloudevent): - _write_output(to_json(cloudevent).decode()) +def write_cloud_event(cloud_event): + _write_output(to_json(cloud_event).decode()) @functions_framework.http @@ -43,6 +43,6 @@ def write_http_declarative(request): return "OK", 200 -@functions_framework.cloudevent -def write_cloudevent_declarative(cloudevent): - _write_output(to_json(cloudevent).decode()) +@functions_framework.cloud_event +def write_cloud_event_declarative(cloud_event): + _write_output(to_json(cloud_event).decode()) diff --git a/tests/test_cloudevent_functions.py b/tests/test_cloud_event_functions.py similarity index 86% rename from tests/test_cloudevent_functions.py rename to tests/test_cloud_event_functions.py index 237225cd..4ad8a527 100644 --- a/tests/test_cloudevent_functions.py +++ b/tests/test_cloud_event_functions.py @@ -36,12 +36,12 @@ def data_payload(): @pytest.fixture -def cloudevent_1_0(): +def cloud_event_1_0(): attributes = { "specversion": "1.0", "id": "my-id", "source": "from-galaxy-far-far-away", - "type": "cloudevent.greet.you", + "type": "cloud_event.greet.you", "time": "2020-08-16T13:58:54.471765", } data = {"name": "john"} @@ -49,11 +49,11 @@ def cloudevent_1_0(): @pytest.fixture -def cloudevent_0_3(): +def cloud_event_0_3(): attributes = { "id": "my-id", "source": "from-galaxy-far-far-away", - "type": "cloudevent.greet.you", + "type": "cloud_event.greet.you", "specversion": "0.3", "time": "2020-08-16T13:58:54.471765", } @@ -66,7 +66,7 @@ def create_headers_binary(): return lambda specversion: { "ce-id": "my-id", "ce-source": "from-galaxy-far-far-away", - "ce-type": "cloudevent.greet.you", + "ce-type": "cloud_event.greet.you", "ce-specversion": specversion, "time": "2020-08-16T13:58:54.471765", } @@ -77,7 +77,7 @@ def create_structured_data(): return lambda specversion: { "id": "my-id", "source": "from-galaxy-far-far-away", - "type": "cloudevent.greet.you", + "type": "cloud_event.greet.you", "specversion": specversion, "time": "2020-08-16T13:58:54.471765", } @@ -91,51 +91,51 @@ def background_event(): @pytest.fixture def client(): - source = TEST_FUNCTIONS_DIR / "cloudevents" / "main.py" + source = TEST_FUNCTIONS_DIR / "cloud_events" / "main.py" target = "function" return create_app(target, source, "cloudevent").test_client() @pytest.fixture def empty_client(): - source = TEST_FUNCTIONS_DIR / "cloudevents" / "empty_data.py" + source = TEST_FUNCTIONS_DIR / "cloud_events" / "empty_data.py" target = "function" return create_app(target, source, "cloudevent").test_client() @pytest.fixture def converted_background_event_client(): - source = TEST_FUNCTIONS_DIR / "cloudevents" / "converted_background_event.py" + source = TEST_FUNCTIONS_DIR / "cloud_events" / "converted_background_event.py" target = "function" return create_app(target, source, "cloudevent").test_client() -def test_event(client, cloudevent_1_0): - headers, data = to_structured(cloudevent_1_0) +def test_event(client, cloud_event_1_0): + headers, data = to_structured(cloud_event_1_0) resp = client.post("/", headers=headers, data=data) assert resp.status_code == 200 assert resp.data == b"OK" -def test_binary_event(client, cloudevent_1_0): - headers, data = to_binary(cloudevent_1_0) +def test_binary_event(client, cloud_event_1_0): + headers, data = to_binary(cloud_event_1_0) resp = client.post("/", headers=headers, data=data) assert resp.status_code == 200 assert resp.data == b"OK" -def test_event_0_3(client, cloudevent_0_3): - headers, data = to_structured(cloudevent_0_3) +def test_event_0_3(client, cloud_event_0_3): + headers, data = to_structured(cloud_event_0_3) resp = client.post("/", headers=headers, data=data) assert resp.status_code == 200 assert resp.data == b"OK" -def test_binary_event_0_3(client, cloudevent_0_3): - headers, data = to_binary(cloudevent_0_3) +def test_binary_event_0_3(client, cloud_event_0_3): + headers, data = to_binary(cloud_event_0_3) resp = client.post("/", headers=headers, data=data) assert resp.status_code == 200 @@ -143,7 +143,7 @@ def test_binary_event_0_3(client, cloudevent_0_3): @pytest.mark.parametrize("specversion", ["0.3", "1.0"]) -def test_cloudevent_missing_required_binary_fields( +def test_cloud_event_missing_required_binary_fields( client, specversion, create_headers_binary, data_payload ): headers = create_headers_binary(specversion) @@ -160,7 +160,7 @@ def test_cloudevent_missing_required_binary_fields( @pytest.mark.parametrize("specversion", ["0.3", "1.0"]) -def test_cloudevent_missing_required_structured_fields( +def test_cloud_event_missing_required_structured_fields( client, specversion, create_structured_data ): headers = {"Content-Type": "application/cloudevents+json"} @@ -186,7 +186,7 @@ def test_invalid_fields_binary(client, create_headers_binary, data_payload): assert "InvalidRequiredFields" in resp.data.decode() -def test_unparsable_cloudevent(client): +def test_unparsable_cloud_event(client): resp = client.post("/", headers={}, data="") assert resp.status_code == 400 diff --git a/tests/test_convert.py b/tests/test_convert.py index ae227c83..9202f567 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -83,7 +83,7 @@ @pytest.fixture -def pubsub_cloudevent_output(): +def pubsub_cloud_event_output(): return from_json(json.dumps(PUBSUB_CLOUD_EVENT)) @@ -121,7 +121,7 @@ def marshalled_pubsub_request(): @pytest.fixture -def raw_pubsub_cloudevent_output(marshalled_pubsub_request): +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"] @@ -138,8 +138,8 @@ def firebase_auth_background_input(): @pytest.fixture -def firebase_auth_cloudevent_output(): - with open(TEST_DATA_DIR / "firebase-auth-cloudevent-output.json", "r") as f: +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()) @@ -150,8 +150,8 @@ def firebase_db_background_input(): @pytest.fixture -def firebase_db_cloudevent_output(): - with open(TEST_DATA_DIR / "firebase-db-cloudevent-output.json", "r") as f: +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()) @@ -170,89 +170,89 @@ def create_ce_headers(): @pytest.mark.parametrize( "event", [PUBSUB_BACKGROUND_EVENT, PUBSUB_BACKGROUND_EVENT_WITHOUT_CONTEXT] ) -def test_pubsub_event_to_cloudevent(event, pubsub_cloudevent_output): +def test_pubsub_event_to_cloud_event(event, pubsub_cloud_event_output): req = flask.Request.from_values(json=event) - cloudevent = event_conversion.background_event_to_cloudevent(req) - assert cloudevent == pubsub_cloudevent_output + cloud_event = event_conversion.background_event_to_cloud_event(req) + assert cloud_event == pubsub_cloud_event_output -def test_firebase_auth_event_to_cloudevent( - firebase_auth_background_input, firebase_auth_cloudevent_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) - cloudevent = event_conversion.background_event_to_cloudevent(req) - assert cloudevent == firebase_auth_cloudevent_output + cloud_event = event_conversion.background_event_to_cloud_event(req) + assert cloud_event == firebase_auth_cloud_event_output -def test_firebase_auth_event_to_cloudevent_no_metadata( - firebase_auth_background_input, firebase_auth_cloudevent_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_cloudevent_output.data["metadata"] + del firebase_auth_cloud_event_output.data["metadata"] req = flask.Request.from_values(json=firebase_auth_background_input) - cloudevent = event_conversion.background_event_to_cloudevent(req) - assert cloudevent == firebase_auth_cloudevent_output + cloud_event = event_conversion.background_event_to_cloud_event(req) + assert cloud_event == firebase_auth_cloud_event_output -def test_firebase_auth_event_to_cloudevent_no_metadata_timestamps( - firebase_auth_background_input, firebase_auth_cloudevent_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_cloudevent_output.data["metadata"]["createTime"] - del firebase_auth_cloudevent_output.data["metadata"]["lastSignInTime"] + 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) - cloudevent = event_conversion.background_event_to_cloudevent(req) - assert cloudevent == firebase_auth_cloudevent_output + cloud_event = event_conversion.background_event_to_cloud_event(req) + assert cloud_event == firebase_auth_cloud_event_output -def test_firebase_auth_event_to_cloudevent_no_uid( - firebase_auth_background_input, firebase_auth_cloudevent_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_cloudevent_output.data["uid"] - del firebase_auth_cloudevent_output["subject"] + 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) - cloudevent = event_conversion.background_event_to_cloudevent(req) - assert cloudevent == firebase_auth_cloudevent_output + cloud_event = event_conversion.background_event_to_cloud_event(req) + assert cloud_event == firebase_auth_cloud_event_output -def test_firebase_db_event_to_cloudevent_default_location( - firebase_db_background_input, firebase_db_cloudevent_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) - cloudevent = event_conversion.background_event_to_cloudevent(req) - assert cloudevent == firebase_db_cloudevent_output + cloud_event = event_conversion.background_event_to_cloud_event(req) + assert cloud_event == firebase_db_cloud_event_output -def test_firebase_db_event_to_cloudevent_location_subdomain( - firebase_db_background_input, firebase_db_cloudevent_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_cloudevent_output["source"] = firebase_db_cloudevent_output[ + 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) - cloudevent = event_conversion.background_event_to_cloudevent(req) - assert cloudevent == firebase_db_cloudevent_output + cloud_event = event_conversion.background_event_to_cloud_event(req) + assert cloud_event == firebase_db_cloud_event_output -def test_firebase_db_event_to_cloudevent_missing_domain( - firebase_db_background_input, firebase_db_cloudevent_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_cloudevent(req) + event_conversion.background_event_to_cloud_event(req) assert ( "Invalid FirebaseDB event payload: missing 'domain'" in exc_info.value.args[0] @@ -364,8 +364,8 @@ def test_marshal_background_event_data_with_topic_path( ("marshalled_pubsub_request", {}), ], ) -def test_pubsub_emulator_request_to_cloudevent( - raw_pubsub_cloudevent_output, request_fixture, overrides, 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) @@ -373,30 +373,30 @@ def test_pubsub_emulator_request_to_cloudevent( path=request_path, json=payload, ) - cloudevent = event_conversion.background_event_to_cloudevent(req) + cloud_event = event_conversion.background_event_to_cloud_event(req) # Remove timestamps as they are generated on the fly. - del raw_pubsub_cloudevent_output["time"] - del raw_pubsub_cloudevent_output.data["message"]["publishTime"] - del cloudevent["time"] - del cloudevent.data["message"]["publishTime"] + 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_cloudevent_output["source"] = overrides["source"] + raw_pubsub_cloud_event_output["source"] = overrides["source"] - assert cloudevent == raw_pubsub_cloudevent_output + assert cloud_event == raw_pubsub_cloud_event_output def test_pubsub_emulator_request_with_invalid_message( - raw_pubsub_request, raw_pubsub_cloudevent_output + 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: - cloudevent = event_conversion.background_event_to_cloudevent(req) + 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] @@ -449,7 +449,7 @@ def test_pubsub_emulator_request_with_invalid_message( ), ], ) -def test_cloudevent_to_legacy_event( +def test_cloud_event_to_legacy_event( create_ce_headers, ce_event_type, ce_source, @@ -459,7 +459,7 @@ def test_cloudevent_to_legacy_event( 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.cloudevent_to_background_event(req) + (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" @@ -468,7 +468,7 @@ def test_cloudevent_to_legacy_event( assert res_data == {"kind": "value"} -def test_cloudevent_to_legacy_event_with_pubsub_message_payload( +def test_cloud_event_to_legacy_event_with_pubsub_message_payload( create_ce_headers, ): headers = create_ce_headers( @@ -484,13 +484,13 @@ def test_cloudevent_to_legacy_event_with_pubsub_message_payload( } req = flask.Request.from_values(headers=headers, json=data) - (res_data, res_context) = event_conversion.cloudevent_to_background_event(req) + (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_cloudevent_to_legacy_event_with_firebase_auth_ce( +def test_cloud_event_to_legacy_event_with_firebase_auth_ce( create_ce_headers, ): headers = create_ce_headers( @@ -506,7 +506,7 @@ def test_cloudevent_to_legacy_event_with_firebase_auth_ce( } req = flask.Request.from_values(headers=headers, json=data) - (res_data, res_context) = event_conversion.cloudevent_to_background_event(req) + (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 == { @@ -518,7 +518,7 @@ def test_cloudevent_to_legacy_event_with_firebase_auth_ce( } -def test_cloudevent_to_legacy_event_with_firebase_auth_ce_empty_metadata( +def test_cloud_event_to_legacy_event_with_firebase_auth_ce_empty_metadata( create_ce_headers, ): headers = create_ce_headers( @@ -528,7 +528,7 @@ def test_cloudevent_to_legacy_event_with_firebase_auth_ce_empty_metadata( data = {"metadata": {}, "uid": "my-id"} req = flask.Request.from_values(headers=headers, json=data) - (res_data, res_context) = event_conversion.cloudevent_to_background_event(req) + (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 @@ -555,7 +555,7 @@ def test_cloudevent_to_legacy_event_with_firebase_auth_ce_empty_metadata( ), ], ) -def test_cloudevent_to_legacy_event_with_invalid_event( +def test_cloud_event_to_legacy_event_with_invalid_event( create_ce_headers, header_overrides, exception_message, @@ -573,7 +573,7 @@ def test_cloudevent_to_legacy_event_with_invalid_event( req = flask.Request.from_values(headers=headers, json={"some": "val"}) with pytest.raises(EventConversionException) as exc_info: - event_conversion.cloudevent_to_background_event(req) + event_conversion.cloud_event_to_background_event(req) assert exception_message in exc_info.value.args[0] diff --git a/tests/test_data/firebase-auth-cloudevent-output.json b/tests/test_data/firebase-auth-cloud-event-output.json similarity index 100% rename from tests/test_data/firebase-auth-cloudevent-output.json rename to tests/test_data/firebase-auth-cloud-event-output.json diff --git a/tests/test_data/firebase-db-cloudevent-output.json b/tests/test_data/firebase-db-cloud-event-output.json similarity index 100% rename from tests/test_data/firebase-db-cloudevent-output.json rename to tests/test_data/firebase-db-cloud-event-output.json diff --git a/tests/test_data/pubsub_text-cloudevent-output.json b/tests/test_data/pubsub_text-cloud-event-output.json similarity index 100% rename from tests/test_data/pubsub_text-cloudevent-output.json rename to tests/test_data/pubsub_text-cloud-event-output.json diff --git a/tests/test_decorator_functions.py b/tests/test_decorator_functions.py index b90003cf..e8c9bc70 100644 --- a/tests/test_decorator_functions.py +++ b/tests/test_decorator_functions.py @@ -29,9 +29,9 @@ @pytest.fixture -def cloudevent_decorator_client(): +def cloud_event_decorator_client(): source = TEST_FUNCTIONS_DIR / "decorators" / "decorator.py" - target = "function_cloudevent" + target = "function_cloud_event" return create_app(target, source).test_client() @@ -43,21 +43,21 @@ def http_decorator_client(): @pytest.fixture -def cloudevent_1_0(): +def cloud_event_1_0(): attributes = { "specversion": "1.0", "id": "my-id", "source": "from-galaxy-far-far-away", - "type": "cloudevent.greet.you", + "type": "cloud_event.greet.you", "time": "2020-08-16T13:58:54.471765", } data = {"name": "john"} return CloudEvent(attributes, data) -def test_cloudevent_decorator(cloudevent_decorator_client, cloudevent_1_0): - headers, data = to_structured(cloudevent_1_0) - resp = cloudevent_decorator_client.post("/", headers=headers, data=data) +def test_cloud_event_decorator(cloud_event_decorator_client, cloud_event_1_0): + headers, data = to_structured(cloud_event_1_0) + resp = cloud_event_decorator_client.post("/", headers=headers, data=data) assert resp.status_code == 200 assert resp.data == b"OK" diff --git a/tests/test_functions/cloudevents/converted_background_event.py b/tests/test_functions/cloud_events/converted_background_event.py similarity index 78% rename from tests/test_functions/cloudevents/converted_background_event.py rename to tests/test_functions/cloud_events/converted_background_event.py index 44b69556..9264251d 100644 --- a/tests/test_functions/cloudevents/converted_background_event.py +++ b/tests/test_functions/cloud_events/converted_background_event.py @@ -16,13 +16,13 @@ import flask -def function(cloudevent): +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: - cloudevent: A CloudEvent as defined by https://github.com/cloudevents/sdk-python. + 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. @@ -41,12 +41,12 @@ def function(cloudevent): } valid_event = ( - cloudevent["id"] == "aaaaaa-1111-bbbb-2222-cccccccccccc" - and cloudevent.data == data - and cloudevent["source"] + 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 cloudevent["type"] == "google.cloud.pubsub.topic.v1.messagePublished" - and cloudevent["time"] == "2020-09-29T11:32:00.000Z" + and cloud_event["type"] == "google.cloud.pubsub.topic.v1.messagePublished" + and cloud_event["time"] == "2020-09-29T11:32:00.000Z" ) if not valid_event: diff --git a/tests/test_functions/cloudevents/empty_data.py b/tests/test_functions/cloud_events/empty_data.py similarity index 78% rename from tests/test_functions/cloudevents/empty_data.py rename to tests/test_functions/cloud_events/empty_data.py index c1000265..d9209667 100644 --- a/tests/test_functions/cloudevents/empty_data.py +++ b/tests/test_functions/cloud_events/empty_data.py @@ -16,13 +16,13 @@ import flask -def function(cloudevent): +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: - cloudevent: A CloudEvent as defined by https://github.com/cloudevents/sdk-python. + 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. @@ -30,9 +30,9 @@ def function(cloudevent): """ valid_event = ( - cloudevent["id"] == "my-id" - and cloudevent["source"] == "from-galaxy-far-far-away" - and cloudevent["type"] == "cloudevent.greet.you" + 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: diff --git a/tests/test_functions/cloudevents/main.py b/tests/test_functions/cloud_events/main.py similarity index 71% rename from tests/test_functions/cloudevents/main.py rename to tests/test_functions/cloud_events/main.py index aa480840..739d2a9d 100644 --- a/tests/test_functions/cloudevents/main.py +++ b/tests/test_functions/cloud_events/main.py @@ -16,24 +16,24 @@ import flask -def function(cloudevent): +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: - cloudevent: A CloudEvent as defined by https://github.com/cloudevents/sdk-python. + 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 = ( - cloudevent["id"] == "my-id" - and cloudevent.data == {"name": "john"} - and cloudevent["source"] == "from-galaxy-far-far-away" - and cloudevent["type"] == "cloudevent.greet.you" - and cloudevent["time"] == "2020-08-16T13:58:54.471765" + 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: diff --git a/tests/test_functions/decorators/decorator.py b/tests/test_functions/decorators/decorator.py index 0c32423c..3aae119d 100644 --- a/tests/test_functions/decorators/decorator.py +++ b/tests/test_functions/decorators/decorator.py @@ -18,25 +18,25 @@ import functions_framework -@functions_framework.cloudevent -def function_cloudevent(cloudevent): +@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: - cloudevent: A CloudEvent as defined by https://github.com/cloudevents/sdk-python. + 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 = ( - cloudevent["id"] == "my-id" - and cloudevent.data == {"name": "john"} - and cloudevent["source"] == "from-galaxy-far-far-away" - and cloudevent["type"] == "cloudevent.greet.you" - and cloudevent["time"] == "2020-08-16T13:58:54.471765" + 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: diff --git a/tests/test_view_functions.py b/tests/test_view_functions.py index 592c8300..219313f9 100644 --- a/tests/test_view_functions.py +++ b/tests/test_view_functions.py @@ -63,12 +63,12 @@ def test_event_view_func_wrapper(monkeypatch): ] -def test_run_cloudevent(): +def test_run_cloud_event(): headers = {"Content-Type": "application/cloudevents+json"} data = json.dumps( { "source": "from-galaxy-far-far-away", - "type": "cloudevent.greet.you", + "type": "cloud_event.greet.you", "specversion": "1.0", "id": "f6a65fcd-eed2-429d-9f71-ec0663d83025", "time": "2020-08-13T02:12:14.946587+00:00", @@ -77,19 +77,19 @@ def test_run_cloudevent(): ) request = pretend.stub(headers=headers, get_data=lambda: data) - function = pretend.call_recorder(lambda cloudevent: "hello") - functions_framework._run_cloudevent(function, request) - expected_cloudevent = from_http(request.headers, request.get_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_cloudevent)] + assert function.calls == [pretend.call(expected_cloud_event)] -def test_cloudevent_view_func_wrapper(): +def test_cloud_event_view_func_wrapper(): headers = {"Content-Type": "application/cloudevents+json"} data = json.dumps( { "source": "from-galaxy-far-far-away", - "type": "cloudevent.greet.you", + "type": "cloud_event.greet.you", "specversion": "1.0", "id": "f6a65fcd-eed2-429d-9f71-ec0663d83025", "time": "2020-08-13T02:12:14.946587+00:00", @@ -100,19 +100,19 @@ def test_cloudevent_view_func_wrapper(): request = pretend.stub(headers=headers, get_data=lambda: data) event = from_http(request.headers, request.get_data()) - function = pretend.call_recorder(lambda cloudevent: cloudevent) + function = pretend.call_recorder(lambda cloud_event: cloud_event) - view_func = functions_framework._cloudevent_view_func_wrapper(function, request) + view_func = functions_framework._cloud_event_view_func_wrapper(function, request) view_func("/some/path") assert function.calls == [pretend.call(event)] -def test_binary_cloudevent_view_func_wrapper(): +def test_binary_cloud_event_view_func_wrapper(): headers = { "ce-specversion": "1.0", "ce-source": "from-galaxy-far-far-away", - "ce-type": "cloudevent.greet.you", + "ce-type": "cloud_event.greet.you", "ce-id": "f6a65fcd-eed2-429d-9f71-ec0663d83025", "ce-time": "2020-08-13T02:12:14.946587+00:00", } @@ -121,9 +121,9 @@ def test_binary_cloudevent_view_func_wrapper(): request = pretend.stub(headers=headers, get_data=lambda: data) event = from_http(request.headers, request.get_data()) - function = pretend.call_recorder(lambda cloudevent: cloudevent) + function = pretend.call_recorder(lambda cloud_event: cloud_event) - view_func = functions_framework._cloudevent_view_func_wrapper(function, request) + view_func = functions_framework._cloud_event_view_func_wrapper(function, request) view_func("/some/path") assert function.calls == [pretend.call(event)] From e44416e8e35a0f997d0dacd9f313306fe63646a4 Mon Sep 17 00:00:00 2001 From: Kayla Nguyen Date: Wed, 10 Nov 2021 14:22:54 -0800 Subject: [PATCH 026/181] feat!: release v3.0.0 (#168) --- CHANGELOG.md | 8 +++++++- setup.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdd38d66..cfbbe99d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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 @@ -125,7 +129,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release -[Unreleased]: https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v2.4.0-beta.2...HEAD +[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 @@ -150,6 +155,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [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 diff --git a/setup.py b/setup.py index 83ec9ccd..18727fd6 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="2.4.0-beta.2", + version="3.0.0", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown", From efb0e84d3f8ada6ac305b216baa6632570c38495 Mon Sep 17 00:00:00 2001 From: Annie Fu <16651409+anniefu@users.noreply.github.com> Date: Wed, 1 Dec 2021 11:26:26 -0800 Subject: [PATCH 027/181] docs: update README to use declarative function signatures (#171) * docs: update README to use declarative function signatures Reduce redundancy in quickstart examples. * Fix newline --- README.md | 88 +++++++++++++++++++++---------------------------------- 1 file changed, 33 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 029620ce..192a9b55 100644 --- a/README.md +++ b/README.md @@ -49,16 +49,19 @@ pip install functions-framework Or, for deployment, add the Functions Framework to your `requirements.txt` file: ``` -functions-framework==2.3.0 +functions-framework==3.* ``` ## Quickstarts -### Quickstart: Hello, World on your local machine +### Quickstart: HTTP Function (Hello World) Create an `main.py` file with the following contents: ```python +import functions_framework + +@functions_framework.http def hello(request): return "Hello world!" ``` @@ -67,30 +70,6 @@ def hello(request): Run the following command: -```sh -functions-framework --target=hello -``` - -Open http://localhost:8080/ in your browser and see *Hello world!*. - - -### Quickstart: Set up a new project - -Create a `main.py` file with the following contents: - -```python -def hello(request): - return "Hello world!" -``` - -Now install the Functions Framework: - -```sh -pip install functions-framework -``` - -Use the `functions-framework` command to start the built-in local development server: - ```sh functions-framework --target hello --debug * Serving Flask app "hello" (lazy loading) @@ -101,17 +80,19 @@ functions-framework --target hello --debug * Running on http://0.0.0.0:8080/ (Press CTRL+C to quit) ``` -(You can also use `functions-framework-python` if you potentially have multiple +(You can also use `functions-framework-python` if you have multiple language frameworks installed). -Send requests to this function using `curl` from another terminal window: +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: Register your function using decorator +### Quickstart: CloudEvent Function Create an `main.py` file with the following contents: @@ -120,27 +101,37 @@ 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!" - + print(f"Received event with ID: {cloud_event['id']} and data {cloud_event.data}") ``` -Run the following command to run `hello_http` target locally: +> Your function is passed a single [CloudEvent](https://github.com/cloudevents/sdk-python/blob/master/cloudevents/sdk/event/v1.py) parameter. + +Run the following command to run `hello_cloud_event` target locally: ```sh -functions-framework --target=hello_http +functions-framework --target=hello_cloud_event ``` -Open http://localhost:8080/ in your browser and see *Hello world!*. - -Run the following command to run `hello_cloud_event` target locally: +In a different terminal, `curl` the Functions Framework server: ```sh -functions-framework --target=hello_cloud_event +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" +}' +``` + +Output from the terminal running `functions-framework`: ``` +Received event with ID: A234-1234-1234 and data hello world +``` More info on sending [CloudEvents](http://cloudevents.io) payloads, see [`examples/cloud_run_cloud_events`](examples/cloud_run_cloud_events/) instruction. @@ -333,7 +324,7 @@ You can configure the Functions Framework using command-line flags or environmen | `--debug` | `DEBUG` | A flag that allows to run functions-framework to run in debug mode, including live reloading. Default: `False` | | `--dry-run` | `DRY_RUN` | A flag that allows for testing the function build from the configuration without creating a server. Default: `False` | -## Enable Google Cloud Functions Events +## Enable Google Cloud Function Events The Functions Framework can unmarshall incoming Google Cloud Functions [event](https://cloud.google.com/functions/docs/concepts/events-triggers#events) payloads to `event` and `context` objects. @@ -356,19 +347,6 @@ documentation on See the [running example](examples/cloud_run_event). -## Enable CloudEvents - -The Functions framework can also unmarshall incoming [CloudEvents](http://cloudevents.io) payloads to the `cloud_event` object. This will be passed as a [CloudEvent](https://github.com/cloudevents/sdk-python) to your function when it receives a request. Note that your function must use the `CloudEvents`-style function signature: - -```python -def hello(cloud_event): - print(f"Received event with ID: {cloud_event['id']}") -``` - -To enable automatic unmarshalling, set the function signature type to `cloudevent` 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. - -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). - ## Advanced Examples More advanced guides can be found in the [`examples/`](examples/) directory. From 6f4a3608634debe0833d0ef5cd769050b5fecb01 Mon Sep 17 00:00:00 2001 From: Arjun Srinivasan <69502+asriniva@users.noreply.github.com> Date: Wed, 8 Dec 2021 09:58:07 -0800 Subject: [PATCH 028/181] fix: Change gunicorn request line limit to unlimited (#173) --- src/functions_framework/_http/gunicorn.py | 1 + tests/test_http.py | 1 + 2 files changed, 2 insertions(+) diff --git a/src/functions_framework/_http/gunicorn.py b/src/functions_framework/_http/gunicorn.py index 48714284..25fdb790 100644 --- a/src/functions_framework/_http/gunicorn.py +++ b/src/functions_framework/_http/gunicorn.py @@ -23,6 +23,7 @@ def __init__(self, app, host, port, debug, **options): "threads": 8, "timeout": 0, "loglevel": "error", + "limit_request_line": 0, } self.options.update(options) self.app = app diff --git a/tests/test_http.py b/tests/test_http.py index e45dbab9..bd301c91 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -100,6 +100,7 @@ def test_gunicorn_application(debug): "threads": 8, "timeout": 0, "loglevel": "error", + "limit_request_line": 0, } assert gunicorn_app.cfg.bind == ["1.2.3.4:1234"] From 9046388fe8c32e897b83315863ee57ccf7d0e8df Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Tue, 28 Dec 2021 17:03:01 -0500 Subject: [PATCH 029/181] fix: Support relative imports for submodules (#169) --- src/functions_framework/_function_registry.py | 4 +++- tests/test_functions.py | 11 ++++++++++ tests/test_functions/relative_imports/main.py | 22 +++++++++++++++++++ tests/test_functions/relative_imports/test.py | 1 + 4 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 tests/test_functions/relative_imports/main.py create mode 100644 tests/test_functions/relative_imports/test.py diff --git a/src/functions_framework/_function_registry.py b/src/functions_framework/_function_registry.py index f7869b24..cedb7e15 100644 --- a/src/functions_framework/_function_registry.py +++ b/src/functions_framework/_function_registry.py @@ -62,7 +62,9 @@ def load_function_module(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) + 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 diff --git a/tests/test_functions.py b/tests/test_functions.py index 64ecc794..c343205f 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -604,3 +604,14 @@ def tests_cloud_to_background_event_client_invalid_source( resp = background_event_client.post("/", headers=headers, json=tempfile_payload) assert resp.status_code == 500 + + +def test_relative_imports(): + source = TEST_FUNCTIONS_DIR / "relative_imports" / "main.py" + target = "function" + + client = create_app(target, source).test_client() + + resp = client.get("/") + assert resp.status_code == 200 + assert resp.data == b"success" 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" From abef66464c8cc8dc51f7b7d94a856602594a06f5 Mon Sep 17 00:00:00 2001 From: Annie Fu <16651409+anniefu@users.noreply.github.com> Date: Thu, 28 Apr 2022 09:33:29 -0700 Subject: [PATCH 030/181] test: fix unit tests (#181) Fix failing assert to look for a different error message, likely due to a change in CloudEvents error message. Also add tests so that code coverage is back at 100%. --- tests/test_cloud_event_functions.py | 2 +- tests/test_convert.py | 8 ++++++++ tests/test_view_functions.py | 16 ++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/test_cloud_event_functions.py b/tests/test_cloud_event_functions.py index 4ad8a527..a673c6e4 100644 --- a/tests/test_cloud_event_functions.py +++ b/tests/test_cloud_event_functions.py @@ -190,7 +190,7 @@ def test_unparsable_cloud_event(client): resp = client.post("/", headers={}, data="") assert resp.status_code == 400 - assert "MissingRequiredFields" in resp.data.decode() + assert "Bad Request" in resp.data.decode() @pytest.mark.parametrize("specversion", ["0.3", "1.0"]) diff --git a/tests/test_convert.py b/tests/test_convert.py index 9202f567..0d41d5ed 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -15,6 +15,7 @@ import pathlib import flask +import pretend import pytest from cloudevents.http import from_json, to_binary @@ -259,6 +260,13 @@ def test_firebase_db_event_to_cloud_event_missing_domain( ) +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", [ diff --git a/tests/test_view_functions.py b/tests/test_view_functions.py index 219313f9..8de543d1 100644 --- a/tests/test_view_functions.py +++ b/tests/test_view_functions.py @@ -14,6 +14,8 @@ import json import pretend +import pytest +import werkzeug from cloudevents.http import from_http @@ -63,6 +65,20 @@ 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( From 0a7937c37bfbe7ced5624264ef426ac8225c617b Mon Sep 17 00:00:00 2001 From: Annie Fu <16651409+anniefu@users.noreply.github.com> Date: Fri, 29 Apr 2022 09:06:33 -0700 Subject: [PATCH 031/181] chore: use Release Please app for releases (#182) This also handles bumping the version number in `setup.py` so that a separate PR doesn't need to made. When the GitHub Release is published by the Release Please app, it will kick off the "Release to PyPI" Workflow. --- .github/release-please.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .github/release-please.yml 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 From 6c9ce8c0e36d0c3752a40060c06e2162641aa805 Mon Sep 17 00:00:00 2001 From: Annie Fu <16651409+anniefu@users.noreply.github.com> Date: Fri, 29 Apr 2022 13:20:50 -0700 Subject: [PATCH 032/181] chore: add GCF buildpack integration test Workflow (#185) See [functions-framework-conformance builidpack integration workflow PR](https://github.com/GoogleCloudPlatform/functions-framework-conformance/pull/99) for more information. --- .../workflows/buildpack-integration-test.yml | 52 +++++++++++++++++++ tests/conformance/prerun.sh | 21 ++++++++ 2 files changed, 73 insertions(+) create mode 100644 .github/workflows/buildpack-integration-test.yml create mode 100755 tests/conformance/prerun.sh diff --git a/.github/workflows/buildpack-integration-test.yml b/.github/workflows/buildpack-integration-test.yml new file mode 100644 index 00000000..7ca8e38b --- /dev/null +++ b/.github/workflows/buildpack-integration-test.yml @@ -0,0 +1,52 @@ +# Validates Functions Framework with GCF buildpacks. +name: Buildpack Integration Test +on: + push: + branches: + - master + workflow_dispatch: +jobs: + python37: + uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.4.1 + with: + http-builder-source: 'tests/conformance' + http-builder-target: 'write_http_declarative' + cloudevent-builder-source: 'tests/conformance' + cloudevent-builder-target: 'write_cloud_event_declarative' + prerun: 'tests/conformance/prerun.sh ${{ github.sha }}' + builder-runtime: 'python37' + # Latest uploaded tag from us.gcr.io/fn-img/buildpacks/python37/builder + builder-tag: 'python37_20220426_3_7_12_RC00' + python38: + uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.4.1 + 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' + # Latest uploaded tag from us.gcr.io/fn-img/buildpacks/python38/builder + builder-tag: 'python38_20220426_3_8_12_RC00' + python39: + uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.4.1 + 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' + # Latest uploaded tag from us.gcr.io/fn-img/buildpacks/python39/builder + builder-tag: 'python39_20220426_3_9_10_RC00' + python310: + uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.4.1 + 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' + # Latest uploaded tag from us.gcr.io/fn-img/buildpacks/python310/builder + builder-tag: 'python310_20220320_3_10_2_RC00' \ No newline at end of file diff --git a/tests/conformance/prerun.sh b/tests/conformance/prerun.sh new file mode 100755 index 00000000..b46f0b51 --- /dev/null +++ b/tests/conformance/prerun.sh @@ -0,0 +1,21 @@ +# 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 \ No newline at end of file From f2285f96072d7ab8f00ada0d5f8c075e2b4ad364 Mon Sep 17 00:00:00 2001 From: Sander van Leeuwen Date: Tue, 10 May 2022 22:37:32 +0200 Subject: [PATCH 033/181] fix: Add functools.wraps decorator (#179) * fix: Add functools.wraps decorator (#178) To make sure function attributes are copied to `_http_view_func_wrapper` Co-authored-by: Annie Fu <16651409+anniefu@users.noreply.github.com> --- src/functions_framework/__init__.py | 1 + tests/test_view_functions.py | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 46c8882b..5d18d2ab 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -95,6 +95,7 @@ def setup_logging(): def _http_view_func_wrapper(function, request): + @functools.wraps(function) def view_func(path): return function(request._get_current_object()) diff --git a/tests/test_view_functions.py b/tests/test_view_functions.py index 8de543d1..f69b2155 100644 --- a/tests/test_view_functions.py +++ b/tests/test_view_functions.py @@ -33,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 = { From a820fd4cdb4bef6cffe0ef68a2d03af922f13d7e Mon Sep 17 00:00:00 2001 From: MikeM Date: Wed, 18 May 2022 06:50:54 +0100 Subject: [PATCH 034/181] fix: for issue #170 gracefully handle pubsub messages without attributes in them (#187) * Handle when attributes not present Co-authored-by: Annie Fu <16651409+anniefu@users.noreply.github.com> --- src/functions_framework/event_conversion.py | 2 +- tests/test_convert.py | 47 +++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/functions_framework/event_conversion.py b/src/functions_framework/event_conversion.py index 28cf2a1b..06e5a812 100644 --- a/src/functions_framework/event_conversion.py +++ b/src/functions_framework/event_conversion.py @@ -317,7 +317,7 @@ def marshal_background_event_data(request): "data": { "@type": _PUBSUB_MESSAGE_TYPE, "data": request_data["message"]["data"], - "attributes": request_data["message"]["attributes"], + "attributes": request_data["message"].get("attributes", {}), }, } except (AttributeError, KeyError, TypeError): diff --git a/tests/test_convert.py b/tests/test_convert.py index 0d41d5ed..592681e7 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -100,6 +100,14 @@ def raw_pubsub_request(): } +@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 { @@ -121,6 +129,27 @@ def marshalled_pubsub_request(): } +@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() @@ -343,6 +372,24 @@ def test_marshal_background_event_data_without_topic_in_path( 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 ): From b4ed66673b3dd762c371f755c493a381c8241b50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Karpe?= <28049739+MichaelKarpe@users.noreply.github.com> Date: Wed, 18 May 2022 23:00:15 +0200 Subject: [PATCH 035/181] feat: allow for watchdog>=2.0.0 (#186) `mkdocs>=1.2.2` [depends on](https://github.com/mkdocs/mkdocs/blob/cdf8a26cafa6af6cc78a45766dfec235bd7286cc/setup.py#L69) `watchdog>=2.0` and the restriction of watchdog dependency for functions-framework-python done in #101 seems to be due [an issue related to watchdog built distributions](https://github.com/gorakhargosh/watchdog/issues/689#issuecomment-748241552) (as explained by @di) which is [now fixed](https://github.com/gorakhargosh/watchdog/pull/807). I propose to allow for `watchdog>=2.0.0` so that a project can use both `mkdocs>=1.2.2` (or `watchdog>=2.0.0`) and `functions-framework-python>=3.0.0`. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 18727fd6..e685a3ec 100644 --- a/setup.py +++ b/setup.py @@ -52,7 +52,7 @@ install_requires=[ "flask>=1.0,<3.0", "click>=7.0,<9.0", - "watchdog>=1.0.0,<2.0.0", + "watchdog>=1.0.0", "gunicorn>=19.2.0,<21.0; platform_system!='Windows'", "cloudevents>=1.2.0,<2.0.0", ], From b7055ed838523cda71bf71bc0149f33271e60ebc Mon Sep 17 00:00:00 2001 From: Grant Timmerman <744973+grant@users.noreply.github.com> Date: Thu, 9 Jun 2022 14:06:13 -0400 Subject: [PATCH 036/181] feat: Add more details to MissingTargetException error (#189) * feat: more detailed function target error message Signed-off-by: Grant Timmerman <744973+grant@users.noreply.github.com> * feat: more detailed function target error message (2) Signed-off-by: Grant Timmerman <744973+grant@users.noreply.github.com> * ci: fix tests Signed-off-by: Grant Timmerman <744973+grant@users.noreply.github.com> * ci: fix ci Signed-off-by: Grant Timmerman <744973+grant@users.noreply.github.com> --- src/functions_framework/_function_registry.py | 11 ++++++++--- tests/test_functions.py | 7 ++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/functions_framework/_function_registry.py b/src/functions_framework/_function_registry.py index cedb7e15..fdcf383f 100644 --- a/src/functions_framework/_function_registry.py +++ b/src/functions_framework/_function_registry.py @@ -38,16 +38,21 @@ 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}".format( - source=source, target=target + "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 " + "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) ) diff --git a/tests/test_functions.py b/tests/test_functions.py index c343205f..c26cb625 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -275,7 +275,8 @@ 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), ) @@ -287,7 +288,7 @@ 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), ) @@ -300,7 +301,7 @@ 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), ) From 9af6591465ce59516c5a3058906c0685a0dcea01 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Thu, 9 Jun 2022 14:13:59 -0400 Subject: [PATCH 037/181] chore(master): release 3.1.0 (#183) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 21 +++++++++++++++++++++ setup.py | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfbbe99d..e03a9b4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ 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.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 diff --git a/setup.py b/setup.py index e685a3ec..ed14b251 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="3.0.0", + version="3.1.0", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown", From 91e2efa9356b120a07906023119219d99a6a0791 Mon Sep 17 00:00:00 2001 From: Pratiksha Kap <107430880+kappratiksha@users.noreply.github.com> Date: Thu, 11 Aug 2022 09:37:39 -0700 Subject: [PATCH 038/181] feat: Scale gunicorn server to serve 1000 concurrent requests (#195) * Setting max threads to 1000 --- .github/workflows/conformance.yml | 29 +++++++++++++++-------- src/functions_framework/_http/gunicorn.py | 2 +- tests/conformance/main.py | 7 ++++++ tests/test_http.py | 4 ++-- 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 940bdf58..09752c94 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -24,46 +24,55 @@ jobs: go-version: '1.16' - name: Run HTTP conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.1.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.6.0 with: - version: 'v1.1.0' + version: 'v1.6.0' functionType: 'http' useBuildpacks: false validateMapping: false cmd: "'functions-framework --source tests/conformance/main.py --target write_http --signature-type http'" - name: Run event conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.1.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.6.0 with: - version: 'v1.1.0' + version: 'v1.6.0' functionType: 'legacyevent' useBuildpacks: false validateMapping: true cmd: "'functions-framework --source tests/conformance/main.py --target write_legacy_event --signature-type event'" - name: Run CloudEvents conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.1.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.6.0 with: - version: 'v1.1.0' + version: 'v1.6.0' functionType: 'cloudevent' useBuildpacks: false validateMapping: true cmd: "'functions-framework --source tests/conformance/main.py --target write_cloud_event --signature-type cloudevent'" - name: Run HTTP conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.1.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.6.0 with: - version: 'v1.1.0' + version: 'v1.6.0' functionType: 'http' useBuildpacks: false validateMapping: false cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative'" - name: Run CloudEvents conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.1.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.6.0 with: - version: 'v1.1.0' + version: 'v1.6.0' functionType: 'cloudevent' useBuildpacks: false validateMapping: true cmd: "'functions-framework --source tests/conformance/main.py --target write_cloud_event_declarative'" + + - name: Run HTTP concurrency tests declarative + uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.6.0 + with: + version: 'v1.6.0' + functionType: 'http' + useBuildpacks: false + validateConcurrency: true + cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative_concurrent'" \ No newline at end of file diff --git a/src/functions_framework/_http/gunicorn.py b/src/functions_framework/_http/gunicorn.py index 25fdb790..f522b67f 100644 --- a/src/functions_framework/_http/gunicorn.py +++ b/src/functions_framework/_http/gunicorn.py @@ -20,7 +20,7 @@ def __init__(self, app, host, port, debug, **options): self.options = { "bind": "%s:%s" % (host, port), "workers": 1, - "threads": 8, + "threads": 1024, "timeout": 0, "loglevel": "error", "limit_request_line": 0, diff --git a/tests/conformance/main.py b/tests/conformance/main.py index e790f626..67926ff6 100644 --- a/tests/conformance/main.py +++ b/tests/conformance/main.py @@ -1,4 +1,5 @@ import json +import time from cloudevents.http import to_json @@ -46,3 +47,9 @@ def write_http_declarative(request): @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 diff --git a/tests/test_http.py b/tests/test_http.py index bd301c91..3414aaf6 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -97,7 +97,7 @@ def test_gunicorn_application(debug): assert gunicorn_app.options == { "bind": "%s:%s" % (host, port), "workers": 1, - "threads": 8, + "threads": 1024, "timeout": 0, "loglevel": "error", "limit_request_line": 0, @@ -105,7 +105,7 @@ def test_gunicorn_application(debug): assert gunicorn_app.cfg.bind == ["1.2.3.4:1234"] assert gunicorn_app.cfg.workers == 1 - assert gunicorn_app.cfg.threads == 8 + assert gunicorn_app.cfg.threads == 1024 assert gunicorn_app.cfg.timeout == 0 assert gunicorn_app.load() == app From a6ce68a4502d277fa22ebbd1a84f7c7940c1cf02 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 17 Aug 2022 14:18:15 -0700 Subject: [PATCH 039/181] chore(master): release 3.2.0 (#197) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ setup.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e03a9b4f..bd7431cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.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) diff --git a/setup.py b/setup.py index ed14b251..48b140dc 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="3.1.0", + version="3.2.0", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown", From 8724548620e815c791f32c73407a78a92975bf67 Mon Sep 17 00:00:00 2001 From: Annie Fu <16651409+anniefu@users.noreply.github.com> Date: Mon, 7 Nov 2022 15:14:58 -0800 Subject: [PATCH 040/181] chore: update buildpack integration test (#203) --- .github/workflows/buildpack-integration-test.yml | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/.github/workflows/buildpack-integration-test.yml b/.github/workflows/buildpack-integration-test.yml index 7ca8e38b..25ec5538 100644 --- a/.github/workflows/buildpack-integration-test.yml +++ b/.github/workflows/buildpack-integration-test.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: jobs: python37: - uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.4.1 + uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.0 with: http-builder-source: 'tests/conformance' http-builder-target: 'write_http_declarative' @@ -15,10 +15,8 @@ jobs: cloudevent-builder-target: 'write_cloud_event_declarative' prerun: 'tests/conformance/prerun.sh ${{ github.sha }}' builder-runtime: 'python37' - # Latest uploaded tag from us.gcr.io/fn-img/buildpacks/python37/builder - builder-tag: 'python37_20220426_3_7_12_RC00' python38: - uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.4.1 + uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.0 with: http-builder-source: 'tests/conformance' http-builder-target: 'write_http_declarative' @@ -26,10 +24,8 @@ jobs: cloudevent-builder-target: 'write_cloud_event_declarative' prerun: 'tests/conformance/prerun.sh ${{ github.sha }}' builder-runtime: 'python38' - # Latest uploaded tag from us.gcr.io/fn-img/buildpacks/python38/builder - builder-tag: 'python38_20220426_3_8_12_RC00' python39: - uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.4.1 + uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.0 with: http-builder-source: 'tests/conformance' http-builder-target: 'write_http_declarative' @@ -40,13 +36,11 @@ jobs: # Latest uploaded tag from us.gcr.io/fn-img/buildpacks/python39/builder builder-tag: 'python39_20220426_3_9_10_RC00' python310: - uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.4.1 + uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.0 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' - # Latest uploaded tag from us.gcr.io/fn-img/buildpacks/python310/builder - builder-tag: 'python310_20220320_3_10_2_RC00' \ No newline at end of file + builder-runtime: 'python310' \ No newline at end of file From bf767958c1edc7f28da6ea337b62c3ae810473b0 Mon Sep 17 00:00:00 2001 From: Annie Fu <16651409+anniefu@users.noreply.github.com> Date: Mon, 7 Nov 2022 15:46:25 -0800 Subject: [PATCH 041/181] chore: fix buildpack integration test for python39 (#204) --- .github/workflows/buildpack-integration-test.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/buildpack-integration-test.yml b/.github/workflows/buildpack-integration-test.yml index 25ec5538..29cff116 100644 --- a/.github/workflows/buildpack-integration-test.yml +++ b/.github/workflows/buildpack-integration-test.yml @@ -33,8 +33,6 @@ jobs: cloudevent-builder-target: 'write_cloud_event_declarative' prerun: 'tests/conformance/prerun.sh ${{ github.sha }}' builder-runtime: 'python39' - # Latest uploaded tag from us.gcr.io/fn-img/buildpacks/python39/builder - builder-tag: 'python39_20220426_3_9_10_RC00' python310: uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.0 with: From 34b8083c53d88d378718916cc2c20de7665150d4 Mon Sep 17 00:00:00 2001 From: Pratiksha Kap <107430880+kappratiksha@users.noreply.github.com> Date: Wed, 9 Nov 2022 15:24:13 -0800 Subject: [PATCH 042/181] fix: Remove the 10MB limit (#205) Remove the 10MB limit used for file upload --- src/functions_framework/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 5d18d2ab..fa638505 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -35,8 +35,6 @@ ) from google.cloud.functions.context import Context -MAX_CONTENT_LENGTH = 10 * 1024 * 1024 - _FUNCTION_STATUS_HEADER_FIELD = "X-Google-Status" _CRASH = "crash" @@ -262,7 +260,6 @@ def create_app(target=None, source=None, signature_type=None): # Create the application _app = flask.Flask(target, template_folder=template_folder) - _app.config["MAX_CONTENT_LENGTH"] = MAX_CONTENT_LENGTH _app.register_error_handler(500, crash_handler) global errorhandler errorhandler = _app.errorhandler From ed0b1d398967baa8a0ded442b1151851f6e27619 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Thu, 10 Nov 2022 10:24:14 -0800 Subject: [PATCH 043/181] chore(master): release 3.2.1 (#206) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ setup.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd7431cc..7ba019d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.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) diff --git a/setup.py b/setup.py index 48b140dc..8be93e27 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="3.2.0", + version="3.2.1", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown", From 5daeb4aaf130f2714fe1a0f799b09cb01703d9bd Mon Sep 17 00:00:00 2001 From: Annie Fu <16651409+anniefu@users.noreply.github.com> Date: Thu, 10 Nov 2022 11:18:31 -0800 Subject: [PATCH 044/181] test: increase start delay for buildpack integration test (#207) This will decrease the flakiness of the tests due to not being able to reach the FF container/server because it hasn't started up fully yet. --- .github/workflows/buildpack-integration-test.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/buildpack-integration-test.yml b/.github/workflows/buildpack-integration-test.yml index 29cff116..ba4aecb7 100644 --- a/.github/workflows/buildpack-integration-test.yml +++ b/.github/workflows/buildpack-integration-test.yml @@ -15,6 +15,7 @@ jobs: cloudevent-builder-target: 'write_cloud_event_declarative' prerun: 'tests/conformance/prerun.sh ${{ github.sha }}' builder-runtime: 'python37' + start-delay: 5 python38: uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.0 with: @@ -24,6 +25,7 @@ jobs: cloudevent-builder-target: 'write_cloud_event_declarative' prerun: 'tests/conformance/prerun.sh ${{ github.sha }}' builder-runtime: 'python38' + start-delay: 5 python39: uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.0 with: @@ -33,6 +35,7 @@ jobs: cloudevent-builder-target: 'write_cloud_event_declarative' prerun: 'tests/conformance/prerun.sh ${{ github.sha }}' builder-runtime: 'python39' + start-delay: 5 python310: uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.0 with: @@ -41,4 +44,5 @@ jobs: cloudevent-builder-source: 'tests/conformance' cloudevent-builder-target: 'write_cloud_event_declarative' prerun: 'tests/conformance/prerun.sh ${{ github.sha }}' - builder-runtime: 'python310' \ No newline at end of file + builder-runtime: 'python310' + start-delay: 5 \ No newline at end of file From 0ced0d214731e97d0c59abd937ab9a15498f26c3 Mon Sep 17 00:00:00 2001 From: Matthew Robertson Date: Fri, 2 Dec 2022 14:38:01 -0800 Subject: [PATCH 045/181] test: add 3.11 to unit and conformance tests (#209) In preparation for adding the python311 runtime to GCF, we should start testing this version. --- .github/workflows/conformance.yml | 4 ++-- .github/workflows/unit.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 09752c94..9ae8a51e 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -5,7 +5,7 @@ jobs: runs-on: ubuntu-18.04 strategy: matrix: - python-version: ['3.8', '3.9', '3.10'] + python-version: ['3.8', '3.9', '3.10', '3.11'] steps: - name: Checkout code uses: actions/checkout@v2 @@ -75,4 +75,4 @@ jobs: functionType: 'http' useBuildpacks: false validateConcurrency: true - cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative_concurrent'" \ No newline at end of file + cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative_concurrent'" diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index ea147493..39bd1be1 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -4,7 +4,7 @@ jobs: test: strategy: matrix: - python: ['3.6', '3.7', '3.8', '3.9', '3.10'] + python: ['3.7', '3.8', '3.9', '3.10', '3.11'] platform: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.platform }} steps: From f013ab4e4c00acae827ad85e6e2ac5698859605f Mon Sep 17 00:00:00 2001 From: Annie Fu <16651409+anniefu@users.noreply.github.com> Date: Tue, 6 Dec 2022 16:10:06 -0800 Subject: [PATCH 046/181] fix: remove DRY_RUN env var and --dry-run flag (#210) Free the DRY_RUN env var name to be used for other purposes by function authors. The original intent of the DRY_RUN was to be used at build time for GCF to validate function syntax without starting the server, but this was never implemented. For local testing purposes, simply starting the functions framework server is a better method. --- README.md | 1 - src/functions_framework/_cli.py | 10 ++-------- tests/test_cli.py | 12 ------------ 3 files changed, 2 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 192a9b55..9dffc60c 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,6 @@ You can configure the Functions Framework using command-line flags or environmen | `--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` | -| `--dry-run` | `DRY_RUN` | A flag that allows for testing the function build from the configuration without creating a server. Default: `False` | ## Enable Google Cloud Function Events diff --git a/src/functions_framework/_cli.py b/src/functions_framework/_cli.py index 663ea50f..5b54a1cd 100644 --- a/src/functions_framework/_cli.py +++ b/src/functions_framework/_cli.py @@ -32,12 +32,6 @@ @click.option("--host", envvar="HOST", type=click.STRING, default="0.0.0.0") @click.option("--port", envvar="PORT", type=click.INT, default=8080) @click.option("--debug", envvar="DEBUG", is_flag=True) -@click.option("--dry-run", envvar="DRY_RUN", is_flag=True) -def _cli(target, source, signature_type, host, port, debug, dry_run): +def _cli(target, source, signature_type, host, port, debug): app = create_app(target, source, signature_type) - if dry_run: - click.echo("Function: {}".format(target)) - click.echo("URL: http://{}:{}/".format(host, port)) - click.echo("Dry run successful, shutting down.") - else: - create_server(app, debug).run(host, port) + create_server(app, debug).run(host, port) diff --git a/tests/test_cli.py b/tests/test_cli.py index aa4a901e..7613b649 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -69,18 +69,6 @@ def test_cli_no_arguments(): [pretend.call("foo", None, "event")], [pretend.call("0.0.0.0", 8080)], ), - ( - ["--target", "foo", "--dry-run"], - {}, - [pretend.call("foo", None, "http")], - [], - ), - ( - [], - {"FUNCTION_TARGET": "foo", "DRY_RUN": "True"}, - [pretend.call("foo", None, "http")], - [], - ), ( ["--target", "foo", "--host", "127.0.0.1"], {}, From aa59a6b6839820946dc72ceea7fd97b3dfd839c2 Mon Sep 17 00:00:00 2001 From: Pratiksha Kap <107430880+kappratiksha@users.noreply.github.com> Date: Mon, 12 Dec 2022 03:35:09 -0800 Subject: [PATCH 047/181] feat: Support strongly typed functions signature (#208) --- src/functions_framework/__init__.py | 69 ++++++++- src/functions_framework/_cli.py | 2 +- src/functions_framework/_function_registry.py | 13 ++ src/functions_framework/_typed_event.py | 105 +++++++++++++ .../typed_events/mismatch_types.py | 43 ++++++ .../typed_events/missing_from_dict.py | 55 +++++++ .../typed_events/missing_parameter.py | 23 +++ .../typed_events/missing_to_dict.py | 55 +++++++ .../typed_events/missing_type.py | 26 ++++ .../typed_events/typed_event.py | 141 ++++++++++++++++++ tests/test_typed_event_functions.py | 123 +++++++++++++++ 11 files changed, 653 insertions(+), 2 deletions(-) create mode 100644 src/functions_framework/_typed_event.py create mode 100644 tests/test_functions/typed_events/mismatch_types.py create mode 100644 tests/test_functions/typed_events/missing_from_dict.py create mode 100644 tests/test_functions/typed_events/missing_parameter.py create mode 100644 tests/test_functions/typed_events/missing_to_dict.py create mode 100644 tests/test_functions/typed_events/missing_type.py create mode 100644 tests/test_functions/typed_events/typed_event.py create mode 100644 tests/test_typed_event_functions.py diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index fa638505..c2a52d74 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -13,12 +13,17 @@ # limitations under the License. import functools +import inspect import io import json import logging import os.path import pathlib import sys +import types + +from inspect import signature +from typing import Type import cloudevents.exceptions as cloud_exceptions import flask @@ -26,7 +31,7 @@ from cloudevents.http import from_http, is_binary -from functions_framework import _function_registry, event_conversion +from functions_framework import _function_registry, _typed_event, event_conversion from functions_framework.background_event import BackgroundEvent from functions_framework.exceptions import ( EventConversionException, @@ -67,6 +72,33 @@ def wrapper(*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): """Decorator that registers http as user function signature type.""" _function_registry.REGISTRY_MAP[ @@ -106,6 +138,26 @@ def _run_cloud_event(function, request): function(event) +def _typed_event_func_wrapper(function, request, inputType: Type): + 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): def view_func(path): ce_exception = None @@ -216,6 +268,21 @@ def _configure_app(app, function, signature_type): 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( diff --git a/src/functions_framework/_cli.py b/src/functions_framework/_cli.py index 5b54a1cd..773dd4cd 100644 --- a/src/functions_framework/_cli.py +++ b/src/functions_framework/_cli.py @@ -26,7 +26,7 @@ @click.option( "--signature-type", envvar="FUNCTION_SIGNATURE_TYPE", - type=click.Choice(["http", "event", "cloudevent"]), + type=click.Choice(["http", "event", "cloudevent", "typed"]), default="http", ) @click.option("--host", envvar="HOST", type=click.STRING, default="0.0.0.0") diff --git a/src/functions_framework/_function_registry.py b/src/functions_framework/_function_registry.py index fdcf383f..f266ee82 100644 --- a/src/functions_framework/_function_registry.py +++ b/src/functions_framework/_function_registry.py @@ -16,6 +16,9 @@ import sys import types +from re import T +from typing import Type + from functions_framework.exceptions import ( InvalidConfigurationException, InvalidTargetTypeException, @@ -28,11 +31,16 @@ 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 = {} + def get_user_function(source, source_module, target): """Returns user function, raises exception for invalid function.""" @@ -120,3 +128,8 @@ def get_func_signature_type(func_name: str, signature_type: str) -> str: 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/_typed_event.py b/src/functions_framework/_typed_event.py new file mode 100644 index 00000000..40e715ae --- /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/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_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 From ccc674796634991afa24f4fc37ded0266d8677e2 Mon Sep 17 00:00:00 2001 From: Pratiksha Kap <107430880+kappratiksha@users.noreply.github.com> Date: Thu, 15 Dec 2022 20:53:49 -0800 Subject: [PATCH 048/181] chore: Add conformance test for typed decorator (#212) --- .github/workflows/conformance.yml | 9 +++++++++ tests/conformance/main.py | 17 +++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 9ae8a51e..03f27450 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -76,3 +76,12 @@ jobs: 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@v1.6.0 + with: + version: 'v1.6.0' + functionType: 'http' + useBuildpacks: false + validateMapping: false + cmd: "'functions-framework --source tests/conformance/main.py --target write_typed_event_declarative'" diff --git a/tests/conformance/main.py b/tests/conformance/main.py index 67926ff6..057edaa7 100644 --- a/tests/conformance/main.py +++ b/tests/conformance/main.py @@ -8,6 +8,17 @@ filename = "function_output.json" +class ConformanceType: + json_request: str + + def __init__(self, json_request: str) -> None: + self.json_request = json_request + + @staticmethod + def from_dict(obj: dict) -> "ConformanceType": + return ConformanceType(json.dumps(obj)) + + def _write_output(content): with open(filename, "w") as f: f.write(content) @@ -53,3 +64,9 @@ def write_cloud_event_declarative(cloud_event): def write_http_declarative_concurrent(request): time.sleep(1) return "OK", 200 + + +@functions_framework.typed(ConformanceType) +def write_typed_event_declarative(x): + _write_output(x.json_request) + return "OK" From 9904e9be1b365f1f5df1d57b1577a91e178797bf Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 21 Dec 2022 07:36:37 -0800 Subject: [PATCH 049/181] chore(master): release 3.3.0 (#211) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 12 ++++++++++++ setup.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ba019d5..26e83453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ 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.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) diff --git a/setup.py b/setup.py index 8be93e27..46b4bdfe 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="3.2.1", + version="3.3.0", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown", From 7868dc110c048d3e1acf082faf36b75c3770e3f3 Mon Sep 17 00:00:00 2001 From: Kenneth Rosario Date: Wed, 1 Feb 2023 14:14:16 -0800 Subject: [PATCH 050/181] feat: configure security score card action (#216) --- .github/workflows/scorecard.yml | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/scorecard.yml diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 00000000..75522f35 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,47 @@ +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: [ "master" ] + +# 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: "Checkout code" + uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # v3.1.0 + with: + persist-credentials: false + + - name: "Run analysis" + uses: ossf/scorecard-action@99c53751e09b9529366343771cc321ec74e9bd3d # v2.0.6 + 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@807578363a7869ca324a79039e6db9c843e0e100 # v2.1.27 + with: + sarif_file: results.sarif From 433f32298fc912e00b6abe7f8a97d3f944c3e6cc Mon Sep 17 00:00:00 2001 From: StepSecurity Bot Date: Thu, 23 Feb 2023 08:55:37 -0800 Subject: [PATCH 051/181] chore: [StepSecurity] Harden GitHub Actions (#218) --- .github/workflows/conformance.yml | 25 +++++++++++++++---------- .github/workflows/lint.yml | 12 ++++++++++-- .github/workflows/release.yml | 14 +++++++++++--- .github/workflows/scorecard.yml | 5 +++++ .github/workflows/unit.yml | 12 ++++++++++-- 5 files changed, 51 insertions(+), 17 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 03f27450..174ee4b7 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -7,11 +7,16 @@ jobs: matrix: python-version: ['3.8', '3.9', '3.10', '3.11'] steps: + - name: Harden Runner + uses: step-security/harden-runner@18bf8ad2ca49c14cbb28b91346d626ccfb00c518 # v2.1.0 + with: + egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs + - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@dc323e67f16fb5f7663d20ff7941f27f5809e9b6 # v2.6.0 - name: Setup Python - uses: actions/setup-python@v2 + uses: actions/setup-python@75f3110429a8c05be0e1bf360334e4cced2b63fa # v2.3.3 with: python-version: ${{ matrix.python-version }} @@ -19,12 +24,12 @@ jobs: run: python -m pip install -e . - name: Setup Go - uses: actions/setup-go@v2 + uses: actions/setup-go@bfdd3570ce990073878bf10f6b2d79082de49492 # v2.2.0 with: go-version: '1.16' - name: Run HTTP conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.6.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c52662e612b2685a027b1c3e02224306517722fc # v1.6.0 with: version: 'v1.6.0' functionType: 'http' @@ -33,7 +38,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http --signature-type http'" - name: Run event conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.6.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c52662e612b2685a027b1c3e02224306517722fc # v1.6.0 with: version: 'v1.6.0' functionType: 'legacyevent' @@ -42,7 +47,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_legacy_event --signature-type event'" - name: Run CloudEvents conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.6.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c52662e612b2685a027b1c3e02224306517722fc # v1.6.0 with: version: 'v1.6.0' functionType: 'cloudevent' @@ -51,7 +56,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_cloud_event --signature-type cloudevent'" - name: Run HTTP conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.6.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c52662e612b2685a027b1c3e02224306517722fc # v1.6.0 with: version: 'v1.6.0' functionType: 'http' @@ -60,7 +65,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative'" - name: Run CloudEvents conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.6.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c52662e612b2685a027b1c3e02224306517722fc # v1.6.0 with: version: 'v1.6.0' functionType: 'cloudevent' @@ -69,7 +74,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_cloud_event_declarative'" - name: Run HTTP concurrency tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.6.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c52662e612b2685a027b1c3e02224306517722fc # v1.6.0 with: version: 'v1.6.0' functionType: 'http' @@ -78,7 +83,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative_concurrent'" - name: Run Typed tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.6.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c52662e612b2685a027b1c3e02224306517722fc # v1.6.0 with: version: 'v1.6.0' functionType: 'http' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7bb0972e..f1822d85 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,12 +1,20 @@ name: Python Lint CI on: [push, pull_request] +permissions: + contents: read + jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - name: Harden Runner + uses: step-security/harden-runner@18bf8ad2ca49c14cbb28b91346d626ccfb00c518 # v2.1.0 + with: + egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs + + - uses: actions/checkout@dc323e67f16fb5f7663d20ff7941f27f5809e9b6 # v2.6.0 - name: Setup Python - uses: actions/setup-python@v2 + uses: actions/setup-python@75f3110429a8c05be0e1bf360334e4cced2b63fa # v2.3.3 - name: Install tox run: python -m pip install tox - name: Lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 31d80732..305faa22 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,23 +4,31 @@ 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@18bf8ad2ca49c14cbb28b91346d626ccfb00c518 # v2.1.0 + with: + egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs + - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@dc323e67f16fb5f7663d20ff7941f27f5809e9b6 # v2.6.0 with: ref: ${{ github.event.release.tag_name }} - name: Install Python - uses: actions/setup-python@v2 + uses: actions/setup-python@75f3110429a8c05be0e1bf360334e4cced2b63fa # v2.3.3 - 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@master + uses: pypa/gh-action-pypi-publish@9b8e7336db3f96a2939a3e9fa827c62f466ca60d # master with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 75522f35..49d3d2ba 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -24,6 +24,11 @@ jobs: id-token: write steps: + - name: Harden Runner + uses: step-security/harden-runner@18bf8ad2ca49c14cbb28b91346d626ccfb00c518 # v2.1.0 + with: + egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs + - name: "Checkout code" uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # v3.1.0 with: diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 39bd1be1..d86078a8 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -1,5 +1,8 @@ name: Python Unit CI on: [push, pull_request] +permissions: + contents: read + jobs: test: strategy: @@ -8,10 +11,15 @@ jobs: platform: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.platform }} steps: + - name: Harden Runner + uses: step-security/harden-runner@18bf8ad2ca49c14cbb28b91346d626ccfb00c518 # v2.1.0 + with: + egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs + - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@dc323e67f16fb5f7663d20ff7941f27f5809e9b6 # v2.6.0 - name: Use Python ${{ matrix.python }} - uses: actions/setup-python@v2 + uses: actions/setup-python@75f3110429a8c05be0e1bf360334e4cced2b63fa # v2.3.3 with: python-version: ${{ matrix.python }} - name: Install tox From 1a0eaa8998c07d029d5297e6bdf987a77bee48ad Mon Sep 17 00:00:00 2001 From: Chi Zhang Date: Thu, 30 Mar 2023 10:23:46 -0700 Subject: [PATCH 052/181] ci: Update the name of python matrix to be consistent with other repos (#224) --- .github/workflows/conformance.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 174ee4b7..ab9159ca 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -5,7 +5,7 @@ jobs: runs-on: ubuntu-18.04 strategy: matrix: - python-version: ['3.8', '3.9', '3.10', '3.11'] + python: ['3.8', '3.9', '3.10', '3.11'] steps: - name: Harden Runner uses: step-security/harden-runner@18bf8ad2ca49c14cbb28b91346d626ccfb00c518 # v2.1.0 @@ -18,7 +18,7 @@ jobs: - name: Setup Python uses: actions/setup-python@75f3110429a8c05be0e1bf360334e4cced2b63fa # v2.3.3 with: - python-version: ${{ matrix.python-version }} + python-version: ${{ matrix.python }} - name: Install the framework run: python -m pip install -e . From 306104b8e1dae299d8fc098f6f3f742660fdfba1 Mon Sep 17 00:00:00 2001 From: Chi Zhang Date: Mon, 3 Apr 2023 18:04:20 -0700 Subject: [PATCH 053/181] ci: Update python matrix (#225) Signed-off-by: GitHub Co-authored-by: chizhg --- .github/workflows/conformance.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index ab9159ca..f5535637 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -5,7 +5,7 @@ jobs: runs-on: ubuntu-18.04 strategy: matrix: - python: ['3.8', '3.9', '3.10', '3.11'] + python: ['3.7', '3.8', '3.9', '3.10', '3.11'] steps: - name: Harden Runner uses: step-security/harden-runner@18bf8ad2ca49c14cbb28b91346d626ccfb00c518 # v2.1.0 From b8d38cddfb09173200a6c9fa4f719fc5051e9880 Mon Sep 17 00:00:00 2001 From: Kenneth Rosario Date: Thu, 6 Apr 2023 11:59:51 -0700 Subject: [PATCH 054/181] chore: address some scorecard findings and update ubuntu version (#226) --- .github/workflows/buildpack-integration-test.yml | 4 ++++ .github/workflows/conformance.yml | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/buildpack-integration-test.yml b/.github/workflows/buildpack-integration-test.yml index ba4aecb7..78c56996 100644 --- a/.github/workflows/buildpack-integration-test.yml +++ b/.github/workflows/buildpack-integration-test.yml @@ -5,6 +5,10 @@ on: branches: - master workflow_dispatch: + +# Declare default permissions as read only. +permissions: read-all + jobs: python37: uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.0 diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index f5535637..ec75d5b4 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -1,8 +1,12 @@ name: Python Conformance CI on: [push, pull_request] + +# Declare default permissions as read only. +permissions: read-all + jobs: build: - runs-on: ubuntu-18.04 + runs-on: ubuntu-latest strategy: matrix: python: ['3.7', '3.8', '3.9', '3.10', '3.11'] From a8f73a51c7ab758620c07d48d13ed8247bd3af31 Mon Sep 17 00:00:00 2001 From: Kenneth Rosario Date: Mon, 10 Apr 2023 09:27:21 -0700 Subject: [PATCH 055/181] chore: set up renovate bot config (#227) --- .github/renovate.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .github/renovate.json diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 00000000..32ac90d6 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["group:allNonMajor", "schedule:monthly"], + "packageRules": [ + { + "description": "Create a PR whenever there is a new major version", + "matchUpdateTypes": [ + "major" + ] + } + ], + "ignorePaths": [ + "examples/**" + ] +} From 2b5963d252894ac7c6f5326ba001c089965bb496 Mon Sep 17 00:00:00 2001 From: Kenneth Rosario Date: Thu, 13 Apr 2023 11:07:46 -0700 Subject: [PATCH 056/181] chore: Configure blunderbuss.yml (#228) --- .github/blunderbuss.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .github/blunderbuss.yml diff --git a/.github/blunderbuss.yml b/.github/blunderbuss.yml new file mode 100644 index 00000000..61347444 --- /dev/null +++ b/.github/blunderbuss.yml @@ -0,0 +1,5 @@ +assign_prs: + - GoogleCloudPlatform/functions-framework-google + +assign_issues: + - GoogleCloudPlatform/functions-framework-google From 91a7c6b148823132b30949c9367c8838be1b3bfd Mon Sep 17 00:00:00 2001 From: StepSecurity Bot Date: Wed, 19 Apr 2023 11:08:59 -0700 Subject: [PATCH 057/181] chore: [StepSecurity] Apply security best practices (#230) --- .github/workflows/codeql.yml | 78 ++++++++++++++++++++++ .github/workflows/dependency-review.yml | 27 ++++++++ examples/cloud_run_cloud_events/Dockerfile | 2 +- examples/cloud_run_decorator/Dockerfile | 2 +- examples/cloud_run_event/Dockerfile | 2 +- examples/cloud_run_http/Dockerfile | 2 +- 6 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/dependency-review.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..a35afc69 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,78 @@ +# 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: ["master"] + pull_request: + # The branches below must be a subset of the branches above + branches: ["master"] + 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@03bee3930647ebbf994244c21ddbc0d4933aab4f # v2.3.0 + with: + egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs + + - name: Checkout repository + uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@7df0ce34898d659f95c0c4a09eaa8d4e32ee64db # v2.2.12 + 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@7df0ce34898d659f95c0c4a09eaa8d4e32ee64db # v2.2.12 + + # â„šī¸ 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@7df0ce34898d659f95c0c4a09eaa8d4e32ee64db # v2.2.12 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 00000000..b4adf6d4 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,27 @@ +# 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@03bee3930647ebbf994244c21ddbc0d4933aab4f # v2.3.0 + with: + egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs + + - name: 'Checkout Repository' + uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + - name: 'Dependency Review' + uses: actions/dependency-review-action@0efb1d1d84fc9633afcdaad14c485cbbc90ef46c # v2.5.1 diff --git a/examples/cloud_run_cloud_events/Dockerfile b/examples/cloud_run_cloud_events/Dockerfile index bc9df896..1bae67aa 100644 --- a/examples/cloud_run_cloud_events/Dockerfile +++ b/examples/cloud_run_cloud_events/Dockerfile @@ -1,6 +1,6 @@ # Use the official Python image. # https://hub.docker.com/_/python -FROM python:3.7-slim +FROM python:3.7-slim@sha256:adbcdfcd0511bab2d6db252e55b983da1b431598ed755c1620b291fbeb5f6f72 # Copy local code to the container image. ENV APP_HOME /app diff --git a/examples/cloud_run_decorator/Dockerfile b/examples/cloud_run_decorator/Dockerfile index 717e5a91..cc3f44c8 100644 --- a/examples/cloud_run_decorator/Dockerfile +++ b/examples/cloud_run_decorator/Dockerfile @@ -1,6 +1,6 @@ # Use the official Python image. # https://hub.docker.com/_/python -FROM python:3.7-slim +FROM python:3.7-slim@sha256:adbcdfcd0511bab2d6db252e55b983da1b431598ed755c1620b291fbeb5f6f72 # Copy local code to the container image. ENV APP_HOME /app diff --git a/examples/cloud_run_event/Dockerfile b/examples/cloud_run_event/Dockerfile index 7fa0df13..d3b4c571 100644 --- a/examples/cloud_run_event/Dockerfile +++ b/examples/cloud_run_event/Dockerfile @@ -1,6 +1,6 @@ # Use the official Python image. # https://hub.docker.com/_/python -FROM python:3.7-slim +FROM python:3.7-slim@sha256:adbcdfcd0511bab2d6db252e55b983da1b431598ed755c1620b291fbeb5f6f72 # Copy local code to the container image. ENV APP_HOME /app diff --git a/examples/cloud_run_http/Dockerfile b/examples/cloud_run_http/Dockerfile index b7d6f502..14f2b2e4 100644 --- a/examples/cloud_run_http/Dockerfile +++ b/examples/cloud_run_http/Dockerfile @@ -1,6 +1,6 @@ # Use the official Python image. # https://hub.docker.com/_/python -FROM python:3.7-slim +FROM python:3.7-slim@sha256:adbcdfcd0511bab2d6db252e55b983da1b431598ed755c1620b291fbeb5f6f72 # Copy local code to the container image. ENV APP_HOME /app From b826b2a643497eb8fd7ae29f61551c33a9034728 Mon Sep 17 00:00:00 2001 From: Kenneth Rosario Date: Fri, 21 Apr 2023 15:11:33 -0700 Subject: [PATCH 058/181] chore: Update blunderbuss.yml according to preference (#229) Co-authored-by: Joseph Lewis III --- .github/blunderbuss.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/blunderbuss.yml b/.github/blunderbuss.yml index 61347444..33581568 100644 --- a/.github/blunderbuss.yml +++ b/.github/blunderbuss.yml @@ -1,5 +1,9 @@ assign_prs: - - GoogleCloudPlatform/functions-framework-google + - KaylaNguyen + - HKWinterhalter + - janell-chen assign_issues: - - GoogleCloudPlatform/functions-framework-google + - KaylaNguyen + - HKWinterhalter + - janell-chen From b4e9fc5d5d477bba0b6de5064825a2ac3e525877 Mon Sep 17 00:00:00 2001 From: Kayla Nguyen Date: Wed, 3 May 2023 17:45:13 -0700 Subject: [PATCH 059/181] chore: update unit tests with headers (#239) --- tests/test_cloud_event_functions.py | 3 ++- tests/test_functions.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_cloud_event_functions.py b/tests/test_cloud_event_functions.py index a673c6e4..691fe388 100644 --- a/tests/test_cloud_event_functions.py +++ b/tests/test_cloud_event_functions.py @@ -187,7 +187,8 @@ def test_invalid_fields_binary(client, create_headers_binary, data_payload): def test_unparsable_cloud_event(client): - resp = client.post("/", headers={}, data="") + headers = {"Content-Type": "application/cloudevents+json"} + resp = client.post("/", headers=headers, data="") assert resp.status_code == 400 assert "Bad Request" in resp.data.decode() diff --git a/tests/test_functions.py b/tests/test_functions.py index c26cb625..501ea488 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -251,7 +251,8 @@ def test_pubsub_payload(background_event_client, background_json): def test_background_function_no_data(background_event_client, background_json): - resp = background_event_client.post("/") + headers = {"Content-Type": "application/json"} + resp = background_event_client.post("/", headers=headers) assert resp.status_code == 400 From 1fc20cc9bedb998c009243b6a75385a85cad88bd Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Thu, 4 May 2023 04:23:56 +0200 Subject: [PATCH 060/181] chore(deps): update actions/checkout action to v3 (#234) --- .github/workflows/conformance.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/unit.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index ec75d5b4..f4e5fc27 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -17,7 +17,7 @@ jobs: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout code - uses: actions/checkout@dc323e67f16fb5f7663d20ff7941f27f5809e9b6 # v2.6.0 + uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 - name: Setup Python uses: actions/setup-python@75f3110429a8c05be0e1bf360334e4cced2b63fa # v2.3.3 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f1822d85..c0341557 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - - uses: actions/checkout@dc323e67f16fb5f7663d20ff7941f27f5809e9b6 # v2.6.0 + - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 - name: Setup Python uses: actions/setup-python@75f3110429a8c05be0e1bf360334e4cced2b63fa # v2.3.3 - name: Install tox diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 305faa22..aa9bd8f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout - uses: actions/checkout@dc323e67f16fb5f7663d20ff7941f27f5809e9b6 # v2.6.0 + uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 with: ref: ${{ github.event.release.tag_name }} - name: Install Python diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index d86078a8..7060b69a 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -17,7 +17,7 @@ jobs: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout - uses: actions/checkout@dc323e67f16fb5f7663d20ff7941f27f5809e9b6 # v2.6.0 + uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 - name: Use Python ${{ matrix.python }} uses: actions/setup-python@75f3110429a8c05be0e1bf360334e4cced2b63fa # v2.3.3 with: From ae590b8e6154634003ea63aaf685fa6ee0f164ee Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Thu, 4 May 2023 04:32:59 +0200 Subject: [PATCH 061/181] chore(deps): update pypa/gh-action-pypi-publish digest to a56da0b (#232) Co-authored-by: Kayla Nguyen --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa9bd8f8..f29303d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: - name: Build distributions run: python -m build - name: Publish - uses: pypa/gh-action-pypi-publish@9b8e7336db3f96a2939a3e9fa827c62f466ca60d # master + uses: pypa/gh-action-pypi-publish@a56da0b891b3dc519c7ee3284aff1fad93cc8598 # master with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} From 586cc4e0833e2e9c82e685c8118be8c3187bd990 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Thu, 4 May 2023 05:09:32 +0200 Subject: [PATCH 062/181] chore(deps): update all non-major dependencies (#233) Co-authored-by: Kayla Nguyen --- .github/workflows/codeql.yml | 8 ++++---- .github/workflows/conformance.yml | 16 ++++++++-------- .github/workflows/dependency-review.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/scorecard.yml | 8 ++++---- .github/workflows/unit.yml | 2 +- 7 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a35afc69..02405c9a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,7 +41,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@03bee3930647ebbf994244c21ddbc0d4933aab4f # v2.3.0 + uses: step-security/harden-runner@6b3083af2869dc3314a0257a42f4af696cc79ba3 # v2.3.1 with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs @@ -50,7 +50,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@7df0ce34898d659f95c0c4a09eaa8d4e32ee64db # v2.2.12 + uses: github/codeql-action/init@f3feb00acb00f31a6f60280e6ace9ca31d91c76a # v2.3.2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -60,7 +60,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@7df0ce34898d659f95c0c4a09eaa8d4e32ee64db # v2.2.12 + uses: github/codeql-action/autobuild@f3feb00acb00f31a6f60280e6ace9ca31d91c76a # v2.3.2 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -73,6 +73,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@7df0ce34898d659f95c0c4a09eaa8d4e32ee64db # v2.2.12 + uses: github/codeql-action/analyze@f3feb00acb00f31a6f60280e6ace9ca31d91c76a # v2.3.2 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index f4e5fc27..e852913b 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -12,7 +12,7 @@ jobs: python: ['3.7', '3.8', '3.9', '3.10', '3.11'] steps: - name: Harden Runner - uses: step-security/harden-runner@18bf8ad2ca49c14cbb28b91346d626ccfb00c518 # v2.1.0 + uses: step-security/harden-runner@6b3083af2869dc3314a0257a42f4af696cc79ba3 # v2.3.1 with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs @@ -33,7 +33,7 @@ jobs: go-version: '1.16' - name: Run HTTP conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@c52662e612b2685a027b1c3e02224306517722fc # v1.6.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@1975792fb34ebbfa058d690666186d669d3a5977 # v1.8.0 with: version: 'v1.6.0' functionType: 'http' @@ -42,7 +42,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http --signature-type http'" - name: Run event conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@c52662e612b2685a027b1c3e02224306517722fc # v1.6.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@1975792fb34ebbfa058d690666186d669d3a5977 # v1.8.0 with: version: 'v1.6.0' functionType: 'legacyevent' @@ -51,7 +51,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_legacy_event --signature-type event'" - name: Run CloudEvents conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@c52662e612b2685a027b1c3e02224306517722fc # v1.6.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@1975792fb34ebbfa058d690666186d669d3a5977 # v1.8.0 with: version: 'v1.6.0' functionType: 'cloudevent' @@ -60,7 +60,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_cloud_event --signature-type cloudevent'" - name: Run HTTP conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@c52662e612b2685a027b1c3e02224306517722fc # v1.6.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@1975792fb34ebbfa058d690666186d669d3a5977 # v1.8.0 with: version: 'v1.6.0' functionType: 'http' @@ -69,7 +69,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative'" - name: Run CloudEvents conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@c52662e612b2685a027b1c3e02224306517722fc # v1.6.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@1975792fb34ebbfa058d690666186d669d3a5977 # v1.8.0 with: version: 'v1.6.0' functionType: 'cloudevent' @@ -78,7 +78,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_cloud_event_declarative'" - name: Run HTTP concurrency tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@c52662e612b2685a027b1c3e02224306517722fc # v1.6.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@1975792fb34ebbfa058d690666186d669d3a5977 # v1.8.0 with: version: 'v1.6.0' functionType: 'http' @@ -87,7 +87,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative_concurrent'" - name: Run Typed tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@c52662e612b2685a027b1c3e02224306517722fc # v1.6.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@1975792fb34ebbfa058d690666186d669d3a5977 # v1.8.0 with: version: 'v1.6.0' functionType: 'http' diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index b4adf6d4..46aaa11b 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@03bee3930647ebbf994244c21ddbc0d4933aab4f # v2.3.0 + uses: step-security/harden-runner@6b3083af2869dc3314a0257a42f4af696cc79ba3 # v2.3.1 with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c0341557..cc896b6d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@18bf8ad2ca49c14cbb28b91346d626ccfb00c518 # v2.1.0 + uses: step-security/harden-runner@6b3083af2869dc3314a0257a42f4af696cc79ba3 # v2.3.1 with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f29303d3..d52aa01d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@18bf8ad2ca49c14cbb28b91346d626ccfb00c518 # v2.1.0 + uses: step-security/harden-runner@6b3083af2869dc3314a0257a42f4af696cc79ba3 # v2.3.1 with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 49d3d2ba..60b0f355 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -25,17 +25,17 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@18bf8ad2ca49c14cbb28b91346d626ccfb00c518 # v2.1.0 + uses: step-security/harden-runner@6b3083af2869dc3314a0257a42f4af696cc79ba3 # v2.3.1 with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: "Checkout code" - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # v3.1.0 + uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@99c53751e09b9529366343771cc321ec74e9bd3d # v2.0.6 + uses: ossf/scorecard-action@80e868c13c90f172d68d1f4501dee99e2479f7af # v2.1.3 with: results_file: results.sarif results_format: sarif @@ -47,6 +47,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@807578363a7869ca324a79039e6db9c843e0e100 # v2.1.27 + uses: github/codeql-action/upload-sarif@f3feb00acb00f31a6f60280e6ace9ca31d91c76a # v2.3.2 with: sarif_file: results.sarif diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 7060b69a..669e0d6a 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -12,7 +12,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@18bf8ad2ca49c14cbb28b91346d626ccfb00c518 # v2.1.0 + uses: step-security/harden-runner@6b3083af2869dc3314a0257a42f4af696cc79ba3 # v2.3.1 with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs From c8d0e4a5c7403edcf5de80022a2766e51d9df8f1 Mon Sep 17 00:00:00 2001 From: Kenneth Rosario Date: Mon, 22 May 2023 18:36:20 -0700 Subject: [PATCH 063/181] chore: fix failing unit tests (#244) --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index fb76a0e8..0fe3dba6 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ envlist = py{35,36,37,38,39,310}-{ubuntu-latest,macos-latest,windows-latest},lin [testenv] usedevelop = true deps = - docker<5 # https://github.com/docker/docker-py/issues/2807 + docker pytest-cov pytest-integration pretend From 82081c49d4b72a852f15e463bdcf9ead6b445c63 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Wed, 24 May 2023 02:14:35 +0200 Subject: [PATCH 064/181] chore(deps): update actions/dependency-review-action action to v3 (#235) --- .github/workflows/dependency-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 46aaa11b..369f9f99 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -24,4 +24,4 @@ jobs: - name: 'Checkout Repository' uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 - name: 'Dependency Review' - uses: actions/dependency-review-action@0efb1d1d84fc9633afcdaad14c485cbbc90ef46c # v2.5.1 + uses: actions/dependency-review-action@f46c48ed6d4f1227fb2d9ea62bf6bcbed315589e # v3.0.4 From 21d9db1cbe63ca693ac2ddc7cfc5c4701eba6ca7 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Wed, 24 May 2023 17:17:35 +0200 Subject: [PATCH 065/181] chore(deps): update actions/setup-go action to v4 (#236) --- .github/workflows/conformance.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index e852913b..74bd0c5a 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -28,7 +28,7 @@ jobs: run: python -m pip install -e . - name: Setup Go - uses: actions/setup-go@bfdd3570ce990073878bf10f6b2d79082de49492 # v2.2.0 + uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1 with: go-version: '1.16' From c492b04e87a55194b7709e471b0ec3e2c630f288 Mon Sep 17 00:00:00 2001 From: Gareth Date: Wed, 24 May 2023 12:24:48 -0700 Subject: [PATCH 066/181] fix: streaming requests cannot access request data (#245) --- src/functions_framework/__init__.py | 7 +++- tests/test_functions.py | 14 ++++++- tests/test_functions/http_streaming/main.py | 44 +++++++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 tests/test_functions/http_streaming/main.py diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index c2a52d74..d4575b57 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -294,10 +294,13 @@ def _configure_app(app, function, signature_type): def read_request(response): """ Force the framework to read the entire request before responding, to avoid - connection errors when returning prematurely. + connection errors when returning prematurely. Skipped on streaming responses + as these may continue to operate on the request after they are returned. """ - flask.request.get_data() + if not response.is_streamed: + flask.request.get_data() + return response diff --git a/tests/test_functions.py b/tests/test_functions.py index 501ea488..81860cae 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. - +import io import json import pathlib import re @@ -490,6 +490,18 @@ def test_function_returns_none(): 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_legacy_function_check_env(monkeypatch): source = TEST_FUNCTIONS_DIR / "http_check_env" / "main.py" target = "function" 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())) From dded5ae9d06c0098ae9e0e018131f45c68060ec1 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Wed, 24 May 2023 23:19:45 +0200 Subject: [PATCH 067/181] chore(deps): update actions/setup-python action to v4 (#237) --- .github/workflows/conformance.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/unit.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 74bd0c5a..7c9661c6 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -20,7 +20,7 @@ jobs: uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 - name: Setup Python - uses: actions/setup-python@75f3110429a8c05be0e1bf360334e4cced2b63fa # v2.3.3 + uses: actions/setup-python@bd6b4b6205c4dbad673328db7b31b7fab9e241c0 # v4.6.1 with: python-version: ${{ matrix.python }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index cc896b6d..c8df3207 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 - name: Setup Python - uses: actions/setup-python@75f3110429a8c05be0e1bf360334e4cced2b63fa # v2.3.3 + uses: actions/setup-python@bd6b4b6205c4dbad673328db7b31b7fab9e241c0 # v4.6.1 - name: Install tox run: python -m pip install tox - name: Lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d52aa01d..6c151db3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ jobs: with: ref: ${{ github.event.release.tag_name }} - name: Install Python - uses: actions/setup-python@75f3110429a8c05be0e1bf360334e4cced2b63fa # v2.3.3 + uses: actions/setup-python@bd6b4b6205c4dbad673328db7b31b7fab9e241c0 # v4.6.1 - name: Install build dependencies run: python -m pip install -U setuptools build wheel - name: Build distributions diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 669e0d6a..68c19e6d 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -19,7 +19,7 @@ jobs: - name: Checkout uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 - name: Use Python ${{ matrix.python }} - uses: actions/setup-python@75f3110429a8c05be0e1bf360334e4cced2b63fa # v2.3.3 + uses: actions/setup-python@bd6b4b6205c4dbad673328db7b31b7fab9e241c0 # v4.6.1 with: python-version: ${{ matrix.python }} - name: Install tox From 6420b67c3b71c9b45dfc7b701ac16412c1430dc8 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Thu, 25 May 2023 14:17:11 -0700 Subject: [PATCH 068/181] chore(master): release 3.4.0 (#217) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> Co-authored-by: Gareth --- CHANGELOG.md | 12 ++++++++++++ setup.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26e83453..9016fc49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ 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.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) diff --git a/setup.py b/setup.py index 46b4bdfe..9e8aedab 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="3.3.0", + version="3.4.0", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown", From f2575377a8ffdfb170ca4c479af3956373d0ac42 Mon Sep 17 00:00:00 2001 From: Kenneth Rosario Date: Wed, 31 May 2023 14:38:09 -0700 Subject: [PATCH 069/181] chore: apply harden runner recommended egress policy (#246) --- .github/workflows/codeql.yml | 10 ++++++++-- .github/workflows/conformance.yml | 13 +++++++++++-- .github/workflows/dependency-review.yml | 9 ++++++--- .github/workflows/lint.yml | 9 +++++++-- .github/workflows/scorecard.yml | 18 ++++++++++++++++-- .github/workflows/unit.yml | 12 ++++++++++-- 6 files changed, 58 insertions(+), 13 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 02405c9a..c2b3df84 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,9 +41,15 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@6b3083af2869dc3314a0257a42f4af696cc79ba3 # v2.3.1 + uses: step-security/harden-runner@128a63446a954579617e875aaab7d2978154e969 # v2.4.0 with: - egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs + disable-sudo: true + egress-policy: block + allowed-endpoints: > + api.github.com:443 + files.pythonhosted.org:443 + github.com:443 + pypi.org:443 - name: Checkout repository uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 7c9661c6..5ea928c5 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -12,9 +12,18 @@ jobs: python: ['3.7', '3.8', '3.9', '3.10', '3.11'] steps: - name: Harden Runner - uses: step-security/harden-runner@6b3083af2869dc3314a0257a42f4af696cc79ba3 # v2.3.1 + uses: step-security/harden-runner@128a63446a954579617e875aaab7d2978154e969 # v2.4.0 with: - egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs + disable-sudo: true + egress-policy: block + allowed-endpoints: > + api.github.com:443 + files.pythonhosted.org:443 + github.com:443 + objects.githubusercontent.com:443 + proxy.golang.org:443 + pypi.org:443 + storage.googleapis.com:443 - name: Checkout code uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 369f9f99..8422c95c 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,10 +17,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@6b3083af2869dc3314a0257a42f4af696cc79ba3 # v2.3.1 + uses: step-security/harden-runner@128a63446a954579617e875aaab7d2978154e969 # v2.4.0 with: - egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - + disable-sudo: true + egress-policy: block + allowed-endpoints: > + api.github.com:443 + github.com:443 - name: 'Checkout Repository' uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 - name: 'Dependency Review' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c8df3207..1c0abd0f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,9 +8,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@6b3083af2869dc3314a0257a42f4af696cc79ba3 # v2.3.1 + uses: step-security/harden-runner@128a63446a954579617e875aaab7d2978154e969 # v2.4.0 with: - egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs + disable-sudo: true + egress-policy: block + allowed-endpoints: > + files.pythonhosted.org:443 + github.com:443 + pypi.org:443 - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 - name: Setup Python diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 60b0f355..8b317b5b 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -9,6 +9,7 @@ on: - cron: '0 */12 * * *' push: branches: [ "master" ] + workflow_dispatch: # Declare default permissions as read only. permissions: read-all @@ -25,9 +26,22 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@6b3083af2869dc3314a0257a42f4af696cc79ba3 # v2.3.1 + uses: step-security/harden-runner@128a63446a954579617e875aaab7d2978154e969 # v2.4.0 with: - egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs + disable-sudo: true + egress-policy: block + allowed-endpoints: > + api.github.com:443 + api.osv.dev:443 + api.securityscorecards.dev:443 + auth.docker.io:443 + bestpractices.coreinfrastructure.org:443 + fulcio.sigstore.dev:443 + github.com:443 + index.docker.io:443 + oss-fuzz-build-logs.storage.googleapis.com:443 + sigstore-tuf-root.storage.googleapis.com:443 + rekor.sigstore.dev:443 - name: "Checkout code" uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 68c19e6d..0aeb0e7c 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -12,9 +12,17 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@6b3083af2869dc3314a0257a42f4af696cc79ba3 # v2.3.1 + uses: step-security/harden-runner@128a63446a954579617e875aaab7d2978154e969 # v2.4.0 with: - egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs + disable-sudo: true + egress-policy: block + allowed-endpoints: > + auth.docker.io:443 + files.pythonhosted.org:443 + github.com:443 + production.cloudflare.docker.com:443 + pypi.org:443 + registry-1.docker.io:443 - name: Checkout uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 From 9c01e69b7e530608b3927c69204bc5f4a22ab645 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Thu, 1 Jun 2023 19:36:36 +0200 Subject: [PATCH 070/181] chore(deps): update pypa/gh-action-pypi-publish digest to 110f54a (#250) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c151db3..5b8d8c81 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: - name: Build distributions run: python -m build - name: Publish - uses: pypa/gh-action-pypi-publish@a56da0b891b3dc519c7ee3284aff1fad93cc8598 # master + uses: pypa/gh-action-pypi-publish@110f54a3871763056757c3e203635d4c5711439f # master with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} From 89c4c56c0bbf79d8bf96a170965b3bd3c0d57edc Mon Sep 17 00:00:00 2001 From: Kenneth Rosario Date: Fri, 2 Jun 2023 13:32:09 -0700 Subject: [PATCH 071/181] chore: add-endpoint-to-allowlist (#252) --- .github/workflows/codeql.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c2b3df84..ce7a8ff1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -50,6 +50,7 @@ jobs: files.pythonhosted.org:443 github.com:443 pypi.org:443 + objects.githubusercontent.com:443 - name: Checkout repository uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 From 08047ea65f267691b8a9852d4271348604d0e431 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Fri, 2 Jun 2023 22:45:00 +0200 Subject: [PATCH 072/181] chore(deps): update all non-major dependencies (#251) --- .github/workflows/codeql.yml | 6 +++--- .github/workflows/dependency-review.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/scorecard.yml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ce7a8ff1..33614b00 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -57,7 +57,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@f3feb00acb00f31a6f60280e6ace9ca31d91c76a # v2.3.2 + uses: github/codeql-action/init@83f0fe6c4988d98a455712a27f0255212bba9bd4 # v2.3.6 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -67,7 +67,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@f3feb00acb00f31a6f60280e6ace9ca31d91c76a # v2.3.2 + uses: github/codeql-action/autobuild@83f0fe6c4988d98a455712a27f0255212bba9bd4 # v2.3.6 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -80,6 +80,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@f3feb00acb00f31a6f60280e6ace9ca31d91c76a # v2.3.2 + uses: github/codeql-action/analyze@83f0fe6c4988d98a455712a27f0255212bba9bd4 # v2.3.6 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 8422c95c..ee6bdbec 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -27,4 +27,4 @@ jobs: - name: 'Checkout Repository' uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 - name: 'Dependency Review' - uses: actions/dependency-review-action@f46c48ed6d4f1227fb2d9ea62bf6bcbed315589e # v3.0.4 + uses: actions/dependency-review-action@1360a344ccb0ab6e9475edef90ad2f46bf8003b1 # v3.0.6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b8d8c81..fd5ccc6a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@6b3083af2869dc3314a0257a42f4af696cc79ba3 # v2.3.1 + uses: step-security/harden-runner@128a63446a954579617e875aaab7d2978154e969 # v2.4.0 with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 8b317b5b..e8b1f04c 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -61,6 +61,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@f3feb00acb00f31a6f60280e6ace9ca31d91c76a # v2.3.2 + uses: github/codeql-action/upload-sarif@83f0fe6c4988d98a455712a27f0255212bba9bd4 # v2.3.6 with: sarif_file: results.sarif From f56fc1fd8419aee0138cebe5298aa6f8af06373c Mon Sep 17 00:00:00 2001 From: Kenneth Rosario Date: Mon, 5 Jun 2023 13:21:30 -0700 Subject: [PATCH 073/181] chore: add security scorecard to repo readme (#249) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9dffc60c..ce094471 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![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] +[![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. From 7f58e3e1a7be544b3de1ddf13eb6022c15185c0e Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Mon, 10 Jul 2023 19:19:44 +0200 Subject: [PATCH 074/181] chore(deps): update pypa/gh-action-pypi-publish digest to 54d67ed (#256) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd5ccc6a..0dced000 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: - name: Build distributions run: python -m build - name: Publish - uses: pypa/gh-action-pypi-publish@110f54a3871763056757c3e203635d4c5711439f # master + uses: pypa/gh-action-pypi-publish@54d67ed3c50a769c633f7db8063c9e634709c1b0 # master with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} From 2e04cc28ae028b8facc85dbdf738e2b8076dbbf7 Mon Sep 17 00:00:00 2001 From: Gareth Date: Mon, 10 Jul 2023 16:30:27 -0700 Subject: [PATCH 075/181] =?UTF-8?q?fix:=20reduce=20gunicorn=20concurrency?= =?UTF-8?q?=20to=20at=20most=204=20*=20maximum=20available=20cor=E2=80=A6?= =?UTF-8?q?=20(#259)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/functions_framework/_http/gunicorn.py | 4 +++- tests/test_http.py | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/functions_framework/_http/gunicorn.py b/src/functions_framework/_http/gunicorn.py index f522b67f..3a9c545b 100644 --- a/src/functions_framework/_http/gunicorn.py +++ b/src/functions_framework/_http/gunicorn.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os + import gunicorn.app.base @@ -20,7 +22,7 @@ def __init__(self, app, host, port, debug, **options): self.options = { "bind": "%s:%s" % (host, port), "workers": 1, - "threads": 1024, + "threads": (os.cpu_count() or 1) * 4, "timeout": 0, "loglevel": "error", "limit_request_line": 0, diff --git a/tests/test_http.py b/tests/test_http.py index 3414aaf6..fbfac9d2 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os import platform import sys @@ -97,7 +98,7 @@ def test_gunicorn_application(debug): assert gunicorn_app.options == { "bind": "%s:%s" % (host, port), "workers": 1, - "threads": 1024, + "threads": os.cpu_count() * 4, "timeout": 0, "loglevel": "error", "limit_request_line": 0, @@ -105,7 +106,7 @@ def test_gunicorn_application(debug): assert gunicorn_app.cfg.bind == ["1.2.3.4:1234"] assert gunicorn_app.cfg.workers == 1 - assert gunicorn_app.cfg.threads == 1024 + assert gunicorn_app.cfg.threads == os.cpu_count() * 4 assert gunicorn_app.cfg.timeout == 0 assert gunicorn_app.load() == app From 671f2cbf1b98b6a10eb70c0b6c0a66610acc85a6 Mon Sep 17 00:00:00 2001 From: Kenneth Rosario Date: Wed, 12 Jul 2023 11:54:55 -0700 Subject: [PATCH 076/181] chore: run buildpack integration test on pull request (#255) --- .github/workflows/buildpack-integration-test.yml | 16 ++++++++++++---- tests/conformance/prerun.sh | 11 +++++------ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/.github/workflows/buildpack-integration-test.yml b/.github/workflows/buildpack-integration-test.yml index 78c56996..ec9fbf3d 100644 --- a/.github/workflows/buildpack-integration-test.yml +++ b/.github/workflows/buildpack-integration-test.yml @@ -4,14 +4,18 @@ on: push: branches: - master + pull_request: workflow_dispatch: + # Runs every day on 12:00 AM PST + schedule: + - cron: "0 0 * * *" # Declare default permissions as read only. permissions: read-all jobs: python37: - uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.0 + uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.4 with: http-builder-source: 'tests/conformance' http-builder-target: 'write_http_declarative' @@ -19,9 +23,10 @@ jobs: cloudevent-builder-target: 'write_cloud_event_declarative' prerun: 'tests/conformance/prerun.sh ${{ github.sha }}' builder-runtime: 'python37' + builder-runtime-version: '3.7' start-delay: 5 python38: - uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.0 + uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.4 with: http-builder-source: 'tests/conformance' http-builder-target: 'write_http_declarative' @@ -29,9 +34,10 @@ jobs: 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@v1.8.0 + uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.4 with: http-builder-source: 'tests/conformance' http-builder-target: 'write_http_declarative' @@ -39,9 +45,10 @@ jobs: 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@v1.8.0 + uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.4 with: http-builder-source: 'tests/conformance' http-builder-target: 'write_http_declarative' @@ -49,4 +56,5 @@ jobs: 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 \ No newline at end of file diff --git a/tests/conformance/prerun.sh b/tests/conformance/prerun.sh index b46f0b51..c37fe62b 100755 --- a/tests/conformance/prerun.sh +++ b/tests/conformance/prerun.sh @@ -7,15 +7,14 @@ set -e FRAMEWORK_VERSION=$1 -if [ -z "${FRAMEWORK_VERSION}" ] - then - echo "Functions Framework version required as first parameter" - exit 1 +if [ -z "${FRAMEWORK_VERSION}" ]; then + echo "Functions Framework version required as first parameter" + exit 1 fi SCRIPT_DIR=$(realpath $(dirname $0)) cd $SCRIPT_DIR -echo "git+https://github.com/GoogleCloudPlatform/functions-framework-python@$FRAMEWORK_VERSION#egg=functions-framework" > requirements.txt -cat requirements.txt \ No newline at end of file +echo "git+https://github.com/GoogleCloudPlatform/functions-framework-python@$FRAMEWORK_VERSION#egg=functions-framework" >requirements.txt +cat requirements.txt From 854509a1e6926495c382e63bd0c3a0117d80b859 Mon Sep 17 00:00:00 2001 From: Gareth Date: Wed, 12 Jul 2023 13:16:50 -0700 Subject: [PATCH 077/181] chore: add conformance test coverage for typed function signature (#254) --- .github/workflows/conformance.yml | 32 +++++++++++++++---------------- tests/conformance/main.py | 22 +++++++++++---------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 5ea928c5..8ebf4e42 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -1,5 +1,9 @@ name: Python Conformance CI -on: [push, pull_request] +on: + push: + branches: + - 'master' + pull_request: # Declare default permissions as read only. permissions: read-all @@ -39,67 +43,61 @@ jobs: - name: Setup Go uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1 with: - go-version: '1.16' + go-version: '1.20' - name: Run HTTP conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@1975792fb34ebbfa058d690666186d669d3a5977 # v1.8.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@5f2a796b58f099d749e70ecc83f531f6701c64af # v1.8.3 with: - version: 'v1.6.0' functionType: 'http' useBuildpacks: false validateMapping: false cmd: "'functions-framework --source tests/conformance/main.py --target write_http --signature-type http'" - name: Run event conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@1975792fb34ebbfa058d690666186d669d3a5977 # v1.8.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@5f2a796b58f099d749e70ecc83f531f6701c64af # v1.8.3 with: - version: 'v1.6.0' functionType: 'legacyevent' useBuildpacks: false validateMapping: true cmd: "'functions-framework --source tests/conformance/main.py --target write_legacy_event --signature-type event'" - name: Run CloudEvents conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@1975792fb34ebbfa058d690666186d669d3a5977 # v1.8.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@5f2a796b58f099d749e70ecc83f531f6701c64af # v1.8.3 with: - version: 'v1.6.0' functionType: 'cloudevent' useBuildpacks: false validateMapping: true cmd: "'functions-framework --source tests/conformance/main.py --target write_cloud_event --signature-type cloudevent'" - name: Run HTTP conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@1975792fb34ebbfa058d690666186d669d3a5977 # v1.8.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@5f2a796b58f099d749e70ecc83f531f6701c64af # v1.8.3 with: - version: 'v1.6.0' functionType: 'http' useBuildpacks: false validateMapping: false cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative'" - name: Run CloudEvents conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@1975792fb34ebbfa058d690666186d669d3a5977 # v1.8.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@5f2a796b58f099d749e70ecc83f531f6701c64af # v1.8.3 with: - version: 'v1.6.0' functionType: 'cloudevent' useBuildpacks: false validateMapping: true cmd: "'functions-framework --source tests/conformance/main.py --target write_cloud_event_declarative'" - name: Run HTTP concurrency tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@1975792fb34ebbfa058d690666186d669d3a5977 # v1.8.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@5f2a796b58f099d749e70ecc83f531f6701c64af # v1.8.3 with: - version: 'v1.6.0' functionType: 'http' useBuildpacks: false validateConcurrency: true cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative_concurrent'" - name: Run Typed tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@1975792fb34ebbfa058d690666186d669d3a5977 # v1.8.0 + uses: GoogleCloudPlatform/functions-framework-conformance/action@5f2a796b58f099d749e70ecc83f531f6701c64af # v1.8.3 with: - version: 'v1.6.0' functionType: 'http' + declarativeType: 'typed' useBuildpacks: false validateMapping: false - cmd: "'functions-framework --source tests/conformance/main.py --target write_typed_event_declarative'" + cmd: "'functions-framework --source tests/conformance/main.py --target typed_conformance_test'" diff --git a/tests/conformance/main.py b/tests/conformance/main.py index 057edaa7..4f164d62 100644 --- a/tests/conformance/main.py +++ b/tests/conformance/main.py @@ -8,15 +8,18 @@ filename = "function_output.json" -class ConformanceType: - json_request: str +class RawJson: + data: dict - def __init__(self, json_request: str) -> None: - self.json_request = json_request + def __init__(self, data): + self.data = data @staticmethod - def from_dict(obj: dict) -> "ConformanceType": - return ConformanceType(json.dumps(obj)) + def from_dict(obj: dict) -> "RawJson": + return RawJson(obj) + + def to_dict(self) -> dict: + return self.data def _write_output(content): @@ -66,7 +69,6 @@ def write_http_declarative_concurrent(request): return "OK", 200 -@functions_framework.typed(ConformanceType) -def write_typed_event_declarative(x): - _write_output(x.json_request) - return "OK" +@functions_framework.typed(RawJson) +def typed_conformance_test(x): + return RawJson({"payload": x.data}) From 3bed433de33abdf37f4decd32822dcde5f98f37a Mon Sep 17 00:00:00 2001 From: Kenneth Rosario Date: Fri, 14 Jul 2023 14:27:47 -0700 Subject: [PATCH 078/181] chore: Fix workflow duplication (#261) --- .github/workflows/lint.yml | 6 +++++- .github/workflows/unit.yml | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1c0abd0f..88af8d1e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,5 +1,9 @@ name: Python Lint CI -on: [push, pull_request] +on: + push: + branches: + - master + pull_request: permissions: contents: read diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 0aeb0e7c..dd40b16e 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -1,5 +1,9 @@ name: Python Unit CI -on: [push, pull_request] +on: + push: + branches: + - master + pull_request: permissions: contents: read From 8829aed1590f9092072660ac0773e3e7ba89edbf Mon Sep 17 00:00:00 2001 From: HKWinterhalter Date: Mon, 17 Jul 2023 17:09:31 -0700 Subject: [PATCH 079/181] ci: rename master branch to main (#263) * ci: rename master branch to main * ci: retarget buildpack integration tests to latest * retarget buildpack integration tests to conformance main --- .github/workflows/buildpack-integration-test.yml | 12 ++++++------ .github/workflows/codeql.yml | 4 ++-- .github/workflows/conformance.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/scorecard.yml | 2 +- .github/workflows/unit.yml | 2 +- README.md | 4 ++-- src/functions_framework/event_conversion.py | 2 +- 9 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/buildpack-integration-test.yml b/.github/workflows/buildpack-integration-test.yml index ec9fbf3d..0c9e6eff 100644 --- a/.github/workflows/buildpack-integration-test.yml +++ b/.github/workflows/buildpack-integration-test.yml @@ -3,7 +3,7 @@ name: Buildpack Integration Test on: push: branches: - - master + - main pull_request: workflow_dispatch: # Runs every day on 12:00 AM PST @@ -15,7 +15,7 @@ permissions: read-all jobs: python37: - uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.4 + uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@main with: http-builder-source: 'tests/conformance' http-builder-target: 'write_http_declarative' @@ -26,7 +26,7 @@ jobs: builder-runtime-version: '3.7' start-delay: 5 python38: - uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.4 + uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@main with: http-builder-source: 'tests/conformance' http-builder-target: 'write_http_declarative' @@ -37,7 +37,7 @@ jobs: builder-runtime-version: '3.8' start-delay: 5 python39: - uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.4 + uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@main with: http-builder-source: 'tests/conformance' http-builder-target: 'write_http_declarative' @@ -48,7 +48,7 @@ jobs: builder-runtime-version: '3.9' start-delay: 5 python310: - uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.4 + uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@main with: http-builder-source: 'tests/conformance' http-builder-target: 'write_http_declarative' @@ -57,4 +57,4 @@ jobs: prerun: 'tests/conformance/prerun.sh ${{ github.sha }}' builder-runtime: 'python310' builder-runtime-version: '3.10' - start-delay: 5 \ No newline at end of file + start-delay: 5 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 33614b00..dd1d1d77 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,10 +13,10 @@ name: "CodeQL" on: push: - branches: ["master"] + branches: ["main"] pull_request: # The branches below must be a subset of the branches above - branches: ["master"] + branches: ["main"] schedule: - cron: "0 0 * * 1" diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 8ebf4e42..b892a3ea 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -2,7 +2,7 @@ name: Python Conformance CI on: push: branches: - - 'master' + - 'main' pull_request: # Declare default permissions as read only. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 88af8d1e..fc00bc6f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,7 +2,7 @@ name: Python Lint CI on: push: branches: - - master + - main pull_request: permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0dced000..519f287b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: - name: Build distributions run: python -m build - name: Publish - uses: pypa/gh-action-pypi-publish@54d67ed3c50a769c633f7db8063c9e634709c1b0 # master + uses: pypa/gh-action-pypi-publish@54d67ed3c50a769c633f7db8063c9e634709c1b0 # main with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index e8b1f04c..0c24297e 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -8,7 +8,7 @@ on: schedule: - cron: '0 */12 * * *' push: - branches: [ "master" ] + branches: [ "main" ] workflow_dispatch: # Declare default permissions as read only. diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index dd40b16e..381c28b0 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -2,7 +2,7 @@ name: Python Unit CI on: push: branches: - - master + - main pull_request: permissions: contents: read diff --git a/README.md b/README.md index ce094471..0433028d 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ def hello_cloud_event(cloud_event): print(f"Received event with ID: {cloud_event['id']} and data {cloud_event.data}") ``` -> Your function is passed a single [CloudEvent](https://github.com/cloudevents/sdk-python/blob/master/cloudevents/sdk/event/v1.py) parameter. +> Your function is passed a single [CloudEvent](https://github.com/cloudevents/sdk-python/blob/main/cloudevents/sdk/event/v1.py) parameter. Run the following command to run `hello_cloud_event` target locally: @@ -302,7 +302,7 @@ After you've written your function, you can simply deploy it from your local mac ### Cloud Run/Cloud Run on GKE -Once you've written your function and added the Functions Framework to your `requirements.txt` file, all that's left is to create a container image. [Check out the Cloud Run quickstart](https://cloud.google.com/run/docs/quickstarts/build-and-deploy) for Python to create a container image and deploy it to Cloud Run. You'll write a `Dockerfile` when you build your container. This `Dockerfile` allows you to specify exactly what goes into your container (including custom binaries, a specific operating system, and more). [Here is an example `Dockerfile` that calls Functions Framework.](https://github.com/GoogleCloudPlatform/functions-framework-python/blob/master/examples/cloud_run_http) +Once you've written your function and added the Functions Framework to your `requirements.txt` file, all that's left is to create a container image. [Check out the Cloud Run quickstart](https://cloud.google.com/run/docs/quickstarts/build-and-deploy) for Python to create a container image and deploy it to Cloud Run. You'll write a `Dockerfile` when you build your container. This `Dockerfile` allows you to specify exactly what goes into your container (including custom binaries, a specific operating system, and more). [Here is an example `Dockerfile` that calls Functions Framework.](https://github.com/GoogleCloudPlatform/functions-framework-python/blob/main/examples/cloud_run_http) If you want even more control over the environment, you can [deploy your container image to Cloud Run on GKE](https://cloud.google.com/run/docs/quickstarts/prebuilt-deploy-gke). With Cloud Run on GKE, you can run your function on a GKE cluster, which gives you additional control over the environment (including use of GPU-based instances, longer timeouts and more). diff --git a/src/functions_framework/event_conversion.py b/src/functions_framework/event_conversion.py index 06e5a812..0e67cdaa 100644 --- a/src/functions_framework/event_conversion.py +++ b/src/functions_framework/event_conversion.py @@ -27,7 +27,7 @@ # Maps background/legacy event types to their equivalent CloudEvent types. # For more info on event mappings see -# https://github.com/GoogleCloudPlatform/functions-framework-conformance/blob/master/docs/mapping.md +# https://github.com/GoogleCloudPlatform/functions-framework-conformance/blob/main/docs/mapping.md _BACKGROUND_TO_CE_TYPE = { "google.pubsub.topic.publish": "google.cloud.pubsub.topic.v1.messagePublished", "providers/cloud.pubsub/eventTypes/topic.publish": "google.cloud.pubsub.topic.v1.messagePublished", From a7076a63665842bc97315b360863901b16a5819f Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Mon, 21 Aug 2023 17:54:31 +0200 Subject: [PATCH 080/181] chore(deps): update all non-major dependencies (#257) --- .github/workflows/codeql.yml | 10 +++++----- .github/workflows/conformance.yml | 22 +++++++++++----------- .github/workflows/dependency-review.yml | 6 +++--- .github/workflows/lint.yml | 6 +++--- .github/workflows/release.yml | 6 +++--- .github/workflows/scorecard.yml | 8 ++++---- .github/workflows/unit.yml | 6 +++--- 7 files changed, 32 insertions(+), 32 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index dd1d1d77..da547011 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,7 +41,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@128a63446a954579617e875aaab7d2978154e969 # v2.4.0 + uses: step-security/harden-runner@8ca2b8b2ece13480cda6dacd3511b49857a23c09 # v2.5.1 with: disable-sudo: true egress-policy: block @@ -53,11 +53,11 @@ jobs: objects.githubusercontent.com:443 - name: Checkout repository - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@83f0fe6c4988d98a455712a27f0255212bba9bd4 # v2.3.6 + uses: github/codeql-action/init@a09933a12a80f87b87005513f0abb1494c27a716 # v2.21.4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -67,7 +67,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@83f0fe6c4988d98a455712a27f0255212bba9bd4 # v2.3.6 + uses: github/codeql-action/autobuild@a09933a12a80f87b87005513f0abb1494c27a716 # v2.21.4 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -80,6 +80,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@83f0fe6c4988d98a455712a27f0255212bba9bd4 # v2.3.6 + uses: github/codeql-action/analyze@a09933a12a80f87b87005513f0abb1494c27a716 # v2.21.4 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index b892a3ea..637a20d4 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -16,7 +16,7 @@ jobs: python: ['3.7', '3.8', '3.9', '3.10', '3.11'] steps: - name: Harden Runner - uses: step-security/harden-runner@128a63446a954579617e875aaab7d2978154e969 # v2.4.0 + uses: step-security/harden-runner@8ca2b8b2ece13480cda6dacd3511b49857a23c09 # v2.5.1 with: disable-sudo: true egress-policy: block @@ -30,10 +30,10 @@ jobs: storage.googleapis.com:443 - name: Checkout code - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Setup Python - uses: actions/setup-python@bd6b4b6205c4dbad673328db7b31b7fab9e241c0 # v4.6.1 + uses: actions/setup-python@61a6322f88396a6271a6ee3565807d608ecaddd1 # v4.7.0 with: python-version: ${{ matrix.python }} @@ -41,12 +41,12 @@ jobs: run: python -m pip install -e . - name: Setup Go - uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1 + uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: go-version: '1.20' - name: Run HTTP conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@5f2a796b58f099d749e70ecc83f531f6701c64af # v1.8.3 + uses: GoogleCloudPlatform/functions-framework-conformance/action@64d4646c26d11281faf5e8813dd0fc8a9551cac3 # v1.8.5 with: functionType: 'http' useBuildpacks: false @@ -54,7 +54,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http --signature-type http'" - name: Run event conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@5f2a796b58f099d749e70ecc83f531f6701c64af # v1.8.3 + uses: GoogleCloudPlatform/functions-framework-conformance/action@64d4646c26d11281faf5e8813dd0fc8a9551cac3 # v1.8.5 with: functionType: 'legacyevent' useBuildpacks: false @@ -62,7 +62,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_legacy_event --signature-type event'" - name: Run CloudEvents conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@5f2a796b58f099d749e70ecc83f531f6701c64af # v1.8.3 + uses: GoogleCloudPlatform/functions-framework-conformance/action@64d4646c26d11281faf5e8813dd0fc8a9551cac3 # v1.8.5 with: functionType: 'cloudevent' useBuildpacks: false @@ -70,7 +70,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_cloud_event --signature-type cloudevent'" - name: Run HTTP conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@5f2a796b58f099d749e70ecc83f531f6701c64af # v1.8.3 + uses: GoogleCloudPlatform/functions-framework-conformance/action@64d4646c26d11281faf5e8813dd0fc8a9551cac3 # v1.8.5 with: functionType: 'http' useBuildpacks: false @@ -78,7 +78,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative'" - name: Run CloudEvents conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@5f2a796b58f099d749e70ecc83f531f6701c64af # v1.8.3 + uses: GoogleCloudPlatform/functions-framework-conformance/action@64d4646c26d11281faf5e8813dd0fc8a9551cac3 # v1.8.5 with: functionType: 'cloudevent' useBuildpacks: false @@ -86,7 +86,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_cloud_event_declarative'" - name: Run HTTP concurrency tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@5f2a796b58f099d749e70ecc83f531f6701c64af # v1.8.3 + uses: GoogleCloudPlatform/functions-framework-conformance/action@64d4646c26d11281faf5e8813dd0fc8a9551cac3 # v1.8.5 with: functionType: 'http' useBuildpacks: false @@ -94,7 +94,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative_concurrent'" - name: Run Typed tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@5f2a796b58f099d749e70ecc83f531f6701c64af # v1.8.3 + uses: GoogleCloudPlatform/functions-framework-conformance/action@64d4646c26d11281faf5e8813dd0fc8a9551cac3 # v1.8.5 with: functionType: 'http' declarativeType: 'typed' diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index ee6bdbec..799874eb 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@128a63446a954579617e875aaab7d2978154e969 # v2.4.0 + uses: step-security/harden-runner@8ca2b8b2ece13480cda6dacd3511b49857a23c09 # v2.5.1 with: disable-sudo: true egress-policy: block @@ -25,6 +25,6 @@ jobs: api.github.com:443 github.com:443 - name: 'Checkout Repository' - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: 'Dependency Review' - uses: actions/dependency-review-action@1360a344ccb0ab6e9475edef90ad2f46bf8003b1 # v3.0.6 + uses: actions/dependency-review-action@f6fff72a3217f580d5afd49a46826795305b63c7 # v3.0.8 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index fc00bc6f..e53049e5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@128a63446a954579617e875aaab7d2978154e969 # v2.4.0 + uses: step-security/harden-runner@8ca2b8b2ece13480cda6dacd3511b49857a23c09 # v2.5.1 with: disable-sudo: true egress-policy: block @@ -21,9 +21,9 @@ jobs: github.com:443 pypi.org:443 - - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Setup Python - uses: actions/setup-python@bd6b4b6205c4dbad673328db7b31b7fab9e241c0 # v4.6.1 + uses: actions/setup-python@61a6322f88396a6271a6ee3565807d608ecaddd1 # v4.7.0 - name: Install tox run: python -m pip install tox - name: Lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 519f287b..89d66fa1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,16 +13,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@128a63446a954579617e875aaab7d2978154e969 # v2.4.0 + uses: step-security/harden-runner@8ca2b8b2ece13480cda6dacd3511b49857a23c09 # v2.5.1 with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: ref: ${{ github.event.release.tag_name }} - name: Install Python - uses: actions/setup-python@bd6b4b6205c4dbad673328db7b31b7fab9e241c0 # v4.6.1 + uses: actions/setup-python@61a6322f88396a6271a6ee3565807d608ecaddd1 # v4.7.0 - name: Install build dependencies run: python -m pip install -U setuptools build wheel - name: Build distributions diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 0c24297e..386dd69c 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@128a63446a954579617e875aaab7d2978154e969 # v2.4.0 + uses: step-security/harden-runner@8ca2b8b2ece13480cda6dacd3511b49857a23c09 # v2.5.1 with: disable-sudo: true egress-policy: block @@ -44,12 +44,12 @@ jobs: rekor.sigstore.dev:443 - name: "Checkout code" - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@80e868c13c90f172d68d1f4501dee99e2479f7af # v2.1.3 + uses: ossf/scorecard-action@08b4669551908b1024bb425080c797723083c031 # v2.2.0 with: results_file: results.sarif results_format: sarif @@ -61,6 +61,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@83f0fe6c4988d98a455712a27f0255212bba9bd4 # v2.3.6 + uses: github/codeql-action/upload-sarif@a09933a12a80f87b87005513f0abb1494c27a716 # v2.21.4 with: sarif_file: results.sarif diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 381c28b0..9f98dea7 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -16,7 +16,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@128a63446a954579617e875aaab7d2978154e969 # v2.4.0 + uses: step-security/harden-runner@8ca2b8b2ece13480cda6dacd3511b49857a23c09 # v2.5.1 with: disable-sudo: true egress-policy: block @@ -29,9 +29,9 @@ jobs: registry-1.docker.io:443 - name: Checkout - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Use Python ${{ matrix.python }} - uses: actions/setup-python@bd6b4b6205c4dbad673328db7b31b7fab9e241c0 # v4.6.1 + uses: actions/setup-python@61a6322f88396a6271a6ee3565807d608ecaddd1 # v4.7.0 with: python-version: ${{ matrix.python }} - name: Install tox From 049e031d802cc6af35784b12b1d41e85781339ea Mon Sep 17 00:00:00 2001 From: Kenneth Rosario Date: Tue, 22 Aug 2023 11:06:16 -0700 Subject: [PATCH 081/181] chre: Update scorecard.yml with missing endpoint (#268) * chre: Update scorecard.yml with missing endpoint * Add wildcard endpoint --- .github/workflows/scorecard.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 386dd69c..d6a0d834 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -36,12 +36,12 @@ jobs: api.securityscorecards.dev:443 auth.docker.io:443 bestpractices.coreinfrastructure.org:443 - fulcio.sigstore.dev:443 github.com:443 index.docker.io:443 oss-fuzz-build-logs.storage.googleapis.com:443 sigstore-tuf-root.storage.googleapis.com:443 - rekor.sigstore.dev:443 + *.sigstore.dev:443 + - name: "Checkout code" uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 From 45aed538b5e39655318c7841457399fa3376ceaf Mon Sep 17 00:00:00 2001 From: Jonathan Ballet Date: Tue, 29 Aug 2023 01:28:46 +0200 Subject: [PATCH 082/181] feat: initial typing of the public API (#248) * Initial typing of the public API This adds type annotations for the 2 main decorators of the library. Closes: https://github.com/GoogleCloudPlatform/functions-framework-python/issues/190 * fix typing * Remove zip_safe flag, as it's not needed anymore. * run black * fix import order --------- Co-authored-by: Gareth --- README.md | 6 ++++-- setup.py | 1 + src/functions_framework/__init__.py | 10 +++++++--- src/functions_framework/py.typed | 0 tests/test_typing.py | 16 ++++++++++++++++ tox.ini | 2 ++ 6 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 src/functions_framework/py.typed create mode 100644 tests/test_typing.py diff --git a/README.md b/README.md index 0433028d..b32b2e3e 100644 --- a/README.md +++ b/README.md @@ -59,10 +59,11 @@ functions-framework==3.* Create an `main.py` file with the following contents: ```python +import flask import functions_framework @functions_framework.http -def hello(request): +def hello(request: flask.Request) -> flask.typing.ResponseReturnValue: return "Hello world!" ``` @@ -98,9 +99,10 @@ Create an `main.py` file with the following contents: ```python import functions_framework +from cloudevents.http.event import CloudEvent @functions_framework.cloud_event -def hello_cloud_event(cloud_event): +def hello_cloud_event(cloud_event: CloudEvent) -> None: print(f"Received event with ID: {cloud_event['id']} and data {cloud_event.data}") ``` diff --git a/setup.py b/setup.py index 9e8aedab..0a98faef 100644 --- a/setup.py +++ b/setup.py @@ -46,6 +46,7 @@ ], 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", diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index d4575b57..8d47670c 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -23,13 +23,14 @@ import types from inspect import signature -from typing import Type +from typing import Callable, Type import cloudevents.exceptions as cloud_exceptions import flask import werkzeug from cloudevents.http import from_http, is_binary +from cloudevents.http.event import CloudEvent from functions_framework import _function_registry, _typed_event, event_conversion from functions_framework.background_event import BackgroundEvent @@ -45,6 +46,9 @@ _CLOUDEVENT_MIME_TYPE = "application/cloudevents+json" +CloudEventFunction = Callable[[CloudEvent], None] +HTTPFunction = Callable[[flask.Request], flask.typing.ResponseReturnValue] + class _LoggingHandler(io.TextIOWrapper): """Logging replacement for stdout and stderr in GCF Python 3.7.""" @@ -59,7 +63,7 @@ def write(self, out): return self.stderr.write(json.dumps(payload) + "\n") -def cloud_event(func): +def cloud_event(func: CloudEventFunction) -> CloudEventFunction: """Decorator that registers cloudevent as user function signature type.""" _function_registry.REGISTRY_MAP[ func.__name__ @@ -99,7 +103,7 @@ def wrapper(*args, **kwargs): return _typed -def http(func): +def http(func: HTTPFunction) -> HTTPFunction: """Decorator that registers http as user function signature type.""" _function_registry.REGISTRY_MAP[ func.__name__ diff --git a/src/functions_framework/py.typed b/src/functions_framework/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_typing.py b/tests/test_typing.py new file mode 100644 index 00000000..279cd636 --- /dev/null +++ b/tests/test_typing.py @@ -0,0 +1,16 @@ +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}") diff --git a/tox.ini b/tox.ini index 0fe3dba6..e8c555b5 100644 --- a/tox.ini +++ b/tox.ini @@ -19,8 +19,10 @@ deps = black twine isort + mypy commands = black --check src tests setup.py conftest.py --exclude tests/test_functions/background_load_error/main.py isort -c src tests setup.py conftest.py + mypy tests/test_typing.py python setup.py --quiet sdist bdist_wheel twine check dist/* From 31907158995a1cda7d735291779fc029fea23c11 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Wed, 6 Sep 2023 03:26:24 +0200 Subject: [PATCH 083/181] chore(deps): update pypa/gh-action-pypi-publish digest to 8cdc2ab (#271) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 89d66fa1..40fc39b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: - name: Build distributions run: python -m build - name: Publish - uses: pypa/gh-action-pypi-publish@54d67ed3c50a769c633f7db8063c9e634709c1b0 # main + uses: pypa/gh-action-pypi-publish@8cdc2ab67c943c5edf5fd9ae1995546b4b550602 # main with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} From 46780dac88c8dfe715babe89f792d08e9ca482e7 Mon Sep 17 00:00:00 2001 From: David Laban Date: Wed, 6 Sep 2023 18:27:02 +0100 Subject: [PATCH 084/181] fix: don't exit on reload if there is a syntax error (#214) * fix: don't exit on reload if there is a syntax error (#213) * style: reformat using black * test: robustness to syntax error in debug mode startup --- src/functions_framework/__init__.py | 23 +++++++++++++++++++++-- tests/test_functions.py | 13 +++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 8d47670c..ece4f446 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -357,11 +357,30 @@ def handle_none(rv): # Execute the module, within the application context with _app.app_context(): - spec.loader.exec_module(source_module) + try: + spec.loader.exec_module(source_module) + function = _function_registry.get_user_function( + source, source_module, target + ) + except Exception as e: + if werkzeug.serving.is_running_from_reloader(): + # When reloading, print out the error immediately, but raise + # it later so the debugger or server can handle it. + import traceback + + traceback.print_exc() + err = e + + def function(*_args, **_kwargs): + raise err from None + + else: + # When not reloading, raise the error immediately so the + # command fails. + raise e from None # Get the configured function signature type signature_type = _function_registry.get_func_signature_type(target, signature_type) - function = _function_registry.get_user_function(source, source_module, target) _configure_app(_app, function, signature_type) diff --git a/tests/test_functions.py b/tests/test_functions.py index 81860cae..f0bd7793 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -323,6 +323,19 @@ def test_invalid_function_definition_function_syntax_error(): ) +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 + + def test_invalid_function_definition_missing_dependency(): source = TEST_FUNCTIONS_DIR / "background_missing_dependency" / "main.py" target = "function" From b58361056acaaaf6b5185fde6adfcd374bc4bb1b Mon Sep 17 00:00:00 2001 From: HKWinterhalter Date: Wed, 6 Sep 2023 11:51:16 -0700 Subject: [PATCH 085/181] chore: update blunderbuss.yml (#273) --- .github/blunderbuss.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/blunderbuss.yml b/.github/blunderbuss.yml index 33581568..ffa474e5 100644 --- a/.github/blunderbuss.yml +++ b/.github/blunderbuss.yml @@ -1,9 +1,7 @@ assign_prs: - - KaylaNguyen - HKWinterhalter - janell-chen assign_issues: - - KaylaNguyen - HKWinterhalter - janell-chen From 8e904c531233f440466774cfb400cd1e587ac6f2 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Fri, 8 Sep 2023 02:40:29 +0200 Subject: [PATCH 086/181] chore(deps): update all non-major dependencies (#272) --- .github/workflows/codeql.yml | 8 ++++---- .github/workflows/conformance.yml | 2 +- .github/workflows/dependency-review.yml | 4 ++-- .github/workflows/lint.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/scorecard.yml | 4 ++-- .github/workflows/unit.yml | 2 +- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index da547011..6a368480 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -53,11 +53,11 @@ jobs: objects.githubusercontent.com:443 - name: Checkout repository - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@a09933a12a80f87b87005513f0abb1494c27a716 # v2.21.4 + uses: github/codeql-action/init@00e563ead9f72a8461b24876bee2d0c2e8bd2ee8 # v2.21.5 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -67,7 +67,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@a09933a12a80f87b87005513f0abb1494c27a716 # v2.21.4 + uses: github/codeql-action/autobuild@00e563ead9f72a8461b24876bee2d0c2e8bd2ee8 # v2.21.5 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -80,6 +80,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@a09933a12a80f87b87005513f0abb1494c27a716 # v2.21.4 + uses: github/codeql-action/analyze@00e563ead9f72a8461b24876bee2d0c2e8bd2ee8 # v2.21.5 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 637a20d4..c7f4352f 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -30,7 +30,7 @@ jobs: storage.googleapis.com:443 - name: Checkout code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - name: Setup Python uses: actions/setup-python@61a6322f88396a6271a6ee3565807d608ecaddd1 # v4.7.0 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 799874eb..707087cd 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -25,6 +25,6 @@ jobs: api.github.com:443 github.com:443 - name: 'Checkout Repository' - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - name: 'Dependency Review' - uses: actions/dependency-review-action@f6fff72a3217f580d5afd49a46826795305b63c7 # v3.0.8 + uses: actions/dependency-review-action@6c5ccdad469c9f8a2996bfecaec55a631a347034 # v3.1.0 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e53049e5..1add91f0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -21,7 +21,7 @@ jobs: github.com:443 pypi.org:443 - - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - name: Setup Python uses: actions/setup-python@61a6322f88396a6271a6ee3565807d608ecaddd1 # v4.7.0 - name: Install tox diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 40fc39b6..2ec287c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 with: ref: ${{ github.event.release.tag_name }} - name: Install Python diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index d6a0d834..e07ddedd 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -44,7 +44,7 @@ jobs: - name: "Checkout code" - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 with: persist-credentials: false @@ -61,6 +61,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@a09933a12a80f87b87005513f0abb1494c27a716 # v2.21.4 + uses: github/codeql-action/upload-sarif@00e563ead9f72a8461b24876bee2d0c2e8bd2ee8 # v2.21.5 with: sarif_file: results.sarif diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 9f98dea7..c09b01c9 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -29,7 +29,7 @@ jobs: registry-1.docker.io:443 - name: Checkout - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - name: Use Python ${{ matrix.python }} uses: actions/setup-python@61a6322f88396a6271a6ee3565807d608ecaddd1 # v4.7.0 with: From cbc0d0ac715dfa3b1cea8b9830bd5cd048f524c3 Mon Sep 17 00:00:00 2001 From: HKWinterhalter Date: Wed, 27 Sep 2023 15:26:37 -0700 Subject: [PATCH 087/181] chore(deps): Remove gunicorn version upper bound (#276) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 0a98faef..e3f92e27 100644 --- a/setup.py +++ b/setup.py @@ -54,7 +54,7 @@ "flask>=1.0,<3.0", "click>=7.0,<9.0", "watchdog>=1.0.0", - "gunicorn>=19.2.0,<21.0; platform_system!='Windows'", + "gunicorn>=19.2.0; platform_system!='Windows'", "cloudevents>=1.2.0,<2.0.0", ], entry_points={ From 5f4bd3348a0c8baae4e0f6379111a00b34448e68 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Thu, 12 Oct 2023 22:34:42 +0200 Subject: [PATCH 088/181] chore(deps): update all non-major dependencies (#278) --- .github/workflows/codeql.yml | 8 ++++---- .github/workflows/conformance.yml | 18 +++++++++--------- .github/workflows/dependency-review.yml | 2 +- .github/workflows/lint.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- .github/workflows/scorecard.yml | 6 +++--- .github/workflows/unit.yml | 4 ++-- 7 files changed, 23 insertions(+), 23 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6a368480..881bdc8b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,7 +41,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@8ca2b8b2ece13480cda6dacd3511b49857a23c09 # v2.5.1 + uses: step-security/harden-runner@1b05615854632b887b69ae1be8cbefe72d3ae423 # v2.6.0 with: disable-sudo: true egress-policy: block @@ -57,7 +57,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@00e563ead9f72a8461b24876bee2d0c2e8bd2ee8 # v2.21.5 + uses: github/codeql-action/init@d90b8d79de6dc1f58e83a1499aa58d6c93dc28de # v2.22.2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -67,7 +67,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@00e563ead9f72a8461b24876bee2d0c2e8bd2ee8 # v2.21.5 + uses: github/codeql-action/autobuild@d90b8d79de6dc1f58e83a1499aa58d6c93dc28de # v2.22.2 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -80,6 +80,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@00e563ead9f72a8461b24876bee2d0c2e8bd2ee8 # v2.21.5 + uses: github/codeql-action/analyze@d90b8d79de6dc1f58e83a1499aa58d6c93dc28de # v2.22.2 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index c7f4352f..a7ed3ebe 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -16,7 +16,7 @@ jobs: python: ['3.7', '3.8', '3.9', '3.10', '3.11'] steps: - name: Harden Runner - uses: step-security/harden-runner@8ca2b8b2ece13480cda6dacd3511b49857a23c09 # v2.5.1 + uses: step-security/harden-runner@1b05615854632b887b69ae1be8cbefe72d3ae423 # v2.6.0 with: disable-sudo: true egress-policy: block @@ -33,7 +33,7 @@ jobs: uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - name: Setup Python - uses: actions/setup-python@61a6322f88396a6271a6ee3565807d608ecaddd1 # v4.7.0 + uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4.7.1 with: python-version: ${{ matrix.python }} @@ -46,7 +46,7 @@ jobs: go-version: '1.20' - name: Run HTTP conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@64d4646c26d11281faf5e8813dd0fc8a9551cac3 # v1.8.5 + uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 with: functionType: 'http' useBuildpacks: false @@ -54,7 +54,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http --signature-type http'" - name: Run event conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@64d4646c26d11281faf5e8813dd0fc8a9551cac3 # v1.8.5 + uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 with: functionType: 'legacyevent' useBuildpacks: false @@ -62,7 +62,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_legacy_event --signature-type event'" - name: Run CloudEvents conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@64d4646c26d11281faf5e8813dd0fc8a9551cac3 # v1.8.5 + uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 with: functionType: 'cloudevent' useBuildpacks: false @@ -70,7 +70,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_cloud_event --signature-type cloudevent'" - name: Run HTTP conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@64d4646c26d11281faf5e8813dd0fc8a9551cac3 # v1.8.5 + uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 with: functionType: 'http' useBuildpacks: false @@ -78,7 +78,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative'" - name: Run CloudEvents conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@64d4646c26d11281faf5e8813dd0fc8a9551cac3 # v1.8.5 + uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 with: functionType: 'cloudevent' useBuildpacks: false @@ -86,7 +86,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_cloud_event_declarative'" - name: Run HTTP concurrency tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@64d4646c26d11281faf5e8813dd0fc8a9551cac3 # v1.8.5 + uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 with: functionType: 'http' useBuildpacks: false @@ -94,7 +94,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative_concurrent'" - name: Run Typed tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@64d4646c26d11281faf5e8813dd0fc8a9551cac3 # v1.8.5 + uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 with: functionType: 'http' declarativeType: 'typed' diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 707087cd..4349019e 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@8ca2b8b2ece13480cda6dacd3511b49857a23c09 # v2.5.1 + uses: step-security/harden-runner@1b05615854632b887b69ae1be8cbefe72d3ae423 # v2.6.0 with: disable-sudo: true egress-policy: block diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1add91f0..56410fc1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@8ca2b8b2ece13480cda6dacd3511b49857a23c09 # v2.5.1 + uses: step-security/harden-runner@1b05615854632b887b69ae1be8cbefe72d3ae423 # v2.6.0 with: disable-sudo: true egress-policy: block @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - name: Setup Python - uses: actions/setup-python@61a6322f88396a6271a6ee3565807d608ecaddd1 # v4.7.0 + uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4.7.1 - name: Install tox run: python -m pip install tox - name: Lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2ec287c8..dbda667c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@8ca2b8b2ece13480cda6dacd3511b49857a23c09 # v2.5.1 + uses: step-security/harden-runner@1b05615854632b887b69ae1be8cbefe72d3ae423 # v2.6.0 with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs @@ -22,7 +22,7 @@ jobs: with: ref: ${{ github.event.release.tag_name }} - name: Install Python - uses: actions/setup-python@61a6322f88396a6271a6ee3565807d608ecaddd1 # v4.7.0 + uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4.7.1 - name: Install build dependencies run: python -m pip install -U setuptools build wheel - name: Build distributions diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index e07ddedd..d13e3d2a 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@8ca2b8b2ece13480cda6dacd3511b49857a23c09 # v2.5.1 + uses: step-security/harden-runner@1b05615854632b887b69ae1be8cbefe72d3ae423 # v2.6.0 with: disable-sudo: true egress-policy: block @@ -49,7 +49,7 @@ jobs: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@08b4669551908b1024bb425080c797723083c031 # v2.2.0 + uses: ossf/scorecard-action@483ef80eb98fb506c348f7d62e28055e49fe2398 # v2.3.0 with: results_file: results.sarif results_format: sarif @@ -61,6 +61,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@00e563ead9f72a8461b24876bee2d0c2e8bd2ee8 # v2.21.5 + uses: github/codeql-action/upload-sarif@d90b8d79de6dc1f58e83a1499aa58d6c93dc28de # v2.22.2 with: sarif_file: results.sarif diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index c09b01c9..6c67bd37 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -16,7 +16,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@8ca2b8b2ece13480cda6dacd3511b49857a23c09 # v2.5.1 + uses: step-security/harden-runner@1b05615854632b887b69ae1be8cbefe72d3ae423 # v2.6.0 with: disable-sudo: true egress-policy: block @@ -31,7 +31,7 @@ jobs: - name: Checkout uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - name: Use Python ${{ matrix.python }} - uses: actions/setup-python@61a6322f88396a6271a6ee3565807d608ecaddd1 # v4.7.0 + uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4.7.1 with: python-version: ${{ matrix.python }} - name: Install tox From 39a1bc5de91b19f9a44fc19d685d92fc60aa9194 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Thu, 12 Oct 2023 23:12:41 +0200 Subject: [PATCH 089/181] chore(deps): update actions/checkout action to v4 (#279) --- .github/workflows/codeql.yml | 2 +- .github/workflows/conformance.yml | 2 +- .github/workflows/dependency-review.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/scorecard.yml | 2 +- .github/workflows/unit.yml | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 881bdc8b..f6f405f2 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -53,7 +53,7 @@ jobs: objects.githubusercontent.com:443 - name: Checkout repository - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index a7ed3ebe..9998536d 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -30,7 +30,7 @@ jobs: storage.googleapis.com:443 - name: Checkout code - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 - name: Setup Python uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4.7.1 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 4349019e..77efd86a 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -25,6 +25,6 @@ jobs: api.github.com:443 github.com:443 - name: 'Checkout Repository' - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 - name: 'Dependency Review' uses: actions/dependency-review-action@6c5ccdad469c9f8a2996bfecaec55a631a347034 # v3.1.0 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 56410fc1..c58f585c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -21,7 +21,7 @@ jobs: github.com:443 pypi.org:443 - - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 - name: Setup Python uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4.7.1 - name: Install tox diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dbda667c..1ebe76fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 with: ref: ${{ github.event.release.tag_name }} - name: Install Python diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index d13e3d2a..f099b350 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -44,7 +44,7 @@ jobs: - name: "Checkout code" - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 with: persist-credentials: false diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 6c67bd37..dbadb281 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -29,7 +29,7 @@ jobs: registry-1.docker.io:443 - name: Checkout - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 - name: Use Python ${{ matrix.python }} uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4.7.1 with: From b247895c3e855b2da6367bd8cc6c39d6f1674bf1 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Thu, 19 Oct 2023 20:20:03 +0200 Subject: [PATCH 090/181] chore(deps): update pypa/gh-action-pypi-publish digest to 79739dc (#277) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ebe76fb..2a85f561 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: - name: Build distributions run: python -m build - name: Publish - uses: pypa/gh-action-pypi-publish@8cdc2ab67c943c5edf5fd9ae1995546b4b550602 # main + uses: pypa/gh-action-pypi-publish@79739dc2f2bf6bcfd21ecf9af9f06bd643dbeeae # main with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} From b22e1d171feb0263775e2a4fa32c580b499bdced Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Wed, 1 Nov 2023 07:21:47 +0100 Subject: [PATCH 091/181] chore(deps): update all non-major dependencies (#285) --- .github/workflows/codeql.yml | 8 ++++---- .github/workflows/conformance.yml | 2 +- .github/workflows/dependency-review.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/scorecard.yml | 6 +++--- .github/workflows/unit.yml | 2 +- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f6f405f2..4c8e2f92 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -53,11 +53,11 @@ jobs: objects.githubusercontent.com:443 - name: Checkout repository - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@d90b8d79de6dc1f58e83a1499aa58d6c93dc28de # v2.22.2 + uses: github/codeql-action/init@74483a38d39275f33fcff5f35b679b5ca4a26a99 # v2.22.5 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -67,7 +67,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@d90b8d79de6dc1f58e83a1499aa58d6c93dc28de # v2.22.2 + uses: github/codeql-action/autobuild@74483a38d39275f33fcff5f35b679b5ca4a26a99 # v2.22.5 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -80,6 +80,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@d90b8d79de6dc1f58e83a1499aa58d6c93dc28de # v2.22.2 + uses: github/codeql-action/analyze@74483a38d39275f33fcff5f35b679b5ca4a26a99 # v2.22.5 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 9998536d..2647410b 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -30,7 +30,7 @@ jobs: storage.googleapis.com:443 - name: Checkout code - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: Setup Python uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4.7.1 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 77efd86a..088ebccf 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -25,6 +25,6 @@ jobs: api.github.com:443 github.com:443 - name: 'Checkout Repository' - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: 'Dependency Review' uses: actions/dependency-review-action@6c5ccdad469c9f8a2996bfecaec55a631a347034 # v3.1.0 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c58f585c..a504c985 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -21,7 +21,7 @@ jobs: github.com:443 pypi.org:443 - - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: Setup Python uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4.7.1 - name: Install tox diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a85f561..65779b28 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: ref: ${{ github.event.release.tag_name }} - name: Install Python diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index f099b350..7130693e 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -44,12 +44,12 @@ jobs: - name: "Checkout code" - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@483ef80eb98fb506c348f7d62e28055e49fe2398 # v2.3.0 + uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1 with: results_file: results.sarif results_format: sarif @@ -61,6 +61,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@d90b8d79de6dc1f58e83a1499aa58d6c93dc28de # v2.22.2 + uses: github/codeql-action/upload-sarif@74483a38d39275f33fcff5f35b679b5ca4a26a99 # v2.22.5 with: sarif_file: results.sarif diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index dbadb281..f9cd712b 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -29,7 +29,7 @@ jobs: registry-1.docker.io:443 - name: Checkout - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: Use Python ${{ matrix.python }} uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4.7.1 with: From 366ca3f27d15c5566c39eeb164ba6228e2b2cfd3 Mon Sep 17 00:00:00 2001 From: Chi Zhang Date: Wed, 1 Nov 2023 13:55:46 -0700 Subject: [PATCH 092/181] ci: Update python matrix (#275) Signed-off-by: GitHub authored-by: chizhg --- .github/workflows/conformance.yml | 2 +- .github/workflows/unit.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 2647410b..886b65f7 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: ['3.7', '3.8', '3.9', '3.10', '3.11'] + python: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] steps: - name: Harden Runner uses: step-security/harden-runner@1b05615854632b887b69ae1be8cbefe72d3ae423 # v2.6.0 diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index f9cd712b..de31a760 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -11,7 +11,7 @@ jobs: test: strategy: matrix: - python: ['3.7', '3.8', '3.9', '3.10', '3.11'] + python: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] platform: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.platform }} steps: From 6b9e9b56f2f364e9a19fd434d88a0fbe22808515 Mon Sep 17 00:00:00 2001 From: HKWinterhalter Date: Mon, 27 Nov 2023 15:04:58 -0800 Subject: [PATCH 093/181] docs: Fix broken Flask Request link in README.md (#286) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b32b2e3e..a234586c 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ def hello(request: flask.Request) -> flask.typing.ResponseReturnValue: return "Hello world!" ``` -> Your function is passed a single parameter, `(request)`, which is a Flask [`Request`](http://flask.pocoo.org/docs/1.0/api/#flask.Request) object. +> Your function is passed a single parameter, `(request)`, which is a Flask [`Request`](https://flask.palletsprojects.com/en/3.0.x/api/#flask.Request) object. Run the following command: From dcc9a25bad86d91024ee758b9748b584d4ca833c Mon Sep 17 00:00:00 2001 From: HKWinterhalter Date: Mon, 27 Nov 2023 16:08:56 -0800 Subject: [PATCH 094/181] chore: Fix file name reference in Dockerfile (#287) --- examples/cloud_run_cloud_events/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/cloud_run_cloud_events/Dockerfile b/examples/cloud_run_cloud_events/Dockerfile index 1bae67aa..08f45150 100644 --- a/examples/cloud_run_cloud_events/Dockerfile +++ b/examples/cloud_run_cloud_events/Dockerfile @@ -12,7 +12,7 @@ COPY . . # Install production dependencies. RUN pip install gunicorn cloudevents functions-framework RUN pip install -r requirements.txt -RUN chmod +x send_cloudevent.py +RUN chmod +x send_cloud_event.py # Run the web service on container startup. CMD ["functions-framework", "--target=hello", "--signature-type=cloudevent"] From 2de849407ec2cf9953b98a2cb34944c438ce39fe Mon Sep 17 00:00:00 2001 From: HKWinterhalter Date: Tue, 28 Nov 2023 14:42:34 -0800 Subject: [PATCH 095/181] chore(deps): update flask dependency range to include v3 (#288) This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | flask ([changelog](https://flask.palletsprojects.com/changes/)) | `>=1.0,<3.0` -> `>=1.0,<4.0` | [![age](https://developer.mend.io/api/mc/badges/age/pypi/flask/3.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/pypi/flask/3.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/pypi/flask/2.3.3/3.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/flask/2.3.3/3.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e3f92e27..77d45587 100644 --- a/setup.py +++ b/setup.py @@ -51,7 +51,7 @@ package_dir={"": "src"}, python_requires=">=3.5, <4", install_requires=[ - "flask>=1.0,<3.0", + "flask>=1.0,<4.0", "click>=7.0,<9.0", "watchdog>=1.0.0", "gunicorn>=19.2.0; platform_system!='Windows'", From c85dc595d9fc1b698df02d15392d243e50dd707b Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Tue, 28 Nov 2023 15:09:12 -0800 Subject: [PATCH 096/181] chore(main): release 3.5.0 (#264) * chore(main): release 3.5.0 * Update CHANGELOG.md to include Flask 3 support message * Update CHANGELOG.md to include Flask 3 support message --------- Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> Co-authored-by: HKWinterhalter --- CHANGELOG.md | 22 ++++++++++++++++++++++ setup.py | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9016fc49..32fe2c35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ 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.5.0](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.4.0...v3.5.0) (2023-11-28) + + +### Features + +* initial typing of the public API ([#248](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/248)) ([45aed53](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/45aed538b5e39655318c7841457399fa3376ceaf)) + + +### Bug Fixes + +* don't exit on reload if there is a syntax error ([#214](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/214)) ([46780da](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/46780dac88c8dfe715babe89f792d08e9ca482e7)) +* reduce gunicorn concurrency to at most 4 * maximum available corâ€Ļ ([#259](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/259)) ([2e04cc2](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/2e04cc28ae028b8facc85dbdf738e2b8076dbbf7)) + + +### Documentation + +* Fix broken Flask Request link in README.md ([#286](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/286)) ([6b9e9b5](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/6b9e9b56f2f364e9a19fd434d88a0fbe22808515)) + +### Dependencies + +* Include support for Flask 3 + ## [3.4.0](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.3.0...v3.4.0) (2023-05-24) diff --git a/setup.py b/setup.py index 77d45587..51a3e27c 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="3.4.0", + version="3.5.0", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown", From b6181e5c90fb142e59f8231ab9cdb24e7c789d9f Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Mon, 4 Dec 2023 23:28:25 +0100 Subject: [PATCH 097/181] chore(deps): update pypa/gh-action-pypi-publish digest to 2f6f737 (#294) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 65779b28..3d8bf13e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: - name: Build distributions run: python -m build - name: Publish - uses: pypa/gh-action-pypi-publish@79739dc2f2bf6bcfd21ecf9af9f06bd643dbeeae # main + uses: pypa/gh-action-pypi-publish@2f6f737ca5f74c637829c0f5c3acd0e29ea5e8bf # main with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} From 85230e418bdfbb8feea101fcaaa995e017cc118e Mon Sep 17 00:00:00 2001 From: Kenneth Rosario Date: Thu, 18 Jan 2024 11:15:40 -0800 Subject: [PATCH 098/181] chore: fix unit test failure (#304) --- tests/test_samples.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_samples.py b/tests/test_samples.py index 65cee7d0..d76d7796 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -24,7 +24,7 @@ def test_cloud_run_http(self): self.stop_all_containers(client) TAG = "cloud_run_http" - client.images.build(path=str(EXAMPLES_DIR / "cloud_run_http"), tag={TAG}) + client.images.build(path=str(EXAMPLES_DIR / "cloud_run_http"), tag=TAG) container = client.containers.run(image=TAG, detach=True, ports={8080: 8080}) timeout = 10 success = False From 8b5c37465d8acb38debe5a2658f17ea956a23079 Mon Sep 17 00:00:00 2001 From: HKWinterhalter Date: Thu, 18 Jan 2024 11:25:49 -0800 Subject: [PATCH 099/181] chore: Update setup.py classifiers (#303) * chore: Update setup.py classifiers Update development status and python versions * chore: Update setup.py classifiers Update development status and python version classifiers --- setup.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index 51a3e27c..3c2c2090 100644 --- a/setup.py +++ b/setup.py @@ -33,16 +33,15 @@ 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"), From 95931fa69de280e13b973c4ab6c3e8791fbae9d1 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Thu, 18 Jan 2024 23:50:22 +0100 Subject: [PATCH 100/181] chore(deps): update all non-major dependencies (#295) Co-authored-by: HKWinterhalter --- .github/workflows/codeql.yml | 8 ++++---- .github/workflows/conformance.yml | 4 ++-- .github/workflows/dependency-review.yml | 4 ++-- .github/workflows/lint.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- .github/workflows/scorecard.yml | 4 ++-- .github/workflows/unit.yml | 4 ++-- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4c8e2f92..0ca62fbe 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,7 +41,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@1b05615854632b887b69ae1be8cbefe72d3ae423 # v2.6.0 + uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 with: disable-sudo: true egress-policy: block @@ -57,7 +57,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@74483a38d39275f33fcff5f35b679b5ca4a26a99 # v2.22.5 + uses: github/codeql-action/init@4759df8df70c5ebe7042c3029bbace20eee13edd # v2.23.1 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -67,7 +67,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@74483a38d39275f33fcff5f35b679b5ca4a26a99 # v2.22.5 + uses: github/codeql-action/autobuild@4759df8df70c5ebe7042c3029bbace20eee13edd # v2.23.1 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -80,6 +80,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@74483a38d39275f33fcff5f35b679b5ca4a26a99 # v2.22.5 + uses: github/codeql-action/analyze@4759df8df70c5ebe7042c3029bbace20eee13edd # v2.23.1 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 886b65f7..79944511 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -16,7 +16,7 @@ jobs: python: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] steps: - name: Harden Runner - uses: step-security/harden-runner@1b05615854632b887b69ae1be8cbefe72d3ae423 # v2.6.0 + uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 with: disable-sudo: true egress-policy: block @@ -33,7 +33,7 @@ jobs: uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: Setup Python - uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4.7.1 + uses: actions/setup-python@b64ffcaf5b410884ad320a9cfac8866006a109aa # v4.8.0 with: python-version: ${{ matrix.python }} diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 088ebccf..439f3e0b 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@1b05615854632b887b69ae1be8cbefe72d3ae423 # v2.6.0 + uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 with: disable-sudo: true egress-policy: block @@ -27,4 +27,4 @@ jobs: - name: 'Checkout Repository' uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: 'Dependency Review' - uses: actions/dependency-review-action@6c5ccdad469c9f8a2996bfecaec55a631a347034 # v3.1.0 + uses: actions/dependency-review-action@c74b580d73376b7750d3d2a50bfb8adc2c937507 # v3.1.5 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a504c985..8bac6754 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@1b05615854632b887b69ae1be8cbefe72d3ae423 # v2.6.0 + uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 with: disable-sudo: true egress-policy: block @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: Setup Python - uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4.7.1 + uses: actions/setup-python@b64ffcaf5b410884ad320a9cfac8866006a109aa # v4.8.0 - name: Install tox run: python -m pip install tox - name: Lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3d8bf13e..3bf8af3f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@1b05615854632b887b69ae1be8cbefe72d3ae423 # v2.6.0 + uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs @@ -22,7 +22,7 @@ jobs: with: ref: ${{ github.event.release.tag_name }} - name: Install Python - uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4.7.1 + uses: actions/setup-python@b64ffcaf5b410884ad320a9cfac8866006a109aa # v4.8.0 - name: Install build dependencies run: python -m pip install -U setuptools build wheel - name: Build distributions diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 7130693e..e3a43a73 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@1b05615854632b887b69ae1be8cbefe72d3ae423 # v2.6.0 + uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 with: disable-sudo: true egress-policy: block @@ -61,6 +61,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@74483a38d39275f33fcff5f35b679b5ca4a26a99 # v2.22.5 + uses: github/codeql-action/upload-sarif@4759df8df70c5ebe7042c3029bbace20eee13edd # v2.23.1 with: sarif_file: results.sarif diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index de31a760..934497be 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -16,7 +16,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@1b05615854632b887b69ae1be8cbefe72d3ae423 # v2.6.0 + uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 with: disable-sudo: true egress-policy: block @@ -31,7 +31,7 @@ jobs: - name: Checkout uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: Use Python ${{ matrix.python }} - uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4.7.1 + uses: actions/setup-python@b64ffcaf5b410884ad320a9cfac8866006a109aa # v4.8.0 with: python-version: ${{ matrix.python }} - name: Install tox From ee1687adf0ea362e8c70dc4897bd47efc61970b2 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Thu, 18 Jan 2024 23:54:56 +0100 Subject: [PATCH 101/181] chore(deps): update github/codeql-action action to v3 (#300) --- .github/workflows/codeql.yml | 6 +++--- .github/workflows/scorecard.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0ca62fbe..234262ef 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -57,7 +57,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@4759df8df70c5ebe7042c3029bbace20eee13edd # v2.23.1 + uses: github/codeql-action/init@0b21cf2492b6b02c465a3e5d7c473717ad7721ba # v3.23.1 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -67,7 +67,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@4759df8df70c5ebe7042c3029bbace20eee13edd # v2.23.1 + uses: github/codeql-action/autobuild@0b21cf2492b6b02c465a3e5d7c473717ad7721ba # v3.23.1 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -80,6 +80,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@4759df8df70c5ebe7042c3029bbace20eee13edd # v2.23.1 + uses: github/codeql-action/analyze@0b21cf2492b6b02c465a3e5d7c473717ad7721ba # v3.23.1 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index e3a43a73..de9b2418 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -61,6 +61,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@4759df8df70c5ebe7042c3029bbace20eee13edd # v2.23.1 + uses: github/codeql-action/upload-sarif@0b21cf2492b6b02c465a3e5d7c473717ad7721ba # v3.23.1 with: sarif_file: results.sarif From 5f66695f5635263f431d9670fa29e16162bebcb8 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Thu, 18 Jan 2024 23:59:50 +0100 Subject: [PATCH 102/181] chore(deps): update actions/setup-go action to v5 (#298) Co-authored-by: HKWinterhalter --- .github/workflows/conformance.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 79944511..03d6b0c6 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -41,7 +41,7 @@ jobs: run: python -m pip install -e . - name: Setup Go - uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 + uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 with: go-version: '1.20' From b03ef3951a03bcefed87a4af8268237ee1400f0d Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Fri, 19 Jan 2024 00:05:31 +0100 Subject: [PATCH 103/181] chore(deps): update actions/setup-python action to v5 (#299) --- .github/workflows/conformance.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/unit.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 03d6b0c6..db33ef01 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -33,7 +33,7 @@ jobs: uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: Setup Python - uses: actions/setup-python@b64ffcaf5b410884ad320a9cfac8866006a109aa # v4.8.0 + uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0 with: python-version: ${{ matrix.python }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8bac6754..90971b86 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: Setup Python - uses: actions/setup-python@b64ffcaf5b410884ad320a9cfac8866006a109aa # v4.8.0 + uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0 - name: Install tox run: python -m pip install tox - name: Lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3bf8af3f..85a8637c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ jobs: with: ref: ${{ github.event.release.tag_name }} - name: Install Python - uses: actions/setup-python@b64ffcaf5b410884ad320a9cfac8866006a109aa # v4.8.0 + uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0 - name: Install build dependencies run: python -m pip install -U setuptools build wheel - name: Build distributions diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 934497be..0d0ed72b 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -31,7 +31,7 @@ jobs: - name: Checkout uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: Use Python ${{ matrix.python }} - uses: actions/setup-python@b64ffcaf5b410884ad320a9cfac8866006a109aa # v4.8.0 + uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0 with: python-version: ${{ matrix.python }} - name: Install tox From e17555304cfaab6494f4a97e0df6d6f9249de4e2 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Fri, 19 Jan 2024 00:15:09 +0100 Subject: [PATCH 104/181] chore(deps): update pypa/gh-action-pypi-publish digest to c12cc61 (#297) Co-authored-by: HKWinterhalter --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 85a8637c..79deaf96 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: - name: Build distributions run: python -m build - name: Publish - uses: pypa/gh-action-pypi-publish@2f6f737ca5f74c637829c0f5c3acd0e29ea5e8bf # main + uses: pypa/gh-action-pypi-publish@c12cc61414480c03e10ea76e2a0a1a17d6c764e2 # main with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} From 3a0cdb8ba1065510d8ca0f2bcabd484cd3ffaaf6 Mon Sep 17 00:00:00 2001 From: June <121135073+KAKAinGoog@users.noreply.github.com> Date: Wed, 14 Feb 2024 13:42:43 -0800 Subject: [PATCH 105/181] feat: avoid long running process when request timeout (#309) Previously function framework use 0 timeout which is actually "no timeout" restrction. This was causing a problem that when user provides a request timeout to Cloud function, process will still continue and consume resources. In this fix, timeout is enabled; default timeout settings is 5 min, same as Cloud run. To make sure timeout settings will be respected, default settings switched from multi-threads to multi-workers. However, user is still allowed to customize workers/threads by assigning env var. But user need to note that timeout won't work when #thread > 1. --- src/functions_framework/__init__.py | 12 ++++++------ src/functions_framework/_http/gunicorn.py | 7 ++++--- src/functions_framework/_typed_event.py | 6 +++--- tests/test_http.py | 12 ++++++------ 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index ece4f446..8c23e5c0 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -65,9 +65,9 @@ def write(self, out): def cloud_event(func: CloudEventFunction) -> CloudEventFunction: """Decorator that registers cloudevent as user function signature type.""" - _function_registry.REGISTRY_MAP[ - func.__name__ - ] = _function_registry.CLOUDEVENT_SIGNATURE_TYPE + _function_registry.REGISTRY_MAP[func.__name__] = ( + _function_registry.CLOUDEVENT_SIGNATURE_TYPE + ) @functools.wraps(func) def wrapper(*args, **kwargs): @@ -105,9 +105,9 @@ def wrapper(*args, **kwargs): def http(func: HTTPFunction) -> HTTPFunction: """Decorator that registers http as user function signature type.""" - _function_registry.REGISTRY_MAP[ - func.__name__ - ] = _function_registry.HTTP_SIGNATURE_TYPE + _function_registry.REGISTRY_MAP[func.__name__] = ( + _function_registry.HTTP_SIGNATURE_TYPE + ) @functools.wraps(func) def wrapper(*args, **kwargs): diff --git a/src/functions_framework/_http/gunicorn.py b/src/functions_framework/_http/gunicorn.py index 3a9c545b..009a06b7 100644 --- a/src/functions_framework/_http/gunicorn.py +++ b/src/functions_framework/_http/gunicorn.py @@ -21,14 +21,15 @@ class GunicornApplication(gunicorn.app.base.BaseApplication): def __init__(self, app, host, port, debug, **options): self.options = { "bind": "%s:%s" % (host, port), - "workers": 1, - "threads": (os.cpu_count() or 1) * 4, - "timeout": 0, + "workers": os.environ.get("WORKERS", (os.cpu_count() or 1) * 4), + "threads": os.environ.get("THREADS", 1), + "timeout": os.environ.get("CLOUD_RUN_TIMEOUT_SECONDS", 300), "loglevel": "error", "limit_request_line": 0, } self.options.update(options) self.app = app + super().__init__() def load_config(self): diff --git a/src/functions_framework/_typed_event.py b/src/functions_framework/_typed_event.py index 40e715ae..413c8f05 100644 --- a/src/functions_framework/_typed_event.py +++ b/src/functions_framework/_typed_event.py @@ -48,9 +48,9 @@ def register_typed_event(decorator_type, func): ) _function_registry.INPUT_TYPE_MAP[func.__name__] = input_type - _function_registry.REGISTRY_MAP[ - func.__name__ - ] = _function_registry.TYPED_SIGNATURE_TYPE + _function_registry.REGISTRY_MAP[func.__name__] = ( + _function_registry.TYPED_SIGNATURE_TYPE + ) """ Checks whether the response type of the typed function has a to_dict method""" diff --git a/tests/test_http.py b/tests/test_http.py index fbfac9d2..0a46fbea 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -97,17 +97,17 @@ def test_gunicorn_application(debug): assert gunicorn_app.app == app assert gunicorn_app.options == { "bind": "%s:%s" % (host, port), - "workers": 1, - "threads": os.cpu_count() * 4, - "timeout": 0, + "workers": os.cpu_count() * 4, + "threads": 1, + "timeout": 300, "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.cfg.workers == os.cpu_count() * 4 + assert gunicorn_app.cfg.threads == 1 + assert gunicorn_app.cfg.timeout == 300 assert gunicorn_app.load() == app From c5c28b61ff829b8defdfe78ec32a4d3ce691de66 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Tue, 20 Feb 2024 22:20:26 +0100 Subject: [PATCH 106/181] chore(deps): update all non-major dependencies (#305) --- .github/workflows/codeql.yml | 8 ++++---- .github/workflows/conformance.yml | 2 +- .github/workflows/dependency-review.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/scorecard.yml | 4 ++-- .github/workflows/unit.yml | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 234262ef..64f617b3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,7 +41,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 + uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 with: disable-sudo: true egress-policy: block @@ -57,7 +57,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@0b21cf2492b6b02c465a3e5d7c473717ad7721ba # v3.23.1 + uses: github/codeql-action/init@379614612a29c9e28f31f39a59013eb8012a51f0 # v3.24.3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -67,7 +67,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@0b21cf2492b6b02c465a3e5d7c473717ad7721ba # v3.23.1 + uses: github/codeql-action/autobuild@379614612a29c9e28f31f39a59013eb8012a51f0 # v3.24.3 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -80,6 +80,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@0b21cf2492b6b02c465a3e5d7c473717ad7721ba # v3.23.1 + uses: github/codeql-action/analyze@379614612a29c9e28f31f39a59013eb8012a51f0 # v3.24.3 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index db33ef01..a6760afa 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -16,7 +16,7 @@ jobs: python: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] steps: - name: Harden Runner - uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 + uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 with: disable-sudo: true egress-policy: block diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 439f3e0b..58c377c2 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 + uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 with: disable-sudo: true egress-policy: block diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 90971b86..e24e995e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 + uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 with: disable-sudo: true egress-policy: block diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 79deaf96..06388579 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 + uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index de9b2418..09ed0cbf 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 + uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 with: disable-sudo: true egress-policy: block @@ -61,6 +61,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@0b21cf2492b6b02c465a3e5d7c473717ad7721ba # v3.23.1 + uses: github/codeql-action/upload-sarif@379614612a29c9e28f31f39a59013eb8012a51f0 # v3.24.3 with: sarif_file: results.sarif diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 0d0ed72b..268abfc1 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -16,7 +16,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 + uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 with: disable-sudo: true egress-policy: block From 8bda09cd8e9addde17b51c28f70238e888e809be Mon Sep 17 00:00:00 2001 From: nifflets <5343516+nifflets@users.noreply.github.com> Date: Mon, 29 Apr 2024 09:39:19 -0700 Subject: [PATCH 107/181] chore: Fix unit tests (#321) * Fix unit tests Workaround for https://github.com/actions/setup-python/issues/696. macos-latest recently was updated to point to macos-14, which does not support Python 3.7, 3.8, 3.9 on ARM. * Update unit.yml * Update unit.yml --- .github/workflows/unit.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 268abfc1..3a7e1342 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -13,6 +13,22 @@ jobs: matrix: python: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] platform: [ubuntu-latest, macos-latest, windows-latest] + # Python <= 3.9 is not available on macos-14 + # Workaround for https://github.com/actions/setup-python/issues/696 + exclude: + - platform: macos-latest + python: '3.9' + - platform: macos-latest + python: '3.8' + - platform: macos-latest + python: '3.7' + include: + - platform: macos-latest + python: '3.9' + - platform: macos-13 + python: '3.8' + - platform: macos-13 + python: '3.7' runs-on: ${{ matrix.platform }} steps: - name: Harden Runner From 5fc2e46bd899ecd4d542fd587c8ad14ad8a1eba9 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Mon, 29 Apr 2024 18:44:15 +0200 Subject: [PATCH 108/181] chore(deps): update actions/dependency-review-action action to v4 (#306) Co-authored-by: Matthew Robertson --- .github/workflows/dependency-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 58c377c2..87b7e199 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -27,4 +27,4 @@ jobs: - name: 'Checkout Repository' uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: 'Dependency Review' - uses: actions/dependency-review-action@c74b580d73376b7750d3d2a50bfb8adc2c937507 # v3.1.5 + uses: actions/dependency-review-action@5bbc3ba658137598168acb2ab73b21c432dd411b # v4.2.5 From 59fdb1e392241bc7bff6b738eebad5f1887ab8a8 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Mon, 29 Apr 2024 19:07:19 +0200 Subject: [PATCH 109/181] chore(deps): update all non-major dependencies (#314) Co-authored-by: Matthew Robertson --- .github/workflows/codeql.yml | 8 ++++---- .github/workflows/conformance.yml | 4 ++-- .github/workflows/dependency-review.yml | 2 +- .github/workflows/lint.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- .github/workflows/scorecard.yml | 4 ++-- .github/workflows/unit.yml | 4 ++-- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 64f617b3..8be2b791 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -53,11 +53,11 @@ jobs: objects.githubusercontent.com:443 - name: Checkout repository - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@379614612a29c9e28f31f39a59013eb8012a51f0 # v3.24.3 + uses: github/codeql-action/init@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -67,7 +67,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@379614612a29c9e28f31f39a59013eb8012a51f0 # v3.24.3 + uses: github/codeql-action/autobuild@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -80,6 +80,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@379614612a29c9e28f31f39a59013eb8012a51f0 # v3.24.3 + uses: github/codeql-action/analyze@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index a6760afa..80ad8afb 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -30,10 +30,10 @@ jobs: storage.googleapis.com:443 - name: Checkout code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 - name: Setup Python - uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0 + uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 with: python-version: ${{ matrix.python }} diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 87b7e199..a79373ca 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -25,6 +25,6 @@ jobs: api.github.com:443 github.com:443 - name: 'Checkout Repository' - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 - name: 'Dependency Review' uses: actions/dependency-review-action@5bbc3ba658137598168acb2ab73b21c432dd411b # v4.2.5 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e24e995e..d560d95f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -21,9 +21,9 @@ jobs: github.com:443 pypi.org:443 - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 - name: Setup Python - uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0 + uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 - name: Install tox run: python -m pip install tox - name: Lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 06388579..63b9f7f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,11 +18,11 @@ jobs: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 with: ref: ${{ github.event.release.tag_name }} - name: Install Python - uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0 + uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 - name: Install build dependencies run: python -m pip install -U setuptools build wheel - name: Build distributions diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 09ed0cbf..150d7e49 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -44,7 +44,7 @@ jobs: - name: "Checkout code" - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 with: persist-credentials: false @@ -61,6 +61,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@379614612a29c9e28f31f39a59013eb8012a51f0 # v3.24.3 + uses: github/codeql-action/upload-sarif@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 with: sarif_file: results.sarif diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 3a7e1342..f396ca2c 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -45,9 +45,9 @@ jobs: registry-1.docker.io:443 - name: Checkout - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 - name: Use Python ${{ matrix.python }} - uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0 + uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 with: python-version: ${{ matrix.python }} - name: Install tox From dfc50594be5e5365ec31c8e1417d49085ff36ce3 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Mon, 29 Apr 2024 15:00:46 -0700 Subject: [PATCH 110/181] chore(main): release 3.6.0 (#311) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ setup.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32fe2c35..23665c2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.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) diff --git a/setup.py b/setup.py index 3c2c2090..6ee9902f 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="3.5.0", + version="3.6.0", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown", From 662bf4ced9aa52efe774662a0f0f496d3d3534fc Mon Sep 17 00:00:00 2001 From: nifflets <5343516+nifflets@users.noreply.github.com> Date: Tue, 7 May 2024 13:54:12 -0700 Subject: [PATCH 111/181] feat: Add execution id (#320) * feat: Add execution id Adds an execution id for each request. When the LOG_EXECUTION_ID env var is set, the execution id will be included in logs. --- setup.py | 1 + src/functions_framework/__init__.py | 37 ++- src/functions_framework/execution_id.py | 156 ++++++++++ tests/test_execution_id.py | 343 ++++++++++++++++++++++ tests/test_functions/execution_id/main.py | 33 +++ tests/test_view_functions.py | 2 +- 6 files changed, 570 insertions(+), 2 deletions(-) create mode 100644 src/functions_framework/execution_id.py create mode 100644 tests/test_execution_id.py create mode 100644 tests/test_functions/execution_id/main.py diff --git a/setup.py b/setup.py index 6ee9902f..d4a9fea5 100644 --- a/setup.py +++ b/setup.py @@ -55,6 +55,7 @@ "watchdog>=1.0.0", "gunicorn>=19.2.0; platform_system!='Windows'", "cloudevents>=1.2.0,<2.0.0", + "Werkzeug>=0.14,<4.0.0", ], entry_points={ "console_scripts": [ diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 8c23e5c0..7474c01e 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -17,6 +17,8 @@ import io import json import logging +import logging.config +import os import os.path import pathlib import sys @@ -32,7 +34,12 @@ from cloudevents.http import from_http, is_binary from cloudevents.http.event import CloudEvent -from functions_framework import _function_registry, _typed_event, event_conversion +from functions_framework import ( + _function_registry, + _typed_event, + event_conversion, + execution_id, +) from functions_framework.background_event import BackgroundEvent from functions_framework.exceptions import ( EventConversionException, @@ -129,6 +136,7 @@ def setup_logging(): def _http_view_func_wrapper(function, request): + @execution_id.set_execution_context(request, _enable_execution_id_logging()) @functools.wraps(function) def view_func(path): return function(request._get_current_object()) @@ -143,6 +151,7 @@ def _run_cloud_event(function, request): def _typed_event_func_wrapper(function, request, inputType: Type): + @execution_id.set_execution_context(request, _enable_execution_id_logging()) def view_func(path): try: data = request.get_json() @@ -163,6 +172,7 @@ def view_func(path): def _cloud_event_view_func_wrapper(function, request): + @execution_id.set_execution_context(request, _enable_execution_id_logging()) def view_func(path): ce_exception = None event = None @@ -198,6 +208,7 @@ def view_func(path): def _event_view_func_wrapper(function, request): + @execution_id.set_execution_context(request, _enable_execution_id_logging()) def view_func(path): if event_conversion.is_convertable_cloud_event(request): # Convert this CloudEvent to the equivalent background event data and context. @@ -332,6 +343,9 @@ def create_app(target=None, source=None, signature_type=None): source_module, spec = _function_registry.load_function_module(source) + if _enable_execution_id_logging(): + _configure_app_execution_id_logging() + # Create the application _app = flask.Flask(target, template_folder=template_folder) _app.register_error_handler(500, crash_handler) @@ -355,6 +369,7 @@ def handle_none(rv): sys.stderr = _LoggingHandler("ERROR", sys.stderr) setup_logging() + _app.wsgi_app = execution_id.WsgiMiddleware(_app.wsgi_app) # Execute the module, within the application context with _app.app_context(): try: @@ -411,6 +426,26 @@ 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": "INFO", "handlers": ["wsgi"]}, + } + ) + + +def _enable_execution_id_logging(): + return os.environ.get("LOG_EXECUTION_ID") + + app = LazyWSGIApp() diff --git a/src/functions_framework/execution_id.py b/src/functions_framework/execution_id.py new file mode 100644 index 00000000..2b106531 --- /dev/null +++ b/src/functions_framework/execution_id.py @@ -0,0 +1,156 @@ +# 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 functools +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__) + + +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(): + return ( + flask.g.execution_id_context + if flask.has_request_context() and "execution_id_context" in flask.g + else None + ) + + +def _set_current_context(context): + 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) + ) + + +# 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) + + +# Sets execution id and span id for the request +def set_execution_context(request, enable_id_logging=False): + 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): + trace_context = re.match( + _TRACE_CONTEXT_REGEX_PATTERN, + request.headers.get(TRACE_CONTEXT_REQUEST_HEADER, ""), + ) + execution_id = request.headers.get(EXECUTION_ID_REQUEST_HEADER) + span_id = trace_context.group("span_id") if trace_context else None + _set_current_context(ExecutionContext(execution_id, span_id)) + + with stderr_redirect, stdout_redirect: + return view_function(*args, **kwargs) + + return 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/tests/test_execution_id.py b/tests/test_execution_id.py new file mode 100644 index 00000000..bfddfc3c --- /dev/null +++ b/tests/test_execution_id.py @@ -0,0 +1,343 @@ +# 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_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) + + assert logs_as_json == expected_logs diff --git a/tests/test_functions/execution_id/main.py b/tests/test_functions/execution_id/main.py new file mode 100644 index 00000000..72d1eaff --- /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.info(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.info(message) + time.sleep(1) + logger.info(message) + return "success", 200 diff --git a/tests/test_view_functions.py b/tests/test_view_functions.py index f69b2155..a32fe9e4 100644 --- a/tests/test_view_functions.py +++ b/tests/test_view_functions.py @@ -25,7 +25,7 @@ def test_http_view_func_wrapper(): function = pretend.call_recorder(lambda request: "Hello") request_object = pretend.stub() - local_proxy = pretend.stub(_get_current_object=lambda: request_object) + local_proxy = pretend.stub(_get_current_object=lambda: request_object, headers={}) view_func = functions_framework._http_view_func_wrapper(function, local_proxy) view_func("/some/path") From 2e7de92e5f9cd83f01222eb06385d66fe0211777 Mon Sep 17 00:00:00 2001 From: nifflets <5343516+nifflets@users.noreply.github.com> Date: Mon, 13 May 2024 11:36:19 -0700 Subject: [PATCH 112/181] feat: support disabling execution id logging (#325) * feat: support disabling execution id logging * Update test_execution_id.py * Update __init__.py * Update __init__.py --- src/functions_framework/__init__.py | 5 +++- tests/test_execution_id.py | 46 ++++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 7474c01e..df4683cb 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -443,7 +443,10 @@ def _configure_app_execution_id_logging(): def _enable_execution_id_logging(): - return os.environ.get("LOG_EXECUTION_ID") + # Based on distutils.util.strtobool + truthy_values = ("y", "yes", "t", "true", "on", "1") + env_var_value = os.environ.get("LOG_EXECUTION_ID") + return env_var_value in truthy_values app = LazyWSGIApp() diff --git a/tests/test_execution_id.py b/tests/test_execution_id.py index bfddfc3c..c650ee31 100644 --- a/tests/test_execution_id.py +++ b/tests/test_execution_id.py @@ -46,7 +46,7 @@ def test_user_function_can_retrieve_execution_id_from_header(): def test_uncaught_exception_in_user_function_sets_execution_id(capsys, monkeypatch): - monkeypatch.setenv("LOG_EXECUTION_ID", "True") + monkeypatch.setenv("LOG_EXECUTION_ID", "true") source = TEST_FUNCTIONS_DIR / "execution_id" / "main.py" target = "error" app = create_app(target, source) @@ -64,7 +64,7 @@ def test_uncaught_exception_in_user_function_sets_execution_id(capsys, monkeypat def test_print_from_user_function_sets_execution_id(capsys, monkeypatch): - monkeypatch.setenv("LOG_EXECUTION_ID", "True") + monkeypatch.setenv("LOG_EXECUTION_ID", "true") source = TEST_FUNCTIONS_DIR / "execution_id" / "main.py" target = "print_message" app = create_app(target, source) @@ -83,7 +83,7 @@ def test_print_from_user_function_sets_execution_id(capsys, monkeypatch): def test_log_from_user_function_sets_execution_id(capsys, monkeypatch): - monkeypatch.setenv("LOG_EXECUTION_ID", "True") + monkeypatch.setenv("LOG_EXECUTION_ID", "true") source = TEST_FUNCTIONS_DIR / "execution_id" / "main.py" target = "log_message" app = create_app(target, source) @@ -136,6 +136,44 @@ def test_does_not_set_execution_id_when_not_enabled(capsys): 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() @@ -283,7 +321,7 @@ def test_log_handler_omits_empty_execution_context(monkeypatch, capsys): @pytest.mark.asyncio async def test_maintains_execution_id_for_concurrent_requests(monkeypatch, capsys): - monkeypatch.setenv("LOG_EXECUTION_ID", "True") + monkeypatch.setenv("LOG_EXECUTION_ID", "true") monkeypatch.setattr( execution_id, "_generate_execution_id", From f08757a17267d768e4c3ca4c6979f2a7db25e83c Mon Sep 17 00:00:00 2001 From: jrmfg <117788025+jrmfg@users.noreply.github.com> Date: Thu, 16 May 2024 12:41:34 -0700 Subject: [PATCH 113/181] feat: restore gunicorn worker default configs from 3.5.0 (#326) * feat: restore defaults present < 3.6.0, but retain customizability * revert the test, too * also restore this assert :) --------- Co-authored-by: Jeremy Fehr --- src/functions_framework/_http/gunicorn.py | 6 +++--- tests/test_http.py | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/functions_framework/_http/gunicorn.py b/src/functions_framework/_http/gunicorn.py index 009a06b7..050f766c 100644 --- a/src/functions_framework/_http/gunicorn.py +++ b/src/functions_framework/_http/gunicorn.py @@ -21,9 +21,9 @@ class GunicornApplication(gunicorn.app.base.BaseApplication): def __init__(self, app, host, port, debug, **options): self.options = { "bind": "%s:%s" % (host, port), - "workers": os.environ.get("WORKERS", (os.cpu_count() or 1) * 4), - "threads": os.environ.get("THREADS", 1), - "timeout": os.environ.get("CLOUD_RUN_TIMEOUT_SECONDS", 300), + "workers": os.environ.get("WORKERS", 1), + "threads": os.environ.get("THREADS", (os.cpu_count() or 1) * 4), + "timeout": os.environ.get("CLOUD_RUN_TIMEOUT_SECONDS", 0), "loglevel": "error", "limit_request_line": 0, } diff --git a/tests/test_http.py b/tests/test_http.py index 0a46fbea..fbfac9d2 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -97,17 +97,17 @@ def test_gunicorn_application(debug): assert gunicorn_app.app == app assert gunicorn_app.options == { "bind": "%s:%s" % (host, port), - "workers": os.cpu_count() * 4, - "threads": 1, - "timeout": 300, + "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 == os.cpu_count() * 4 - assert gunicorn_app.cfg.threads == 1 - assert gunicorn_app.cfg.timeout == 300 + 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 From 0ecd98507b4ee458e2aea858119255f9f1dc1cea Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Thu, 16 May 2024 21:57:01 +0200 Subject: [PATCH 114/181] chore(deps): update all non-major dependencies (#322) Co-authored-by: jrmfg <117788025+jrmfg@users.noreply.github.com> --- .github/workflows/codeql.yml | 10 +++++----- .github/workflows/conformance.yml | 6 +++--- .github/workflows/dependency-review.yml | 6 +++--- .github/workflows/lint.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- .github/workflows/scorecard.yml | 8 ++++---- .github/workflows/unit.yml | 4 ++-- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8be2b791..f2ba168f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,7 +41,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 + uses: step-security/harden-runner@a4aa98b93cab29d9b1101a6143fb8bce00e2eac4 # v2.7.1 with: disable-sudo: true egress-policy: block @@ -53,11 +53,11 @@ jobs: objects.githubusercontent.com:443 - name: Checkout repository - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 + uses: github/codeql-action/init@b7cec7526559c32f1616476ff32d17ba4c59b2d6 # v3.25.5 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -67,7 +67,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 + uses: github/codeql-action/autobuild@b7cec7526559c32f1616476ff32d17ba4c59b2d6 # v3.25.5 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -80,6 +80,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 + uses: github/codeql-action/analyze@b7cec7526559c32f1616476ff32d17ba4c59b2d6 # v3.25.5 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 80ad8afb..9369f779 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -16,7 +16,7 @@ jobs: python: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] steps: - name: Harden Runner - uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 + uses: step-security/harden-runner@a4aa98b93cab29d9b1101a6143fb8bce00e2eac4 # v2.7.1 with: disable-sudo: true egress-policy: block @@ -30,7 +30,7 @@ jobs: storage.googleapis.com:443 - name: Checkout code - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 - name: Setup Python uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 @@ -41,7 +41,7 @@ jobs: run: python -m pip install -e . - name: Setup Go - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 + uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7 # v5.0.1 with: go-version: '1.20' diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index a79373ca..29b80a11 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 + uses: step-security/harden-runner@a4aa98b93cab29d9b1101a6143fb8bce00e2eac4 # v2.7.1 with: disable-sudo: true egress-policy: block @@ -25,6 +25,6 @@ jobs: api.github.com:443 github.com:443 - name: 'Checkout Repository' - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 - name: 'Dependency Review' - uses: actions/dependency-review-action@5bbc3ba658137598168acb2ab73b21c432dd411b # v4.2.5 + uses: actions/dependency-review-action@0c155c5e8556a497adf53f2c18edabf945ed8e70 # v4.3.2 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d560d95f..efbce442 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 + uses: step-security/harden-runner@a4aa98b93cab29d9b1101a6143fb8bce00e2eac4 # v2.7.1 with: disable-sudo: true egress-policy: block @@ -21,7 +21,7 @@ jobs: github.com:443 pypi.org:443 - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 - name: Setup Python uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 - name: Install tox diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 63b9f7f8..3eea6984 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,12 +13,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 + uses: step-security/harden-runner@a4aa98b93cab29d9b1101a6143fb8bce00e2eac4 # v2.7.1 with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 with: ref: ${{ github.event.release.tag_name }} - name: Install Python diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 150d7e49..6b89ddbc 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 + uses: step-security/harden-runner@a4aa98b93cab29d9b1101a6143fb8bce00e2eac4 # v2.7.1 with: disable-sudo: true egress-policy: block @@ -44,12 +44,12 @@ jobs: - name: "Checkout code" - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1 + uses: ossf/scorecard-action@dc50aa9510b46c811795eb24b2f1ba02a914e534 # v2.3.3 with: results_file: results.sarif results_format: sarif @@ -61,6 +61,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 + uses: github/codeql-action/upload-sarif@b7cec7526559c32f1616476ff32d17ba4c59b2d6 # v3.25.5 with: sarif_file: results.sarif diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index f396ca2c..7943f75a 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -32,7 +32,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 + uses: step-security/harden-runner@a4aa98b93cab29d9b1101a6143fb8bce00e2eac4 # v2.7.1 with: disable-sudo: true egress-policy: block @@ -45,7 +45,7 @@ jobs: registry-1.docker.io:443 - name: Checkout - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 - name: Use Python ${{ matrix.python }} uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 with: From 3d4389229cfa0bc9bf29783a9f831525a2d1aaea Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Thu, 16 May 2024 22:03:34 +0200 Subject: [PATCH 115/181] chore(deps): update pypa/gh-action-pypi-publish digest to 699cd61 (#313) Co-authored-by: jrmfg <117788025+jrmfg@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3eea6984..9c11e448 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: - name: Build distributions run: python -m build - name: Publish - uses: pypa/gh-action-pypi-publish@c12cc61414480c03e10ea76e2a0a1a17d6c764e2 # main + uses: pypa/gh-action-pypi-publish@699cd6103f50bf5c3b2f070c70712d109c168e6c # main with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} From fff38ae6ecb1054bad676900216663050e6edf10 Mon Sep 17 00:00:00 2001 From: jrmfg <117788025+jrmfg@users.noreply.github.com> Date: Thu, 16 May 2024 13:34:44 -0700 Subject: [PATCH 116/181] fix: update scorecard.yml (#327) needed for security scorecard --- .github/workflows/scorecard.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 6b89ddbc..dde198ab 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -36,6 +36,7 @@ jobs: api.securityscorecards.dev:443 auth.docker.io:443 bestpractices.coreinfrastructure.org:443 + bestpractices.dev:443 github.com:443 index.docker.io:443 oss-fuzz-build-logs.storage.googleapis.com:443 From 2601975285386fc573de8033381edc99527ef3c9 Mon Sep 17 00:00:00 2001 From: Jeremy Fehr <117788025+jrmfg@users.noreply.github.com> Date: Fri, 17 May 2024 13:11:33 -0700 Subject: [PATCH 117/181] feat: (opt-in): terminate handling of work when the request has already timed out (#328) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overhead-free (or at least very cheap). The “timeout” gunicorn config means drastically different things for sync and non-sync workers: > Workers silent for more than this many seconds are killed and restarted. > > Value is a positive number or 0. Setting it to 0 has the effect of > infinite timeouts by disabling timeouts for all workers entirely. > > Generally, the default of thirty seconds should suffice. Only set this > noticeably higher if you’re sure of the repercussions for sync workers. > For the non sync workers it just means that the worker process is still > communicating and is not tied to the length of time required to handle a > single request. So. For cases where threads = 1 (user set or our defaults), we’ll use the sync worker and let the regular timeout functionality do its thing. For cases where threads > 1, we’re using the gthread worker, and timeout means something completely different and not really user-observable. So we’ll leave the communication timeout (default gunicorn “timeout”) at 30 seconds, but create our own gthread-derived worker class to use instead, which terminates request handling (with no mind to gunicorn’s “graceful shutdown” config), to emulate GCFv1. The arbiter spawns these workers, so we have to maintain some sort of global timeout state for us to read in our custom gthread worker. In the future, we should consider letting the user adjust the graceful shutdown seconds. But the default of 30 seems like it’s worked fine historically, so it’s hard to argue for changing it. IIUC, this means that on gen 2, there’s a small behavior difference for the sync workers compared to gen 1, in that gen 2 sync worker workloads will get an extra 30 seconds of timeout to gracefully shut down. I don’t think monkeying with this config and opting-in to sync workers is very common, though, so let’s not worry about it here; everyone should be on the gthread path outlined above. * fix tests * small test fixes give up on coverage support for things that are tested in different processes, or in gthread, because it looks like pytest-cov gave up on support for these, where as coverage has out-of-the-box support * format * isort everything * skip tests on mac there's something test-specific about how mac pickles functions for execution in multiprocessing.Process which is causing problems. it seems somewhere in the innards of flask and gunicorn and macos... since this feature is opt-in anyway, let's just skip testing darwin. * sort tuple of dicts in async tests before asserting causes flakes sometimes in workflows * use double-quotes * also skip tests on windows - this is all built for gunicorn, there's no value adding it for windows anyway * skip import on windows * easy stuff * add a few tests for sync worker timeouts these shouldn't have changed with this commit --- .../send_cloud_event.py | 2 +- playground/main.py | 16 ++ src/functions_framework/_http/gunicorn.py | 42 ++- src/functions_framework/exceptions.py | 6 +- src/functions_framework/request_timeout.py | 42 +++ tests/test_execution_id.py | 3 +- tests/test_functions/timeout/main.py | 12 + tests/test_timeouts.py | 265 ++++++++++++++++++ tox.ini | 1 + 9 files changed, 381 insertions(+), 8 deletions(-) create mode 100644 playground/main.py create mode 100644 src/functions_framework/request_timeout.py create mode 100644 tests/test_functions/timeout/main.py create mode 100644 tests/test_timeouts.py diff --git a/examples/cloud_run_cloud_events/send_cloud_event.py b/examples/cloud_run_cloud_events/send_cloud_event.py index b523c31a..c08b8f93 100644 --- a/examples/cloud_run_cloud_events/send_cloud_event.py +++ b/examples/cloud_run_cloud_events/send_cloud_event.py @@ -13,9 +13,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -from cloudevents.http import CloudEvent, to_structured import requests +from cloudevents.http import CloudEvent, to_structured # Create a cloudevent using https://github.com/cloudevents/sdk-python # Note we only need source and type because the cloudevents constructor by diff --git a/playground/main.py b/playground/main.py new file mode 100644 index 00000000..b974ddbc --- /dev/null +++ b/playground/main.py @@ -0,0 +1,16 @@ +import logging +import time + +import functions_framework + +logger = logging.getLogger(__name__) + + +@functions_framework.http +def main(request): + timeout = 2 + for _ in range(timeout * 10): + time.sleep(0.1) + logger.info("logging message after timeout elapsed") + return "Hello, world!" + diff --git a/src/functions_framework/_http/gunicorn.py b/src/functions_framework/_http/gunicorn.py index 050f766c..92cad90e 100644 --- a/src/functions_framework/_http/gunicorn.py +++ b/src/functions_framework/_http/gunicorn.py @@ -1,4 +1,4 @@ -# Copyright 2020 Google LLC +# Copyright 2024 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -16,17 +16,43 @@ 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": os.environ.get("WORKERS", 1), - "threads": os.environ.get("THREADS", (os.cpu_count() or 1) * 4), - "timeout": os.environ.get("CLOUD_RUN_TIMEOUT_SECONDS", 0), - "loglevel": "error", + "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 @@ -38,3 +64,9 @@ def load_config(self): 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) diff --git a/src/functions_framework/exceptions.py b/src/functions_framework/exceptions.py index 671a28a4..81b9f8f0 100644 --- a/src/functions_framework/exceptions.py +++ b/src/functions_framework/exceptions.py @@ -1,4 +1,4 @@ -# Copyright 2020 Google LLC +# Copyright 2024 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -35,3 +35,7 @@ class MissingTargetException(FunctionsFrameworkException): class EventConversionException(FunctionsFrameworkException): pass + + +class RequestTimeoutException(FunctionsFrameworkException): + pass diff --git a/src/functions_framework/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/test_execution_id.py b/tests/test_execution_id.py index c650ee31..a2601817 100644 --- a/tests/test_execution_id.py +++ b/tests/test_execution_id.py @@ -378,4 +378,5 @@ async def test_maintains_execution_id_for_concurrent_requests(monkeypatch, capsy logs = record.err.strip().split("\n") logs_as_json = tuple(json.loads(log) for log in logs) - assert logs_as_json == expected_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_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_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/tox.ini b/tox.ini index e8c555b5..6ba6d3b4 100644 --- a/tox.ini +++ b/tox.ini @@ -5,6 +5,7 @@ envlist = py{35,36,37,38,39,310}-{ubuntu-latest,macos-latest,windows-latest},lin usedevelop = true deps = docker + pytest-asyncio pytest-cov pytest-integration pretend From 04c1fdc4185b8a97eb46d72b6432d32d5d70dffc Mon Sep 17 00:00:00 2001 From: Jeremy Fehr <117788025+jrmfg@users.noreply.github.com> Date: Fri, 17 May 2024 13:20:22 -0700 Subject: [PATCH 118/181] fix: Update scorecard.yml (#329) --- .github/workflows/scorecard.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index dde198ab..9f84e511 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -33,6 +33,7 @@ jobs: 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 From 0ebea7d818a6dd62aa98df813eed6a79948d6b84 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 22 May 2024 09:48:27 -0700 Subject: [PATCH 119/181] chore(main): release 3.7.0 (#324) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 16 ++++++++++++++++ setup.py | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23665c2e..5f0e13a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ 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.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) diff --git a/setup.py b/setup.py index d4a9fea5..5e529845 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="3.6.0", + version="3.7.0", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown", From 02472e7315d0fd642db26441b3cb21f799906739 Mon Sep 17 00:00:00 2001 From: Jeremy Fehr <117788025+jrmfg@users.noreply.github.com> Date: Wed, 22 May 2024 10:25:28 -0700 Subject: [PATCH 120/181] fix: add www.bestpractices.dev:443 to scorecard (#330) * fix: update scorecard.yml * fix: update scorecard.yml --- .github/workflows/scorecard.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 9f84e511..b7ed460a 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -38,6 +38,7 @@ jobs: 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 From 94763d83fb931c16682acbc978c094de9f6b1aea Mon Sep 17 00:00:00 2001 From: HKWinterhalter Date: Sun, 2 Jun 2024 23:47:03 -0700 Subject: [PATCH 121/181] chore: Update blunderbuss.yml (#333) * Update blunderbuss.yml * chore: Update blunderbuss.yml * chore: Update blunderbuss.yml Remove assignees - to be replaced with other mechanism --- .github/blunderbuss.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/blunderbuss.yml b/.github/blunderbuss.yml index ffa474e5..8b137891 100644 --- a/.github/blunderbuss.yml +++ b/.github/blunderbuss.yml @@ -1,7 +1 @@ -assign_prs: - - HKWinterhalter - - janell-chen -assign_issues: - - HKWinterhalter - - janell-chen From d1d0753b6ea0dcc4222e28fc61002ac563b54cac Mon Sep 17 00:00:00 2001 From: nifflets <5343516+nifflets@users.noreply.github.com> Date: Tue, 11 Jun 2024 10:33:25 -0700 Subject: [PATCH 122/181] feat: Set default logging level to align with Flask's defaults (#336) --- src/functions_framework/__init__.py | 2 +- tests/test_functions/execution_id/main.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index df4683cb..22fbf44c 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -437,7 +437,7 @@ def _configure_app_execution_id_logging(): "stream": "ext://functions_framework.execution_id.logging_stream", }, }, - "root": {"level": "INFO", "handlers": ["wsgi"]}, + "root": {"level": "WARNING", "handlers": ["wsgi"]}, } ) diff --git a/tests/test_functions/execution_id/main.py b/tests/test_functions/execution_id/main.py index 72d1eaff..f6677603 100644 --- a/tests/test_functions/execution_id/main.py +++ b/tests/test_functions/execution_id/main.py @@ -12,7 +12,7 @@ def print_message(request): def log_message(request): json = request.get_json(silent=True) - logger.info(json.get("message")) + logger.warning(json.get("message")) return "success", 200 @@ -27,7 +27,7 @@ def error(request): def sleep(request): json = request.get_json(silent=True) message = json.get("message") - logger.info(message) + logger.warning(message) time.sleep(1) - logger.info(message) + logger.warning(message) return "success", 200 From 7ba78506745c06acb0da39e31e4927dbbd50a07a Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Tue, 25 Jun 2024 18:26:58 +0200 Subject: [PATCH 123/181] chore(deps): update all non-major dependencies (#335) --- .github/workflows/codeql.yml | 10 +++++----- .github/workflows/conformance.yml | 4 ++-- .github/workflows/dependency-review.yml | 6 +++--- .github/workflows/lint.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- .github/workflows/scorecard.yml | 6 +++--- .github/workflows/unit.yml | 4 ++-- 7 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f2ba168f..1159d924 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,7 +41,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@a4aa98b93cab29d9b1101a6143fb8bce00e2eac4 # v2.7.1 + uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1 with: disable-sudo: true egress-policy: block @@ -53,11 +53,11 @@ jobs: objects.githubusercontent.com:443 - name: Checkout repository - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@b7cec7526559c32f1616476ff32d17ba4c59b2d6 # v3.25.5 + uses: github/codeql-action/init@23acc5c183826b7a8a97bce3cecc52db901f8251 # v3.25.10 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -67,7 +67,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@b7cec7526559c32f1616476ff32d17ba4c59b2d6 # v3.25.5 + uses: github/codeql-action/autobuild@23acc5c183826b7a8a97bce3cecc52db901f8251 # v3.25.10 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -80,6 +80,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@b7cec7526559c32f1616476ff32d17ba4c59b2d6 # v3.25.5 + uses: github/codeql-action/analyze@23acc5c183826b7a8a97bce3cecc52db901f8251 # v3.25.10 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 9369f779..a7305644 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -16,7 +16,7 @@ jobs: python: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] steps: - name: Harden Runner - uses: step-security/harden-runner@a4aa98b93cab29d9b1101a6143fb8bce00e2eac4 # v2.7.1 + uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1 with: disable-sudo: true egress-policy: block @@ -30,7 +30,7 @@ jobs: storage.googleapis.com:443 - name: Checkout code - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Setup Python uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 29b80a11..47acd65b 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@a4aa98b93cab29d9b1101a6143fb8bce00e2eac4 # v2.7.1 + uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1 with: disable-sudo: true egress-policy: block @@ -25,6 +25,6 @@ jobs: api.github.com:443 github.com:443 - name: 'Checkout Repository' - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: 'Dependency Review' - uses: actions/dependency-review-action@0c155c5e8556a497adf53f2c18edabf945ed8e70 # v4.3.2 + uses: actions/dependency-review-action@72eb03d02c7872a771aacd928f3123ac62ad6d3a # v4.3.3 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index efbce442..244db273 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@a4aa98b93cab29d9b1101a6143fb8bce00e2eac4 # v2.7.1 + uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1 with: disable-sudo: true egress-policy: block @@ -21,7 +21,7 @@ jobs: github.com:443 pypi.org:443 - - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Setup Python uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 - name: Install tox diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9c11e448..3fb61679 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,12 +13,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@a4aa98b93cab29d9b1101a6143fb8bce00e2eac4 # v2.7.1 + uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1 with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: ref: ${{ github.event.release.tag_name }} - name: Install Python diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index b7ed460a..fb1d6581 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@a4aa98b93cab29d9b1101a6143fb8bce00e2eac4 # v2.7.1 + uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1 with: disable-sudo: true egress-policy: block @@ -47,7 +47,7 @@ jobs: - name: "Checkout code" - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: persist-credentials: false @@ -64,6 +64,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@b7cec7526559c32f1616476ff32d17ba4c59b2d6 # v3.25.5 + uses: github/codeql-action/upload-sarif@23acc5c183826b7a8a97bce3cecc52db901f8251 # v3.25.10 with: sarif_file: results.sarif diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 7943f75a..332d563d 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -32,7 +32,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@a4aa98b93cab29d9b1101a6143fb8bce00e2eac4 # v2.7.1 + uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1 with: disable-sudo: true egress-policy: block @@ -45,7 +45,7 @@ jobs: registry-1.docker.io:443 - name: Checkout - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Use Python ${{ matrix.python }} uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 with: From fb82fb66c79a0591bd8d882bda51425c68a880dc Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Tue, 25 Jun 2024 18:31:33 +0200 Subject: [PATCH 124/181] chore(deps): update pypa/gh-action-pypi-publish digest to ec4db0b (#334) Co-authored-by: Jeremy Fehr <117788025+jrmfg@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3fb61679..710d3ca8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: - name: Build distributions run: python -m build - name: Publish - uses: pypa/gh-action-pypi-publish@699cd6103f50bf5c3b2f070c70712d109c168e6c # main + uses: pypa/gh-action-pypi-publish@ec4db0b4ddc65acdf4bff5fa45ac92d78b56bdf0 # main with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} From 102bc8ab9dbd3926b8791bb9fbedd68762fdd16e Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 26 Jun 2024 10:12:51 -0700 Subject: [PATCH 125/181] chore(main): release 3.8.0 (#332) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> Co-authored-by: Jeremy Fehr <117788025+jrmfg@users.noreply.github.com> --- CHANGELOG.md | 12 ++++++++++++ setup.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f0e13a1..f9209989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.8.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) diff --git a/setup.py b/setup.py index 5e529845..6cfa3fec 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="3.7.0", + version="3.8.0", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown", From d622f137e8a2419fc487c867d67e12d0204b586b Mon Sep 17 00:00:00 2001 From: Jeremy Fehr <117788025+jrmfg@users.noreply.github.com> Date: Fri, 26 Jul 2024 15:40:18 -0700 Subject: [PATCH 126/181] fix: upgrade gunicorn to 22 to fix CVE-2024-1135 (#341) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 6cfa3fec..ff13955c 100644 --- a/setup.py +++ b/setup.py @@ -53,7 +53,7 @@ "flask>=1.0,<4.0", "click>=7.0,<9.0", "watchdog>=1.0.0", - "gunicorn>=19.2.0; platform_system!='Windows'", + "gunicorn>=22.0.0; platform_system!='Windows'", "cloudevents>=1.2.0,<2.0.0", "Werkzeug>=0.14,<4.0.0", ], From 432acc188f4a8809a7cd225e070df97629cb6a74 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Fri, 26 Jul 2024 15:46:10 -0700 Subject: [PATCH 127/181] chore(main): release 3.8.1 (#342) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ setup.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9209989..515ce06e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.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) diff --git a/setup.py b/setup.py index ff13955c..e89e6296 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="3.8.0", + version="3.8.1", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown", From 0f29362e5710de07388cf72889214a5e00931219 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Sat, 27 Jul 2024 01:48:52 +0200 Subject: [PATCH 128/181] chore(deps): update all non-major dependencies (#339) Co-authored-by: Jeremy Fehr <117788025+jrmfg@users.noreply.github.com> --- .github/workflows/codeql.yml | 8 ++++---- .github/workflows/conformance.yml | 6 +++--- .github/workflows/dependency-review.yml | 4 ++-- .github/workflows/lint.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- .github/workflows/scorecard.yml | 6 +++--- .github/workflows/unit.yml | 4 ++-- 7 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1159d924..12fc1e5d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,7 +41,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1 + uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v2.9.0 with: disable-sudo: true egress-policy: block @@ -57,7 +57,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@23acc5c183826b7a8a97bce3cecc52db901f8251 # v3.25.10 + uses: github/codeql-action/init@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -67,7 +67,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@23acc5c183826b7a8a97bce3cecc52db901f8251 # v3.25.10 + uses: github/codeql-action/autobuild@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -80,6 +80,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@23acc5c183826b7a8a97bce3cecc52db901f8251 # v3.25.10 + uses: github/codeql-action/analyze@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index a7305644..6b345d29 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -16,7 +16,7 @@ jobs: python: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] steps: - name: Harden Runner - uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1 + uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v2.9.0 with: disable-sudo: true egress-policy: block @@ -33,7 +33,7 @@ jobs: uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Setup Python - uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 + uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1 with: python-version: ${{ matrix.python }} @@ -41,7 +41,7 @@ jobs: run: python -m pip install -e . - name: Setup Go - uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7 # v5.0.1 + uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: go-version: '1.20' diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 47acd65b..e85dd0e7 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1 + uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v2.9.0 with: disable-sudo: true egress-policy: block @@ -27,4 +27,4 @@ jobs: - name: 'Checkout Repository' uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: 'Dependency Review' - uses: actions/dependency-review-action@72eb03d02c7872a771aacd928f3123ac62ad6d3a # v4.3.3 + uses: actions/dependency-review-action@5a2ce3f5b92ee19cbb1541a4984c76d921601d7c # v4.3.4 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 244db273..6e36e9f2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1 + uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v2.9.0 with: disable-sudo: true egress-policy: block @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Setup Python - uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 + uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1 - name: Install tox run: python -m pip install tox - name: Lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 710d3ca8..de3548af 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1 + uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v2.9.0 with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs @@ -22,7 +22,7 @@ jobs: with: ref: ${{ github.event.release.tag_name }} - name: Install Python - uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 + uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1 - name: Install build dependencies run: python -m pip install -U setuptools build wheel - name: Build distributions diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index fb1d6581..54031979 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1 + uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v2.9.0 with: disable-sudo: true egress-policy: block @@ -52,7 +52,7 @@ jobs: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@dc50aa9510b46c811795eb24b2f1ba02a914e534 # v2.3.3 + uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0 with: results_file: results.sarif results_format: sarif @@ -64,6 +64,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@23acc5c183826b7a8a97bce3cecc52db901f8251 # v3.25.10 + uses: github/codeql-action/upload-sarif@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15 with: sarif_file: results.sarif diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 332d563d..a0bbb3df 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -32,7 +32,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1 + uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v2.9.0 with: disable-sudo: true egress-policy: block @@ -47,7 +47,7 @@ jobs: - name: Checkout uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Use Python ${{ matrix.python }} - uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 + uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1 with: python-version: ${{ matrix.python }} - name: Install tox From 6bc8e5a760c74307cac7735580999629bbd42c03 Mon Sep 17 00:00:00 2001 From: Jeremy Fehr <117788025+jrmfg@users.noreply.github.com> Date: Fri, 26 Jul 2024 16:52:42 -0700 Subject: [PATCH 129/181] chore: remove personal local testing folder (#340) --- playground/main.py | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 playground/main.py diff --git a/playground/main.py b/playground/main.py deleted file mode 100644 index b974ddbc..00000000 --- a/playground/main.py +++ /dev/null @@ -1,16 +0,0 @@ -import logging -import time - -import functions_framework - -logger = logging.getLogger(__name__) - - -@functions_framework.http -def main(request): - timeout = 2 - for _ in range(timeout * 10): - time.sleep(0.1) - logger.info("logging message after timeout elapsed") - return "Hello, world!" - From 7196e9f5b862c76a21cfe851332cd387c6e6a0ed Mon Sep 17 00:00:00 2001 From: Jeremy Fehr <117788025+jrmfg@users.noreply.github.com> Date: Wed, 18 Sep 2024 09:02:23 -0700 Subject: [PATCH 130/181] chore: Update blunderbuss.yml (#344) --- .github/blunderbuss.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/blunderbuss.yml b/.github/blunderbuss.yml index 8b137891..e913bef1 100644 --- a/.github/blunderbuss.yml +++ b/.github/blunderbuss.yml @@ -1 +1,9 @@ +assign_prs: + - HKwinterhalter + - nifflets + - liuyunnnn +assign_issues: + - HKwinterhalter + - nifflets + - liuyunnnn From b01ea7d396f0faab69ccae6a3f7c307ab31a4e4c Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Thu, 19 Sep 2024 22:39:00 +0200 Subject: [PATCH 131/181] chore(deps): update all non-major dependencies (#343) --- .github/workflows/codeql.yml | 8 ++++---- .github/workflows/conformance.yml | 4 ++-- .github/workflows/dependency-review.yml | 2 +- .github/workflows/lint.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- .github/workflows/scorecard.yml | 4 ++-- .github/workflows/unit.yml | 4 ++-- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 12fc1e5d..6b1d2ac3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,7 +41,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v2.9.0 + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 with: disable-sudo: true egress-policy: block @@ -57,7 +57,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15 + uses: github/codeql-action/init@294a9d92911152fe08befb9ec03e240add280cb3 # v3.26.8 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -67,7 +67,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15 + uses: github/codeql-action/autobuild@294a9d92911152fe08befb9ec03e240add280cb3 # v3.26.8 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -80,6 +80,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15 + uses: github/codeql-action/analyze@294a9d92911152fe08befb9ec03e240add280cb3 # v3.26.8 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 6b345d29..f2d5fcdf 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -16,7 +16,7 @@ jobs: python: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] steps: - name: Harden Runner - uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v2.9.0 + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 with: disable-sudo: true egress-policy: block @@ -33,7 +33,7 @@ jobs: uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Setup Python - uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1 + uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0 with: python-version: ${{ matrix.python }} diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index e85dd0e7..fb1a582f 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v2.9.0 + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 with: disable-sudo: true egress-policy: block diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6e36e9f2..cb570fc7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v2.9.0 + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 with: disable-sudo: true egress-policy: block @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Setup Python - uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1 + uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0 - name: Install tox run: python -m pip install tox - name: Lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de3548af..87e51a08 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v2.9.0 + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs @@ -22,7 +22,7 @@ jobs: with: ref: ${{ github.event.release.tag_name }} - name: Install Python - uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1 + uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0 - name: Install build dependencies run: python -m pip install -U setuptools build wheel - name: Build distributions diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 54031979..a25d4bf0 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v2.9.0 + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 with: disable-sudo: true egress-policy: block @@ -64,6 +64,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15 + uses: github/codeql-action/upload-sarif@294a9d92911152fe08befb9ec03e240add280cb3 # v3.26.8 with: sarif_file: results.sarif diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index a0bbb3df..27c9a1e1 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -32,7 +32,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v2.9.0 + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 with: disable-sudo: true egress-policy: block @@ -47,7 +47,7 @@ jobs: - name: Checkout uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Use Python ${{ matrix.python }} - uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1 + uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0 with: python-version: ${{ matrix.python }} - name: Install tox From 2b51b1b5af0b6dad8b50c6292ca42379721e1840 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Tue, 24 Sep 2024 19:10:02 +0200 Subject: [PATCH 132/181] chore(deps): update pypa/gh-action-pypi-publish digest to 897895f (#338) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 87e51a08..5bcee5e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: - name: Build distributions run: python -m build - name: Publish - uses: pypa/gh-action-pypi-publish@ec4db0b4ddc65acdf4bff5fa45ac92d78b56bdf0 # main + uses: pypa/gh-action-pypi-publish@897895f1e160c830e369f9779632ebc134688e1b # main with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} From 578894b9e60593e06c2ba4ca19f4571906ea8163 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Wed, 2 Oct 2024 01:05:54 +0200 Subject: [PATCH 133/181] chore(deps): update pypa/gh-action-pypi-publish digest to f760068 (#347) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5bcee5e6..93806d9a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: - name: Build distributions run: python -m build - name: Publish - uses: pypa/gh-action-pypi-publish@897895f1e160c830e369f9779632ebc134688e1b # main + uses: pypa/gh-action-pypi-publish@f7600683efdcb7656dec5b29656edb7bc586e597 # main with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} From 35204161e9d98e23dc220517fcf5b3c0c687914f Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Fri, 4 Oct 2024 01:15:52 +0200 Subject: [PATCH 134/181] chore(deps): update all non-major dependencies (#348) --- .github/workflows/codeql.yml | 8 ++++---- .github/workflows/conformance.yml | 2 +- .github/workflows/dependency-review.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/scorecard.yml | 4 ++-- .github/workflows/unit.yml | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6b1d2ac3..cff560f1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -53,11 +53,11 @@ jobs: objects.githubusercontent.com:443 - name: Checkout repository - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@294a9d92911152fe08befb9ec03e240add280cb3 # v3.26.8 + uses: github/codeql-action/init@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -67,7 +67,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@294a9d92911152fe08befb9ec03e240add280cb3 # v3.26.8 + uses: github/codeql-action/autobuild@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -80,6 +80,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@294a9d92911152fe08befb9ec03e240add280cb3 # v3.26.8 + uses: github/codeql-action/analyze@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index f2d5fcdf..0d86d788 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -30,7 +30,7 @@ jobs: storage.googleapis.com:443 - name: Checkout code - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 - name: Setup Python uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index fb1a582f..ee09082a 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -25,6 +25,6 @@ jobs: api.github.com:443 github.com:443 - name: 'Checkout Repository' - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 - name: 'Dependency Review' uses: actions/dependency-review-action@5a2ce3f5b92ee19cbb1541a4984c76d921601d7c # v4.3.4 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index cb570fc7..718a5ebc 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -21,7 +21,7 @@ jobs: github.com:443 pypi.org:443 - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 - name: Setup Python uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0 - name: Install tox diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 93806d9a..69165b1b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: ref: ${{ github.event.release.tag_name }} - name: Install Python diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index a25d4bf0..e6da4669 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -47,7 +47,7 @@ jobs: - name: "Checkout code" - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: persist-credentials: false @@ -64,6 +64,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@294a9d92911152fe08befb9ec03e240add280cb3 # v3.26.8 + uses: github/codeql-action/upload-sarif@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11 with: sarif_file: results.sarif diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 27c9a1e1..463340a5 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -45,7 +45,7 @@ jobs: registry-1.docker.io:443 - name: Checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 - name: Use Python ${{ matrix.python }} uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0 with: From 57c8701a7e266bea3ebb7c3825b2b13f1e8576ac Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Thu, 7 Nov 2024 01:22:58 +0100 Subject: [PATCH 135/181] chore(deps): update pypa/gh-action-pypi-publish digest to 1f5d4ec (#351) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 69165b1b..9b5609a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: - name: Build distributions run: python -m build - name: Publish - uses: pypa/gh-action-pypi-publish@f7600683efdcb7656dec5b29656edb7bc586e597 # main + uses: pypa/gh-action-pypi-publish@1f5d4ec244f65dce93685ee3e98e77123f090866 # main with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} From d5ac3d8d01fdb71f7454a0433e586f1eb4a0f6fe Mon Sep 17 00:00:00 2001 From: Katie McLaughlin Date: Thu, 14 Nov 2024 08:32:17 +1100 Subject: [PATCH 136/181] fix: remove unused import (#349) --- src/functions_framework/_function_registry.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/functions_framework/_function_registry.py b/src/functions_framework/_function_registry.py index f266ee82..2214b5fd 100644 --- a/src/functions_framework/_function_registry.py +++ b/src/functions_framework/_function_registry.py @@ -16,7 +16,6 @@ import sys import types -from re import T from typing import Type from functions_framework.exceptions import ( From 0279c8daf526fc01e63cbc6381403a19fb9b4781 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 13 Nov 2024 13:40:29 -0800 Subject: [PATCH 137/181] chore(main): release 3.8.2 (#353) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ setup.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 515ce06e..e2438f81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.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) diff --git a/setup.py b/setup.py index e89e6296..14f0b106 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="3.8.1", + version="3.8.2", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown", From 6a1dfbc20b66809d6d5f2e467fcdd3849756b0e7 Mon Sep 17 00:00:00 2001 From: James Ma Date: Tue, 3 Dec 2024 09:23:38 -0800 Subject: [PATCH 138/181] Update README.md (#355) --- README.md | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index a234586c..d564ad92 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,8 @@ Python functions -- brought to you by the Google Cloud Functions team. The Functions Framework lets you write lightweight functions that run in many different environments, including: -* [Google Cloud Functions](https://cloud.google.com/functions/) +* [Google Cloud Run Functions](https://cloud.google.com/functions/) * Your local development machine -* [Cloud Run and Cloud Run for Anthos](https://cloud.google.com/run/) * [Knative](https://github.com/knative/)-based environments The framework allows you to go from: @@ -294,7 +293,7 @@ https://cloud.google.com/functions/docs/tutorials/pubsub#functions_helloworld_pu ## Run your function on serverless platforms -### Google Cloud Functions +### Google Cloud Run functions This Functions Framework is based on the [Python Runtime on Google Cloud Functions](https://cloud.google.com/functions/docs/concepts/python-runtime). @@ -302,12 +301,6 @@ On Cloud Functions, using the Functions Framework is not necessary: you don't ne After you've written your function, you can simply deploy it from your local machine using the `gcloud` command-line tool. [Check out the Cloud Functions quickstart](https://cloud.google.com/functions/docs/quickstart). -### Cloud Run/Cloud Run on GKE - -Once you've written your function and added the Functions Framework to your `requirements.txt` file, all that's left is to create a container image. [Check out the Cloud Run quickstart](https://cloud.google.com/run/docs/quickstarts/build-and-deploy) for Python to create a container image and deploy it to Cloud Run. You'll write a `Dockerfile` when you build your container. This `Dockerfile` allows you to specify exactly what goes into your container (including custom binaries, a specific operating system, and more). [Here is an example `Dockerfile` that calls Functions Framework.](https://github.com/GoogleCloudPlatform/functions-framework-python/blob/main/examples/cloud_run_http) - -If you want even more control over the environment, you can [deploy your container image to Cloud Run on GKE](https://cloud.google.com/run/docs/quickstarts/prebuilt-deploy-gke). With Cloud Run on GKE, you can run your function on a GKE cluster, which gives you additional control over the environment (including use of GPU-based instances, longer timeouts and more). - ### Container environments based on Knative Cloud Run and Cloud Run on GKE both implement the [Knative Serving API](https://www.knative.dev/docs/). The Functions Framework is designed to be compatible with Knative environments. Just build and deploy your container to a Knative environment. @@ -325,10 +318,10 @@ You can configure the Functions Framework using command-line flags or environmen | `--source` | `FUNCTION_SOURCE` | The path to the file containing your function. Default: `main.py` (in the current working directory) | | `--debug` | `DEBUG` | A flag that allows to run functions-framework to run in debug mode, including live reloading. Default: `False` | -## Enable Google Cloud Function Events +## Enable Google Cloud Run function Events The Functions Framework can unmarshall incoming -Google Cloud Functions [event](https://cloud.google.com/functions/docs/concepts/events-triggers#events) payloads to `event` and `context` objects. +Google Cloud Run functions [event](https://cloud.google.com/functions/docs/concepts/events-triggers#events) payloads to `event` and `context` objects. These will be passed as arguments to your function when it receives a request. Note that your function must use the `event`-style function signature: From 0b521ab15328f8aa96ff70e706f06223d134c62d Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Wed, 18 Dec 2024 20:01:27 +0100 Subject: [PATCH 139/181] chore(deps): update pypa/gh-action-pypi-publish digest to 916e576 (#354) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b5609a5..815c6308 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: - name: Build distributions run: python -m build - name: Publish - uses: pypa/gh-action-pypi-publish@1f5d4ec244f65dce93685ee3e98e77123f090866 # main + uses: pypa/gh-action-pypi-publish@916e57631f04a497e4bec0e29e80684e45b4305e # main with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} From 1d389748f924ee022cba995edced0a1a920c0d04 Mon Sep 17 00:00:00 2001 From: HKWinterhalter Date: Mon, 17 Mar 2025 15:53:13 -0700 Subject: [PATCH 140/181] chore: remove all assignees from blunderbuss.yml (#362) * chore: remove all assignees from blunderbuss.yml --- .github/blunderbuss.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/blunderbuss.yml b/.github/blunderbuss.yml index e913bef1..8b137891 100644 --- a/.github/blunderbuss.yml +++ b/.github/blunderbuss.yml @@ -1,9 +1 @@ -assign_prs: - - HKwinterhalter - - nifflets - - liuyunnnn -assign_issues: - - HKwinterhalter - - nifflets - - liuyunnnn From c6eab2fecb913d5bab1d5dbd6ba2e34b7d6cf9b9 Mon Sep 17 00:00:00 2001 From: nifflets <5343516+nifflets@users.noreply.github.com> Date: Wed, 19 Mar 2025 11:24:18 -0700 Subject: [PATCH 141/181] fix: Update minimum required version of Flask to 2.0 (#356) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 14f0b106..016c4e3e 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,7 @@ package_dir={"": "src"}, python_requires=">=3.5, <4", install_requires=[ - "flask>=1.0,<4.0", + "flask>=2.0,<4.0", "click>=7.0,<9.0", "watchdog>=1.0.0", "gunicorn>=22.0.0; platform_system!='Windows'", From 9348c87cf05eae3726d041f26e43db586951cebb Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Tue, 6 May 2025 13:54:32 -0400 Subject: [PATCH 142/181] docs: Add a development guide (#359) --- DEVELOPMENT.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 DEVELOPMENT.md diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 00000000..f6966656 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,55 @@ +# Development + +Quickstart for functions-framework maintainers. + +## Environment setup + +Install [tox](https://pypi.org/p/tox), the test runner: + +``` +$ python -m pip install -U tox +``` + +## Running linting + +Linting can be run with: + +``` +$ python -m tox -e lint +``` + +## Running tests + +All tests can be run with: + +``` +$ python -m tox +``` + +Tests for the current Python version can be run with: + +``` +$ python -m tox -e py +``` + +Tests for a specific Python version can be run with: + +``` +$ python -m tox -e py3.12 +``` + +A specific test file (e.g. `tests/test_cli.py`) can be run with: + +``` +$ python -m tox -e py -- tests/test_cli.py +``` + +A specific test in the file (e.g. `test_cli_no_arguements` in `tests/test_cli.py`) can be run with: + +``` +$ python -m tox -e py -- tests/test_cli.py::test_cli_no_arguments +``` + +## Releasing + +Releases are triggered via the [Release Please](https://github.com/apps/release-please) app, which in turn kicks off the [Release to PyPI](https://github.com/GoogleCloudPlatform/functions-framework-python/blob/main/.github/workflows/release.yml) workflow. From c0fa420970d36d57adceaf70bbaea784e427e594 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Tue, 6 May 2025 13:54:49 -0400 Subject: [PATCH 143/181] fix: Update test suite for EOL Python versions (#360) --- .github/workflows/conformance.yml | 10 ++++++++-- .github/workflows/unit.yml | 15 +++++++++++++-- tox.ini | 24 ++++++++++++++++++++++-- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 0d86d788..4e066990 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -10,10 +10,16 @@ permissions: read-all jobs: build: - runs-on: ubuntu-latest strategy: matrix: - python: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] + python: ['3.9', '3.10', '3.11', '3.12'] + platform: [ubuntu-latest] + include: + - platform: ubuntu-22.04 + python: '3.8' + - platform: ubuntu-22.04 + python: '3.7' + runs-on: ${{ matrix.platform }} steps: - name: Harden Runner uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 463340a5..404b5159 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -1,5 +1,5 @@ name: Python Unit CI -on: +on: push: branches: - main @@ -13,8 +13,9 @@ jobs: matrix: python: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] platform: [ubuntu-latest, macos-latest, windows-latest] - # Python <= 3.9 is not available on macos-14 + # Python <= 3.9 is not available on macos-latest # Workaround for https://github.com/actions/setup-python/issues/696 + # Python <= 3.8 is not available on ubuntu-latest exclude: - platform: macos-latest python: '3.9' @@ -22,6 +23,10 @@ jobs: python: '3.8' - platform: macos-latest python: '3.7' + - platform: ubuntu-latest + python: '3.8' + - platform: ubuntu-latest + python: '3.7' include: - platform: macos-latest python: '3.9' @@ -29,6 +34,10 @@ jobs: python: '3.8' - platform: macos-13 python: '3.7' + - platform: ubuntu-22.04 + python: '3.8' + - platform: ubuntu-22.04 + python: '3.7' runs-on: ${{ matrix.platform }} steps: - name: Harden Runner @@ -39,7 +48,9 @@ jobs: allowed-endpoints: > auth.docker.io:443 files.pythonhosted.org:443 + api.github.com:443 github.com:443 + objects.githubusercontent.com:443 production.cloudflare.docker.com:443 pypi.org:443 registry-1.docker.io:443 diff --git a/tox.ini b/tox.ini index 6ba6d3b4..71550e76 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,24 @@ [tox] -envlist = py{35,36,37,38,39,310}-{ubuntu-latest,macos-latest,windows-latest},lint +envlist = + lint + py312-ubuntu-latest + py312-macos-latest + py312-windows-latest + py311-ubuntu-latest + py311-macos-latest + py311-windows-latest + py310-ubuntu-latest + py310-macos-latest + py310-windows-latest + py39-ubuntu-latest + py39-macos-13 + py39-windows-latest + py38-ubuntu-22.04 + py38-macos-13 + py38-windows-latest + py37-ubuntu-22.04 + py37-macos-13 + py37-windows-latest [testenv] usedevelop = true @@ -21,9 +40,10 @@ deps = twine isort mypy + build commands = black --check src tests setup.py conftest.py --exclude tests/test_functions/background_load_error/main.py isort -c src tests setup.py conftest.py mypy tests/test_typing.py - python setup.py --quiet sdist bdist_wheel + python -m build twine check dist/* From 4c44d08c5ebb304e80a3adc3f8e6d150987b29bb Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Wed, 14 May 2025 14:35:46 -0400 Subject: [PATCH 144/181] fix: Switch to `pyproject.toml` based builds (#365) * fix: Switch to `pyproject.toml` based builds * chore: Add newly supported Python versions (3.11 & 3.12) * fix: remove `setup.py` from linting steps * chore: disable Python 3.12 conformance tests --- .../workflows/buildpack-integration-test.yml | 26 +++++++ pyproject.toml | 57 +++++++++++++++ setup.py | 69 ------------------- tox.ini | 4 +- 4 files changed, 85 insertions(+), 71 deletions(-) create mode 100644 pyproject.toml delete mode 100644 setup.py diff --git a/.github/workflows/buildpack-integration-test.yml b/.github/workflows/buildpack-integration-test.yml index 0c9e6eff..234c24ef 100644 --- a/.github/workflows/buildpack-integration-test.yml +++ b/.github/workflows/buildpack-integration-test.yml @@ -58,3 +58,29 @@ jobs: builder-runtime: 'python310' builder-runtime-version: '3.10' start-delay: 5 + python311: + uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@main + with: + http-builder-source: 'tests/conformance' + http-builder-target: 'write_http_declarative' + cloudevent-builder-source: 'tests/conformance' + cloudevent-builder-target: 'write_cloud_event_declarative' + prerun: 'tests/conformance/prerun.sh ${{ github.sha }}' + builder-runtime: 'python311' + builder-runtime-version: '3.11' + start-delay: 5 +# Python 3.12 conformance tests are disabled due to the buildpack defaulting to +# Ubuntu 18.04, which has no Python 3.12 version, and being unable to specify +# the OS/stack via the conformance test configuration +# +# python312: +# uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@main +# with: +# http-builder-source: 'tests/conformance' +# http-builder-target: 'write_http_declarative' +# cloudevent-builder-source: 'tests/conformance' +# cloudevent-builder-target: 'write_cloud_event_declarative' +# prerun: 'tests/conformance/prerun.sh ${{ github.sha }}' +# builder-runtime: 'python312' +# builder-runtime-version: '3.12' +# start-delay: 5 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..5f25ecf8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,57 @@ +[project] +name = "functions-framework" +version = "3.8.2" +description = "An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team." +readme = "README.md" +requires-python = ">=3.5, <4" +# Once we drop support for Python 3.7 and 3.8, this can become +# license = "Apache-2.0" +license = {text = "Apache-2.0"} +authors = [ + { name = "Google LLC", email = "googleapis-packages@google.com" } +] +maintainers = [ + { name = "Google LLC", email = "googleapis-packages@google.com" } +] +keywords = ["functions-framework"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = [ + "flask>=2.0,<4.0", + "click>=7.0,<9.0", + "watchdog>=1.0.0", + "gunicorn>=22.0.0; platform_system!='Windows'", + "cloudevents>=1.2.0,<2.0.0", + "Werkzeug>=0.14,<4.0.0", +] + +[project.urls] +Homepage = "https://github.com/googlecloudplatform/functions-framework-python" + +[project.scripts] +ff = "functions_framework._cli:_cli" +functions-framework = "functions_framework._cli:_cli" +functions_framework = "functions_framework._cli:_cli" +functions-framework-python = "functions_framework._cli:_cli" +functions_framework_python = "functions_framework._cli:_cli" + +[build-system] +requires = ["setuptools>=61.0.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +functions_framework = ["py.typed"] + +[tool.setuptools.package-dir] +"" = "src" diff --git a/setup.py b/setup.py deleted file mode 100644 index 016c4e3e..00000000 --- a/setup.py +++ /dev/null @@ -1,69 +0,0 @@ -# Copyright 2020 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from io import open -from os import path - -from setuptools import find_packages, setup - -here = path.abspath(path.dirname(__file__)) - -# Get the long description from the README file -with open(path.join(here, "README.md"), encoding="utf-8") as f: - long_description = f.read() - -setup( - name="functions-framework", - version="3.8.2", - description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/googlecloudplatform/functions-framework-python", - author="Google LLC", - author_email="googleapis-packages@google.com", - classifiers=[ - "Development Status :: 5 - Production/Stable ", - "Intended Audience :: Developers", - "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - ], - keywords="functions-framework", - packages=find_packages(where="src"), - package_data={"functions_framework": ["py.typed"]}, - namespace_packages=["google", "google.cloud"], - package_dir={"": "src"}, - python_requires=">=3.5, <4", - install_requires=[ - "flask>=2.0,<4.0", - "click>=7.0,<9.0", - "watchdog>=1.0.0", - "gunicorn>=22.0.0; platform_system!='Windows'", - "cloudevents>=1.2.0,<2.0.0", - "Werkzeug>=0.14,<4.0.0", - ], - entry_points={ - "console_scripts": [ - "ff=functions_framework._cli:_cli", - "functions-framework=functions_framework._cli:_cli", - "functions_framework=functions_framework._cli:_cli", - "functions-framework-python=functions_framework._cli:_cli", - "functions_framework_python=functions_framework._cli:_cli", - ] - }, -) diff --git a/tox.ini b/tox.ini index 71550e76..1599608c 100644 --- a/tox.ini +++ b/tox.ini @@ -42,8 +42,8 @@ deps = mypy build commands = - black --check src tests setup.py conftest.py --exclude tests/test_functions/background_load_error/main.py - isort -c src tests setup.py conftest.py + black --check src tests conftest.py --exclude tests/test_functions/background_load_error/main.py + isort -c src tests conftest.py mypy tests/test_typing.py python -m build twine check dist/* From f57a0163d0b20eac4a77d5776262a3942780df9a Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Fri, 16 May 2025 11:26:56 -0700 Subject: [PATCH 145/181] chore(main): release 3.8.3 (#363) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 14 ++++++++++++++ pyproject.toml | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2438f81..8231717d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.8.3](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.8.2...v3.8.3) (2025-05-14) + + +### Bug Fixes + +* Switch to `pyproject.toml` based builds ([#365](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/365)) ([4c44d08](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/4c44d08c5ebb304e80a3adc3f8e6d150987b29bb)) +* Update minimum required version of Flask to 2.0 ([#356](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/356)) ([c6eab2f](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/c6eab2fecb913d5bab1d5dbd6ba2e34b7d6cf9b9)) +* Update test suite for EOL Python versions ([#360](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/360)) ([c0fa420](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/c0fa420970d36d57adceaf70bbaea784e427e594)) + + +### Documentation + +* Add a development guide ([#359](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/359)) ([9348c87](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/9348c87cf05eae3726d041f26e43db586951cebb)) + ## [3.8.2](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.8.1...v3.8.2) (2024-11-13) diff --git a/pyproject.toml b/pyproject.toml index 5f25ecf8..c160562f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "functions-framework" -version = "3.8.2" +version = "3.8.3" description = "An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team." readme = "README.md" requires-python = ">=3.5, <4" From 3fdeaa0dd799553f92ebcef919e8755763a166c5 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Sat, 17 May 2025 00:39:34 +0200 Subject: [PATCH 146/181] chore(deps): update pypa/gh-action-pypi-publish digest to db8f07d (#358) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 815c6308..443c7ee8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: - name: Build distributions run: python -m build - name: Publish - uses: pypa/gh-action-pypi-publish@916e57631f04a497e4bec0e29e80684e45b4305e # main + uses: pypa/gh-action-pypi-publish@db8f07d3871a0a180efa06b95d467625c19d5d5f # main with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} From 0ab5f03f5f47f96903197c4e5419f7ac1b238f3c Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Sat, 17 May 2025 00:45:23 +0200 Subject: [PATCH 147/181] chore(deps): update all non-major dependencies (#352) --- .github/workflows/codeql.yml | 10 +++++----- .github/workflows/conformance.yml | 10 +++++----- .github/workflows/dependency-review.yml | 6 +++--- .github/workflows/lint.yml | 6 +++--- .github/workflows/release.yml | 6 +++--- .github/workflows/scorecard.yml | 8 ++++---- .github/workflows/unit.yml | 6 +++--- 7 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index cff560f1..46f6a4d1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,7 +41,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: disable-sudo: true egress-policy: block @@ -53,11 +53,11 @@ jobs: objects.githubusercontent.com:443 - name: Checkout repository - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11 + uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -67,7 +67,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11 + uses: github/codeql-action/autobuild@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -80,6 +80,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11 + uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 4e066990..9dde6036 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -22,7 +22,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: disable-sudo: true egress-policy: block @@ -36,10 +36,10 @@ jobs: storage.googleapis.com:443 - name: Checkout code - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Python - uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python }} @@ -47,9 +47,9 @@ jobs: run: python -m pip install -e . - name: Setup Go - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: - go-version: '1.20' + go-version: '1.24' - name: Run HTTP conformance tests uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index ee09082a..5baf3aa0 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: disable-sudo: true egress-policy: block @@ -25,6 +25,6 @@ jobs: api.github.com:443 github.com:443 - name: 'Checkout Repository' - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: 'Dependency Review' - uses: actions/dependency-review-action@5a2ce3f5b92ee19cbb1541a4984c76d921601d7c # v4.3.4 + uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 718a5ebc..4fa8dff2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: disable-sudo: true egress-policy: block @@ -21,9 +21,9 @@ jobs: github.com:443 pypi.org:443 - - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Python - uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - name: Install tox run: python -m pip install tox - name: Lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 443c7ee8..269f14c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,16 +13,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.event.release.tag_name }} - name: Install Python - uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - name: Install build dependencies run: python -m pip install -U setuptools build wheel - name: Build distributions diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index e6da4669..72b82523 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: disable-sudo: true egress-policy: block @@ -47,12 +47,12 @@ jobs: - name: "Checkout code" - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0 + uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1 with: results_file: results.sarif results_format: sarif @@ -64,6 +64,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11 + uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 with: sarif_file: results.sarif diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 404b5159..e16d4de8 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -41,7 +41,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: disable-sudo: true egress-policy: block @@ -56,9 +56,9 @@ jobs: registry-1.docker.io:443 - name: Checkout - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Use Python ${{ matrix.python }} - uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python }} - name: Install tox From cc2b9b584fcc3daaa2762ae62a3ce1277a488a1c Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Mon, 9 Jun 2025 13:24:43 -0700 Subject: [PATCH 148/181] fix: Pin cloudevent sdk version to support python3.7. (#373) * fix: Pin cloudevent sdk version to support python3.7. * Pin cloudevent version on examples. * Pin cloudevent versions. --- examples/cloud_run_cloud_events/requirements.txt | 2 +- examples/cloud_run_decorator/requirements.txt | 1 + examples/cloud_run_event/requirements.txt | 1 + examples/cloud_run_http/requirements.txt | 1 + examples/docker-compose/requirements.txt | 1 + examples/skaffold/requirements.txt | 1 + pyproject.toml | 10 ++++------ 7 files changed, 10 insertions(+), 7 deletions(-) diff --git a/examples/cloud_run_cloud_events/requirements.txt b/examples/cloud_run_cloud_events/requirements.txt index 0a7427c7..43d925f1 100644 --- a/examples/cloud_run_cloud_events/requirements.txt +++ b/examples/cloud_run_cloud_events/requirements.txt @@ -1,3 +1,3 @@ # Optionally include additional dependencies here -cloudevents>=1.2.0 +cloudevents==1.11.0 # Pin version - last version compatible w/ python3.7 requests diff --git a/examples/cloud_run_decorator/requirements.txt b/examples/cloud_run_decorator/requirements.txt index 33c5f99f..3f8c88a5 100644 --- a/examples/cloud_run_decorator/requirements.txt +++ b/examples/cloud_run_decorator/requirements.txt @@ -1 +1,2 @@ # Optionally include additional dependencies here +cloudevents==1.11.0 # Pin version - last version compatible w/ python3.7 diff --git a/examples/cloud_run_event/requirements.txt b/examples/cloud_run_event/requirements.txt index 33c5f99f..3f8c88a5 100644 --- a/examples/cloud_run_event/requirements.txt +++ b/examples/cloud_run_event/requirements.txt @@ -1 +1,2 @@ # Optionally include additional dependencies here +cloudevents==1.11.0 # Pin version - last version compatible w/ python3.7 diff --git a/examples/cloud_run_http/requirements.txt b/examples/cloud_run_http/requirements.txt index 33c5f99f..3f8c88a5 100644 --- a/examples/cloud_run_http/requirements.txt +++ b/examples/cloud_run_http/requirements.txt @@ -1 +1,2 @@ # Optionally include additional dependencies here +cloudevents==1.11.0 # Pin version - last version compatible w/ python3.7 diff --git a/examples/docker-compose/requirements.txt b/examples/docker-compose/requirements.txt index 3601409f..c856b8d8 100644 --- a/examples/docker-compose/requirements.txt +++ b/examples/docker-compose/requirements.txt @@ -1 +1,2 @@ # Add any Python requirements here +cloudevents==1.11.0 # Pin version - last version compatible w/ python3.7 diff --git a/examples/skaffold/requirements.txt b/examples/skaffold/requirements.txt index 3601409f..c856b8d8 100644 --- a/examples/skaffold/requirements.txt +++ b/examples/skaffold/requirements.txt @@ -1 +1,2 @@ # Add any Python requirements here +cloudevents==1.11.0 # Pin version - last version compatible w/ python3.7 diff --git a/pyproject.toml b/pyproject.toml index c160562f..fa001304 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,12 +6,10 @@ readme = "README.md" requires-python = ">=3.5, <4" # Once we drop support for Python 3.7 and 3.8, this can become # license = "Apache-2.0" -license = {text = "Apache-2.0"} -authors = [ - { name = "Google LLC", email = "googleapis-packages@google.com" } -] +license = { text = "Apache-2.0" } +authors = [{ name = "Google LLC", email = "googleapis-packages@google.com" }] maintainers = [ - { name = "Google LLC", email = "googleapis-packages@google.com" } + { name = "Google LLC", email = "googleapis-packages@google.com" }, ] keywords = ["functions-framework"] classifiers = [ @@ -29,7 +27,7 @@ dependencies = [ "click>=7.0,<9.0", "watchdog>=1.0.0", "gunicorn>=22.0.0; platform_system!='Windows'", - "cloudevents>=1.2.0,<2.0.0", + "cloudevents>=1.2.0,<=1.11.0", # Must support python 3.7 "Werkzeug>=0.14,<4.0.0", ] From 37e0bf764ff24ebb82ba18bcac1bee6b03cecb13 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 10 Jun 2025 12:26:22 -0700 Subject: [PATCH 149/181] fix(ci): specify python version in tox environment (#375) * fix(ci): specify python version in tox environment * Enable shell for github workflows. --- .github/workflows/unit.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index e16d4de8..32b34fdb 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -64,4 +64,8 @@ jobs: - name: Install tox run: python -m pip install tox - name: Test - run: python -m tox -e py-${{ matrix.platform }} + shell: bash + run: | + # Remove dots from python version string, i.e. 3.10 -> 310 + PY_VERSION=$(echo "${{ matrix.python }}" | sed 's/\.//g') + python -m tox -e py${PY_VERSION}-${{ matrix.platform }} From 49f698517a06d0e47b2aadc2d603e0b193770440 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 10 Jun 2025 12:44:30 -0700 Subject: [PATCH 150/181] feat: add support for async functions (#364) * feat: Introduce functions_framework.aio submodule that support async function execution. * Remove httpx. * Update pyproject.toml to include extra async package. * Update test deps. * Improve test coverage. * Make linter happy. * Fix test harness to support py37. * Remove version filter in tox file. * Remove dependency-groups in pyproject.toml for now. * Use py3.8 compatible types. * Fix more incompatibility with python38 * Pin cloudevent sdk to python37 compatible version. * Fix more py37 incompatibility. * fix: Prevent test_aio.py collection errors on Python 3.7 Add pytest_ignore_collect hook to skip test_aio.py entirely on Python 3.7 to prevent ImportError during test collection. The previous approach using only pytest_collection_modifyitems was too late in the process - the error occurred when pytest tried to import the module before skip markers could be applied. Both hooks are marked as safe to remove when Python 3.7 support is dropped. * style: Apply black formatting to conftest.py * fix: Use modern pytest collection_path parameter and return None - Replace deprecated 'path' parameter with 'collection_path' in pytest_ignore_collect - Return None instead of False to let pytest use default behavior - This should fix the issue where pytest was collecting tests from .tox/.pkg/ * fix: Skip tests parametrized with None on Python 3.7 Simplify the check to just skip any test parametrized with None value. On Python 3.7, create_asgi_app is always None due to the conditional import, so this catches all async-related parametrized tests. * fix: Replace asyncio.to_thread with Python 3.8 compatible code Use asyncio.get_event_loop().run_in_executor() instead of asyncio.to_thread() for Python 3.8 compatibility. Added TODO comments to switch back when Python 3.8 support is dropped. * fix: Improve async test detection for Python 3.7 - Use a list of async keywords (async, asgi, aio, starlette) - Check for these keywords in test names, file paths, and parameters - This catches more async-related tests including those with "aio" prefix * fix: Handle Flask vs Starlette redirect behavior differences - Remove unnecessary follow_redirects=True from Starlette TestClient - Make test_http_function_request_url_empty_path aware of framework differences - Starlette TestClient normalizes empty path "" to "/" while Flask preserves it - Test now expects appropriate behavior for each framework * fix: Exclude aio module from coverage on Python 3.7 Add special coverage configuration for Python 3.7 that excludes the aio module since it requires Python 3.8+ due to Starlette dependency. This prevents coverage failures on Python 3.7. * fix: Simplify conftest.py. * fix: Use full environment names for py37 coverage exclusion The tox environment names in GitHub Actions include the OS suffix (e.g., py37-ubuntu-22.04), so we need to match the full names. * fix: Explicitly list each py37 environment for coverage exclusion - List py37-ubuntu-22.04 and py37-macos-13 explicitly - Place py37 settings before general windows-latest setting - This should properly exclude aio module from coverage on Python 3.7 * fix: Add Python 3.7 specific coverage configuration - Create .coveragerc-py37 to exclude aio module from coverage on Python 3.7 - Use --cov-config flag to specify this file for py37 environments only - This prevents the aio module exclusion from affecting Python 3.8+ tests --- .coveragerc-py37 | 10 + conftest.py | 50 +++ pyproject.toml | 6 +- setup.py | 72 ++++ src/functions_framework/aio/__init__.py | 250 ++++++++++++++ tests/test_aio.py | 190 +++++++++++ tests/test_cloud_event_functions.py | 71 ++-- tests/test_decorator_functions.py | 87 ++++- tests/test_functions.py | 323 +++++++++++------- .../cloud_events/async_empty_data.py | 38 +++ .../test_functions/cloud_events/async_main.py | 40 +++ .../decorators/async_decorator.py | 98 ++++++ .../http_check_env/async_main.py | 36 ++ .../http_request_check/async_main.py | 40 +++ .../http_streaming/async_main.py | 46 +++ .../test_functions/http_trigger/async_main.py | 48 +++ .../http_trigger_sleep/async_main.py | 33 ++ .../http_with_import/async_main.py | 29 ++ tests/test_typing.py | 12 + tox.ini | 9 + 20 files changed, 1316 insertions(+), 172 deletions(-) create mode 100644 .coveragerc-py37 create mode 100644 setup.py create mode 100644 src/functions_framework/aio/__init__.py create mode 100644 tests/test_aio.py create mode 100644 tests/test_functions/cloud_events/async_empty_data.py create mode 100644 tests/test_functions/cloud_events/async_main.py create mode 100644 tests/test_functions/decorators/async_decorator.py create mode 100644 tests/test_functions/http_check_env/async_main.py create mode 100644 tests/test_functions/http_request_check/async_main.py create mode 100644 tests/test_functions/http_streaming/async_main.py create mode 100644 tests/test_functions/http_trigger/async_main.py create mode 100644 tests/test_functions/http_trigger_sleep/async_main.py create mode 100644 tests/test_functions/http_with_import/async_main.py diff --git a/.coveragerc-py37 b/.coveragerc-py37 new file mode 100644 index 00000000..13be2ea1 --- /dev/null +++ b/.coveragerc-py37 @@ -0,0 +1,10 @@ +[run] +# Coverage configuration specifically for Python 3.7 environments +# Excludes the aio module which requires Python 3.8+ (Starlette dependency) +# This file is only used by py37-* tox environments +omit = + */functions_framework/aio/* + */.tox/* + */tests/* + */venv/* + */.venv/* \ No newline at end of file diff --git a/conftest.py b/conftest.py index 21572fda..f72314ed 100644 --- a/conftest.py +++ b/conftest.py @@ -42,3 +42,53 @@ def isolate_logging(): sys.stderr = sys.__stderr__ logging.shutdown() reload(logging) + + +# Safe to remove when we drop Python 3.7 support +def pytest_ignore_collect(collection_path, config): + """Ignore async test files on Python 3.7 since Starlette requires Python 3.8+""" + if sys.version_info >= (3, 8): + return None + + # Skip test_aio.py entirely on Python 3.7 + if collection_path.name == "test_aio.py": + return True + + return None + + +# Safe to remove when we drop Python 3.7 support +def pytest_collection_modifyitems(config, items): + """Skip async-related tests on Python 3.7 since Starlette requires Python 3.8+""" + if sys.version_info >= (3, 8): + return + + skip_async = pytest.mark.skip( + reason="Async features require Python 3.8+ (Starlette dependency)" + ) + + # Keywords that indicate async-related tests + async_keywords = ["async", "asgi", "aio", "starlette"] + + for item in items: + skip_test = False + + if hasattr(item, "callspec") and hasattr(item.callspec, "params"): + for param_name, param_value in item.callspec.params.items(): + # Check if test has fixtures with async-related parameters + if isinstance(param_value, str) and any( + keyword in param_value.lower() for keyword in async_keywords + ): + skip_test = True + break + # Skip tests parametrized with None (create_asgi_app on Python 3.7) + if param_value is None: + skip_test = True + break + + # Skip tests that explicitly test async functionality + if any(keyword in item.name.lower() for keyword in async_keywords): + skip_test = True + + if skip_test: + item.add_marker(skip_async) diff --git a/pyproject.toml b/pyproject.toml index fa001304..3a631b5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "functions-framework" version = "3.8.3" description = "An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team." readme = "README.md" -requires-python = ">=3.5, <4" +requires-python = ">=3.7, <4" # Once we drop support for Python 3.7 and 3.8, this can become # license = "Apache-2.0" license = { text = "Apache-2.0" } @@ -29,11 +29,15 @@ dependencies = [ "gunicorn>=22.0.0; platform_system!='Windows'", "cloudevents>=1.2.0,<=1.11.0", # Must support python 3.7 "Werkzeug>=0.14,<4.0.0", + "httpx>=0.24.1", ] [project.urls] Homepage = "https://github.com/googlecloudplatform/functions-framework-python" +[project.optional-dependencies] +async = ["starlette>=0.37.0,<1.0.0; python_version>='3.8'"] + [project.scripts] ff = "functions_framework._cli:_cli" functions-framework = "functions_framework._cli:_cli" diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..1c35d39b --- /dev/null +++ b/setup.py @@ -0,0 +1,72 @@ +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from io import open +from os import path + +from setuptools import find_packages, setup + +here = path.abspath(path.dirname(__file__)) + +# Get the long description from the README file +with open(path.join(here, "README.md"), encoding="utf-8") as f: + long_description = f.read() + +setup( + name="functions-framework", + version="3.8.2", + description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/googlecloudplatform/functions-framework-python", + author="Google LLC", + author_email="googleapis-packages@google.com", + classifiers=[ + "Development Status :: 5 - Production/Stable ", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + ], + keywords="functions-framework", + packages=find_packages(where="src"), + package_data={"functions_framework": ["py.typed"]}, + namespace_packages=["google", "google.cloud"], + package_dir={"": "src"}, + python_requires=">=3.5, <4", + install_requires=[ + "flask>=1.0,<4.0", + "click>=7.0,<9.0", + "watchdog>=1.0.0", + "gunicorn>=22.0.0; platform_system!='Windows'", + "cloudevents>=1.2.0,<2.0.0", + "Werkzeug>=0.14,<4.0.0", + ], + extras_require={ + "async": ["starlette>=0.37.0,<1.0.0"], + }, + entry_points={ + "console_scripts": [ + "ff=functions_framework._cli:_cli", + "functions-framework=functions_framework._cli:_cli", + "functions_framework=functions_framework._cli:_cli", + "functions-framework-python=functions_framework._cli:_cli", + "functions_framework_python=functions_framework._cli:_cli", + ] + }, +) diff --git a/src/functions_framework/aio/__init__.py b/src/functions_framework/aio/__init__.py new file mode 100644 index 00000000..832d6818 --- /dev/null +++ b/src/functions_framework/aio/__init__.py @@ -0,0 +1,250 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import functools +import inspect +import os + +from typing import Any, Awaitable, Callable, Dict, Tuple, Union + +from cloudevents.http import from_http +from cloudevents.http.event import CloudEvent + +from functions_framework import _function_registry +from functions_framework.exceptions import ( + FunctionsFrameworkException, + MissingSourceException, +) + +try: + from starlette.applications import Starlette + from starlette.exceptions import HTTPException + from starlette.requests import Request + from starlette.responses import JSONResponse, Response + from starlette.routing import Route +except ImportError: + raise FunctionsFrameworkException( + "Starlette is not installed. Install the framework with the 'async' extra: " + "pip install functions-framework[async]" + ) + +HTTPResponse = Union[ + Response, # Functions can return a full Starlette Response object + str, # Str returns are wrapped in Response(result) + Dict[Any, Any], # Dict returns are wrapped in JSONResponse(result) + Tuple[Any, int], # Flask-style (content, status_code) supported + None, # None raises HTTPException +] + +_FUNCTION_STATUS_HEADER_FIELD = "X-Google-Status" +_CRASH = "crash" + +CloudEventFunction = Callable[[CloudEvent], Union[None, Awaitable[None]]] +HTTPFunction = Callable[[Request], Union[HTTPResponse, Awaitable[HTTPResponse]]] + + +def cloud_event(func: CloudEventFunction) -> CloudEventFunction: + """Decorator that registers cloudevent as user function signature type.""" + _function_registry.REGISTRY_MAP[func.__name__] = ( + _function_registry.CLOUDEVENT_SIGNATURE_TYPE + ) + if inspect.iscoroutinefunction(func): + + @functools.wraps(func) + async def async_wrapper(*args, **kwargs): + return await func(*args, **kwargs) + + return async_wrapper + + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + +def http(func: HTTPFunction) -> HTTPFunction: + """Decorator that registers http as user function signature type.""" + _function_registry.REGISTRY_MAP[func.__name__] = ( + _function_registry.HTTP_SIGNATURE_TYPE + ) + + if inspect.iscoroutinefunction(func): + + @functools.wraps(func) + async def async_wrapper(*args, **kwargs): + return await func(*args, **kwargs) + + return async_wrapper + + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + +async def _crash_handler(request, exc): + headers = {_FUNCTION_STATUS_HEADER_FIELD: _CRASH} + return Response(f"Internal Server Error: {exc}", status_code=500, headers=headers) + + +def _http_func_wrapper(function, is_async): + @functools.wraps(function) + async def handler(request): + if is_async: + result = await function(request) + else: + # TODO: Use asyncio.to_thread when we drop Python 3.8 support + # Python 3.8 compatible version of asyncio.to_thread + loop = asyncio.get_event_loop() + result = await loop.run_in_executor(None, function, request) + if isinstance(result, str): + return Response(result) + elif isinstance(result, dict): + return JSONResponse(result) + elif isinstance(result, tuple) and len(result) == 2: + # Support Flask-style tuple response + content, status_code = result + return Response(content, status_code=status_code) + elif result is None: + raise HTTPException(status_code=500, detail="No response returned") + else: + return result + + return handler + + +def _cloudevent_func_wrapper(function, is_async): + @functools.wraps(function) + async def handler(request): + data = await request.body() + + try: + event = from_http(request.headers, data) + except Exception as e: + raise HTTPException( + 400, detail=f"Bad Request: Got CloudEvent exception: {repr(e)}" + ) + if is_async: + await function(event) + else: + # TODO: Use asyncio.to_thread when we drop Python 3.8 support + # Python 3.8 compatible version of asyncio.to_thread + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, function, event) + return Response("OK") + + return handler + + +async def _handle_not_found(request: Request): + raise HTTPException(status_code=404, detail="Not Found") + + +def create_asgi_app(target=None, source=None, signature_type=None): + """Create an ASGI application for the function. + + Args: + target: The name of the target function to invoke + source: The source file containing the function + signature_type: The signature type of the function + ('http', 'event', 'cloudevent', or 'typed') + + Returns: + A Starlette ASGI application instance + """ + target = _function_registry.get_function_target(target) + source = _function_registry.get_function_source(source) + + if not os.path.exists(source): + raise MissingSourceException( + f"File {source} that is expected to define function doesn't exist" + ) + + source_module, spec = _function_registry.load_function_module(source) + spec.loader.exec_module(source_module) + function = _function_registry.get_user_function(source, source_module, target) + signature_type = _function_registry.get_func_signature_type(target, signature_type) + + is_async = inspect.iscoroutinefunction(function) + routes = [] + if signature_type == _function_registry.HTTP_SIGNATURE_TYPE: + http_handler = _http_func_wrapper(function, is_async) + routes.append( + Route( + "/", + endpoint=http_handler, + methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH"], + ), + ) + routes.append(Route("/robots.txt", endpoint=_handle_not_found, methods=["GET"])) + routes.append( + Route("/favicon.ico", endpoint=_handle_not_found, methods=["GET"]) + ) + routes.append( + Route( + "/{path:path}", + http_handler, + methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH"], + ) + ) + elif signature_type == _function_registry.CLOUDEVENT_SIGNATURE_TYPE: + cloudevent_handler = _cloudevent_func_wrapper(function, is_async) + routes.append(Route("/{path:path}", cloudevent_handler, methods=["POST"])) + routes.append(Route("/", cloudevent_handler, methods=["POST"])) + elif signature_type == _function_registry.TYPED_SIGNATURE_TYPE: + raise FunctionsFrameworkException( + f"ASGI server does not support typed events (signature type: '{signature_type}'). " + ) + elif signature_type == _function_registry.BACKGROUNDEVENT_SIGNATURE_TYPE: + raise FunctionsFrameworkException( + f"ASGI server does not support legacy background events (signature type: '{signature_type}'). " + "Use 'cloudevent' signature type instead." + ) + else: + raise FunctionsFrameworkException( + f"Unsupported signature type for ASGI server: {signature_type}" + ) + + exception_handlers = { + 500: _crash_handler, + } + app = Starlette(routes=routes, exception_handlers=exception_handlers) + return app + + +class LazyASGIApp: + """ + Wrap the ASGI app in a lazily initialized wrapper to prevent initialization + at import-time + """ + + def __init__(self, target=None, source=None, signature_type=None): + self.target = target + self.source = source + self.signature_type = signature_type + + self.app = None + self._app_initialized = False + + async def __call__(self, scope, receive, send): + if not self._app_initialized: + self.app = create_asgi_app(self.target, self.source, self.signature_type) + self._app_initialized = True + await self.app(scope, receive, send) + + +app = LazyASGIApp() diff --git a/tests/test_aio.py b/tests/test_aio.py new file mode 100644 index 00000000..cf69479a --- /dev/null +++ b/tests/test_aio.py @@ -0,0 +1,190 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pathlib +import re +import sys +import tempfile + +from unittest.mock import Mock, call + +if sys.version_info >= (3, 8): + from unittest.mock import AsyncMock + +import pytest + +from functions_framework import exceptions +from functions_framework.aio import ( + LazyASGIApp, + _cloudevent_func_wrapper, + _http_func_wrapper, + create_asgi_app, +) + +TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" + + +def test_import_error_without_starlette(monkeypatch): + import builtins + + original_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name.startswith("starlette"): + raise ImportError(f"No module named '{name}'") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", mock_import) + + # Remove the module from sys.modules to force re-import + if "functions_framework.aio" in sys.modules: + del sys.modules["functions_framework.aio"] + + with pytest.raises(exceptions.FunctionsFrameworkException) as excinfo: + import functions_framework.aio + + assert "Starlette is not installed" in str(excinfo.value) + assert "pip install functions-framework[async]" in str(excinfo.value) + + +def test_invalid_function_definition_missing_function_file(): + source = TEST_FUNCTIONS_DIR / "missing_function_file" / "main.py" + target = "function" + + with pytest.raises(exceptions.MissingSourceException) as excinfo: + create_asgi_app(target, source) + + assert re.match( + r"File .* that is expected to define function doesn't exist", str(excinfo.value) + ) + + +def test_asgi_typed_signature_not_supported(): + source = TEST_FUNCTIONS_DIR / "typed_events" / "typed_event.py" + target = "function_typed" + + with pytest.raises(exceptions.FunctionsFrameworkException) as excinfo: + create_asgi_app(target, source, "typed") + + assert "ASGI server does not support typed events (signature type: 'typed')" in str( + excinfo.value + ) + + +def test_asgi_background_event_not_supported(): + source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py" + target = "function" + + with pytest.raises(exceptions.FunctionsFrameworkException) as excinfo: + create_asgi_app(target, source, "event") + + assert ( + "ASGI server does not support legacy background events (signature type: 'event')" + in str(excinfo.value) + ) + assert "Use 'cloudevent' signature type instead" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_lazy_asgi_app(monkeypatch): + actual_app = AsyncMock() + create_asgi_app_mock = Mock(return_value=actual_app) + monkeypatch.setattr("functions_framework.aio.create_asgi_app", create_asgi_app_mock) + + # Test that it's lazy + target, source, signature_type = "func", "source.py", "http" + lazy_app = LazyASGIApp(target, source, signature_type) + + assert lazy_app.app is None + assert lazy_app._app_initialized is False + + # Mock ASGI call parameters + scope = {"type": "http", "method": "GET", "path": "/"} + receive = AsyncMock() + send = AsyncMock() + + # Test that it's initialized when called + await lazy_app(scope, receive, send) + + assert lazy_app.app is actual_app + assert lazy_app._app_initialized is True + assert create_asgi_app_mock.call_count == 1 + assert create_asgi_app_mock.call_args == call(target, source, signature_type) + + # Verify the app was called + actual_app.assert_called_once_with(scope, receive, send) + + # Test that subsequent calls use the same app + create_asgi_app_mock.reset_mock() + actual_app.reset_mock() + + await lazy_app(scope, receive, send) + + assert create_asgi_app_mock.call_count == 0 # Should not create app again + actual_app.assert_called_once_with(scope, receive, send) # Should be called again + + +@pytest.mark.asyncio +async def test_http_func_wrapper_json_response(): + async def http_func(request): + return {"message": "hello", "count": 42} + + wrapper = _http_func_wrapper(http_func, is_async=True) + + request = Mock() + response = await wrapper(request) + + assert response.__class__.__name__ == "JSONResponse" + assert b'"message":"hello"' in response.body + assert b'"count":42' in response.body + + +@pytest.mark.asyncio +async def test_http_func_wrapper_sync_function(): + def sync_http_func(request): + return "sync response" + + wrapper = _http_func_wrapper(sync_http_func, is_async=False) + + request = Mock() + response = await wrapper(request) + + assert response.__class__.__name__ == "Response" + assert response.body == b"sync response" + + +@pytest.mark.asyncio +async def test_cloudevent_func_wrapper_sync_function(): + called_with_event = None + + def sync_cloud_event(event): + nonlocal called_with_event + called_with_event = event + + wrapper = _cloudevent_func_wrapper(sync_cloud_event, is_async=False) + + request = Mock() + request.body = AsyncMock( + return_value=b'{"specversion": "1.0", "type": "test.event", "source": "test-source", "id": "123", "data": {"test": "data"}}' + ) + request.headers = {"content-type": "application/cloudevents+json"} + + response = await wrapper(request) + + assert response.body == b"OK" + assert response.status_code == 200 + + assert called_with_event is not None + assert called_with_event["type"] == "test.event" + assert called_with_event["source"] == "test-source" diff --git a/tests/test_cloud_event_functions.py b/tests/test_cloud_event_functions.py index 691fe388..2e7c281d 100644 --- a/tests/test_cloud_event_functions.py +++ b/tests/test_cloud_event_functions.py @@ -13,13 +13,25 @@ # limitations under the License. import json import pathlib +import sys import pytest -from cloudevents.http import CloudEvent, to_binary, to_structured +from cloudevents import conversion as ce_conversion +from cloudevents.http import CloudEvent + +if sys.version_info >= (3, 8): + from starlette.testclient import TestClient as StarletteTestClient +else: + StarletteTestClient = None from functions_framework import create_app +if sys.version_info >= (3, 8): + from functions_framework.aio import create_asgi_app +else: + create_asgi_app = None + TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" TEST_DATA_DIR = pathlib.Path(__file__).resolve().parent / "test_data" @@ -89,57 +101,63 @@ def background_event(): return json.load(f) -@pytest.fixture -def client(): - source = TEST_FUNCTIONS_DIR / "cloud_events" / "main.py" +@pytest.fixture(params=["main.py", "async_main.py"]) +def client(request): + source = TEST_FUNCTIONS_DIR / "cloud_events" / request.param target = "function" - return create_app(target, source, "cloudevent").test_client() + if not request.param.startswith("async_"): + return create_app(target, source, "cloudevent").test_client() + app = create_asgi_app(target, source, "cloudevent") + return StarletteTestClient(app) -@pytest.fixture -def empty_client(): - source = TEST_FUNCTIONS_DIR / "cloud_events" / "empty_data.py" +@pytest.fixture(params=["empty_data.py", "async_empty_data.py"]) +def empty_client(request): + source = TEST_FUNCTIONS_DIR / "cloud_events" / request.param target = "function" - return create_app(target, source, "cloudevent").test_client() + if not request.param.startswith("async_"): + return create_app(target, source, "cloudevent").test_client() + app = create_asgi_app(target, source, "cloudevent") + return StarletteTestClient(app) @pytest.fixture -def converted_background_event_client(): +def converted_background_event_client(request): source = TEST_FUNCTIONS_DIR / "cloud_events" / "converted_background_event.py" target = "function" return create_app(target, source, "cloudevent").test_client() def test_event(client, cloud_event_1_0): - headers, data = to_structured(cloud_event_1_0) + headers, data = ce_conversion.to_structured(cloud_event_1_0) resp = client.post("/", headers=headers, data=data) assert resp.status_code == 200 - assert resp.data == b"OK" + assert resp.text == "OK" def test_binary_event(client, cloud_event_1_0): - headers, data = to_binary(cloud_event_1_0) + headers, data = ce_conversion.to_binary(cloud_event_1_0) resp = client.post("/", headers=headers, data=data) assert resp.status_code == 200 - assert resp.data == b"OK" + assert resp.text == "OK" def test_event_0_3(client, cloud_event_0_3): - headers, data = to_structured(cloud_event_0_3) + headers, data = ce_conversion.to_structured(cloud_event_0_3) resp = client.post("/", headers=headers, data=data) assert resp.status_code == 200 - assert resp.data == b"OK" + assert resp.text == "OK" def test_binary_event_0_3(client, cloud_event_0_3): - headers, data = to_binary(cloud_event_0_3) + headers, data = ce_conversion.to_binary(cloud_event_0_3) resp = client.post("/", headers=headers, data=data) assert resp.status_code == 200 - assert resp.data == b"OK" + assert resp.text == "OK" @pytest.mark.parametrize("specversion", ["0.3", "1.0"]) @@ -156,7 +174,7 @@ def test_cloud_event_missing_required_binary_fields( resp = client.post("/", headers=invalid_headers, json=data_payload) assert resp.status_code == 400 - assert "MissingRequiredFields" in resp.get_data().decode() + assert "MissingRequiredFields" in resp.text @pytest.mark.parametrize("specversion", ["0.3", "1.0"]) @@ -174,7 +192,7 @@ def test_cloud_event_missing_required_structured_fields( resp = client.post("/", headers=headers, json=invalid_data) assert resp.status_code == 400 - assert "MissingRequiredFields" in resp.data.decode() + assert "MissingRequiredFields" in resp.text def test_invalid_fields_binary(client, create_headers_binary, data_payload): @@ -183,7 +201,7 @@ def test_invalid_fields_binary(client, create_headers_binary, data_payload): resp = client.post("/", headers=headers, json=data_payload) assert resp.status_code == 400 - assert "InvalidRequiredFields" in resp.data.decode() + assert "InvalidRequiredFields" in resp.text def test_unparsable_cloud_event(client): @@ -191,7 +209,7 @@ def test_unparsable_cloud_event(client): resp = client.post("/", headers=headers, data="") assert resp.status_code == 400 - assert "Bad Request" in resp.data.decode() + assert "Bad Request" in resp.text @pytest.mark.parametrize("specversion", ["0.3", "1.0"]) @@ -200,7 +218,7 @@ def test_empty_data_binary(empty_client, create_headers_binary, specversion): resp = empty_client.post("/", headers=headers, json="") assert resp.status_code == 200 - assert resp.get_data() == b"OK" + assert resp.text == "OK" @pytest.mark.parametrize("specversion", ["0.3", "1.0"]) @@ -211,7 +229,7 @@ def test_empty_data_structured(empty_client, specversion, create_structured_data resp = empty_client.post("/", headers=headers, json=data) assert resp.status_code == 200 - assert resp.get_data() == b"OK" + assert resp.text == "OK" @pytest.mark.parametrize("specversion", ["0.3", "1.0"]) @@ -220,7 +238,7 @@ def test_no_mime_type_structured(empty_client, specversion, create_structured_da resp = empty_client.post("/", headers={}, json=data) assert resp.status_code == 200 - assert resp.get_data() == b"OK" + assert resp.text == "OK" def test_background_event(converted_background_event_client, background_event): @@ -228,5 +246,6 @@ def test_background_event(converted_background_event_client, background_event): "/", headers={}, json=background_event ) + print(resp.text) assert resp.status_code == 200 - assert resp.get_data() == b"OK" + assert resp.text == "OK" diff --git a/tests/test_decorator_functions.py b/tests/test_decorator_functions.py index e8c9bc70..435aa815 100644 --- a/tests/test_decorator_functions.py +++ b/tests/test_decorator_functions.py @@ -12,13 +12,27 @@ # See the License for the specific language governing permissions and # limitations under the License. import pathlib +import sys import pytest -from cloudevents.http import CloudEvent, to_binary, to_structured +from cloudevents import conversion as ce_conversion +from cloudevents.http import CloudEvent + +# Conditional import for Starlette +if sys.version_info >= (3, 8): + from starlette.testclient import TestClient as StarletteTestClient +else: + StarletteTestClient = None from functions_framework import create_app +# Conditional import for async functionality +if sys.version_info >= (3, 8): + from functions_framework.aio import create_asgi_app +else: + create_asgi_app = None + TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" # Python 3.5: ModuleNotFoundError does not exist @@ -28,18 +42,24 @@ _ModuleNotFoundError = ImportError -@pytest.fixture -def cloud_event_decorator_client(): - source = TEST_FUNCTIONS_DIR / "decorators" / "decorator.py" +@pytest.fixture(params=["decorator.py", "async_decorator.py"]) +def cloud_event_decorator_client(request): + source = TEST_FUNCTIONS_DIR / "decorators" / request.param target = "function_cloud_event" - return create_app(target, source).test_client() + if not request.param.startswith("async_"): + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app) -@pytest.fixture -def http_decorator_client(): - source = TEST_FUNCTIONS_DIR / "decorators" / "decorator.py" +@pytest.fixture(params=["decorator.py", "async_decorator.py"]) +def http_decorator_client(request): + source = TEST_FUNCTIONS_DIR / "decorators" / request.param target = "function_http" - return create_app(target, source).test_client() + if not request.param.startswith("async_"): + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app) @pytest.fixture @@ -56,14 +76,55 @@ def cloud_event_1_0(): def test_cloud_event_decorator(cloud_event_decorator_client, cloud_event_1_0): - headers, data = to_structured(cloud_event_1_0) + headers, data = ce_conversion.to_structured(cloud_event_1_0) resp = cloud_event_decorator_client.post("/", headers=headers, data=data) - assert resp.status_code == 200 - assert resp.data == b"OK" + assert resp.text == "OK" def test_http_decorator(http_decorator_client): resp = http_decorator_client.post("/my_path", json={"mode": "path"}) assert resp.status_code == 200 - assert resp.data == b"/my_path" + assert resp.text == "/my_path" + + +def test_aio_sync_cloud_event_decorator(cloud_event_1_0): + """Test aio decorator with sync cloud event function.""" + source = TEST_FUNCTIONS_DIR / "decorators" / "async_decorator.py" + target = "function_cloud_event_sync" + + app = create_asgi_app(target, source) + client = StarletteTestClient(app) + + headers, data = ce_conversion.to_structured(cloud_event_1_0) + resp = client.post("/", headers=headers, data=data) + assert resp.status_code == 200 + assert resp.text == "OK" + + +def test_aio_sync_http_decorator(): + source = TEST_FUNCTIONS_DIR / "decorators" / "async_decorator.py" + target = "function_http_sync" + + app = create_asgi_app(target, source) + client = StarletteTestClient(app) + + resp = client.post("/my_path?mode=path") + assert resp.status_code == 200 + assert resp.text == "/my_path" + + resp = client.post("/other_path") + assert resp.status_code == 200 + assert resp.text == "sync response" + + +def test_aio_http_dict_response(): + source = TEST_FUNCTIONS_DIR / "decorators" / "async_decorator.py" + target = "function_http_dict_response" + + app = create_asgi_app(target, source) + client = StarletteTestClient(app) + + resp = client.post("/") + assert resp.status_code == 200 + assert resp.json() == {"message": "hello", "count": 42, "success": True} diff --git a/tests/test_functions.py b/tests/test_functions.py index f0bd7793..9107dc68 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -12,19 +12,31 @@ # See the License for the specific language governing permissions and # limitations under the License. -import io import json import pathlib import re +import sys import time import pretend import pytest +# Conditional import for Starlette +if sys.version_info >= (3, 8): + from starlette.testclient import TestClient as StarletteTestClient +else: + StarletteTestClient = None + import functions_framework from functions_framework import LazyWSGIApp, create_app, errorhandler, exceptions +# Conditional import for async functionality +if sys.version_info >= (3, 8): + from functions_framework.aio import create_asgi_app +else: + create_asgi_app = None + TEST_FUNCTIONS_DIR = pathlib.Path.cwd() / "tests" / "test_functions" @@ -72,127 +84,181 @@ def create_ce_headers(): } -def test_http_function_executes_success(): - source = TEST_FUNCTIONS_DIR / "http_trigger" / "main.py" +@pytest.fixture(params=["main.py", "async_main.py"]) +def http_trigger_client(request): + source = TEST_FUNCTIONS_DIR / "http_trigger" / request.param target = "function" + if not request.param.startswith("async_"): + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app, raise_server_exceptions=False) - client = create_app(target, source).test_client() - - resp = client.post("/my_path", json={"mode": "SUCCESS"}) - assert resp.status_code == 200 - assert resp.data == b"success" - -def test_http_function_executes_failure(): - source = TEST_FUNCTIONS_DIR / "http_trigger" / "main.py" +@pytest.fixture(params=["main.py", "async_main.py"]) +def http_request_check_client(request): + source = TEST_FUNCTIONS_DIR / "http_request_check" / request.param target = "function" - - client = create_app(target, source).test_client() - - resp = client.get("/", json={"mode": "FAILURE"}) - assert resp.status_code == 400 - assert resp.data == b"failure" + if not request.param.startswith("async_"): + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient( + app, + # Override baseurl to use localhost instead of default http://testserver. + base_url="http://localhost", + ) -def test_http_function_executes_throw(): - source = TEST_FUNCTIONS_DIR / "http_trigger" / "main.py" +@pytest.fixture(params=["main.py", "async_main.py"]) +def http_check_env_client(request): + source = TEST_FUNCTIONS_DIR / "http_check_env" / request.param target = "function" + if not request.param.startswith("async_"): + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app) - client = create_app(target, source).test_client() - resp = client.put("/", json={"mode": "THROW"}) - assert resp.status_code == 500 +@pytest.fixture(params=["main.py", "async_main.py"]) +def http_trigger_sleep_client(request): + source = TEST_FUNCTIONS_DIR / "http_trigger_sleep" / request.param + target = "function" + if not request.param.startswith("async_"): + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app) -def test_http_function_request_url_empty_path(): - source = TEST_FUNCTIONS_DIR / "http_request_check" / "main.py" +@pytest.fixture(params=["main.py", "async_main.py"]) +def http_with_import_client(request): + source = TEST_FUNCTIONS_DIR / "http_with_import" / request.param target = "function" + if not request.param.startswith("async_"): + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app) - client = create_app(target, source).test_client() - resp = client.get("", json={"mode": "url"}) - assert resp.status_code == 308 - assert resp.location == "http://localhost/" +@pytest.fixture(params=["sync", "async"]) +def http_method_check_client(request): + source = TEST_FUNCTIONS_DIR / "http_method_check" / "main.py" + target = "function" + if not request.param == "async": + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app) -def test_http_function_request_url_slash(): - source = TEST_FUNCTIONS_DIR / "http_request_check" / "main.py" +@pytest.fixture(params=["sync", "async"]) +def module_is_correct_client(request): + source = TEST_FUNCTIONS_DIR / "module_is_correct" / "main.py" target = "function" + if not request.param == "async": + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app) - client = create_app(target, source).test_client() - resp = client.get("/", json={"mode": "url"}) - assert resp.status_code == 200 - assert resp.data == b"http://localhost/" +@pytest.fixture(params=["sync", "async"]) +def returns_none_client(request): + source = TEST_FUNCTIONS_DIR / "returns_none" / "main.py" + target = "function" + if not request.param == "async": + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app) -def test_http_function_rquest_url_path(): - source = TEST_FUNCTIONS_DIR / "http_request_check" / "main.py" +@pytest.fixture(params=["sync", "async"]) +def relative_imports_client(request): + source = TEST_FUNCTIONS_DIR / "relative_imports" / "main.py" target = "function" + if not request.param == "async": + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app) - client = create_app(target, source).test_client() - resp = client.get("/my_path", json={"mode": "url"}) +def test_http_function_executes_success(http_trigger_client): + resp = http_trigger_client.post("/my_path", json={"mode": "SUCCESS"}) assert resp.status_code == 200 - assert resp.data == b"http://localhost/my_path" + assert resp.text == "success" -def test_http_function_request_path_slash(): - source = TEST_FUNCTIONS_DIR / "http_request_check" / "main.py" - target = "function" +def test_http_function_executes_failure(http_trigger_client): + resp = http_trigger_client.post("/", json={"mode": "FAILURE"}) + assert resp.status_code == 400 + assert resp.text == "failure" - client = create_app(target, source).test_client() - resp = client.get("/", json={"mode": "path"}) - assert resp.status_code == 200 - assert resp.data == b"/" +def test_http_function_executes_throw(http_trigger_client): + resp = http_trigger_client.put("/", json={"mode": "THROW"}) + assert resp.status_code == 500 -def test_http_function_request_path_path(): - source = TEST_FUNCTIONS_DIR / "http_request_check" / "main.py" - target = "function" +def test_http_function_request_url_empty_path(http_request_check_client): + # Starlette TestClient normalizes empty path "" to "/" before making the request, + # while Flask preserves the empty path and lets the server handle the redirect + if StarletteTestClient and isinstance( + http_request_check_client, StarletteTestClient + ): + # Starlette TestClient converts "" to "/" so we get a direct 200 response + resp = http_request_check_client.post("", json={"mode": "url"}) + assert resp.status_code == 200 + assert resp.text == "http://localhost/" + else: + # Flask returns a 308 redirect from empty path to "/" + resp = http_request_check_client.post("", json={"mode": "url"}) + assert resp.status_code == 308 + assert resp.location == "http://localhost/" + + +def test_http_function_request_url_slash(http_request_check_client): + resp = http_request_check_client.post("/", json={"mode": "url"}) + assert resp.status_code == 200 + assert resp.text == "http://localhost/" - client = create_app(target, source).test_client() - resp = client.get("/my_path", json={"mode": "path"}) +def test_http_function_rquest_url_path(http_request_check_client): + resp = http_request_check_client.post("/my_path", json={"mode": "url"}) assert resp.status_code == 200 - assert resp.data == b"/my_path" + assert resp.text == "http://localhost/my_path" -def test_http_function_check_env_function_target(): - source = TEST_FUNCTIONS_DIR / "http_check_env" / "main.py" - target = "function" +def test_http_function_request_path_slash(http_request_check_client): + resp = http_request_check_client.post("/", json={"mode": "path"}) + assert resp.status_code == 200 + assert resp.text == "/" - client = create_app(target, source).test_client() - resp = client.post("/", json={"mode": "FUNCTION_TARGET"}) +def test_http_function_request_path_path(http_request_check_client): + resp = http_request_check_client.post("/my_path", json={"mode": "path"}) assert resp.status_code == 200 - assert resp.data == b"function" + assert resp.text == "/my_path" -def test_http_function_check_env_function_signature_type(): - source = TEST_FUNCTIONS_DIR / "http_check_env" / "main.py" - target = "function" - - client = create_app(target, source).test_client() - - resp = client.post("/", json={"mode": "FUNCTION_SIGNATURE_TYPE"}) +def test_http_function_check_env_function_target(http_check_env_client): + resp = http_check_env_client.post("/", json={"mode": "FUNCTION_TARGET"}) assert resp.status_code == 200 - assert resp.data == b"http" + # Use .content for StarletteTestClient, .data for Flask test client (both return bytes) + data = getattr(resp, "content", getattr(resp, "data", None)) + assert data == b"function" -def test_http_function_execution_time(): - source = TEST_FUNCTIONS_DIR / "http_trigger_sleep" / "main.py" - target = "function" +def test_http_function_check_env_function_signature_type(http_check_env_client): + resp = http_check_env_client.post("/", json={"mode": "FUNCTION_SIGNATURE_TYPE"}) + assert resp.status_code == 200 + assert resp.text == "http" - client = create_app(target, source).test_client() +def test_http_function_execution_time(http_trigger_sleep_client): start_time = time.time() - resp = client.get("/", json={"mode": "1000"}) + resp = http_trigger_sleep_client.post("/", json={"mode": "1000"}) execution_time_sec = time.time() - start_time assert resp.status_code == 200 - assert resp.data == b"OK" + assert resp.text == "OK" + # Check that the execution time is roughly correct (allowing some buffer) + assert execution_time_sec > 0.9 def test_background_function_executes(background_event_client, background_json): @@ -268,7 +334,8 @@ def test_invalid_function_definition_missing_function_file(): ) -def test_invalid_function_definition_multiple_entry_points(): +@pytest.mark.parametrize("create_app", [create_app, create_asgi_app]) +def test_invalid_function_definition_multiple_entry_points(create_app): source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" target = "function" @@ -281,7 +348,8 @@ def test_invalid_function_definition_multiple_entry_points(): ) -def test_invalid_function_definition_multiple_entry_points_invalid_function(): +@pytest.mark.parametrize("create_app", [create_app, create_asgi_app]) +def test_invalid_function_definition_multiple_entry_points_invalid_function(create_app): source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" target = "invalidFunction" @@ -294,7 +362,8 @@ def test_invalid_function_definition_multiple_entry_points_invalid_function(): ) -def test_invalid_function_definition_multiple_entry_points_not_a_function(): +@pytest.mark.parametrize("create_app", [create_app, create_asgi_app]) +def test_invalid_function_definition_multiple_entry_points_not_a_function(create_app): source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" target = "notAFunction" @@ -308,7 +377,8 @@ def test_invalid_function_definition_multiple_entry_points_not_a_function(): ) -def test_invalid_function_definition_function_syntax_error(): +@pytest.mark.parametrize("create_app", [create_app, create_asgi_app]) +def test_invalid_function_definition_function_syntax_error(create_app): source = TEST_FUNCTIONS_DIR / "background_load_error" / "main.py" target = "function" @@ -336,7 +406,8 @@ def test_invalid_function_definition_function_syntax_robustness_with_debug(monke assert resp.status_code == 500 -def test_invalid_function_definition_missing_dependency(): +@pytest.mark.parametrize("create_app", [create_app, create_asgi_app]) +def test_invalid_function_definition_missing_dependency(create_app): source = TEST_FUNCTIONS_DIR / "background_missing_dependency" / "main.py" target = "function" @@ -346,7 +417,8 @@ def test_invalid_function_definition_missing_dependency(): assert "No module named 'nonexistentpackage'" in str(excinfo.value) -def test_invalid_configuration(): +@pytest.mark.parametrize("create_app", [create_app, create_asgi_app]) +def test_invalid_configuration(create_app): with pytest.raises(exceptions.InvalidConfigurationException) as excinfo: create_app(None, None, None) @@ -356,7 +428,8 @@ def test_invalid_configuration(): ) -def test_invalid_signature_type(): +@pytest.mark.parametrize("create_app", [create_app, create_asgi_app]) +def test_invalid_signature_type(create_app): source = TEST_FUNCTIONS_DIR / "http_trigger" / "main.py" target = "function" @@ -382,54 +455,39 @@ def test_http_function_flask_render_template(): ) -def test_http_function_with_import(): - source = TEST_FUNCTIONS_DIR / "http_with_import" / "main.py" - target = "function" - - client = create_app(target, source).test_client() - - resp = client.get("/") +def test_http_function_with_import(http_with_import_client): + resp = http_with_import_client.get("/") assert resp.status_code == 200 - assert resp.data == b"Hello" + assert resp.text == "Hello" @pytest.mark.parametrize( - "method, data", + "method, text", [ - ("get", b"GET"), - ("head", b""), # body will be empty - ("post", b"POST"), - ("put", b"PUT"), - ("delete", b"DELETE"), - ("options", b"OPTIONS"), - ("trace", b"TRACE"), - ("patch", b"PATCH"), + ("get", "GET"), + ("head", ""), # body will be empty + ("post", "POST"), + ("put", "PUT"), + ("delete", "DELETE"), + ("options", "OPTIONS"), + # ("trace", "TRACE"), # unsupported in httpx + ("patch", "PATCH"), ], ) -def test_http_function_all_methods(method, data): - source = TEST_FUNCTIONS_DIR / "http_method_check" / "main.py" - target = "function" - - client = create_app(target, source).test_client() - - resp = getattr(client, method)("/") +def test_http_function_all_methods(http_method_check_client, method, text): + resp = getattr(http_method_check_client, method)("/") assert resp.status_code == 200 - assert resp.data == data + assert resp.text == text @pytest.mark.parametrize("path", ["robots.txt", "favicon.ico"]) -def test_error_paths(path): - source = TEST_FUNCTIONS_DIR / "http_trigger" / "main.py" - target = "function" - - client = create_app(target, source).test_client() - - resp = client.get("/{}".format(path)) +def test_error_paths(http_trigger_client, path): + resp = http_trigger_client.get("/{}".format(path)) assert resp.status_code == 404 - assert b"Not Found" in resp.data + assert "Not Found" in resp.text @pytest.mark.parametrize( @@ -473,12 +531,8 @@ def function(): pass -def test_class_in_main_is_in_right_module(): - source = TEST_FUNCTIONS_DIR / "module_is_correct" / "main.py" - target = "function" - - client = create_app(target, source).test_client() - resp = client.get("/") +def test_class_in_main_is_in_right_module(module_is_correct_client): + resp = module_is_correct_client.get("/") assert resp.status_code == 200 @@ -493,12 +547,8 @@ def test_flask_current_app_is_available(): assert resp.status_code == 200 -def test_function_returns_none(): - source = TEST_FUNCTIONS_DIR / "returns_none" / "main.py" - target = "function" - - client = create_app(target, source).test_client() - resp = client.get("/") +def test_function_returns_none(returns_none_client): + resp = returns_none_client.get("/") assert resp.status_code == 500 @@ -515,6 +565,20 @@ def test_function_returns_stream(): assert resp.data.decode("utf-8") == "1.0\n3.0\n6.0\n10.0\n" +def test_async_function_returns_stream(): + source = TEST_FUNCTIONS_DIR / "http_streaming" / "async_main.py" + target = "function" + + client = StarletteTestClient(create_asgi_app(target, source)) + + collected_response = "" + with client.stream("POST", "/", content="1\n2\n3\n4\n") as resp: + assert resp.status_code == 200 + for text in resp.iter_text(): + collected_response += text + assert collected_response == "1.0\n3.0\n6.0\n10.0\n" + + def test_legacy_function_check_env(monkeypatch): source = TEST_FUNCTIONS_DIR / "http_check_env" / "main.py" target = "function" @@ -633,12 +697,7 @@ def tests_cloud_to_background_event_client_invalid_source( assert resp.status_code == 500 -def test_relative_imports(): - source = TEST_FUNCTIONS_DIR / "relative_imports" / "main.py" - target = "function" - - client = create_app(target, source).test_client() - - resp = client.get("/") +def test_relative_imports(relative_imports_client): + resp = relative_imports_client.get("/") assert resp.status_code == 200 - assert resp.data == b"success" + assert resp.text == "success" diff --git a/tests/test_functions/cloud_events/async_empty_data.py b/tests/test_functions/cloud_events/async_empty_data.py new file mode 100644 index 00000000..afc94c99 --- /dev/null +++ b/tests/test_functions/cloud_events/async_empty_data.py @@ -0,0 +1,38 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used to test handling CloudEvent (async) functions.""" +from starlette.exceptions import HTTPException + + +async def function(cloud_event): + """Test Event function that checks to see if a valid CloudEvent was sent. + + The function returns 200 if it received the expected event, otherwise 500. + + Args: + cloud_event: A CloudEvent as defined by https://github.com/cloudevents/sdk-python. + + Returns: + HTTP status code indicating whether valid event was sent or not. + + """ + valid_event = ( + cloud_event["id"] == "my-id" + and cloud_event["source"] == "from-galaxy-far-far-away" + and cloud_event["type"] == "cloud_event.greet.you" + ) + + if not valid_event: + raise HTTPException(status_code=500, detail="Something went wrong internally.") diff --git a/tests/test_functions/cloud_events/async_main.py b/tests/test_functions/cloud_events/async_main.py new file mode 100644 index 00000000..7e9b5423 --- /dev/null +++ b/tests/test_functions/cloud_events/async_main.py @@ -0,0 +1,40 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used to test handling CloudEvent (async) functions.""" +from starlette.exceptions import HTTPException + + +async def function(cloud_event): + """Test Event function that checks to see if a valid CloudEvent was sent. + + The function returns 200 if it received the expected event, otherwise 500. + + Args: + cloud_event: A CloudEvent as defined by https://github.com/cloudevents/sdk-python. + + Returns: + HTTP status code indicating whether valid event was sent or not. + + """ + valid_event = ( + cloud_event["id"] == "my-id" + and cloud_event.data == {"name": "john"} + and cloud_event["source"] == "from-galaxy-far-far-away" + and cloud_event["type"] == "cloud_event.greet.you" + and cloud_event["time"] == "2020-08-16T13:58:54.471765" + ) + + if not valid_event: + raise HTTPException(status_code=500, detail="Something went wrong internally.") diff --git a/tests/test_functions/decorators/async_decorator.py b/tests/test_functions/decorators/async_decorator.py new file mode 100644 index 00000000..0c0db7e4 --- /dev/null +++ b/tests/test_functions/decorators/async_decorator.py @@ -0,0 +1,98 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used to test handling functions using decorators.""" +from starlette.exceptions import HTTPException + +import functions_framework.aio + + +@functions_framework.aio.cloud_event +async def function_cloud_event(cloud_event): + """Test Event function that checks to see if a valid CloudEvent was sent. + + The function returns 200 if it received the expected event, otherwise 500. + + Args: + cloud_event: A CloudEvent as defined by https://github.com/cloudevents/sdk-python. + + Returns: + HTTP status code indicating whether valid event was sent or not. + """ + valid_event = ( + cloud_event["id"] == "my-id" + and cloud_event.data == {"name": "john"} + and cloud_event["source"] == "from-galaxy-far-far-away" + and cloud_event["type"] == "cloud_event.greet.you" + and cloud_event["time"] == "2020-08-16T13:58:54.471765" + ) + + if not valid_event: + raise HTTPException(500) + + +@functions_framework.aio.http +async def function_http(request): + """Test function which returns the requested element of the HTTP request. + + Name of the requested HTTP request element is provided in the 'mode' field in + the incoming JSON document. + + Args: + request: The HTTP request which triggered this function. Must contain name + of the requested HTTP request element in the 'mode' field in JSON document + in request body. + + Returns: + Value of the requested HTTP request element, or 'Bad Request' status in case + of unrecognized incoming request. + """ + data = await request.json() + mode = data["mode"] + if mode == "path": + return request.url.path + else: + raise HTTPException(400) + + +@functions_framework.aio.cloud_event +def function_cloud_event_sync(cloud_event): + """Test sync CloudEvent function with aio decorator.""" + valid_event = ( + cloud_event["id"] == "my-id" + and cloud_event.data == {"name": "john"} + and cloud_event["source"] == "from-galaxy-far-far-away" + and cloud_event["type"] == "cloud_event.greet.you" + and cloud_event["time"] == "2020-08-16T13:58:54.471765" + ) + + if not valid_event: + raise HTTPException(500) + + +@functions_framework.aio.http +def function_http_sync(request): + """Test sync HTTP function with aio decorator.""" + # Use query params since they're accessible synchronously + mode = request.query_params.get("mode") + if mode == "path": + return request.url.path + else: + return "sync response" + + +@functions_framework.aio.http +def function_http_dict_response(request): + """Test sync HTTP function returning dict with aio decorator.""" + return {"message": "hello", "count": 42, "success": True} diff --git a/tests/test_functions/http_check_env/async_main.py b/tests/test_functions/http_check_env/async_main.py new file mode 100644 index 00000000..dd91faec --- /dev/null +++ b/tests/test_functions/http_check_env/async_main.py @@ -0,0 +1,36 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used in Worker tests of environment variables setup.""" +import os + +X_GOOGLE_FUNCTION_NAME = "gcf-function" +X_GOOGLE_ENTRY_POINT = "function" +HOME = "/tmp" + + +async def function(request): + """Test function which returns the requested environment variable value. + + Args: + request: The HTTP request which triggered this function. Must contain name + of the requested environment variable in the 'mode' field in JSON document + in request body. + + Returns: + Value of the requested environment variable. + """ + data = await request.json() + name = data.get("mode") + return os.environ[name] diff --git a/tests/test_functions/http_request_check/async_main.py b/tests/test_functions/http_request_check/async_main.py new file mode 100644 index 00000000..bf0e7ce5 --- /dev/null +++ b/tests/test_functions/http_request_check/async_main.py @@ -0,0 +1,40 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used in Worker tests of HTTP request contents.""" + + +async def function(request): + """Test function which returns the requested element of the HTTP request. + + Name of the requested HTTP request element is provided in the 'mode' field in + the incoming JSON document. + + Args: + request: The HTTP request which triggered this function. Must contain name + of the requested HTTP request element in the 'mode' field in JSON document + in request body. + + Returns: + Value of the requested HTTP request element, or 'Bad Request' status in case + of unrecognized incoming request. + """ + data = await request.json() + mode = data.get("mode") + if mode == "path": + return request.url.path + elif mode == "url": + return str(request.url) + else: + return "invalid request", 400 diff --git a/tests/test_functions/http_streaming/async_main.py b/tests/test_functions/http_streaming/async_main.py new file mode 100644 index 00000000..1db2a7b9 --- /dev/null +++ b/tests/test_functions/http_streaming/async_main.py @@ -0,0 +1,46 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Async function used in Worker tests of handling HTTP functions.""" + +import asyncio + +from starlette.responses import StreamingResponse + + +async def function(request): + """Test async HTTP function that reads a stream of integers and returns a stream + providing the sum of values read so far. + + Args: + request: The HTTP request which triggered this function. Must contain a + stream of new line separated integers. + + Returns: + A Starlette StreamingResponse. + """ + print("INVOKED THE ASYNC STREAM FUNCTION!!!") + + body = await request.body() + body_str = body.decode("utf-8") + lines = body_str.strip().split("\n") if body_str.strip() else [] + + def generate(): + sum_so_far = 0 + for line in lines: + if line.strip(): + sum_so_far += float(line) + yield (str(sum_so_far) + "\n").encode("utf-8") + + return StreamingResponse(generate()) diff --git a/tests/test_functions/http_trigger/async_main.py b/tests/test_functions/http_trigger/async_main.py new file mode 100644 index 00000000..0e487d52 --- /dev/null +++ b/tests/test_functions/http_trigger/async_main.py @@ -0,0 +1,48 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used in Worker tests of handling HTTP functions.""" + +from starlette.exceptions import HTTPException +from starlette.responses import Response + + +async def function(request): + """Test HTTP function whose behavior depends on the given mode. + + The function returns a success, a failure, or throws an exception, depending + on the given mode. + + Args: + request: The HTTP request which triggered this function. Must contain name + of the requested mode in the 'mode' field in JSON document in request + body. + + Returns: + Value and status code defined for the given mode. + + Raises: + Exception: Thrown when requested in the incoming mode specification. + """ + data = await request.json() + mode = data.get("mode") + print("Mode: " + mode) + if mode == "SUCCESS": + return "success", 200 + elif mode == "FAILURE": + raise HTTPException(status_code=400, detail="failure") + elif mode == "THROW": + raise Exception("omg") + else: + return "invalid request", 400 diff --git a/tests/test_functions/http_trigger_sleep/async_main.py b/tests/test_functions/http_trigger_sleep/async_main.py new file mode 100644 index 00000000..fe77be1e --- /dev/null +++ b/tests/test_functions/http_trigger_sleep/async_main.py @@ -0,0 +1,33 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Async function used in Worker tests of function execution time.""" +import asyncio + + +async def function(request): + """Async test function which sleeps for the given number of seconds. + + The test verifies that it gets the response from the function only after the + given number of seconds. + + Args: + request: The HTTP request which triggered this function. Must contain the + requested number of seconds in the 'mode' field in JSON document in + request body. + """ + payload = await request.json() + sleep_sec = int(payload.get("mode")) / 1000.0 + await asyncio.sleep(sleep_sec) + return "OK" diff --git a/tests/test_functions/http_with_import/async_main.py b/tests/test_functions/http_with_import/async_main.py new file mode 100644 index 00000000..75a1dcac --- /dev/null +++ b/tests/test_functions/http_with_import/async_main.py @@ -0,0 +1,29 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used in Worker tests of handling HTTP functions.""" + +from foo import bar + + +async def function(request): + """Test HTTP function which imports from another file + + Args: + request: The HTTP request which triggered this function. + + Returns: + The imported return value and status code defined for the given mode. + """ + return bar diff --git a/tests/test_typing.py b/tests/test_typing.py index 279cd636..0ca90b47 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -14,3 +14,15 @@ def hello(request: flask.Request) -> flask.typing.ResponseReturnValue: @functions_framework.cloud_event def hello_cloud_event(cloud_event: CloudEvent) -> None: print(f"Received event: id={cloud_event['id']} and data={cloud_event.data}") + + from starlette.requests import Request + + import functions_framework.aio + + @functions_framework.aio.http + async def hello_async(request: Request) -> str: + return "Hello world!" + + @functions_framework.aio.cloud_event + async def hello_cloud_event_async(cloud_event: CloudEvent) -> None: + print(f"Received event: id={cloud_event['id']} and data={cloud_event.data}") diff --git a/tox.ini b/tox.ini index 1599608c..fd3e38a6 100644 --- a/tox.ini +++ b/tox.ini @@ -24,12 +24,19 @@ envlist = usedevelop = true deps = docker + httpx pytest-asyncio pytest-cov pytest-integration pretend +extras = + async setenv = PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 + # Python 3.7: Use .coveragerc-py37 to exclude aio module from coverage since it requires Python 3.8+ (Starlette dependency) + py37-ubuntu-22.04: PYTESTARGS = --cov=functions_framework --cov-config=.coveragerc-py37 --cov-branch --cov-report term-missing --cov-fail-under=100 + py37-macos-13: PYTESTARGS = --cov=functions_framework --cov-config=.coveragerc-py37 --cov-branch --cov-report term-missing --cov-fail-under=100 + py37-windows-latest: PYTESTARGS = windows-latest: PYTESTARGS = commands = pytest {env:PYTESTARGS} {posargs} @@ -41,6 +48,8 @@ deps = isort mypy build +extras = + async commands = black --check src tests conftest.py --exclude tests/test_functions/background_load_error/main.py isort -c src tests conftest.py From 268acf121015bf2a5592715e4cfe582f9d236ff8 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Thu, 12 Jun 2025 15:41:35 -0700 Subject: [PATCH 151/181] feat: add flag to run functions framework in asgi stack (#376) * feat: add ASGI server support for async functions - Add uvicorn and uvicorn-worker to async optional dependencies - Refactor gunicorn.py with BaseGunicornApplication for shared config - Add UvicornApplication class for ASGI apps - Add StarletteApplication in asgi.py for development mode - Update HTTPServer to auto-detect Flask (WSGI) vs other (ASGI) apps - Add --gateway CLI flag to choose between wsgi and asgi - Update test_http.py to use Flask instance in tests * fix: apply black and isort formatting to source files * test: add comprehensive tests for ASGI server support - Add tests for HTTPServer ASGI/WSGI auto-detection - Add tests for StarletteApplication and UvicornApplication - Add tests for CLI --gateway flag functionality - Add integration tests for async functions with ASGI - Ensure 100% code coverage for new ASGI features - Apply black and isort formatting to test files * fix: skip async test files on Python 3.7 * fix: exclude async code from Python 3.7 coverage * fix: Install uvicorn on windows. * fix: unable to use reload in starlette. * fix: add missing endpoint parameter to Route constructors in ASGI * feat: add async conformance tests with ASGI gateway * fix: set UvicornWorker class before parent init and update tests * fix: update asgi tests to remove reload option * feat: add async-specific conformance tests for ASGI mode * fix: apply black formatting to async files * fix: disable validateMapping for CloudEvent tests in ASGI mode ASGI mode does not support automatic conversion from legacy events to CloudEvents, so validateMapping must be false for CloudEvent conformance tests. * fix: avoid mutating options dict in Gunicorn applications Create a copy of the options dict before modifying it to prevent side effects when the same options dict is reused elsewhere. This could cause issues with timeout tests. * fix: add pragma comments for Python 3.7 coverage and fix options handling - Add pragma: no cover comments for ASGI-specific code paths that won't execute in Python 3.7 - Fix options dict handling to use consistent variable names to avoid confusion * fix: revert to separate GunicornApplication and UvicornApplication classes Remove the BaseGunicornApplication abstraction as it was causing issues with the timeout mechanism. Each class now independently extends gunicorn.app.base.BaseApplication, which is cleaner and avoids the problems we were seeing with shared state and options handling. * refactor: restore GunicornApplication to match main branch Remove unnecessary changes to GunicornApplication class, keeping only the UvicornApplication addition for ASGI support. * chore: Untrack uv.lock * chore: rename confirmance test (asgi) github workflow * chore: cleanup .gitignore. * chore: clean up unncessary comments. --- .coveragerc-py37 | 13 ++- .github/workflows/conformance-asgi.yml | 91 ++++++++++++++++ .gitignore | 1 + conftest.py | 4 +- pyproject.toml | 6 +- src/functions_framework/_cli.py | 17 ++- src/functions_framework/_http/__init__.py | 37 +++++-- src/functions_framework/_http/asgi.py | 43 ++++++++ src/functions_framework/_http/gunicorn.py | 25 +++++ src/functions_framework/aio/__init__.py | 8 +- tests/conformance/async_main.py | 59 +++++++++++ tests/test_asgi.py | 120 ++++++++++++++++++++++ tests/test_cli.py | 21 ++++ tests/test_functions.py | 2 - tests/test_http.py | 119 ++++++++++++++++++++- 15 files changed, 544 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/conformance-asgi.yml create mode 100644 src/functions_framework/_http/asgi.py create mode 100644 tests/conformance/async_main.py create mode 100644 tests/test_asgi.py diff --git a/.coveragerc-py37 b/.coveragerc-py37 index 13be2ea1..fb6dbb6e 100644 --- a/.coveragerc-py37 +++ b/.coveragerc-py37 @@ -4,7 +4,18 @@ # This file is only used by py37-* tox environments omit = */functions_framework/aio/* + */functions_framework/_http/asgi.py */.tox/* */tests/* */venv/* - */.venv/* \ No newline at end of file + */.venv/* + +[report] +exclude_lines = + # Have to re-enable the standard pragma + pragma: no cover + + # Don't complain about async-specific imports and code + from functions_framework.aio import + from functions_framework._http.asgi import + from functions_framework._http.gunicorn import UvicornApplication \ No newline at end of file diff --git a/.github/workflows/conformance-asgi.yml b/.github/workflows/conformance-asgi.yml new file mode 100644 index 00000000..c69d1862 --- /dev/null +++ b/.github/workflows/conformance-asgi.yml @@ -0,0 +1,91 @@ +name: Python Conformance CI (asgi) +on: + push: + branches: + - 'main' + pull_request: + +# Declare default permissions as read only. +permissions: read-all + +jobs: + build: + strategy: + matrix: + python: ['3.8', '3.9', '3.10', '3.11', '3.12'] + platform: [ubuntu-latest] + runs-on: ${{ matrix.platform }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 + with: + disable-sudo: true + egress-policy: block + allowed-endpoints: > + api.github.com:443 + files.pythonhosted.org:443 + github.com:443 + objects.githubusercontent.com:443 + proxy.golang.org:443 + pypi.org:443 + storage.googleapis.com:443 + + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: ${{ matrix.python }} + + - name: Install the framework with async extras + run: python -m pip install -e .[async] + + - name: Setup Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version: '1.24' + + - name: Run HTTP conformance tests + uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + with: + functionType: 'http' + useBuildpacks: false + validateMapping: false + cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http --signature-type http --gateway asgi'" + + - name: Run CloudEvents conformance tests + uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + with: + functionType: 'cloudevent' + useBuildpacks: false + validateMapping: false + cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event --signature-type cloudevent --gateway asgi'" + + - name: Run HTTP conformance tests declarative + uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + with: + functionType: 'http' + useBuildpacks: false + validateMapping: false + cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative --gateway asgi'" + + - name: Run CloudEvents conformance tests declarative + uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + with: + functionType: 'cloudevent' + useBuildpacks: false + validateMapping: false + cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event_declarative --gateway asgi'" + + - name: Run HTTP concurrency tests declarative + uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + with: + functionType: 'http' + useBuildpacks: false + validateConcurrency: true + cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative_concurrent --gateway asgi'" + + # Note: Event (legacy) and Typed tests are not supported in ASGI mode + # Note: validateMapping is set to false for CloudEvent tests because ASGI mode + # does not support automatic conversion from legacy events to CloudEvents \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8b5379fe..967d4513 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ dist/ function_output.json serverlog_stderr.txt serverlog_stdout.txt +venv/ diff --git a/conftest.py b/conftest.py index f72314ed..1d17e9bf 100644 --- a/conftest.py +++ b/conftest.py @@ -50,8 +50,8 @@ def pytest_ignore_collect(collection_path, config): if sys.version_info >= (3, 8): return None - # Skip test_aio.py entirely on Python 3.7 - if collection_path.name == "test_aio.py": + # Skip test_aio.py and test_asgi.py entirely on Python 3.7 + if collection_path.name in ["test_aio.py", "test_asgi.py"]: return True return None diff --git a/pyproject.toml b/pyproject.toml index 3a631b5d..350d8997 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,11 @@ dependencies = [ Homepage = "https://github.com/googlecloudplatform/functions-framework-python" [project.optional-dependencies] -async = ["starlette>=0.37.0,<1.0.0; python_version>='3.8'"] +async = [ + "starlette>=0.37.0,<1.0.0; python_version>='3.8'", + "uvicorn>=0.18.0,<1.0.0; python_version>='3.8'", + "uvicorn-worker>=0.2.0,<1.0.0; python_version>='3.8'" +] [project.scripts] ff = "functions_framework._cli:_cli" diff --git a/src/functions_framework/_cli.py b/src/functions_framework/_cli.py index 773dd4cd..e27b5446 100644 --- a/src/functions_framework/_cli.py +++ b/src/functions_framework/_cli.py @@ -32,6 +32,19 @@ @click.option("--host", envvar="HOST", type=click.STRING, default="0.0.0.0") @click.option("--port", envvar="PORT", type=click.INT, default=8080) @click.option("--debug", envvar="DEBUG", is_flag=True) -def _cli(target, source, signature_type, host, port, debug): - app = create_app(target, source, signature_type) +@click.option( + "--gateway", + envvar="GATEWAY", + type=click.Choice(["wsgi", "asgi"]), + default="wsgi", + help="Server gateway interface type (wsgi for sync, asgi for async)", +) +def _cli(target, source, signature_type, host, port, debug, gateway): + if gateway == "asgi": # pragma: no cover + from functions_framework.aio import create_asgi_app + + app = create_asgi_app(target, source, signature_type) + else: + app = create_app(target, source, signature_type) + create_server(app, debug).run(host, port) diff --git a/src/functions_framework/_http/__init__.py b/src/functions_framework/_http/__init__.py index ca9b0f5c..fa2cbc09 100644 --- a/src/functions_framework/_http/__init__.py +++ b/src/functions_framework/_http/__init__.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from flask import Flask + from functions_framework._http.flask import FlaskApplication @@ -21,15 +23,30 @@ def __init__(self, app, debug, **options): self.debug = debug self.options = options - if self.debug: - self.server_class = FlaskApplication - else: - try: - from functions_framework._http.gunicorn import GunicornApplication - - self.server_class = GunicornApplication - except ImportError as e: + if isinstance(app, Flask): + if self.debug: self.server_class = FlaskApplication + else: + try: + from functions_framework._http.gunicorn import GunicornApplication + + self.server_class = GunicornApplication + except ImportError as e: + self.server_class = FlaskApplication + else: # pragma: no cover + if self.debug: + from functions_framework._http.asgi import StarletteApplication + + self.server_class = StarletteApplication + else: + try: + from functions_framework._http.gunicorn import UvicornApplication + + self.server_class = UvicornApplication + except ImportError as e: + from functions_framework._http.asgi import StarletteApplication + + self.server_class = StarletteApplication def run(self, host, port): http_server = self.server_class( @@ -38,5 +55,5 @@ def run(self, host, port): http_server.run() -def create_server(wsgi_app, debug, **options): - return HTTPServer(wsgi_app, debug, **options) +def create_server(app, debug, **options): + return HTTPServer(app, debug, **options) diff --git a/src/functions_framework/_http/asgi.py b/src/functions_framework/_http/asgi.py new file mode 100644 index 00000000..083ffc2e --- /dev/null +++ b/src/functions_framework/_http/asgi.py @@ -0,0 +1,43 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import uvicorn + + +class StarletteApplication: + """A Starlette application that uses Uvicorn for direct serving (development mode).""" + + def __init__(self, app, host, port, debug, **options): + """Initialize the Starlette application. + + Args: + app: The ASGI application to serve + host: The host to bind to + port: The port to bind to + debug: Whether to run in debug mode + **options: Additional options to pass to Uvicorn + """ + self.app = app + self.host = host + self.port = port + self.debug = debug + + self.options = { + "log_level": "debug" if debug else "error", + } + self.options.update(options) + + def run(self): + """Run the Uvicorn server directly.""" + uvicorn.run(self.app, host=self.host, port=int(self.port), **self.options) diff --git a/src/functions_framework/_http/gunicorn.py b/src/functions_framework/_http/gunicorn.py index 92cad90e..745ce2f8 100644 --- a/src/functions_framework/_http/gunicorn.py +++ b/src/functions_framework/_http/gunicorn.py @@ -70,3 +70,28 @@ class GThreadWorkerWithTimeoutSupport(ThreadWorker): # pragma: no cover def handle_request(self, req, conn): with ThreadingTimeout(TIMEOUT_SECONDS): super(GThreadWorkerWithTimeoutSupport, self).handle_request(req, conn) + + +class UvicornApplication(gunicorn.app.base.BaseApplication): + """Gunicorn application for ASGI apps using Uvicorn workers.""" + + def __init__(self, app, host, port, debug, **options): + self.options = { + "bind": "%s:%s" % (host, port), + "workers": int(os.environ.get("WORKERS", 1)), + "worker_class": "uvicorn_worker.UvicornWorker", + "timeout": int(os.environ.get("CLOUD_RUN_TIMEOUT_SECONDS", 0)), + "loglevel": os.environ.get("GUNICORN_LOG_LEVEL", "error"), + "limit_request_line": 0, + } + self.options.update(options) + self.app = app + + super().__init__() + + def load_config(self): + for key, value in self.options.items(): + self.cfg.set(key, value) + + def load(self): + return self.app diff --git a/src/functions_framework/aio/__init__.py b/src/functions_framework/aio/__init__.py index 832d6818..21f12754 100644 --- a/src/functions_framework/aio/__init__.py +++ b/src/functions_framework/aio/__init__.py @@ -197,14 +197,16 @@ def create_asgi_app(target=None, source=None, signature_type=None): routes.append( Route( "/{path:path}", - http_handler, + endpoint=http_handler, methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH"], ) ) elif signature_type == _function_registry.CLOUDEVENT_SIGNATURE_TYPE: cloudevent_handler = _cloudevent_func_wrapper(function, is_async) - routes.append(Route("/{path:path}", cloudevent_handler, methods=["POST"])) - routes.append(Route("/", cloudevent_handler, methods=["POST"])) + routes.append( + Route("/{path:path}", endpoint=cloudevent_handler, methods=["POST"]) + ) + routes.append(Route("/", endpoint=cloudevent_handler, methods=["POST"])) elif signature_type == _function_registry.TYPED_SIGNATURE_TYPE: raise FunctionsFrameworkException( f"ASGI server does not support typed events (signature type: '{signature_type}'). " diff --git a/tests/conformance/async_main.py b/tests/conformance/async_main.py new file mode 100644 index 00000000..2a7b30a1 --- /dev/null +++ b/tests/conformance/async_main.py @@ -0,0 +1,59 @@ +import asyncio +import json + +from cloudevents.http import to_json + +import functions_framework.aio + +filename = "function_output.json" + + +class RawJson: + data: dict + + def __init__(self, data): + self.data = data + + @staticmethod + def from_dict(obj: dict) -> "RawJson": + return RawJson(obj) + + def to_dict(self) -> dict: + return self.data + + +def _write_output(content): + with open(filename, "w") as f: + f.write(content) + + +async def write_http(request): + json_data = await request.json() + _write_output(json.dumps(json_data)) + return "OK", 200 + + +async def write_cloud_event(cloud_event): + _write_output(to_json(cloud_event).decode()) + + +@functions_framework.aio.http +async def write_http_declarative(request): + json_data = await request.json() + _write_output(json.dumps(json_data)) + return "OK", 200 + + +@functions_framework.aio.cloud_event +async def write_cloud_event_declarative(cloud_event): + _write_output(to_json(cloud_event).decode()) + + +@functions_framework.aio.http +async def write_http_declarative_concurrent(request): + await asyncio.sleep(1) + return "OK", 200 + + +# Note: Typed events are not supported in ASGI mode yet +# Legacy event functions are also not supported in ASGI mode diff --git a/tests/test_asgi.py b/tests/test_asgi.py new file mode 100644 index 00000000..cd117bd3 --- /dev/null +++ b/tests/test_asgi.py @@ -0,0 +1,120 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys + +import flask +import pretend +import pytest + +import functions_framework._http + +try: + from starlette.applications import Starlette +except ImportError: + pass + + +def test_httpserver_detects_asgi_app(): + flask_app = flask.Flask("test") + flask_wrapper = functions_framework._http.HTTPServer(flask_app, debug=True) + assert flask_wrapper.server_class.__name__ == "FlaskApplication" + + starlette_app = Starlette(routes=[]) + starlette_wrapper = functions_framework._http.HTTPServer(starlette_app, debug=True) + assert starlette_wrapper.server_class.__name__ == "StarletteApplication" + + +@pytest.mark.skipif("platform.system() == 'Windows'") +def test_httpserver_production_asgi(): + starlette_app = Starlette(routes=[]) + wrapper = functions_framework._http.HTTPServer(starlette_app, debug=False) + assert wrapper.server_class.__name__ == "UvicornApplication" + + +def test_starlette_application_init(): + from functions_framework._http.asgi import StarletteApplication + + app = pretend.stub() + host = "1.2.3.4" + port = "5678" + + # Test debug mode + starlette_app = StarletteApplication(app, host, port, debug=True, custom="value") + assert starlette_app.app == app + assert starlette_app.host == host + assert starlette_app.port == port + assert starlette_app.debug is True + assert starlette_app.options["log_level"] == "debug" + assert starlette_app.options["custom"] == "value" + + # Test production mode + starlette_app = StarletteApplication(app, host, port, debug=False) + assert starlette_app.options["log_level"] == "error" + + +@pytest.mark.skipif("platform.system() == 'Windows'") +def test_uvicorn_application_init(): + from functions_framework._http.gunicorn import UvicornApplication + + app = pretend.stub() + host = "1.2.3.4" + port = "1234" + + uvicorn_app = UvicornApplication(app, host, port, debug=False) + assert uvicorn_app.app == app + assert uvicorn_app.options["worker_class"] == "uvicorn_worker.UvicornWorker" + assert uvicorn_app.options["bind"] == "1.2.3.4:1234" + assert uvicorn_app.load() == app + + +def test_httpserver_fallback_on_import_error(monkeypatch): + starlette_app = Starlette(routes=[]) + + monkeypatch.setitem(sys.modules, "functions_framework._http.gunicorn", None) + + wrapper = functions_framework._http.HTTPServer(starlette_app, debug=False) + assert wrapper.server_class.__name__ == "StarletteApplication" + + +def test_starlette_application_run(monkeypatch): + uvicorn_run_calls = [] + + def mock_uvicorn_run(app, **kwargs): + uvicorn_run_calls.append((app, kwargs)) + + uvicorn_stub = pretend.stub(run=mock_uvicorn_run) + monkeypatch.setitem(sys.modules, "uvicorn", uvicorn_stub) + + # Clear and re-import to get fresh module with mocked uvicorn + if "functions_framework._http.asgi" in sys.modules: + del sys.modules["functions_framework._http.asgi"] + + from functions_framework._http.asgi import StarletteApplication + + app = pretend.stub() + host = "1.2.3.4" + port = "5678" + + starlette_app = StarletteApplication(app, host, port, debug=True, custom="value") + starlette_app.run() + + assert len(uvicorn_run_calls) == 1 + assert uvicorn_run_calls[0][0] == app + assert uvicorn_run_calls[0][1] == { + "host": host, + "port": int(port), + "log_level": "debug", + "custom": "value", + } diff --git a/tests/test_cli.py b/tests/test_cli.py index 7613b649..17445d11 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import sys + import pretend import pytest @@ -103,3 +105,22 @@ def test_cli(monkeypatch, args, env, create_app_calls, run_calls): assert result.exit_code == 0 assert create_app.calls == create_app_calls assert wsgi_server.run.calls == run_calls + + +def test_asgi_cli(monkeypatch): + asgi_server = pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None)) + asgi_app = pretend.stub() + + create_asgi_app = pretend.call_recorder(lambda *a, **kw: asgi_app) + aio_module = pretend.stub(create_asgi_app=create_asgi_app) + monkeypatch.setitem(sys.modules, "functions_framework.aio", aio_module) + + create_server = pretend.call_recorder(lambda *a, **kw: asgi_server) + monkeypatch.setattr(functions_framework._cli, "create_server", create_server) + + runner = CliRunner() + result = runner.invoke(_cli, ["--target", "foo", "--gateway", "asgi"]) + + assert result.exit_code == 0 + assert create_asgi_app.calls == [pretend.call("foo", None, "http")] + assert asgi_server.run.calls == [pretend.call("0.0.0.0", 8080)] diff --git a/tests/test_functions.py b/tests/test_functions.py index 9107dc68..534f4a88 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -21,7 +21,6 @@ import pretend import pytest -# Conditional import for Starlette if sys.version_info >= (3, 8): from starlette.testclient import TestClient as StarletteTestClient else: @@ -31,7 +30,6 @@ from functions_framework import LazyWSGIApp, create_app, errorhandler, exceptions -# Conditional import for async functionality if sys.version_info >= (3, 8): from functions_framework.aio import create_asgi_app else: diff --git a/tests/test_http.py b/tests/test_http.py index fbfac9d2..df9d4c6c 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -16,6 +16,7 @@ import platform import sys +import flask import pretend import pytest @@ -45,7 +46,7 @@ def test_create_server(monkeypatch, debug): ], ) def test_httpserver(monkeypatch, debug, gunicorn_missing, expected): - app = pretend.stub() + app = flask.Flask("test") http_server = pretend.stub(run=pretend.call_recorder(lambda: None)) server_classes = { "flask": pretend.call_recorder(lambda *a, **kw: http_server), @@ -133,3 +134,119 @@ def test_flask_application(debug): assert app.run.calls == [ pretend.call(host, port, debug=debug, a=options["a"], b=options["b"]), ] + + +@pytest.mark.parametrize( + "debug, uvicorn_missing, expected", + [ + (True, False, "starlette"), + (False, False, "uvicorn" if platform.system() != "Windows" else "starlette"), + (True, True, "starlette"), + (False, True, "starlette"), + ], +) +def test_httpserver_asgi(monkeypatch, debug, uvicorn_missing, expected): + app = pretend.stub() + http_server = pretend.stub(run=pretend.call_recorder(lambda: None)) + server_classes = { + "starlette": pretend.call_recorder(lambda *a, **kw: http_server), + "uvicorn": pretend.call_recorder(lambda *a, **kw: http_server), + } + options = {"a": pretend.stub(), "b": pretend.stub()} + + from functions_framework._http import asgi + + monkeypatch.setattr(asgi, "StarletteApplication", server_classes["starlette"]) + + if uvicorn_missing or platform.system() == "Windows": + monkeypatch.setitem(sys.modules, "functions_framework._http.gunicorn", None) + else: + from functions_framework._http import gunicorn + + monkeypatch.setattr(gunicorn, "UvicornApplication", server_classes["uvicorn"]) + + wrapper = functions_framework._http.HTTPServer(app, debug, **options) + + assert wrapper.app == app + assert wrapper.server_class == server_classes[expected] + assert wrapper.options == options + + host = pretend.stub() + port = pretend.stub() + + wrapper.run(host, port) + + assert wrapper.server_class.calls == [ + pretend.call(app, host, port, debug, **options) + ] + assert http_server.run.calls == [pretend.call()] + + +@pytest.mark.skipif("platform.system() == 'Windows'") +def test_uvicorn_application(): + app = pretend.stub() + host = "1.2.3.4" + port = "1234" + options = {} + + import functions_framework._http.gunicorn + + uvicorn_app = functions_framework._http.gunicorn.UvicornApplication( + app, host, port, debug=False, **options + ) + + assert uvicorn_app.app == app + assert uvicorn_app.options == { + "bind": "%s:%s" % (host, port), + "workers": 1, + "timeout": 0, + "loglevel": "error", + "limit_request_line": 0, + "worker_class": "uvicorn_worker.UvicornWorker", + } + + assert uvicorn_app.cfg.bind == ["1.2.3.4:1234"] + assert uvicorn_app.cfg.workers == 1 + assert uvicorn_app.cfg.timeout == 0 + assert uvicorn_app.load() == app + + +@pytest.mark.parametrize("debug", [True, False]) +def test_starlette_application(monkeypatch, debug): + uvicorn_run = pretend.call_recorder(lambda *a, **kw: None) + uvicorn_stub = pretend.stub(run=uvicorn_run) + monkeypatch.setitem(sys.modules, "uvicorn", uvicorn_stub) + + # Clear and re-import to get fresh module with mocked uvicorn + if "functions_framework._http.asgi" in sys.modules: + del sys.modules["functions_framework._http.asgi"] + + from functions_framework._http.asgi import StarletteApplication + + app = pretend.stub() + host = "1.2.3.4" + port = "5678" + options = {"custom": "value"} + + starlette_app = StarletteApplication(app, host, port, debug, **options) + + assert starlette_app.app == app + assert starlette_app.host == host + assert starlette_app.port == port + assert starlette_app.debug == debug + assert starlette_app.options == { + "log_level": "debug" if debug else "error", + "custom": "value", + } + + starlette_app.run() + + assert uvicorn_run.calls == [ + pretend.call( + app, + host=host, + port=int(port), + log_level="debug" if debug else "error", + custom="value", + ) + ] From 1123eeac8cedae23af8980a928f01f5ad100d9de Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 17 Jun 2025 14:04:37 -0700 Subject: [PATCH 152/181] feat: add execution_id support for async stack (#377) * feat: add execution_id support for async stack - Add contextvars support to execution_id.py for async-safe context storage - Create AsgiMiddleware class to inject execution_id into ASGI requests - Add set_execution_context_async decorator for both sync and async functions - Update LoggingHandlerAddExecutionId to support both Flask g and contextvars - Integrate execution_id support in aio/__init__.py with proper exception handling - Add comprehensive async tests matching sync test functionality - Follow Starlette best practices for exception handling The implementation enables automatic execution_id injection and logging for async functions when LOG_EXECUTION_ID=true, matching the existing sync stack behavior. * refactor: move exception logging to crash handler for cleaner code - Remove try/catch blocks from wrapper functions - Centralize exception logging in _crash_handler - Extract execution_id directly from request headers in crash handler - Temporarily set context when logging exceptions to ensure execution_id is included - This approach is cleaner and more similar to Flask's centralized exception handling * refactor: improve code organization based on feedback - Move imports to top of file instead of inside functions - Extract common header parsing logic into _extract_context_from_headers helper - Reduce code duplication between sync and async decorators - Add comment explaining why crash handler needs to extract context from headers - This addresses the context reset issue where decorators clean up before exception handlers run * fix: preserve execution context for exception handlers - Don't reset context on exception, only on successful completion - This allows exception handlers to access execution_id naturally - Simplify crash handler since context is now available - Rely on Python's automatic contextvar cleanup when task completes - Each request runs in its own task, so no risk of context leakage This is more correct and follows the principle that context should be available throughout the entire request lifecycle, including error handling. * style: apply black and isort formatting - Format code with black for consistent style - Sort imports with isort for better organization - All linting checks now pass * refactor: clean up async tests and remove redundant comments * chore: remove uv.lock from version control * style: fix black formatting * fix: skip async execution_id tests on Python 3.7 * refactor: reuse _enable_execution_id_logging from main module * chore: more cleanup. * test: remove unnecessary pragma no cover for sync_wrapper * test: improve coverage by removing unnecessary pragma no cover annotations * style: fix black formatting * style: fix isort import ordering * test: add back pragma no cover for genuinely hard-to-test edge cases * refactor: simplify async decorator by removing dead code branch * fix: exclude async-specific code from py37 coverage The AsgiMiddleware class and set_execution_context_async function in execution_id.py require Python 3.8+ due to async dependencies. These are now excluded from coverage calculations in Python 3.7 environments. * fix: improve async execution ID context propagation using contextvars - Use contextvars.copy_context() to properly propagate execution context in async functions - Implement AsyncExecutionIdHandler to handle JSON logging with execution_id - Redirect logging output from stderr to stdout for consistency - Add build dependency to dev dependencies - Update tests to reflect new logging output location * feat: Add execution ID logging for async functions Refactors the async logging implementation to align with the sync version, ensuring consistent execution ID logging across both stacks. * chore: clean up impl. * refactor: define custom exception handling middleware to avoid duplicate log of traceback. * style: run black * chore: clean up code a little more. * fix: propagate context in ce fns. * style: more nits. * chore: remove unncessary debug flag. * fix: respond to PR comments * fix: respond to more PR comments --- .coveragerc-py37 | 6 +- conftest.py | 8 +- pyproject.toml | 10 + src/functions_framework/aio/__init__.py | 117 +++++- src/functions_framework/execution_id.py | 125 +++++- tests/test_aio.py | 4 + tests/test_execution_id.py | 1 + tests/test_execution_id_async.py | 365 ++++++++++++++++++ .../test_functions/execution_id/async_main.py | 62 +++ 9 files changed, 665 insertions(+), 33 deletions(-) create mode 100644 tests/test_execution_id_async.py create mode 100644 tests/test_functions/execution_id/async_main.py diff --git a/.coveragerc-py37 b/.coveragerc-py37 index fb6dbb6e..b1c98d23 100644 --- a/.coveragerc-py37 +++ b/.coveragerc-py37 @@ -18,4 +18,8 @@ exclude_lines = # Don't complain about async-specific imports and code from functions_framework.aio import from functions_framework._http.asgi import - from functions_framework._http.gunicorn import UvicornApplication \ No newline at end of file + from functions_framework._http.gunicorn import UvicornApplication + + # Exclude async-specific classes and functions in execution_id.py + class AsgiMiddleware: + def set_execution_context_async \ No newline at end of file diff --git a/conftest.py b/conftest.py index 1d17e9bf..257f60d4 100644 --- a/conftest.py +++ b/conftest.py @@ -50,8 +50,12 @@ def pytest_ignore_collect(collection_path, config): if sys.version_info >= (3, 8): return None - # Skip test_aio.py and test_asgi.py entirely on Python 3.7 - if collection_path.name in ["test_aio.py", "test_asgi.py"]: + # Skip test_aio.py, test_asgi.py, and test_execution_id_async.py entirely on Python 3.7 + if collection_path.name in [ + "test_aio.py", + "test_asgi.py", + "test_execution_id_async.py", + ]: return True return None diff --git a/pyproject.toml b/pyproject.toml index 350d8997..2b6e0639 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,3 +61,13 @@ functions_framework = ["py.typed"] [tool.setuptools.package-dir] "" = "src" + +[dependency-groups] +dev = [ + "black>=23.3.0", + "build>=1.1.1", + "isort>=5.11.5", + "pretend>=1.0.9", + "pytest>=7.4.4", + "pytest-asyncio>=0.21.2", +] diff --git a/src/functions_framework/aio/__init__.py b/src/functions_framework/aio/__init__.py index 21f12754..4245f2d1 100644 --- a/src/functions_framework/aio/__init__.py +++ b/src/functions_framework/aio/__init__.py @@ -13,16 +13,24 @@ # limitations under the License. import asyncio +import contextvars import functools import inspect +import logging +import logging.config import os +import traceback from typing import Any, Awaitable, Callable, Dict, Tuple, Union from cloudevents.http import from_http from cloudevents.http.event import CloudEvent -from functions_framework import _function_registry +from functions_framework import ( + _enable_execution_id_logging, + _function_registry, + execution_id, +) from functions_framework.exceptions import ( FunctionsFrameworkException, MissingSourceException, @@ -31,6 +39,7 @@ try: from starlette.applications import Starlette from starlette.exceptions import HTTPException + from starlette.middleware import Middleware from starlette.requests import Request from starlette.responses import JSONResponse, Response from starlette.routing import Route @@ -96,29 +105,27 @@ def wrapper(*args, **kwargs): return wrapper -async def _crash_handler(request, exc): - headers = {_FUNCTION_STATUS_HEADER_FIELD: _CRASH} - return Response(f"Internal Server Error: {exc}", status_code=500, headers=headers) - - -def _http_func_wrapper(function, is_async): +def _http_func_wrapper(function, is_async, enable_id_logging=False): + @execution_id.set_execution_context_async(enable_id_logging) @functools.wraps(function) async def handler(request): if is_async: result = await function(request) else: # TODO: Use asyncio.to_thread when we drop Python 3.8 support - # Python 3.8 compatible version of asyncio.to_thread loop = asyncio.get_event_loop() - result = await loop.run_in_executor(None, function, request) + ctx = contextvars.copy_context() + result = await loop.run_in_executor(None, ctx.run, function, request) if isinstance(result, str): return Response(result) elif isinstance(result, dict): return JSONResponse(result) elif isinstance(result, tuple) and len(result) == 2: - # Support Flask-style tuple response content, status_code = result - return Response(content, status_code=status_code) + if isinstance(content, dict): + return JSONResponse(content, status_code=status_code) + else: + return Response(content, status_code=status_code) elif result is None: raise HTTPException(status_code=500, detail="No response returned") else: @@ -127,7 +134,8 @@ async def handler(request): return handler -def _cloudevent_func_wrapper(function, is_async): +def _cloudevent_func_wrapper(function, is_async, enable_id_logging=False): + @execution_id.set_execution_context_async(enable_id_logging) @functools.wraps(function) async def handler(request): data = await request.body() @@ -142,9 +150,9 @@ async def handler(request): await function(event) else: # TODO: Use asyncio.to_thread when we drop Python 3.8 support - # Python 3.8 compatible version of asyncio.to_thread loop = asyncio.get_event_loop() - await loop.run_in_executor(None, function, event) + ctx = contextvars.copy_context() + await loop.run_in_executor(None, ctx.run, function, event) return Response("OK") return handler @@ -154,6 +162,64 @@ async def _handle_not_found(request: Request): raise HTTPException(status_code=404, detail="Not Found") +def _configure_app_execution_id_logging(): + logging.config.dictConfig( + { + "version": 1, + "handlers": { + "asgi": { + "class": "logging.StreamHandler", + "stream": "ext://functions_framework.execution_id.logging_stream", + }, + }, + "root": {"level": "INFO", "handlers": ["asgi"]}, + } + ) + + +class ExceptionHandlerMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": # pragma: no cover + await self.app(scope, receive, send) + return + + try: + await self.app(scope, receive, send) + except Exception as exc: + logger = logging.getLogger() + tb_lines = traceback.format_exception(type(exc), exc, exc.__traceback__) + tb_text = "".join(tb_lines) + + path = scope.get("path", "/") + method = scope.get("method", "GET") + error_msg = f"Exception on {path} [{method}]\n{tb_text}".rstrip() + + logger.error(error_msg) + + headers = [ + [b"content-type", b"text/plain"], + [_FUNCTION_STATUS_HEADER_FIELD.encode(), _CRASH.encode()], + ] + + await send( + { + "type": "http.response.start", + "status": 500, + "headers": headers, + } + ) + await send( + { + "type": "http.response.body", + "body": b"Internal Server Error", + } + ) + # Don't re-raise to prevent starlette from printing traceback again + + def create_asgi_app(target=None, source=None, signature_type=None): """Create an ASGI application for the function. @@ -175,6 +241,11 @@ def create_asgi_app(target=None, source=None, signature_type=None): ) source_module, spec = _function_registry.load_function_module(source) + + enable_id_logging = _enable_execution_id_logging() + if enable_id_logging: + _configure_app_execution_id_logging() + spec.loader.exec_module(source_module) function = _function_registry.get_user_function(source, source_module, target) signature_type = _function_registry.get_func_signature_type(target, signature_type) @@ -182,7 +253,7 @@ def create_asgi_app(target=None, source=None, signature_type=None): is_async = inspect.iscoroutinefunction(function) routes = [] if signature_type == _function_registry.HTTP_SIGNATURE_TYPE: - http_handler = _http_func_wrapper(function, is_async) + http_handler = _http_func_wrapper(function, is_async, enable_id_logging) routes.append( Route( "/", @@ -202,7 +273,9 @@ def create_asgi_app(target=None, source=None, signature_type=None): ) ) elif signature_type == _function_registry.CLOUDEVENT_SIGNATURE_TYPE: - cloudevent_handler = _cloudevent_func_wrapper(function, is_async) + cloudevent_handler = _cloudevent_func_wrapper( + function, is_async, enable_id_logging + ) routes.append( Route("/{path:path}", endpoint=cloudevent_handler, methods=["POST"]) ) @@ -221,10 +294,14 @@ def create_asgi_app(target=None, source=None, signature_type=None): f"Unsupported signature type for ASGI server: {signature_type}" ) - exception_handlers = { - 500: _crash_handler, - } - app = Starlette(routes=routes, exception_handlers=exception_handlers) + app = Starlette( + routes=routes, + middleware=[ + Middleware(ExceptionHandlerMiddleware), + Middleware(execution_id.AsgiMiddleware), + ], + ) + return app diff --git a/src/functions_framework/execution_id.py b/src/functions_framework/execution_id.py index 2b106531..df412187 100644 --- a/src/functions_framework/execution_id.py +++ b/src/functions_framework/execution_id.py @@ -13,7 +13,9 @@ # limitations under the License. import contextlib +import contextvars import functools +import inspect import io import json import logging @@ -38,6 +40,9 @@ logger = logging.getLogger(__name__) +# Context variable for async execution context +execution_context_var = contextvars.ContextVar("execution_context", default=None) + class ExecutionContext: def __init__(self, execution_id=None, span_id=None): @@ -46,7 +51,10 @@ def __init__(self, execution_id=None, span_id=None): def _get_current_context(): - return ( + context = execution_context_var.get() + if context is not None: + return context + return ( # pragma: no cover flask.g.execution_id_context if flask.has_request_context() and "execution_id_context" in flask.g else None @@ -54,6 +62,8 @@ def _get_current_context(): def _set_current_context(context): + execution_context_var.set(context) + # Also set in Flask context if available for sync if flask.has_request_context(): flask.g.execution_id_context = context @@ -65,6 +75,18 @@ def _generate_execution_id(): ) +def _extract_context_from_headers(headers): + """Extract execution context from request headers.""" + trace_context = re.match( + _TRACE_CONTEXT_REGEX_PATTERN, + headers.get(TRACE_CONTEXT_REQUEST_HEADER, ""), + ) + execution_id = headers.get(EXECUTION_ID_REQUEST_HEADER) + span_id = trace_context.group("span_id") if trace_context else None + + return ExecutionContext(execution_id, span_id) + + # Middleware to add execution id to request header if one does not already exist class WsgiMiddleware: def __init__(self, wsgi_app): @@ -78,8 +100,42 @@ def __call__(self, environ, start_response): return self.wsgi_app(environ, start_response) -# Sets execution id and span id for the request +class AsgiMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] == "http": # pragma: no branch + execution_id_header = b"function-execution-id" + execution_id = None + + for name, value in scope.get("headers", []): + if name.lower() == execution_id_header: + execution_id = value.decode("latin-1") + break + + if not execution_id: + execution_id = _generate_execution_id() + new_headers = list(scope.get("headers", [])) + new_headers.append( + (execution_id_header, execution_id.encode("latin-1")) + ) + scope["headers"] = new_headers + + await self.app(scope, receive, send) + + def set_execution_context(request, enable_id_logging=False): + """Decorator for Flask/WSGI handlers that sets execution context. + + Takes request object at decoration time (Flask pattern where request is available + via thread-local context when decorator is applied). + + Usage: + @set_execution_context(request, enable_id_logging=True) + def view_func(path): + ... + """ if enable_id_logging: stdout_redirect = contextlib.redirect_stdout( LoggingHandlerAddExecutionId(sys.stdout) @@ -94,22 +150,71 @@ def set_execution_context(request, enable_id_logging=False): def decorator(view_function): @functools.wraps(view_function) def wrapper(*args, **kwargs): - trace_context = re.match( - _TRACE_CONTEXT_REGEX_PATTERN, - request.headers.get(TRACE_CONTEXT_REQUEST_HEADER, ""), - ) - execution_id = request.headers.get(EXECUTION_ID_REQUEST_HEADER) - span_id = trace_context.group("span_id") if trace_context else None - _set_current_context(ExecutionContext(execution_id, span_id)) + context = _extract_context_from_headers(request.headers) + _set_current_context(context) with stderr_redirect, stdout_redirect: - return view_function(*args, **kwargs) + result = view_function(*args, **kwargs) + return result return wrapper return decorator +def set_execution_context_async(enable_id_logging=False): + """Decorator for ASGI/async handlers that sets execution context. + + Unlike set_execution_context which takes request at decoration time (Flask pattern), + this expects the decorated function to receive request as its first parameter (ASGI pattern). + + Usage: + @set_execution_context_async(enable_id_logging=True) + async def handler(request, *args, **kwargs): + ... + """ + if enable_id_logging: + stdout_redirect = contextlib.redirect_stdout( + LoggingHandlerAddExecutionId(sys.stdout) + ) + stderr_redirect = contextlib.redirect_stderr( + LoggingHandlerAddExecutionId(sys.stderr) + ) + else: + stdout_redirect = contextlib.nullcontext() + stderr_redirect = contextlib.nullcontext() + + def decorator(func): + @functools.wraps(func) + async def async_wrapper(request, *args, **kwargs): + context = _extract_context_from_headers(request.headers) + token = execution_context_var.set(context) + + with stderr_redirect, stdout_redirect: + result = await func(request, *args, **kwargs) + + execution_context_var.reset(token) + return result + + @functools.wraps(func) + def sync_wrapper(request, *args, **kwargs): + context = _extract_context_from_headers(request.headers) + token = execution_context_var.set(context) + + with stderr_redirect, stdout_redirect: + result = func(request, *args, **kwargs) + + execution_context_var.reset(token) + return result + + if inspect.iscoroutinefunction(func): + return async_wrapper + else: + return sync_wrapper + + return decorator + + @LocalProxy def logging_stream(): return LoggingHandlerAddExecutionId(stream=flask.logging.wsgi_errors_stream) diff --git a/tests/test_aio.py b/tests/test_aio.py index cf69479a..4f34c279 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -143,6 +143,8 @@ async def http_func(request): wrapper = _http_func_wrapper(http_func, is_async=True) request = Mock() + request.headers = Mock() + request.headers.get = Mock(return_value="") response = await wrapper(request) assert response.__class__.__name__ == "JSONResponse" @@ -158,6 +160,8 @@ def sync_http_func(request): wrapper = _http_func_wrapper(sync_http_func, is_async=False) request = Mock() + request.headers = Mock() + request.headers.get = Mock(return_value="") response = await wrapper(request) assert response.__class__.__name__ == "Response" diff --git a/tests/test_execution_id.py b/tests/test_execution_id.py index a2601817..b8c5b9f0 100644 --- a/tests/test_execution_id.py +++ b/tests/test_execution_id.py @@ -223,6 +223,7 @@ def view_func(): monkeypatch.setattr( execution_id, "_generate_execution_id", lambda: TEST_EXECUTION_ID ) + mock_g = Mock() monkeypatch.setattr(execution_id.flask, "g", mock_g) monkeypatch.setattr(execution_id.flask, "has_request_context", lambda: True) diff --git a/tests/test_execution_id_async.py b/tests/test_execution_id_async.py new file mode 100644 index 00000000..01e638a1 --- /dev/null +++ b/tests/test_execution_id_async.py @@ -0,0 +1,365 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import asyncio +import json +import pathlib +import re + +from functools import partial +from unittest.mock import Mock + +import pytest + +from starlette.testclient import TestClient + +from functions_framework import execution_id +from functions_framework.aio import create_asgi_app + +TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" +TEST_EXECUTION_ID = "test_execution_id" +TEST_SPAN_ID = "123456" + + +def test_user_function_can_retrieve_execution_id_from_header(): + source = TEST_FUNCTIONS_DIR / "execution_id" / "async_main.py" + target = "async_function" + app = create_asgi_app(target, source) + client = TestClient(app) + resp = client.post( + "/", + headers={ + "Function-Execution-Id": TEST_EXECUTION_ID, + "Content-Type": "application/json", + }, + ) + + assert resp.json()["execution_id"] == TEST_EXECUTION_ID + + +def test_uncaught_exception_in_user_function_sets_execution_id(capsys, monkeypatch): + monkeypatch.setenv("LOG_EXECUTION_ID", "true") + source = TEST_FUNCTIONS_DIR / "execution_id" / "async_main.py" + target = "async_error" + app = create_asgi_app(target, source) + # Don't raise server exceptions so we can capture the logs + client = TestClient(app, raise_server_exceptions=False) + resp = client.post( + "/", + headers={ + "Function-Execution-Id": TEST_EXECUTION_ID, + "Content-Type": "application/json", + }, + ) + assert resp.status_code == 500 + record = capsys.readouterr() + assert f'"execution_id": "{TEST_EXECUTION_ID}"' in record.err + assert '"logging.googleapis.com/labels"' in record.err + assert "ZeroDivisionError" in record.err + + +def test_print_from_user_function_sets_execution_id(capsys, monkeypatch): + monkeypatch.setenv("LOG_EXECUTION_ID", "true") + source = TEST_FUNCTIONS_DIR / "execution_id" / "async_main.py" + target = "async_print_message" + app = create_asgi_app(target, source) + client = TestClient(app) + client.post( + "/", + headers={ + "Function-Execution-Id": TEST_EXECUTION_ID, + "Content-Type": "application/json", + }, + json={"message": "some-message"}, + ) + record = capsys.readouterr() + assert f'"execution_id": "{TEST_EXECUTION_ID}"' in record.out + assert '"message": "some-message"' in record.out + + +def test_log_from_user_function_sets_execution_id(capsys, monkeypatch): + monkeypatch.setenv("LOG_EXECUTION_ID", "true") + source = TEST_FUNCTIONS_DIR / "execution_id" / "async_main.py" + target = "async_log_message" + app = create_asgi_app(target, source) + client = TestClient(app) + client.post( + "/", + headers={ + "Function-Execution-Id": TEST_EXECUTION_ID, + "Content-Type": "application/json", + }, + json={"message": json.dumps({"custom-field": "some-message"})}, + ) + record = capsys.readouterr() + assert f'"execution_id": "{TEST_EXECUTION_ID}"' in record.err + assert '"custom-field": "some-message"' in record.err + assert '"logging.googleapis.com/labels"' in record.err + + +def test_user_function_can_retrieve_generated_execution_id(monkeypatch): + monkeypatch.setattr( + execution_id, "_generate_execution_id", lambda: TEST_EXECUTION_ID + ) + source = TEST_FUNCTIONS_DIR / "execution_id" / "async_main.py" + target = "async_function" + app = create_asgi_app(target, source) + client = TestClient(app) + resp = client.post( + "/", + headers={ + "Content-Type": "application/json", + }, + ) + + assert resp.json()["execution_id"] == TEST_EXECUTION_ID + + +def test_does_not_set_execution_id_when_not_enabled(capsys): + source = TEST_FUNCTIONS_DIR / "execution_id" / "async_main.py" + target = "async_print_message" + app = create_asgi_app(target, source) + client = TestClient(app) + client.post( + "/", + headers={ + "Function-Execution-Id": TEST_EXECUTION_ID, + "Content-Type": "application/json", + }, + json={"message": "some-message"}, + ) + record = capsys.readouterr() + assert f'"execution_id": "{TEST_EXECUTION_ID}"' not in record.out + assert "some-message" in record.out + + +def test_does_not_set_execution_id_when_env_var_is_false(capsys, monkeypatch): + monkeypatch.setenv("LOG_EXECUTION_ID", "false") + source = TEST_FUNCTIONS_DIR / "execution_id" / "async_main.py" + target = "async_print_message" + app = create_asgi_app(target, source) + client = TestClient(app) + client.post( + "/", + headers={ + "Function-Execution-Id": TEST_EXECUTION_ID, + "Content-Type": "application/json", + }, + json={"message": "some-message"}, + ) + record = capsys.readouterr() + assert f'"execution_id": "{TEST_EXECUTION_ID}"' not in record.out + assert "some-message" in record.out + + +def test_does_not_set_execution_id_when_env_var_is_not_bool_like(capsys, monkeypatch): + monkeypatch.setenv("LOG_EXECUTION_ID", "maybe") + source = TEST_FUNCTIONS_DIR / "execution_id" / "async_main.py" + target = "async_print_message" + app = create_asgi_app(target, source) + client = TestClient(app) + client.post( + "/", + headers={ + "Function-Execution-Id": TEST_EXECUTION_ID, + "Content-Type": "application/json", + }, + json={"message": "some-message"}, + ) + record = capsys.readouterr() + assert f'"execution_id": "{TEST_EXECUTION_ID}"' not in record.out + assert "some-message" in record.out + + +def test_generate_execution_id(): + expected_matching_regex = "^[0-9a-zA-Z]{12}$" + actual_execution_id = execution_id._generate_execution_id() + + match = re.match(expected_matching_regex, actual_execution_id).group(0) + assert match == actual_execution_id + + +@pytest.mark.parametrize( + "headers,expected_execution_id,expected_span_id,should_generate", + [ + ( + { + "X-Cloud-Trace-Context": f"TRACE_ID/{TEST_SPAN_ID};o=1", + "Function-Execution-Id": TEST_EXECUTION_ID, + }, + TEST_EXECUTION_ID, + TEST_SPAN_ID, + False, + ), + ({}, None, None, True), # Middleware will generate an ID + ( + { + "X-Cloud-Trace-Context": "malformed trace context string", + "Function-Execution-Id": TEST_EXECUTION_ID, + }, + TEST_EXECUTION_ID, + None, + False, + ), + ], +) +def test_set_execution_context_headers( + headers, expected_execution_id, expected_span_id, should_generate +): + source = TEST_FUNCTIONS_DIR / "execution_id" / "async_main.py" + target = "async_trace_test" + app = create_asgi_app(target, source) + client = TestClient(app) + + resp = client.post("/", headers=headers) + + result = resp.json() + if should_generate: + # When no execution ID is provided, middleware generates one + assert result.get("execution_id") is not None + assert len(result.get("execution_id")) == 12 # Generated IDs are 12 chars + else: + assert result.get("execution_id") == expected_execution_id + assert result.get("span_id") == expected_span_id + + +@pytest.mark.asyncio +async def test_maintains_execution_id_for_concurrent_requests(monkeypatch, capsys): + monkeypatch.setenv("LOG_EXECUTION_ID", "true") + + expected_logs = ( + { + "message": "message1", + "logging.googleapis.com/labels": {"execution_id": "test-execution-id-1"}, + }, + { + "message": "message2", + "logging.googleapis.com/labels": {"execution_id": "test-execution-id-2"}, + }, + { + "message": "message1", + "logging.googleapis.com/labels": {"execution_id": "test-execution-id-1"}, + }, + { + "message": "message2", + "logging.googleapis.com/labels": {"execution_id": "test-execution-id-2"}, + }, + ) + + source = TEST_FUNCTIONS_DIR / "execution_id" / "async_main.py" + target = "async_sleep" + app = create_asgi_app(target, source) + client = TestClient(app) + loop = asyncio.get_event_loop() + response1 = loop.run_in_executor( + None, + partial( + client.post, + "/", + headers={ + "Content-Type": "application/json", + "Function-Execution-Id": "test-execution-id-1", + }, + json={"message": "message1"}, + ), + ) + response2 = loop.run_in_executor( + None, + partial( + client.post, + "/", + headers={ + "Content-Type": "application/json", + "Function-Execution-Id": "test-execution-id-2", + }, + json={"message": "message2"}, + ), + ) + await asyncio.wait((response1, response2)) + record = capsys.readouterr() + logs = record.err.strip().split("\n") + logs_as_json = tuple(json.loads(log) for log in logs) + + sort_key = lambda d: d["message"] + assert sorted(logs_as_json, key=sort_key) == sorted(expected_logs, key=sort_key) + + +def test_async_decorator_with_sync_function(): + def sync_func(request): + return {"status": "ok"} + + wrapped = execution_id.set_execution_context_async(enable_id_logging=False)( + sync_func + ) + + request = Mock() + request.headers = Mock() + request.headers.get = Mock(return_value="") + + result = wrapped(request) + + assert result == {"status": "ok"} + + +def test_sync_cloudevent_function_has_execution_context(monkeypatch, capsys): + """Test that sync CloudEvent functions can access execution context.""" + monkeypatch.setenv("LOG_EXECUTION_ID", "true") + + source = TEST_FUNCTIONS_DIR / "execution_id" / "async_main.py" + target = "sync_cloudevent_with_context" + app = create_asgi_app(target, source, signature_type="cloudevent") + client = TestClient(app) + + response = client.post( + "/", + headers={ + "ce-specversion": "1.0", + "ce-type": "com.example.test", + "ce-source": "test-source", + "ce-id": "test-id", + "Function-Execution-Id": TEST_EXECUTION_ID, + "Content-Type": "application/json", + }, + json={"message": "test"}, + ) + + assert response.status_code == 200 + assert response.text == "OK" + + record = capsys.readouterr() + assert f"Execution ID in sync CloudEvent: {TEST_EXECUTION_ID}" in record.err + assert "No execution context in sync CloudEvent function!" not in record.err + + +def test_cloudevent_returns_500(capsys, monkeypatch): + monkeypatch.setenv("LOG_EXECUTION_ID", "true") + source = TEST_FUNCTIONS_DIR / "execution_id" / "async_main.py" + target = "async_cloudevent_error" + app = create_asgi_app(target, source, signature_type="cloudevent") + client = TestClient(app, raise_server_exceptions=False) + resp = client.post( + "/", + headers={ + "ce-specversion": "1.0", + "ce-type": "com.example.test", + "ce-source": "test-source", + "ce-id": "test-id", + "Function-Execution-Id": TEST_EXECUTION_ID, + "Content-Type": "application/json", + }, + ) + assert resp.status_code == 500 + record = capsys.readouterr() + assert f'"execution_id": "{TEST_EXECUTION_ID}"' in record.err + assert '"logging.googleapis.com/labels"' in record.err + assert "ValueError" in record.err diff --git a/tests/test_functions/execution_id/async_main.py b/tests/test_functions/execution_id/async_main.py new file mode 100644 index 00000000..7149e7fb --- /dev/null +++ b/tests/test_functions/execution_id/async_main.py @@ -0,0 +1,62 @@ +import asyncio +import logging + +from functions_framework import execution_id + +logger = logging.getLogger(__name__) + + +async def async_print_message(request): + json = await request.json() + print(json.get("message")) + return {"status": "success"}, 200 + + +async def async_log_message(request): + json = await request.json() + logger.warning(json.get("message")) + return {"status": "success"}, 200 + + +async def async_function(request): + return {"execution_id": request.headers.get("Function-Execution-Id")} + + +async def async_error(request): + return 1 / 0 + + +async def async_sleep(request): + json = await request.json() + message = json.get("message") + logger.warning(message) + await asyncio.sleep(1) + logger.warning(message) + return {"status": "success"}, 200 + + +async def async_trace_test(request): + context = execution_id._get_current_context() + return { + "execution_id": context.execution_id if context else None, + "span_id": context.span_id if context else None, + } + + +def sync_function_in_async_context(request): + return { + "execution_id": request.headers.get("Function-Execution-Id"), + "type": "sync", + } + + +def sync_cloudevent_with_context(cloud_event): + context = execution_id._get_current_context() + if context: + logger.info(f"Execution ID in sync CloudEvent: {context.execution_id}") + else: + logger.error("No execution context in sync CloudEvent function!") + + +async def async_cloudevent_error(cloudevent): + raise ValueError("This is a test error") From 42b7fdd153f2360e3e2fc92a74d0674f424a99e4 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Wed, 18 Jun 2025 10:23:17 -0700 Subject: [PATCH 153/181] refactor: rename GATEWAY flag to FUNCTION_GATEWAY. (#379) --- src/functions_framework/_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/functions_framework/_cli.py b/src/functions_framework/_cli.py index e27b5446..ec2474d4 100644 --- a/src/functions_framework/_cli.py +++ b/src/functions_framework/_cli.py @@ -34,7 +34,7 @@ @click.option("--debug", envvar="DEBUG", is_flag=True) @click.option( "--gateway", - envvar="GATEWAY", + envvar="FUNCTION_GATEWAY", type=click.Choice(["wsgi", "asgi"]), default="wsgi", help="Server gateway interface type (wsgi for sync, asgi for async)", From a576a8f28a6029fc5b5ab0725d2aa9c6c5f4304f Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Wed, 18 Jun 2025 10:25:22 -0700 Subject: [PATCH 154/181] fix: set default log level for asgi logger to WARNING to match default python behavior (#381) * fix: set default log level for asgi logger to WARNING to match default python behavior. * fix: fix broken test. --- src/functions_framework/aio/__init__.py | 2 +- tests/test_functions/execution_id/async_main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/functions_framework/aio/__init__.py b/src/functions_framework/aio/__init__.py index 4245f2d1..e30b5f99 100644 --- a/src/functions_framework/aio/__init__.py +++ b/src/functions_framework/aio/__init__.py @@ -172,7 +172,7 @@ def _configure_app_execution_id_logging(): "stream": "ext://functions_framework.execution_id.logging_stream", }, }, - "root": {"level": "INFO", "handlers": ["asgi"]}, + "root": {"level": "WARNING", "handlers": ["asgi"]}, } ) diff --git a/tests/test_functions/execution_id/async_main.py b/tests/test_functions/execution_id/async_main.py index 7149e7fb..4485e3f4 100644 --- a/tests/test_functions/execution_id/async_main.py +++ b/tests/test_functions/execution_id/async_main.py @@ -53,7 +53,7 @@ def sync_function_in_async_context(request): def sync_cloudevent_with_context(cloud_event): context = execution_id._get_current_context() if context: - logger.info(f"Execution ID in sync CloudEvent: {context.execution_id}") + logger.warning(f"Execution ID in sync CloudEvent: {context.execution_id}") else: logger.error("No execution context in sync CloudEvent function!") From 58deaf1e819c73ee38294f4dc8837bac0c533d35 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Mon, 23 Jun 2025 08:54:16 -0700 Subject: [PATCH 155/181] refactor: replace --gateway flag with --asgi boolean flag (#383) * refactor: replace --gateway flag with --asgi boolean flag Simplify the CLI by replacing `--gateway asgi` with `--asgi`. The new flag is more intuitive as WSGI remains the default and ASGI is opt-in. Also updates the environment variable to FUNCTION_USE_ASGI for clarity. * fix: update conformance tests to use --asgi flag --- .github/workflows/conformance-asgi.yml | 10 +++++----- src/functions_framework/_cli.py | 13 ++++++------- tests/test_cli.py | 2 +- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/.github/workflows/conformance-asgi.yml b/.github/workflows/conformance-asgi.yml index c69d1862..a62fcb71 100644 --- a/.github/workflows/conformance-asgi.yml +++ b/.github/workflows/conformance-asgi.yml @@ -52,7 +52,7 @@ jobs: functionType: 'http' useBuildpacks: false validateMapping: false - cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http --signature-type http --gateway asgi'" + cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http --signature-type http --asgi'" - name: Run CloudEvents conformance tests uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 @@ -60,7 +60,7 @@ jobs: functionType: 'cloudevent' useBuildpacks: false validateMapping: false - cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event --signature-type cloudevent --gateway asgi'" + cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event --signature-type cloudevent --asgi'" - name: Run HTTP conformance tests declarative uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 @@ -68,7 +68,7 @@ jobs: functionType: 'http' useBuildpacks: false validateMapping: false - cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative --gateway asgi'" + cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative --asgi'" - name: Run CloudEvents conformance tests declarative uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 @@ -76,7 +76,7 @@ jobs: functionType: 'cloudevent' useBuildpacks: false validateMapping: false - cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event_declarative --gateway asgi'" + cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event_declarative --asgi'" - name: Run HTTP concurrency tests declarative uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 @@ -84,7 +84,7 @@ jobs: functionType: 'http' useBuildpacks: false validateConcurrency: true - cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative_concurrent --gateway asgi'" + cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative_concurrent --asgi'" # Note: Event (legacy) and Typed tests are not supported in ASGI mode # Note: validateMapping is set to false for CloudEvent tests because ASGI mode diff --git a/src/functions_framework/_cli.py b/src/functions_framework/_cli.py index ec2474d4..c2ba9f4b 100644 --- a/src/functions_framework/_cli.py +++ b/src/functions_framework/_cli.py @@ -33,14 +33,13 @@ @click.option("--port", envvar="PORT", type=click.INT, default=8080) @click.option("--debug", envvar="DEBUG", is_flag=True) @click.option( - "--gateway", - envvar="FUNCTION_GATEWAY", - type=click.Choice(["wsgi", "asgi"]), - default="wsgi", - help="Server gateway interface type (wsgi for sync, asgi for async)", + "--asgi", + envvar="FUNCTION_USE_ASGI", + is_flag=True, + help="Use ASGI server for function execution", ) -def _cli(target, source, signature_type, host, port, debug, gateway): - if gateway == "asgi": # pragma: no cover +def _cli(target, source, signature_type, host, port, debug, asgi): + if asgi: # pragma: no cover from functions_framework.aio import create_asgi_app app = create_asgi_app(target, source, signature_type) diff --git a/tests/test_cli.py b/tests/test_cli.py index 17445d11..4e5a0a08 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -119,7 +119,7 @@ def test_asgi_cli(monkeypatch): monkeypatch.setattr(functions_framework._cli, "create_server", create_server) runner = CliRunner() - result = runner.invoke(_cli, ["--target", "foo", "--gateway", "asgi"]) + result = runner.invoke(_cli, ["--target", "foo", "--asgi"]) assert result.exit_code == 0 assert create_asgi_app.calls == [pretend.call("foo", None, "http")] From 2de6eec6fae132b8b1fb41e7024a2260a05bc072 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 22 Jul 2025 11:51:04 -0700 Subject: [PATCH 156/181] fix: resolve CI failures for egress policies and Python 3.7 buildpack support (#388) * fix: add GitHub Actions CDN to egress allowlist The conformance workflow was failing with ECONNREFUSED errors when trying to download Python binaries from GitHub releases. This was caused by the harden-runner egress policy blocking connections to the GitHub Actions CDN IP addresses. Added *.actions.githubusercontent.com:443 to the allowed endpoints to fix Python setup for all versions (3.7, 3.8, etc). * fix: remove Python 3.7 from buildpack integration tests Google Cloud Buildpacks dropped Python 3.7 support for Ubuntu 22.04. The version is not available in their runtime manifest. Note: Functions Framework still supports Python 3.7, which is tested in unit and conformance tests using GitHub Actions with Ubuntu 20.04. * fix: use correct domain for GitHub release assets The Python binaries are actually hosted on release-assets.githubusercontent.com, not *.actions.githubusercontent.com * fix: add release-assets domain to unit and conformance-asgi workflows The same ECONNREFUSED issue was affecting multiple workflows with harden-runner egress policies --- .github/workflows/buildpack-integration-test.yml | 11 ----------- .github/workflows/conformance-asgi.yml | 1 + .github/workflows/conformance.yml | 1 + .github/workflows/unit.yml | 1 + 4 files changed, 3 insertions(+), 11 deletions(-) diff --git a/.github/workflows/buildpack-integration-test.yml b/.github/workflows/buildpack-integration-test.yml index 234c24ef..2c028fa9 100644 --- a/.github/workflows/buildpack-integration-test.yml +++ b/.github/workflows/buildpack-integration-test.yml @@ -14,17 +14,6 @@ on: permissions: read-all jobs: - python37: - uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@main - with: - http-builder-source: 'tests/conformance' - http-builder-target: 'write_http_declarative' - cloudevent-builder-source: 'tests/conformance' - cloudevent-builder-target: 'write_cloud_event_declarative' - prerun: 'tests/conformance/prerun.sh ${{ github.sha }}' - builder-runtime: 'python37' - builder-runtime-version: '3.7' - start-delay: 5 python38: uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@main with: diff --git a/.github/workflows/conformance-asgi.yml b/.github/workflows/conformance-asgi.yml index a62fcb71..904a7773 100644 --- a/.github/workflows/conformance-asgi.yml +++ b/.github/workflows/conformance-asgi.yml @@ -29,6 +29,7 @@ jobs: proxy.golang.org:443 pypi.org:443 storage.googleapis.com:443 + release-assets.githubusercontent.com:443 - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 9dde6036..7d10b8af 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -34,6 +34,7 @@ jobs: proxy.golang.org:443 pypi.org:443 storage.googleapis.com:443 + release-assets.githubusercontent.com:443 - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 32b34fdb..28ed5b1e 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -54,6 +54,7 @@ jobs: production.cloudflare.docker.com:443 pypi.org:443 registry-1.docker.io:443 + release-assets.githubusercontent.com:443 - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 From 82ba117e1309c52e6ba8c11c5d8cb22f253c256d Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 22 Jul 2025 12:20:03 -0700 Subject: [PATCH 157/181] refactor: move async dependencies from optional to direct (#386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR moves starlette, uvicorn, and uvicorn-worker from optional dependencies to direct dependencies, simplifying the installation process for users who need async functionality. ## Impact Analysis - **Installation size increase: < 1MB** (specifically 816KB, ~3.5% increase) - Base installation: 23MB → With async deps: 24MB - Removes the need for `pip install functions-framework[async]` - Maintains Python 3.8+ requirement for these dependencies ## Changes - Move async dependencies to direct dependencies in `pyproject.toml` - Remove `[async]` extra dependency configuration - Update imports to be direct instead of conditional - Remove "Starlette is not installed" error messages - Update tests to reflect direct dependency availability ## Test Plan All existing tests pass with 100% coverage. The async functionality remains unchanged, just the installation method is simplified. --- .github/workflows/conformance-asgi.yml | 4 ++-- pyproject.toml | 10 +++------- src/functions_framework/aio/__init__.py | 19 ++++++------------- tests/test_aio.py | 23 ----------------------- tests/test_asgi.py | 7 ++----- tox.ini | 4 ---- 6 files changed, 13 insertions(+), 54 deletions(-) diff --git a/.github/workflows/conformance-asgi.yml b/.github/workflows/conformance-asgi.yml index 904a7773..8a61cd4c 100644 --- a/.github/workflows/conformance-asgi.yml +++ b/.github/workflows/conformance-asgi.yml @@ -39,8 +39,8 @@ jobs: with: python-version: ${{ matrix.python }} - - name: Install the framework with async extras - run: python -m pip install -e .[async] + - name: Install the framework + run: python -m pip install -e . - name: Setup Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 diff --git a/pyproject.toml b/pyproject.toml index 2b6e0639..9aa34365 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,18 +30,14 @@ dependencies = [ "cloudevents>=1.2.0,<=1.11.0", # Must support python 3.7 "Werkzeug>=0.14,<4.0.0", "httpx>=0.24.1", + "starlette>=0.37.0,<1.0.0; python_version>='3.8'", + "uvicorn>=0.18.0,<1.0.0; python_version>='3.8'", + "uvicorn-worker>=0.2.0,<1.0.0; python_version>='3.8'", ] [project.urls] Homepage = "https://github.com/googlecloudplatform/functions-framework-python" -[project.optional-dependencies] -async = [ - "starlette>=0.37.0,<1.0.0; python_version>='3.8'", - "uvicorn>=0.18.0,<1.0.0; python_version>='3.8'", - "uvicorn-worker>=0.2.0,<1.0.0; python_version>='3.8'" -] - [project.scripts] ff = "functions_framework._cli:_cli" functions-framework = "functions_framework._cli:_cli" diff --git a/src/functions_framework/aio/__init__.py b/src/functions_framework/aio/__init__.py index e30b5f99..8e5f9dc7 100644 --- a/src/functions_framework/aio/__init__.py +++ b/src/functions_framework/aio/__init__.py @@ -25,6 +25,12 @@ from cloudevents.http import from_http from cloudevents.http.event import CloudEvent +from starlette.applications import Starlette +from starlette.exceptions import HTTPException +from starlette.middleware import Middleware +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Route from functions_framework import ( _enable_execution_id_logging, @@ -36,19 +42,6 @@ MissingSourceException, ) -try: - from starlette.applications import Starlette - from starlette.exceptions import HTTPException - from starlette.middleware import Middleware - from starlette.requests import Request - from starlette.responses import JSONResponse, Response - from starlette.routing import Route -except ImportError: - raise FunctionsFrameworkException( - "Starlette is not installed. Install the framework with the 'async' extra: " - "pip install functions-framework[async]" - ) - HTTPResponse = Union[ Response, # Functions can return a full Starlette Response object str, # Str returns are wrapped in Response(result) diff --git a/tests/test_aio.py b/tests/test_aio.py index 4f34c279..e7533b1d 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -35,29 +35,6 @@ TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" -def test_import_error_without_starlette(monkeypatch): - import builtins - - original_import = builtins.__import__ - - def mock_import(name, *args, **kwargs): - if name.startswith("starlette"): - raise ImportError(f"No module named '{name}'") - return original_import(name, *args, **kwargs) - - monkeypatch.setattr(builtins, "__import__", mock_import) - - # Remove the module from sys.modules to force re-import - if "functions_framework.aio" in sys.modules: - del sys.modules["functions_framework.aio"] - - with pytest.raises(exceptions.FunctionsFrameworkException) as excinfo: - import functions_framework.aio - - assert "Starlette is not installed" in str(excinfo.value) - assert "pip install functions-framework[async]" in str(excinfo.value) - - def test_invalid_function_definition_missing_function_file(): source = TEST_FUNCTIONS_DIR / "missing_function_file" / "main.py" target = "function" diff --git a/tests/test_asgi.py b/tests/test_asgi.py index cd117bd3..e5b97e60 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -18,12 +18,9 @@ import pretend import pytest -import functions_framework._http +from starlette.applications import Starlette -try: - from starlette.applications import Starlette -except ImportError: - pass +import functions_framework._http def test_httpserver_detects_asgi_app(): diff --git a/tox.ini b/tox.ini index fd3e38a6..cb0873b6 100644 --- a/tox.ini +++ b/tox.ini @@ -29,8 +29,6 @@ deps = pytest-cov pytest-integration pretend -extras = - async setenv = PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 # Python 3.7: Use .coveragerc-py37 to exclude aio module from coverage since it requires Python 3.8+ (Starlette dependency) @@ -48,8 +46,6 @@ deps = isort mypy build -extras = - async commands = black --check src tests conftest.py --exclude tests/test_functions/background_load_error/main.py isort -c src tests conftest.py From ef48e70ee21432a5c7ff014e064b8424254ef289 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 22 Jul 2025 17:50:07 -0700 Subject: [PATCH 158/181] feat: auto-detect ASGI mode for @aio decorated functions (#387) ## Summary - Implement automatic ASGI mode detection for functions decorated with `@aio.http` or `@aio.cloud_event` - The approach creates a Flask app first, loads the module within its context, then checks if ASGI is needed - This results in an unused Flask app for ASGI functions, but we accept this memory overhead as a trade-off - The `--asgi` CLI flag still works and skips the Flask app creation for optimization ## Implementation Details - Added `ASGI_FUNCTIONS` set to `_function_registry.py` to track functions that require ASGI - Updated `@aio.http` and `@aio.cloud_event` decorators to register functions in `ASGI_FUNCTIONS` - Modified `create_app()` to auto-detect ASGI requirements after module loading: 1. Always creates a Flask app first 2. Loads the user module within Flask app context 3. Checks if target function is in `ASGI_FUNCTIONS` registry 4. If ASGI is needed, delegates to `create_asgi_app_from_module()` - The `--asgi` CLI flag continues to work, bypassing Flask app creation entirely for performance ## Trade-offs - **Memory overhead**: ASGI functions will have an unused Flask app instance created during auto-detection - **Accepted trade-off**: This avoids loading modules twice which could cause side effects - **Optimization available**: Users can still use `--asgi` flag to skip Flask app creation entirely ## Test plan - [x] Added tests to verify decorators register functions in `ASGI_FUNCTIONS` - [x] Added CLI tests to verify auto-detection works for `@aio` decorated functions - [x] Added CLI tests to verify regular functions still use Flask/WSGI mode - [x] Added proper test isolation with registry cleanup fixtures - [x] All existing tests pass - [x] Linting passes --- .coveragerc-py37 | 9 ++-- src/functions_framework/__init__.py | 28 +++++++++++ src/functions_framework/_cli.py | 5 +- src/functions_framework/_function_registry.py | 4 ++ src/functions_framework/aio/__init__.py | 30 ++++++++++++ tests/test_cli.py | 39 +++++++++++++++ tests/test_decorator_functions.py | 47 +++++++++++++++++++ tests/test_function_registry.py | 16 +++++++ tests/test_functions.py | 2 +- 9 files changed, 170 insertions(+), 10 deletions(-) diff --git a/.coveragerc-py37 b/.coveragerc-py37 index b1c98d23..efb63fec 100644 --- a/.coveragerc-py37 +++ b/.coveragerc-py37 @@ -12,14 +12,11 @@ omit = [report] exclude_lines = - # Have to re-enable the standard pragma pragma: no cover - - # Don't complain about async-specific imports and code from functions_framework.aio import from functions_framework._http.asgi import from functions_framework._http.gunicorn import UvicornApplication - - # Exclude async-specific classes and functions in execution_id.py class AsgiMiddleware: - def set_execution_context_async \ No newline at end of file + def set_execution_context_async + return create_asgi_app_from_module + app = create_asgi_app\(target, source, signature_type\) \ No newline at end of file diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 22fbf44c..31169f4c 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -327,6 +327,16 @@ def crash_handler(e): def create_app(target=None, source=None, signature_type=None): + """Create an app for the function. + + Args: + target: The name of the target function to invoke + source: The source file containing the function + signature_type: The signature type of the function + + Returns: + A Flask WSGI app or Starlette ASGI app depending on function decorators + """ target = _function_registry.get_function_target(target) source = _function_registry.get_function_source(source) @@ -370,6 +380,7 @@ def handle_none(rv): setup_logging() _app.wsgi_app = execution_id.WsgiMiddleware(_app.wsgi_app) + # Execute the module, within the application context with _app.app_context(): try: @@ -394,6 +405,23 @@ def function(*_args, **_kwargs): # command fails. raise e from None + use_asgi = target in _function_registry.ASGI_FUNCTIONS + if use_asgi: + # This function needs ASGI, delegate to create_asgi_app + # Note: @aio decorators only register functions in ASGI_FUNCTIONS when the + # module is imported. We can't know if a function uses @aio until after + # we load the module. + # + # To avoid loading modules twice, we always create a Flask app first, load the + # module within its context, then check if ASGI is needed. This results in an + # unused Flask app for ASGI functions, but we accept this memory overhead as a + # trade-off. + from functions_framework.aio import create_asgi_app_from_module + + return create_asgi_app_from_module( + target, source, signature_type, source_module, spec + ) + # Get the configured function signature type signature_type = _function_registry.get_func_signature_type(target, signature_type) diff --git a/src/functions_framework/_cli.py b/src/functions_framework/_cli.py index c2ba9f4b..48455ea6 100644 --- a/src/functions_framework/_cli.py +++ b/src/functions_framework/_cli.py @@ -16,7 +16,7 @@ import click -from functions_framework import create_app +from functions_framework import _function_registry, create_app from functions_framework._http import create_server @@ -39,11 +39,10 @@ help="Use ASGI server for function execution", ) def _cli(target, source, signature_type, host, port, debug, asgi): - if asgi: # pragma: no cover + if asgi: from functions_framework.aio import create_asgi_app app = create_asgi_app(target, source, signature_type) else: app = create_app(target, source, signature_type) - create_server(app, debug).run(host, port) diff --git a/src/functions_framework/_function_registry.py b/src/functions_framework/_function_registry.py index 2214b5fd..1f08c794 100644 --- a/src/functions_framework/_function_registry.py +++ b/src/functions_framework/_function_registry.py @@ -40,6 +40,10 @@ # Keys are the user function name, values are the type of the function input INPUT_TYPE_MAP = {} +# ASGI_FUNCTIONS stores function names that require ASGI mode. +# Functions decorated with @aio.http or @aio.cloud_event are added here. +ASGI_FUNCTIONS = set() + def get_user_function(source, source_module, target): """Returns user function, raises exception for invalid function.""" diff --git a/src/functions_framework/aio/__init__.py b/src/functions_framework/aio/__init__.py index 8e5f9dc7..a56fe942 100644 --- a/src/functions_framework/aio/__init__.py +++ b/src/functions_framework/aio/__init__.py @@ -62,6 +62,7 @@ def cloud_event(func: CloudEventFunction) -> CloudEventFunction: _function_registry.REGISTRY_MAP[func.__name__] = ( _function_registry.CLOUDEVENT_SIGNATURE_TYPE ) + _function_registry.ASGI_FUNCTIONS.add(func.__name__) if inspect.iscoroutinefunction(func): @functools.wraps(func) @@ -82,6 +83,7 @@ def http(func: HTTPFunction) -> HTTPFunction: _function_registry.REGISTRY_MAP[func.__name__] = ( _function_registry.HTTP_SIGNATURE_TYPE ) + _function_registry.ASGI_FUNCTIONS.add(func.__name__) if inspect.iscoroutinefunction(func): @@ -213,6 +215,29 @@ async def __call__(self, scope, receive, send): # Don't re-raise to prevent starlette from printing traceback again +def create_asgi_app_from_module(target, source, signature_type, source_module, spec): + """Create an ASGI application from an already-loaded module. + + Args: + target: The name of the target function to invoke + source: The source file containing the function + signature_type: The signature type of the function + source_module: The already-loaded module + spec: The module spec + + Returns: + A Starlette ASGI application instance + """ + enable_id_logging = _enable_execution_id_logging() + if enable_id_logging: # pragma: no cover + _configure_app_execution_id_logging() + + function = _function_registry.get_user_function(source, source_module, target) + signature_type = _function_registry.get_func_signature_type(target, signature_type) + + return _create_asgi_app_with_function(function, signature_type, enable_id_logging) + + def create_asgi_app(target=None, source=None, signature_type=None): """Create an ASGI application for the function. @@ -243,6 +268,11 @@ def create_asgi_app(target=None, source=None, signature_type=None): function = _function_registry.get_user_function(source, source_module, target) signature_type = _function_registry.get_func_signature_type(target, signature_type) + return _create_asgi_app_with_function(function, signature_type, enable_id_logging) + + +def _create_asgi_app_with_function(function, signature_type, enable_id_logging): + """Create an ASGI app with the given function and signature type.""" is_async = inspect.iscoroutinefunction(function) routes = [] if signature_type == _function_registry.HTTP_SIGNATURE_TYPE: diff --git a/tests/test_cli.py b/tests/test_cli.py index 4e5a0a08..75c93f20 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os +import pathlib import sys import pretend @@ -20,9 +22,30 @@ from click.testing import CliRunner import functions_framework +import functions_framework._function_registry as _function_registry from functions_framework._cli import _cli +# Conditional import for Starlette (Python 3.8+) +if sys.version_info >= (3, 8): + from starlette.applications import Starlette +else: + Starlette = None + + +@pytest.fixture(autouse=True) +def clean_registries(): + """Clean up both REGISTRY_MAP and ASGI_FUNCTIONS registries.""" + original_registry_map = _function_registry.REGISTRY_MAP.copy() + original_asgi = _function_registry.ASGI_FUNCTIONS.copy() + _function_registry.REGISTRY_MAP.clear() + _function_registry.ASGI_FUNCTIONS.clear() + yield + _function_registry.REGISTRY_MAP.clear() + _function_registry.REGISTRY_MAP.update(original_registry_map) + _function_registry.ASGI_FUNCTIONS.clear() + _function_registry.ASGI_FUNCTIONS.update(original_asgi) + def test_cli_no_arguments(): runner = CliRunner() @@ -124,3 +147,19 @@ def test_asgi_cli(monkeypatch): assert result.exit_code == 0 assert create_asgi_app.calls == [pretend.call("foo", None, "http")] assert asgi_server.run.calls == [pretend.call("0.0.0.0", 8080)] + + +def test_cli_auto_detects_asgi_decorator(): + """Test that CLI auto-detects @aio decorated functions without --asgi flag.""" + # Use the actual async_decorator.py test file which has @aio.http decorated functions + test_functions_dir = pathlib.Path(__file__).parent / "test_functions" / "decorators" + source = test_functions_dir / "async_decorator.py" + + # Call create_app without any asgi flag - should auto-detect + app = functions_framework.create_app(target="function_http", source=str(source)) + + # Verify it created a Starlette app (ASGI) + assert isinstance(app, Starlette) + + # Verify the function was registered in ASGI_FUNCTIONS + assert "function_http" in _function_registry.ASGI_FUNCTIONS diff --git a/tests/test_decorator_functions.py b/tests/test_decorator_functions.py index 435aa815..3a6e5e99 100644 --- a/tests/test_decorator_functions.py +++ b/tests/test_decorator_functions.py @@ -19,6 +19,8 @@ from cloudevents import conversion as ce_conversion from cloudevents.http import CloudEvent +import functions_framework._function_registry as registry + # Conditional import for Starlette if sys.version_info >= (3, 8): from starlette.testclient import TestClient as StarletteTestClient @@ -35,6 +37,21 @@ TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" + +@pytest.fixture(autouse=True) +def clean_registries(): + """Clean up both REGISTRY_MAP and ASGI_FUNCTIONS registries.""" + original_registry_map = registry.REGISTRY_MAP.copy() + original_asgi = registry.ASGI_FUNCTIONS.copy() + registry.REGISTRY_MAP.clear() + registry.ASGI_FUNCTIONS.clear() + yield + registry.REGISTRY_MAP.clear() + registry.REGISTRY_MAP.update(original_registry_map) + registry.ASGI_FUNCTIONS.clear() + registry.ASGI_FUNCTIONS.update(original_asgi) + + # Python 3.5: ModuleNotFoundError does not exist try: _ModuleNotFoundError = ModuleNotFoundError @@ -128,3 +145,33 @@ def test_aio_http_dict_response(): resp = client.post("/") assert resp.status_code == 200 assert resp.json() == {"message": "hello", "count": 42, "success": True} + + +def test_aio_decorators_register_asgi_functions(): + """Test that @aio decorators add function names to ASGI_FUNCTIONS registry.""" + from functions_framework.aio import cloud_event, http + + @http + async def test_http_func(request): + return "test" + + @cloud_event + async def test_cloud_event_func(event): + pass + + assert "test_http_func" in registry.ASGI_FUNCTIONS + assert "test_cloud_event_func" in registry.ASGI_FUNCTIONS + + assert registry.REGISTRY_MAP["test_http_func"] == "http" + assert registry.REGISTRY_MAP["test_cloud_event_func"] == "cloudevent" + + @http + def test_http_sync(request): + return "sync" + + @cloud_event + def test_cloud_event_sync(event): + pass + + assert "test_http_sync" in registry.ASGI_FUNCTIONS + assert "test_cloud_event_sync" in registry.ASGI_FUNCTIONS diff --git a/tests/test_function_registry.py b/tests/test_function_registry.py index e3ae3c7e..5b517cdc 100644 --- a/tests/test_function_registry.py +++ b/tests/test_function_registry.py @@ -13,9 +13,25 @@ # limitations under the License. import os +import pytest + from functions_framework import _function_registry +@pytest.fixture(autouse=True) +def clean_registries(): + """Clean up both REGISTRY_MAP and ASGI_FUNCTIONS registries.""" + original_registry_map = _function_registry.REGISTRY_MAP.copy() + original_asgi = _function_registry.ASGI_FUNCTIONS.copy() + _function_registry.REGISTRY_MAP.clear() + _function_registry.ASGI_FUNCTIONS.clear() + yield + _function_registry.REGISTRY_MAP.clear() + _function_registry.REGISTRY_MAP.update(original_registry_map) + _function_registry.ASGI_FUNCTIONS.clear() + _function_registry.ASGI_FUNCTIONS.update(original_asgi) + + def test_get_function_signature(): test_cases = [ { diff --git a/tests/test_functions.py b/tests/test_functions.py index 534f4a88..bafb5e8b 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -495,7 +495,7 @@ def test_error_paths(http_trigger_client, path): def test_lazy_wsgi_app(monkeypatch, target, source, signature_type): actual_app_stub = pretend.stub() wsgi_app = pretend.call_recorder(lambda *a, **kw: actual_app_stub) - create_app = pretend.call_recorder(lambda *a: wsgi_app) + create_app = pretend.call_recorder(lambda *a, **kw: wsgi_app) monkeypatch.setattr(functions_framework, "create_app", create_app) # Test that it's lazy From d5ac6b4329683993a15d05a19ffdc97c57e04759 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 23 Jul 2025 16:04:49 -0700 Subject: [PATCH 159/181] chore(main): release 3.9.0 (#374) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 18 ++++++++++++++++++ pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8231717d..465d5c1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.9.0](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.8.3...v3.9.0) (2025-07-23) + + +### Features + +* add execution_id support for async stack ([#377](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/377)) ([1123eea](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/1123eeac8cedae23af8980a928f01f5ad100d9de)) +* add flag to run functions framework in asgi stack ([#376](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/376)) ([268acf1](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/268acf121015bf2a5592715e4cfe582f9d236ff8)) +* add support for async functions ([#364](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/364)) ([49f6985](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/49f698517a06d0e47b2aadc2d603e0b193770440)) +* auto-detect ASGI mode for [@aio](https://github.com/aio) decorated functions ([#387](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/387)) ([ef48e70](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/ef48e70ee21432a5c7ff014e064b8424254ef289)) + + +### Bug Fixes + +* **ci:** specify python version in tox environment ([#375](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/375)) ([37e0bf7](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/37e0bf764ff24ebb82ba18bcac1bee6b03cecb13)) +* Pin cloudevent sdk version to support python3.7. ([#373](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/373)) ([cc2b9b5](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/cc2b9b584fcc3daaa2762ae62a3ce1277a488a1c)) +* resolve CI failures for egress policies and Python 3.7 buildpack support ([#388](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/388)) ([2de6eec](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/2de6eec6fae132b8b1fb41e7024a2260a05bc072)) +* set default log level for asgi logger to WARNING to match default python behavior ([#381](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/381)) ([a576a8f](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/a576a8f28a6029fc5b5ab0725d2aa9c6c5f4304f)) + ## [3.8.3](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.8.2...v3.8.3) (2025-05-14) diff --git a/pyproject.toml b/pyproject.toml index 9aa34365..2fcbabbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "functions-framework" -version = "3.8.3" +version = "3.9.0" description = "An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team." readme = "README.md" requires-python = ">=3.7, <4" diff --git a/setup.py b/setup.py index 1c35d39b..1dd17d7a 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="3.8.2", + version="3.9.0", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown", From 26fb101d556a39262855a304c1ac351856ce4b61 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Thu, 24 Jul 2025 10:28:56 -0700 Subject: [PATCH 160/181] fix: remove unused httpx dependency (#389) httpx was accidentally added in #364 but is not used anywhere in the codebase. Removing it reduces our dependency count from 28 to 20 packages. Impact: - Removes 8 unnecessary packages (httpx + 7 transitive deps) - Reduces installation size by ~2MB - Keeps only the actually needed async dependencies --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2fcbabbc..a7c1778a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,6 @@ dependencies = [ "gunicorn>=22.0.0; platform_system!='Windows'", "cloudevents>=1.2.0,<=1.11.0", # Must support python 3.7 "Werkzeug>=0.14,<4.0.0", - "httpx>=0.24.1", "starlette>=0.37.0,<1.0.0; python_version>='3.8'", "uvicorn>=0.18.0,<1.0.0; python_version>='3.8'", "uvicorn-worker>=0.2.0,<1.0.0; python_version>='3.8'", From a1c557c26588ebeacd6852ddbce9c7866006bc39 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Thu, 24 Jul 2025 19:29:35 +0200 Subject: [PATCH 161/181] chore(deps): update pypa/gh-action-pypi-publish digest to e9ccbe5 (#369) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 269f14c7..77207e54 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: - name: Build distributions run: python -m build - name: Publish - uses: pypa/gh-action-pypi-publish@db8f07d3871a0a180efa06b95d467625c19d5d5f # main + uses: pypa/gh-action-pypi-publish@e9ccbe5a211ba3e8363f472cae362b56b104e796 # main with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} From 0c643782d30d7295c10a3ad5ffd1e5f448bfd16f Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Thu, 24 Jul 2025 19:52:44 +0200 Subject: [PATCH 162/181] chore(deps): update all non-major dependencies (#370) * chore(deps): update all non-major dependencies * keep cloudevents version --------- Co-authored-by: Andras Kerekes --- .github/workflows/codeql.yml | 8 ++++---- .github/workflows/conformance-asgi.yml | 12 ++++++------ .github/workflows/conformance.yml | 16 ++++++++-------- .github/workflows/dependency-review.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/scorecard.yml | 6 +++--- .github/workflows/unit.yml | 2 +- pyproject.toml | 2 +- 9 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 46f6a4d1..270f890c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,7 +41,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: disable-sudo: true egress-policy: block @@ -57,7 +57,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 + uses: github/codeql-action/init@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -67,7 +67,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 + uses: github/codeql-action/autobuild@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -80,6 +80,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 + uses: github/codeql-action/analyze@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/conformance-asgi.yml b/.github/workflows/conformance-asgi.yml index 8a61cd4c..b3f3601d 100644 --- a/.github/workflows/conformance-asgi.yml +++ b/.github/workflows/conformance-asgi.yml @@ -17,7 +17,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: disable-sudo: true egress-policy: block @@ -48,7 +48,7 @@ jobs: go-version: '1.24' - name: Run HTTP conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 with: functionType: 'http' useBuildpacks: false @@ -56,7 +56,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http --signature-type http --asgi'" - name: Run CloudEvents conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 with: functionType: 'cloudevent' useBuildpacks: false @@ -64,7 +64,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event --signature-type cloudevent --asgi'" - name: Run HTTP conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 with: functionType: 'http' useBuildpacks: false @@ -72,7 +72,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative --asgi'" - name: Run CloudEvents conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 with: functionType: 'cloudevent' useBuildpacks: false @@ -80,7 +80,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event_declarative --asgi'" - name: Run HTTP concurrency tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 with: functionType: 'http' useBuildpacks: false diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 7d10b8af..9eedb0a2 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -22,7 +22,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: disable-sudo: true egress-policy: block @@ -53,7 +53,7 @@ jobs: go-version: '1.24' - name: Run HTTP conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 with: functionType: 'http' useBuildpacks: false @@ -61,7 +61,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http --signature-type http'" - name: Run event conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 with: functionType: 'legacyevent' useBuildpacks: false @@ -69,7 +69,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_legacy_event --signature-type event'" - name: Run CloudEvents conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 with: functionType: 'cloudevent' useBuildpacks: false @@ -77,7 +77,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_cloud_event --signature-type cloudevent'" - name: Run HTTP conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 with: functionType: 'http' useBuildpacks: false @@ -85,7 +85,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative'" - name: Run CloudEvents conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 with: functionType: 'cloudevent' useBuildpacks: false @@ -93,7 +93,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_cloud_event_declarative'" - name: Run HTTP concurrency tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 with: functionType: 'http' useBuildpacks: false @@ -101,7 +101,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative_concurrent'" - name: Run Typed tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6 + uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 with: functionType: 'http' declarativeType: 'typed' diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 5baf3aa0..02009c72 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: disable-sudo: true egress-policy: block diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 4fa8dff2..1c99b0f7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: disable-sudo: true egress-policy: block diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 77207e54..6bb76655 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 72b82523..23b0c7c0 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: disable-sudo: true egress-policy: block @@ -52,7 +52,7 @@ jobs: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1 + uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 with: results_file: results.sarif results_format: sarif @@ -64,6 +64,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 + uses: github/codeql-action/upload-sarif@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4 with: sarif_file: results.sarif diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 28ed5b1e..90e9e915 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -41,7 +41,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: disable-sudo: true egress-policy: block diff --git a/pyproject.toml b/pyproject.toml index a7c1778a..4a00ace8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "click>=7.0,<9.0", "watchdog>=1.0.0", "gunicorn>=22.0.0; platform_system!='Windows'", - "cloudevents>=1.2.0,<=1.11.0", # Must support python 3.7 + "cloudevents>=1.2.0,<=1.11.0", # Must support python 3.7 "Werkzeug>=0.14,<4.0.0", "starlette>=0.37.0,<1.0.0; python_version>='3.8'", "uvicorn>=0.18.0,<1.0.0; python_version>='3.8'", From 9e625c9a79cf9a0a35778adad285c20be7535b8f Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Thu, 24 Jul 2025 11:07:58 -0700 Subject: [PATCH 163/181] chore(main): release 3.9.1 (#390) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 465d5c1b..f7be45f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.9.1](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.9.0...v3.9.1) (2025-07-24) + + +### Bug Fixes + +* remove unused httpx dependency ([#389](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/389)) ([26fb101](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/26fb101d556a39262855a304c1ac351856ce4b61)) + ## [3.9.0](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.8.3...v3.9.0) (2025-07-23) diff --git a/pyproject.toml b/pyproject.toml index 4a00ace8..968ed607 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "functions-framework" -version = "3.9.0" +version = "3.9.1" description = "An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team." readme = "README.md" requires-python = ">=3.7, <4" diff --git a/setup.py b/setup.py index 1dd17d7a..e1467ced 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="3.9.0", + version="3.9.1", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown", From 1b6c428d559991134240473f6622f8759a3360d5 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Thu, 24 Jul 2025 13:28:25 -0700 Subject: [PATCH 164/181] fix: increase start delay for ASGI conformance tests to address flaky failures (#391) The ASGI conformance tests are failing sporadically with connection refused errors, where the server appears to not be ready when the test client attempts to connect. Hypothesis: The default 1-second start delay may be insufficient for Uvicorn/ASGI server startup in CI environments. This change increases startDelay to 5 seconds to match the buildpack integration tests, which have been running reliably. Note: The GitHub Action expects startDelay (camelCase) as an input parameter, which it then passes to the conformance test client as -start-delay flag. This is an attempt to diagnose and fix the intermittent failures - further investigation may be needed if the issue persists. --- .github/workflows/conformance-asgi.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/conformance-asgi.yml b/.github/workflows/conformance-asgi.yml index b3f3601d..69f2b215 100644 --- a/.github/workflows/conformance-asgi.yml +++ b/.github/workflows/conformance-asgi.yml @@ -54,6 +54,7 @@ jobs: useBuildpacks: false validateMapping: false cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http --signature-type http --asgi'" + startDelay: 5 - name: Run CloudEvents conformance tests uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 @@ -62,6 +63,7 @@ jobs: useBuildpacks: false validateMapping: false cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event --signature-type cloudevent --asgi'" + startDelay: 5 - name: Run HTTP conformance tests declarative uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 @@ -70,6 +72,7 @@ jobs: useBuildpacks: false validateMapping: false cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative --asgi'" + startDelay: 5 - name: Run CloudEvents conformance tests declarative uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 @@ -78,6 +81,7 @@ jobs: useBuildpacks: false validateMapping: false cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event_declarative --asgi'" + startDelay: 5 - name: Run HTTP concurrency tests declarative uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 @@ -86,6 +90,7 @@ jobs: useBuildpacks: false validateConcurrency: true cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative_concurrent --asgi'" + startDelay: 5 # Note: Event (legacy) and Typed tests are not supported in ASGI mode # Note: validateMapping is set to false for CloudEvent tests because ASGI mode From f1dc285f07f7f5daa398d1a510573b4d15117332 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Mon, 28 Jul 2025 10:55:45 -0700 Subject: [PATCH 165/181] chore(main): release 3.9.2 (#392) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7be45f0..73ed6d36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.9.2](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.9.1...v3.9.2) (2025-07-24) + + +### Bug Fixes + +* increase start delay for ASGI conformance tests to address flaky failures ([#391](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/391)) ([1b6c428](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/1b6c428d559991134240473f6622f8759a3360d5)) + ## [3.9.1](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.9.0...v3.9.1) (2025-07-24) diff --git a/pyproject.toml b/pyproject.toml index 968ed607..19912786 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "functions-framework" -version = "3.9.1" +version = "3.9.2" description = "An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team." readme = "README.md" requires-python = ">=3.7, <4" diff --git a/setup.py b/setup.py index e1467ced..3ba48c93 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="3.9.1", + version="3.9.2", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown", From 9b37f85f6c37078119c2ea3cc91e6b3c00954a8c Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Mon, 4 Aug 2025 13:27:44 -0700 Subject: [PATCH 166/181] fix(ci): Add release-assets.githubusercontent.com to allowed endpoints (#394) The CodeQL workflow was failing because the `step-security/harden-runner` was blocking egress traffic to the endpoint used for downloading the CodeQL bundle. The download from `https://github.com/github/codeql-action/releases` redirects to `release-assets.githubusercontent.com`. This change adds `release-assets.githubusercontent.com` to the list of allowed endpoints to resolve the `ECONNREFUSED` error and removes previous incorrect attempts. --- .github/workflows/codeql.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 270f890c..e34c1f9b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -51,6 +51,7 @@ jobs: github.com:443 pypi.org:443 objects.githubusercontent.com:443 + release-assets.githubusercontent.com:443 - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 From a07b1e4f81d84424189488022e5f3d59c06014cc Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Mon, 4 Aug 2025 13:39:47 -0700 Subject: [PATCH 167/181] feat: Add async and streaming examples (#393) Adds two new examples to demonstrate asynchronous and streaming capabilities: - `cloud_run_async`: Shows how to create basic asynchronous HTTP and CloudEvent functions. - `cloud_run_streaming_http`: Shows how to create both synchronous and asynchronous streaming HTTP functions. The examples include instructions for running locally with the `functions-framework` CLI and deploying to Cloud Run with `gcloud`. --- examples/README.md | 2 + examples/cloud_run_async/README.md | 68 +++++++++++++++++++ examples/cloud_run_async/main.py | 36 ++++++++++ examples/cloud_run_async/requirements.txt | 4 ++ examples/cloud_run_async/send_cloud_event.py | 38 +++++++++++ examples/cloud_run_streaming_http/README.md | 65 ++++++++++++++++++ examples/cloud_run_streaming_http/main.py | 59 ++++++++++++++++ .../cloud_run_streaming_http/requirements.txt | 1 + 8 files changed, 273 insertions(+) create mode 100644 examples/cloud_run_async/README.md create mode 100644 examples/cloud_run_async/main.py create mode 100644 examples/cloud_run_async/requirements.txt create mode 100644 examples/cloud_run_async/send_cloud_event.py create mode 100644 examples/cloud_run_streaming_http/README.md create mode 100644 examples/cloud_run_streaming_http/main.py create mode 100644 examples/cloud_run_streaming_http/requirements.txt diff --git a/examples/README.md b/examples/README.md index 7960a743..47b7c398 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,6 +5,8 @@ * [`cloud_run_http`](./cloud_run_http/) - Deploying an HTTP function to [Cloud Run](http://cloud.google.com/run) with the Functions Framework * [`cloud_run_event`](./cloud_run_event/) - Deploying a CloudEvent function to [Cloud Run](http://cloud.google.com/run) with the Functions Framework * [`cloud_run_cloud_events`](cloud_run_cloud_events/) - Deploying a [CloudEvent](https://github.com/cloudevents/sdk-python) function to [Cloud Run](http://cloud.google.com/run) with the Functions Framework +* [`cloud_run_async`](./cloud_run_async/) - Deploying asynchronous HTTP and CloudEvent functions to [Cloud Run](http://cloud.google.com/run) with the Functions Framework +* [`cloud_run_streaming_http`](./cloud_run_streaming_http/) - Deploying streaming HTTP functions to [Cloud Run](http://cloud.google.com/run) with the Functions Framework ## Development Tools * [`docker-compose`](./docker-compose) - diff --git a/examples/cloud_run_async/README.md b/examples/cloud_run_async/README.md new file mode 100644 index 00000000..81de2747 --- /dev/null +++ b/examples/cloud_run_async/README.md @@ -0,0 +1,68 @@ +# Deploying async functions to Cloud Run + +This sample shows how to deploy asynchronous functions to [Cloud Run functions](https://cloud.google.com/functions) with the Functions Framework. It includes examples for both HTTP and CloudEvent functions, which can be found in the `main.py` file. + +## Dependencies + +Install the dependencies for this example: + +```sh +pip install -r requirements.txt +``` + +## Running locally + +### HTTP Function + +To run the HTTP function locally, use the `functions-framework` command: + +```sh +functions-framework --target=hello_async_http +``` + +Then, send a request to it from another terminal: + +```sh +curl localhost:8080 +# Output: Hello, async world! +``` + +### CloudEvent Function + +To run the CloudEvent function, specify the target and set the signature type: + +```sh +functions-framework --target=hello_async_cloudevent --signature-type=cloudevent +``` + +Then, in another terminal, send a sample CloudEvent using the provided script: + +```sh +python send_cloud_event.py +``` + +## Deploying to Cloud Run + +You can deploy these functions to Cloud Run using the `gcloud` CLI. + +### HTTP Function + +```sh +gcloud run deploy async-http-function \ + --source . \ + --function hello_async_http \ + --base-image python312 \ + --region +``` + +### CloudEvent Function + +```sh +gcloud run deploy async-cloudevent-function \ + --source . \ + --function hello_async_cloudevent \ + --base-image python312 \ + --region +``` + +After deploying, you can invoke the CloudEvent function by sending an HTTP POST request with a CloudEvent payload to its URL. diff --git a/examples/cloud_run_async/main.py b/examples/cloud_run_async/main.py new file mode 100644 index 00000000..40062a95 --- /dev/null +++ b/examples/cloud_run_async/main.py @@ -0,0 +1,36 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import functions_framework.aio + +@functions_framework.aio.http +async def hello_async_http(request): + """ + An async HTTP function. + Args: + request (starlette.requests.Request): The request object. + Returns: + The response text, or an instance of any Starlette response class + (e.g. `starlette.responses.Response`). + """ + return "Hello, async world!" + +@functions_framework.aio.cloud_event +async def hello_async_cloudevent(cloud_event): + """ + An async CloudEvent function. + Args: + cloud_event (cloudevents.http.CloudEvent): The CloudEvent object. + """ + print(f"Received event with ID: {cloud_event['id']} and data {cloud_event.data}") diff --git a/examples/cloud_run_async/requirements.txt b/examples/cloud_run_async/requirements.txt new file mode 100644 index 00000000..b862cce2 --- /dev/null +++ b/examples/cloud_run_async/requirements.txt @@ -0,0 +1,4 @@ +functions-framework>=3.9.2,<4.0.0 + +# For testing +httpx<=0.28.1 diff --git a/examples/cloud_run_async/send_cloud_event.py b/examples/cloud_run_async/send_cloud_event.py new file mode 100644 index 00000000..714bc719 --- /dev/null +++ b/examples/cloud_run_async/send_cloud_event.py @@ -0,0 +1,38 @@ +#!/usr/local/bin/python + +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import asyncio +import httpx + +from cloudevents.http import CloudEvent, to_structured + + +async def main(): + attributes = { + "Content-Type": "application/json", + "source": "from-galaxy-far-far-away", + "type": "cloudevent.greet.you", + } + data = {"name": "john"} + + event = CloudEvent(attributes, data) + + headers, data = to_structured(event) + + async with httpx.AsyncClient() as client: + await client.post("http://localhost:8080/", headers=headers, data=data) + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/cloud_run_streaming_http/README.md b/examples/cloud_run_streaming_http/README.md new file mode 100644 index 00000000..839b09c7 --- /dev/null +++ b/examples/cloud_run_streaming_http/README.md @@ -0,0 +1,65 @@ +# Deploying streaming functions to Cloud Run + +This sample shows how to deploy streaming functions to [Cloud Run](http://cloud.google.com/run) with the Functions Framework. The `main.py` file contains examples for both synchronous and asynchronous streaming. + +## Dependencies + +Install the dependencies for this example: + +```sh +pip install -r requirements.txt +``` + +## Running locally + +### Synchronous Streaming + +To run the synchronous streaming function locally: + +```sh +functions-framework --target=hello_stream +``` + +Then, send a request to it from another terminal: + +```sh +curl localhost:8080 +``` + +### Asynchronous Streaming + +To run the asynchronous streaming function locally: + +```sh +functions-framework --target=hello_stream_async +``` + +Then, send a request to it from another terminal: + +```sh +curl localhost:8080 +``` + +## Deploying to Cloud Run + +You can deploy these functions to Cloud Run using the `gcloud` CLI. + +### Synchronous Streaming + +```sh +gcloud run deploy streaming-function \ + --source . \ + --function hello_stream \ + --base-image python312 \ + --region +``` + +### Asynchronous Streaming + +```sh +gcloud run deploy streaming-async-function \ + --source . \ + --function hello_stream_async \ + --base-image python312 \ + --region +``` diff --git a/examples/cloud_run_streaming_http/main.py b/examples/cloud_run_streaming_http/main.py new file mode 100644 index 00000000..5010b1a3 --- /dev/null +++ b/examples/cloud_run_streaming_http/main.py @@ -0,0 +1,59 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import time +import asyncio +import functions_framework +import functions_framework.aio +from starlette.responses import StreamingResponse + +# Helper function for the synchronous streaming example. +def slow_numbers(minimum, maximum): + yield '
    ' + for number in range(minimum, maximum + 1): + yield '
  • %d
  • ' % number + time.sleep(0.5) + yield '
' + +@functions_framework.http +def hello_stream(request): + """ + A synchronous HTTP function that streams a response. + Args: + request (flask.Request): The request object. + Returns: + A generator, which will be streamed as the response. + """ + generator = slow_numbers(1, 10) + return generator, {'Content-Type': 'text/html'} + +# Helper function for the asynchronous streaming example. +async def slow_numbers_async(minimum, maximum): + yield '
    ' + for number in range(minimum, maximum + 1): + yield '
  • %d
  • ' % number + await asyncio.sleep(0.5) + yield '
' + +@functions_framework.aio.http +async def hello_stream_async(request): + """ + An asynchronous HTTP function that streams a response. + Args: + request (starlette.requests.Request): The request object. + Returns: + A starlette.responses.StreamingResponse. + """ + generator = slow_numbers_async(1, 10) + return StreamingResponse(generator, media_type='text/html') diff --git a/examples/cloud_run_streaming_http/requirements.txt b/examples/cloud_run_streaming_http/requirements.txt new file mode 100644 index 00000000..83e77d02 --- /dev/null +++ b/examples/cloud_run_streaming_http/requirements.txt @@ -0,0 +1 @@ +functions-framework>=3.9.2,<4.0.0 From 3597a56e82958d97ee915f564d074e3faa4efb56 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Mon, 10 Nov 2025 16:20:51 +0000 Subject: [PATCH 168/181] chore(deps): update all non-major dependencies (#400) --- .github/workflows/codeql.yml | 10 +++++----- .github/workflows/conformance-asgi.yml | 6 +++--- .github/workflows/conformance.yml | 6 +++--- .github/workflows/dependency-review.yml | 6 +++--- .github/workflows/lint.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- .github/workflows/scorecard.yml | 8 ++++---- .github/workflows/unit.yml | 4 ++-- pyproject.toml | 2 +- 9 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e34c1f9b..e5e575fb 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,7 +41,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: disable-sudo: true egress-policy: block @@ -54,11 +54,11 @@ jobs: release-assets.githubusercontent.com:443 - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4 + uses: github/codeql-action/init@5d5cd550d3e189c569da8f16ea8de2d821c9bf7a # v3.31.2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -68,7 +68,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4 + uses: github/codeql-action/autobuild@5d5cd550d3e189c569da8f16ea8de2d821c9bf7a # v3.31.2 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -81,6 +81,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4 + uses: github/codeql-action/analyze@5d5cd550d3e189c569da8f16ea8de2d821c9bf7a # v3.31.2 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/conformance-asgi.yml b/.github/workflows/conformance-asgi.yml index 69f2b215..ee568faa 100644 --- a/.github/workflows/conformance-asgi.yml +++ b/.github/workflows/conformance-asgi.yml @@ -17,7 +17,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: disable-sudo: true egress-policy: block @@ -32,7 +32,7 @@ jobs: release-assets.githubusercontent.com:443 - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Setup Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 @@ -45,7 +45,7 @@ jobs: - name: Setup Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: - go-version: '1.24' + go-version: '1.25' - name: Run HTTP conformance tests uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 9eedb0a2..34f997a6 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -22,7 +22,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: disable-sudo: true egress-policy: block @@ -37,7 +37,7 @@ jobs: release-assets.githubusercontent.com:443 - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Setup Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 @@ -50,7 +50,7 @@ jobs: - name: Setup Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: - go-version: '1.24' + go-version: '1.25' - name: Run HTTP conformance tests uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 02009c72..efa405f8 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: disable-sudo: true egress-policy: block @@ -25,6 +25,6 @@ jobs: api.github.com:443 github.com:443 - name: 'Checkout Repository' - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: 'Dependency Review' - uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1 + uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1c99b0f7..e9088c6a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: disable-sudo: true egress-policy: block @@ -21,7 +21,7 @@ jobs: github.com:443 pypi.org:443 - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Setup Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - name: Install tox diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6bb76655..c209a7ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,12 +13,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: ref: ${{ github.event.release.tag_name }} - name: Install Python diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 23b0c7c0..6fdac794 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: disable-sudo: true egress-policy: block @@ -47,12 +47,12 @@ jobs: - name: "Checkout code" - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: results_file: results.sarif results_format: sarif @@ -64,6 +64,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4 + uses: github/codeql-action/upload-sarif@5d5cd550d3e189c569da8f16ea8de2d821c9bf7a # v3.31.2 with: sarif_file: results.sarif diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 90e9e915..20f24675 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -41,7 +41,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: disable-sudo: true egress-policy: block @@ -57,7 +57,7 @@ jobs: release-assets.githubusercontent.com:443 - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Use Python ${{ matrix.python }} uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: diff --git a/pyproject.toml b/pyproject.toml index 19912786..27368e37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "click>=7.0,<9.0", "watchdog>=1.0.0", "gunicorn>=22.0.0; platform_system!='Windows'", - "cloudevents>=1.2.0,<=1.11.0", # Must support python 3.7 + "cloudevents>=1.12.0,<=1.12.0", # Must support python 3.7 "Werkzeug>=0.14,<4.0.0", "starlette>=0.37.0,<1.0.0; python_version>='3.8'", "uvicorn>=0.18.0,<1.0.0; python_version>='3.8'", From ef45fae46896b50a7a3e0e5c3cb4813519e3cb76 Mon Sep 17 00:00:00 2001 From: Maeve <167252720+maemayve@users.noreply.github.com> Date: Mon, 10 Nov 2025 10:13:59 -0800 Subject: [PATCH 169/181] fix: remove Python 3.7 test execution (#402) Python 3.7 is a decomissioned runtime. Tests are failing because of language features not supported by this python version. Disable these tests rather than backfixing. --- .coveragerc-py37 | 22 ---------------------- .github/workflows/conformance.yml | 2 -- .github/workflows/unit.yml | 10 +--------- tox.ini | 7 ------- 4 files changed, 1 insertion(+), 40 deletions(-) delete mode 100644 .coveragerc-py37 diff --git a/.coveragerc-py37 b/.coveragerc-py37 deleted file mode 100644 index efb63fec..00000000 --- a/.coveragerc-py37 +++ /dev/null @@ -1,22 +0,0 @@ -[run] -# Coverage configuration specifically for Python 3.7 environments -# Excludes the aio module which requires Python 3.8+ (Starlette dependency) -# This file is only used by py37-* tox environments -omit = - */functions_framework/aio/* - */functions_framework/_http/asgi.py - */.tox/* - */tests/* - */venv/* - */.venv/* - -[report] -exclude_lines = - pragma: no cover - from functions_framework.aio import - from functions_framework._http.asgi import - from functions_framework._http.gunicorn import UvicornApplication - class AsgiMiddleware: - def set_execution_context_async - return create_asgi_app_from_module - app = create_asgi_app\(target, source, signature_type\) \ No newline at end of file diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 34f997a6..6c14136e 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -17,8 +17,6 @@ jobs: include: - platform: ubuntu-22.04 python: '3.8' - - platform: ubuntu-22.04 - python: '3.7' runs-on: ${{ matrix.platform }} steps: - name: Harden Runner diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 20f24675..96a880d2 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -11,7 +11,7 @@ jobs: test: strategy: matrix: - python: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] + python: ['3.8', '3.9', '3.10', '3.11', '3.12'] platform: [ubuntu-latest, macos-latest, windows-latest] # Python <= 3.9 is not available on macos-latest # Workaround for https://github.com/actions/setup-python/issues/696 @@ -21,23 +21,15 @@ jobs: python: '3.9' - platform: macos-latest python: '3.8' - - platform: macos-latest - python: '3.7' - platform: ubuntu-latest python: '3.8' - - platform: ubuntu-latest - python: '3.7' include: - platform: macos-latest python: '3.9' - platform: macos-13 python: '3.8' - - platform: macos-13 - python: '3.7' - platform: ubuntu-22.04 python: '3.8' - - platform: ubuntu-22.04 - python: '3.7' runs-on: ${{ matrix.platform }} steps: - name: Harden Runner diff --git a/tox.ini b/tox.ini index cb0873b6..2e36e689 100644 --- a/tox.ini +++ b/tox.ini @@ -16,9 +16,6 @@ envlist = py38-ubuntu-22.04 py38-macos-13 py38-windows-latest - py37-ubuntu-22.04 - py37-macos-13 - py37-windows-latest [testenv] usedevelop = true @@ -31,10 +28,6 @@ deps = pretend setenv = PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 - # Python 3.7: Use .coveragerc-py37 to exclude aio module from coverage since it requires Python 3.8+ (Starlette dependency) - py37-ubuntu-22.04: PYTESTARGS = --cov=functions_framework --cov-config=.coveragerc-py37 --cov-branch --cov-report term-missing --cov-fail-under=100 - py37-macos-13: PYTESTARGS = --cov=functions_framework --cov-config=.coveragerc-py37 --cov-branch --cov-report term-missing --cov-fail-under=100 - py37-windows-latest: PYTESTARGS = windows-latest: PYTESTARGS = commands = pytest {env:PYTESTARGS} {posargs} From 2cf966f037d83fe0d8e69cee8d41ff238db79164 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 10:20:01 -0800 Subject: [PATCH 170/181] chore(main): release 3.10.0 (#395) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 13 +++++++++++++ pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73ed6d36..b817ff60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.10.0](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.9.2...v3.10.0) (2025-11-10) + + +### Features + +* Add async and streaming examples ([#393](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/393)) ([a07b1e4](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/a07b1e4f81d84424189488022e5f3d59c06014cc)) + + +### Bug Fixes + +* **ci:** Add release-assets.githubusercontent.com to allowed endpoints ([#394](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/394)) ([9b37f85](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/9b37f85f6c37078119c2ea3cc91e6b3c00954a8c)) +* remove Python 3.7 test execution ([#402](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/402)) ([ef45fae](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/ef45fae46896b50a7a3e0e5c3cb4813519e3cb76)) + ## [3.9.2](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.9.1...v3.9.2) (2025-07-24) diff --git a/pyproject.toml b/pyproject.toml index 27368e37..b187260f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "functions-framework" -version = "3.9.2" +version = "3.10.0" description = "An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team." readme = "README.md" requires-python = ">=3.7, <4" diff --git a/setup.py b/setup.py index 3ba48c93..28d0ea11 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="3.9.2", + version="3.10.0", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown", From 8d74a7b9ecead58df77208d6e4717419a9aa9447 Mon Sep 17 00:00:00 2001 From: Maeve <167252720+maemayve@users.noreply.github.com> Date: Mon, 17 Nov 2025 14:39:44 -0800 Subject: [PATCH 171/181] fix: Correct cloudevents dependency to allow 1.11.0 (#405) Python 3.7 is no longer supported so don't allow versions prior to 1.11.0 but allow 1.11.0 as Python 3.8 is still supported by the framework. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b187260f..6d98287c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "click>=7.0,<9.0", "watchdog>=1.0.0", "gunicorn>=22.0.0; platform_system!='Windows'", - "cloudevents>=1.12.0,<=1.12.0", # Must support python 3.7 + "cloudevents>=1.11.0,<=1.12.0", # Must support python 3.8 "Werkzeug>=0.14,<4.0.0", "starlette>=0.37.0,<1.0.0; python_version>='3.8'", "uvicorn>=0.18.0,<1.0.0; python_version>='3.8'", From 43e63f6f847d89eeb018add028fb6222f2fac38c Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Fri, 13 Feb 2026 14:48:58 -0800 Subject: [PATCH 172/181] fix: pin lint tool versions to avoid surprise breakages (#413) --- tox.ini | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index 2e36e689..6b866c51 100644 --- a/tox.ini +++ b/tox.ini @@ -34,10 +34,10 @@ commands = pytest {env:PYTESTARGS} {posargs} [testenv:lint] basepython=python3 deps = - black + black>=25,<26 twine - isort - mypy + isort>=5,<6 + mypy>=1,<2 build commands = black --check src tests conftest.py --exclude tests/test_functions/background_load_error/main.py From 0a3f09e6695dbf9c350940fee1eaf15ea4632e84 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Fri, 13 Feb 2026 15:23:26 -0800 Subject: [PATCH 173/181] chore: configure dependabot to use fix commit type when updating depedencies (#411) Today, release-please doesn't trigger a release for commits with chore: commit type. We'd like to cut a release when updating dependencies that impacts the packaged artifact, so we'll instruct dependabot to use fix commit type instead of chore(deps) it uses today. --- .github/renovate.json | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/.github/renovate.json b/.github/renovate.json index 32ac90d6..f7ad311a 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,15 +1,29 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["group:allNonMajor", "schedule:monthly"], + "extends": [ + "group:allNonMajor", + "schedule:monthly" + ], + "semanticCommits": "enabled", "packageRules": [ { "description": "Create a PR whenever there is a new major version", - "matchUpdateTypes": [ - "major" - ] + "matchUpdateTypes": ["major"] + }, + { + "description": "Use releasable commit type for runtime dependency updates", + "matchManagers": ["pep621", "pip_setup"], + "matchDepTypes": ["dependencies"], + "semanticCommitType": "fix", + "semanticCommitScope": "deps" + }, + { + "description": "Keep development-only dependency updates non-releasable", + "matchManagers": ["pep621"], + "matchDepTypes": ["dependency-groups"], + "semanticCommitType": "chore", + "semanticCommitScope": "deps" } ], - "ignorePaths": [ - "examples/**" - ] + "ignorePaths": ["examples/**"] } From f5fd4ddd55bb4b11fd93abff389f3035261330fb Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Tue, 17 Feb 2026 18:41:29 +0000 Subject: [PATCH 174/181] chore(deps): update actions/setup-go action to v6 (#410) Co-authored-by: Daniel Lee --- .github/workflows/conformance-asgi.yml | 2 +- .github/workflows/conformance.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/conformance-asgi.yml b/.github/workflows/conformance-asgi.yml index ee568faa..a67d8c42 100644 --- a/.github/workflows/conformance-asgi.yml +++ b/.github/workflows/conformance-asgi.yml @@ -43,7 +43,7 @@ jobs: run: python -m pip install -e . - name: Setup Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: go-version: '1.25' diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 6c14136e..9604ebe9 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -46,7 +46,7 @@ jobs: run: python -m pip install -e . - name: Setup Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: go-version: '1.25' From e90a9890a0c9b9affc768b7e631e68fd05944c4f Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Tue, 17 Feb 2026 19:06:25 +0000 Subject: [PATCH 175/181] chore(deps): update actions/checkout action to v6 (#408) --- .github/workflows/codeql.yml | 2 +- .github/workflows/conformance-asgi.yml | 2 +- .github/workflows/conformance.yml | 2 +- .github/workflows/dependency-review.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/scorecard.yml | 2 +- .github/workflows/unit.yml | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e5e575fb..c3114a39 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -54,7 +54,7 @@ jobs: release-assets.githubusercontent.com:443 - name: Checkout repository - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/conformance-asgi.yml b/.github/workflows/conformance-asgi.yml index a67d8c42..453ec9e3 100644 --- a/.github/workflows/conformance-asgi.yml +++ b/.github/workflows/conformance-asgi.yml @@ -32,7 +32,7 @@ jobs: release-assets.githubusercontent.com:443 - name: Checkout code - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 9604ebe9..a40d7325 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -35,7 +35,7 @@ jobs: release-assets.githubusercontent.com:443 - name: Checkout code - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index efa405f8..0362f060 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -25,6 +25,6 @@ jobs: api.github.com:443 github.com:443 - name: 'Checkout Repository' - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: 'Dependency Review' uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e9088c6a..0704d917 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -21,7 +21,7 @@ jobs: github.com:443 pypi.org:443 - - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - name: Install tox diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c209a7ed..18a4bc6e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - name: Checkout - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.release.tag_name }} - name: Install Python diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 6fdac794..3688db76 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -47,7 +47,7 @@ jobs: - name: "Checkout code" - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 96a880d2..37d8f81f 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -49,7 +49,7 @@ jobs: release-assets.githubusercontent.com:443 - name: Checkout - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Use Python ${{ matrix.python }} uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: From 80fdae75cdb2d4d389a17f690a1ab8401b8d3916 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:29:55 -0800 Subject: [PATCH 176/181] chore(main): release 3.10.1 (#406) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b817ff60..14e609c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.10.1](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.10.0...v3.10.1) (2026-02-17) + + +### Bug Fixes + +* Correct cloudevents dependency to allow 1.11.0 ([#405](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/405)) ([8d74a7b](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/8d74a7b9ecead58df77208d6e4717419a9aa9447)) +* pin lint tool versions to avoid surprise breakages ([#413](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/413)) ([43e63f6](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/43e63f6f847d89eeb018add028fb6222f2fac38c)) + ## [3.10.0](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.9.2...v3.10.0) (2025-11-10) diff --git a/pyproject.toml b/pyproject.toml index 6d98287c..972e7e52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "functions-framework" -version = "3.10.0" +version = "3.10.1" description = "An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team." readme = "README.md" requires-python = ">=3.7, <4" diff --git a/setup.py b/setup.py index 28d0ea11..db6479ea 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="3.10.0", + version="3.10.1", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown", From 6de54973227faab57429d3fcfa33ced4df6c7b65 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Tue, 17 Feb 2026 20:37:00 +0000 Subject: [PATCH 177/181] chore(deps): update all non-major dependencies (#407) --- .github/workflows/codeql.yml | 8 ++++---- .github/workflows/conformance-asgi.yml | 14 +++++++------- .github/workflows/conformance.yml | 18 +++++++++--------- .github/workflows/dependency-review.yml | 4 ++-- .github/workflows/lint.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/scorecard.yml | 4 ++-- .github/workflows/unit.yml | 2 +- 8 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c3114a39..fe4c7c54 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,7 +41,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: disable-sudo: true egress-policy: block @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@5d5cd550d3e189c569da8f16ea8de2d821c9bf7a # v3.31.2 + uses: github/codeql-action/init@f5c2471be782132e47a6e6f9c725e56730d6e9a3 # v3.32.3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -68,7 +68,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@5d5cd550d3e189c569da8f16ea8de2d821c9bf7a # v3.31.2 + uses: github/codeql-action/autobuild@f5c2471be782132e47a6e6f9c725e56730d6e9a3 # v3.32.3 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -81,6 +81,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@5d5cd550d3e189c569da8f16ea8de2d821c9bf7a # v3.31.2 + uses: github/codeql-action/analyze@f5c2471be782132e47a6e6f9c725e56730d6e9a3 # v3.32.3 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/conformance-asgi.yml b/.github/workflows/conformance-asgi.yml index 453ec9e3..ccb10a5c 100644 --- a/.github/workflows/conformance-asgi.yml +++ b/.github/workflows/conformance-asgi.yml @@ -17,7 +17,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: disable-sudo: true egress-policy: block @@ -45,10 +45,10 @@ jobs: - name: Setup Go uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: - go-version: '1.25' + go-version: '1.26' - name: Run HTTP conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: functionType: 'http' useBuildpacks: false @@ -57,7 +57,7 @@ jobs: startDelay: 5 - name: Run CloudEvents conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: functionType: 'cloudevent' useBuildpacks: false @@ -66,7 +66,7 @@ jobs: startDelay: 5 - name: Run HTTP conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: functionType: 'http' useBuildpacks: false @@ -75,7 +75,7 @@ jobs: startDelay: 5 - name: Run CloudEvents conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: functionType: 'cloudevent' useBuildpacks: false @@ -84,7 +84,7 @@ jobs: startDelay: 5 - name: Run HTTP concurrency tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: functionType: 'http' useBuildpacks: false diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index a40d7325..92f084c9 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -20,7 +20,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: disable-sudo: true egress-policy: block @@ -48,10 +48,10 @@ jobs: - name: Setup Go uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: - go-version: '1.25' + go-version: '1.26' - name: Run HTTP conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: functionType: 'http' useBuildpacks: false @@ -59,7 +59,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http --signature-type http'" - name: Run event conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: functionType: 'legacyevent' useBuildpacks: false @@ -67,7 +67,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_legacy_event --signature-type event'" - name: Run CloudEvents conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: functionType: 'cloudevent' useBuildpacks: false @@ -75,7 +75,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_cloud_event --signature-type cloudevent'" - name: Run HTTP conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: functionType: 'http' useBuildpacks: false @@ -83,7 +83,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative'" - name: Run CloudEvents conformance tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: functionType: 'cloudevent' useBuildpacks: false @@ -91,7 +91,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_cloud_event_declarative'" - name: Run HTTP concurrency tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: functionType: 'http' useBuildpacks: false @@ -99,7 +99,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative_concurrent'" - name: Run Typed tests declarative - uses: GoogleCloudPlatform/functions-framework-conformance/action@c7b9c8798fb35e454f76da185a40547ee55c784e # v1.8.7 + uses: GoogleCloudPlatform/functions-framework-conformance/action@403fda9e6e176aae87646aace9bed075cee8e7fd # v1.8.8 with: functionType: 'http' declarativeType: 'typed' diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 0362f060..5dbfda76 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: disable-sudo: true egress-policy: block @@ -27,4 +27,4 @@ jobs: - name: 'Checkout Repository' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: 'Dependency Review' - uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1 + uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0704d917..6ca5a4ac 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: disable-sudo: true egress-policy: block diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 18a4bc6e..2caddcba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 3688db76..412aedc9 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: disable-sudo: true egress-policy: block @@ -64,6 +64,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@5d5cd550d3e189c569da8f16ea8de2d821c9bf7a # v3.31.2 + uses: github/codeql-action/upload-sarif@f5c2471be782132e47a6e6f9c725e56730d6e9a3 # v3.32.3 with: sarif_file: results.sarif diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 37d8f81f..c94865e2 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -33,7 +33,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Harden Runner - uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: disable-sudo: true egress-policy: block From b41ee77d6fb61a9e0a76f17d561a221e50fe788a Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 17 Feb 2026 15:23:16 -0800 Subject: [PATCH 178/181] fix: remove macos-13 from test matrix (runner retired) (#414) --- .github/workflows/unit.yml | 2 -- tox.ini | 1 - 2 files changed, 3 deletions(-) diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index c94865e2..adab381d 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -26,8 +26,6 @@ jobs: include: - platform: macos-latest python: '3.9' - - platform: macos-13 - python: '3.8' - platform: ubuntu-22.04 python: '3.8' runs-on: ${{ matrix.platform }} diff --git a/tox.ini b/tox.ini index 6b866c51..0e69c33d 100644 --- a/tox.ini +++ b/tox.ini @@ -14,7 +14,6 @@ envlist = py39-macos-13 py39-windows-latest py38-ubuntu-22.04 - py38-macos-13 py38-windows-latest [testenv] From 715ba9a4a8075eed73d78c28209763a7eb67f01f Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Wed, 17 Jun 2026 10:57:21 -0700 Subject: [PATCH 179/181] fix(ci): update allowed endpoints for harden-runner (#427) --- .github/workflows/dependency-review.yml | 2 ++ .github/workflows/unit.yml | 1 + 2 files changed, 3 insertions(+) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 5dbfda76..bcf29c0b 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -22,7 +22,9 @@ jobs: disable-sudo: true egress-policy: block allowed-endpoints: > + api.deps.dev:443 api.github.com:443 + api.securityscorecards.dev:443 github.com:443 - name: 'Checkout Repository' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index adab381d..2901f5f3 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -42,6 +42,7 @@ jobs: github.com:443 objects.githubusercontent.com:443 production.cloudflare.docker.com:443 + production.cloudfront.docker.com:443 pypi.org:443 registry-1.docker.io:443 release-assets.githubusercontent.com:443 From c6501715bb57348ead817ee90c18622b3c1c31ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christopher=20M=C3=BCller?= <63948181+SpielerNogard@users.noreply.github.com> Date: Wed, 17 Jun 2026 20:13:27 +0200 Subject: [PATCH 180/181] fix(deps): bump starlette to >=1.0.1 on Python 3.10+ to fix PYSEC-2026-161 (#423) Starlette <=1.0.0 is vulnerable to a missing Host header validation that poisons request.url.path and bypasses path-based security checks (GHSA-86qp-5c8j-p5mr / PYSEC-2026-161). The fix only landed in 1.0.1, which requires Python >=3.10. Constraint is split by interpreter version so Python 3.8/3.9 users keep the existing 0.x line (no upstream fix available) while Python 3.10+ pulls the patched 1.x line. Co-authored-by: Daniel Lee --- pyproject.toml | 3 ++- setup.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 972e7e52..04baad0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,8 @@ dependencies = [ "gunicorn>=22.0.0; platform_system!='Windows'", "cloudevents>=1.11.0,<=1.12.0", # Must support python 3.8 "Werkzeug>=0.14,<4.0.0", - "starlette>=0.37.0,<1.0.0; python_version>='3.8'", + "starlette>=0.37.0,<1.0.0; python_version>='3.8' and python_version<'3.10'", + "starlette>=1.0.1,<2.0.0; python_version>='3.10'", "uvicorn>=0.18.0,<1.0.0; python_version>='3.8'", "uvicorn-worker>=0.2.0,<1.0.0; python_version>='3.8'", ] diff --git a/setup.py b/setup.py index db6479ea..41244a6c 100644 --- a/setup.py +++ b/setup.py @@ -58,7 +58,10 @@ "Werkzeug>=0.14,<4.0.0", ], extras_require={ - "async": ["starlette>=0.37.0,<1.0.0"], + "async": [ + "starlette>=0.37.0,<1.0.0; python_version<'3.10'", + "starlette>=1.0.1,<2.0.0; python_version>='3.10'", + ], }, entry_points={ "console_scripts": [ From d13d9aa4666b553e0e435813ab799c6f43e96bd8 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:17:02 -0700 Subject: [PATCH 181/181] chore(main): release 3.10.2 (#417) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14e609c4..20d13326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.10.2](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.10.1...v3.10.2) (2026-06-17) + + +### Bug Fixes + +* **ci:** update allowed endpoints for harden-runner ([#427](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/427)) ([715ba9a](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/715ba9a4a8075eed73d78c28209763a7eb67f01f)) +* **deps:** bump starlette to >=1.0.1 on Python 3.10+ to fix PYSEC-2026-161 ([#423](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/423)) ([c650171](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/c6501715bb57348ead817ee90c18622b3c1c31ab)) +* remove macos-13 from test matrix (runner retired) ([#414](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/414)) ([b41ee77](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/b41ee77d6fb61a9e0a76f17d561a221e50fe788a)) + ## [3.10.1](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.10.0...v3.10.1) (2026-02-17) diff --git a/pyproject.toml b/pyproject.toml index 04baad0e..cb6549fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "functions-framework" -version = "3.10.1" +version = "3.10.2" description = "An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team." readme = "README.md" requires-python = ">=3.7, <4" diff --git a/setup.py b/setup.py index 41244a6c..10dfee0d 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="3.10.1", + version="3.10.2", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown",