From 2a533c2ad321f25bc43b18e0fac50721be43b644 Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Thu, 3 Apr 2025 08:23:49 +0900 Subject: [PATCH 01/25] feat: Introduce functions_framework.aio submodule that support async function execution. --- setup.py | 3 + src/functions_framework/aio/__init__.py | 244 ++++++++++++++ tests/test_cloud_event_functions.py | 62 ++-- tests/test_decorator_functions.py | 34 +- tests/test_functions.py | 299 ++++++++++-------- .../cloud_events/async_empty_data.py | 38 +++ .../test_functions/cloud_events/async_main.py | 40 +++ .../decorators/async_decorator.py | 66 ++++ .../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 | 4 +- 16 files changed, 865 insertions(+), 169 deletions(-) create mode 100644 src/functions_framework/aio/__init__.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/setup.py b/setup.py index 14f0b106..1c35d39b 100644 --- a/setup.py +++ b/setup.py @@ -57,6 +57,9 @@ "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", diff --git a/src/functions_framework/aio/__init__.py b/src/functions_framework/aio/__init__.py new file mode 100644 index 00000000..5541d642 --- /dev/null +++ b/src/functions_framework/aio/__init__.py @@ -0,0 +1,244 @@ +# 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, 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: + result = await asyncio.to_thread(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: + await asyncio.to_thread(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_cloud_event_functions.py b/tests/test_cloud_event_functions.py index 691fe388..20f2af66 100644 --- a/tests/test_cloud_event_functions.py +++ b/tests/test_cloud_event_functions.py @@ -16,9 +16,12 @@ import pytest -from cloudevents.http import CloudEvent, to_binary, to_structured +from cloudevents import conversion as ce_conversion +from cloudevents.http import CloudEvent +from starlette.testclient import TestClient as StarletteTestClient from functions_framework import create_app +from functions_framework.aio import create_asgi_app TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" TEST_DATA_DIR = pathlib.Path(__file__).resolve().parent / "test_data" @@ -89,57 +92,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 +165,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 +183,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 +192,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 +200,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 +209,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 +220,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 +229,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 +237,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..7917b869 100644 --- a/tests/test_decorator_functions.py +++ b/tests/test_decorator_functions.py @@ -15,9 +15,12 @@ import pytest -from cloudevents.http import CloudEvent, to_binary, to_structured +from cloudevents import conversion as ce_conversion +from cloudevents.http import CloudEvent +from starlette.testclient import TestClient as StarletteTestClient from functions_framework import create_app +from functions_framework.aio import create_asgi_app TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" @@ -28,18 +31,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 +65,13 @@ 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" diff --git a/tests/test_functions.py b/tests/test_functions.py index f0bd7793..145a86ab 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import io import json import pathlib import re @@ -21,9 +20,12 @@ import pretend import pytest +from starlette.testclient import TestClient as StarletteTestClient + import functions_framework from functions_framework import LazyWSGIApp, create_app, errorhandler, exceptions +from functions_framework.aio import create_asgi_app TEST_FUNCTIONS_DIR = pathlib.Path.cwd() / "tests" / "test_functions" @@ -72,127 +74,173 @@ def create_ce_headers(): } -def test_http_function_executes_success(): - source = TEST_FUNCTIONS_DIR / "http_trigger" / "main.py" +@pytest.fixture(params=["main.py", "async_main.py"]) +def http_trigger_client(request): + source = TEST_FUNCTIONS_DIR / "http_trigger" / request.param target = "function" - - client = create_app(target, source).test_client() - - resp = client.post("/my_path", json={"mode": "SUCCESS"}) - assert resp.status_code == 200 - assert resp.data == b"success" + if not request.param.startswith("async_"): + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app, raise_server_exceptions=False) -def test_http_function_executes_failure(): - source = TEST_FUNCTIONS_DIR / "http_trigger" / "main.py" +@pytest.fixture(params=["main.py", "async_main.py"]) +def http_request_check_client(request): + source = TEST_FUNCTIONS_DIR / "http_request_check" / request.param target = "function" + if not request.param.startswith("async_"): + return create_app(target, source).test_client() + app = create_asgi_app(target, source) + return StarletteTestClient(app, base_url="http://localhost") - client = create_app(target, source).test_client() - resp = client.get("/", json={"mode": "FAILURE"}) - assert resp.status_code == 400 - assert resp.data == b"failure" +@pytest.fixture(params=["main.py", "async_main.py"]) +def http_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) -def test_http_function_executes_throw(): - source = TEST_FUNCTIONS_DIR / "http_trigger" / "main.py" +@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) - 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_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) -def test_http_function_request_url_empty_path(): - source = TEST_FUNCTIONS_DIR / "http_request_check" / "main.py" +@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) - client = create_app(target, source).test_client() - - resp = client.get("", json={"mode": "url"}) - assert resp.status_code == 308 - assert resp.location == "http://localhost/" + source = TEST_FUNCTIONS_DIR / "module_is_correct" / "main.py" -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): + if isinstance(http_request_check_client, StarletteTestClient): + pytest.skip( + "This specific redirect test (empty path '' -> '/') is not " + " applicableto Starlette's default behavior." + ) + resp = http_request_check_client.post("", json={"mode": "url"}) + assert resp.status_code == 308 + assert resp.location == "http://localhost/" - client = create_app(target, source).test_client() - resp = client.get("/my_path", json={"mode": "path"}) +def test_http_function_request_url_slash(http_request_check_client): + resp = http_request_check_client.post("/", json={"mode": "url"}) assert resp.status_code == 200 - assert resp.data == b"/my_path" + assert resp.text == "http://localhost/" -def test_http_function_check_env_function_target(): - source = TEST_FUNCTIONS_DIR / "http_check_env" / "main.py" - target = "function" +def test_http_function_rquest_url_path(http_request_check_client): + resp = http_request_check_client.post("/my_path", json={"mode": "url"}) + assert resp.status_code == 200 + assert resp.text == "http://localhost/my_path" - client = create_app(target, source).test_client() - resp = client.post("/", json={"mode": "FUNCTION_TARGET"}) +def test_http_function_request_path_slash(http_request_check_client): + resp = http_request_check_client.post("/", json={"mode": "path"}) assert resp.status_code == 200 - assert resp.data == b"function" + assert resp.text == "/" -def test_http_function_check_env_function_signature_type(): - source = TEST_FUNCTIONS_DIR / "http_check_env" / "main.py" - target = "function" +def test_http_function_request_path_path(http_request_check_client): + resp = http_request_check_client.post("/my_path", json={"mode": "path"}) + assert resp.status_code == 200 + assert resp.text == "/my_path" - client = create_app(target, source).test_client() - resp = client.post("/", json={"mode": "FUNCTION_SIGNATURE_TYPE"}) +def test_http_function_check_env_function_target(http_check_env_client): + resp = http_check_env_client.post("/", json={"mode": "FUNCTION_TARGET"}) assert resp.status_code == 200 - assert resp.data == b"http" + # Use .content for StarletteTestClient, .data for Flask test client (both return bytes) + data = getattr(resp, "content", getattr(resp, "data", None)) + assert data == b"function" -def test_http_function_execution_time(): - source = TEST_FUNCTIONS_DIR / "http_trigger_sleep" / "main.py" - target = "function" +def test_http_function_check_env_function_signature_type(http_check_env_client): + resp = http_check_env_client.post("/", json={"mode": "FUNCTION_SIGNATURE_TYPE"}) + assert resp.status_code == 200 + assert resp.text == "http" - client = create_app(target, source).test_client() +def test_http_function_execution_time(http_trigger_sleep_client): start_time = time.time() - resp = client.get("/", json={"mode": "1000"}) + resp = http_trigger_sleep_client.post("/", json={"mode": "1000"}) execution_time_sec = time.time() - start_time assert resp.status_code == 200 - assert resp.data == b"OK" + assert resp.text == "OK" + # Check that the execution time is roughly correct (allowing some buffer) + assert execution_time_sec > 0.9 def test_background_function_executes(background_event_client, background_json): @@ -268,7 +316,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 +330,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 +344,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 +359,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 +388,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 +399,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 +410,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 +437,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 +513,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 +529,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 +547,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 +679,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..b9fa33f4 --- /dev/null +++ b/tests/test_functions/decorators/async_decorator.py @@ -0,0 +1,66 @@ +# 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) 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 6ba6d3b4..1c2707bb 100644 --- a/tox.ini +++ b/tox.ini @@ -5,10 +5,11 @@ envlist = py{35,36,37,38,39,310}-{ubuntu-latest,macos-latest,windows-latest},lin usedevelop = true deps = docker - pytest-asyncio + httpx pytest-cov pytest-integration pretend +extras = async setenv = PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 windows-latest: PYTESTARGS = @@ -21,6 +22,7 @@ deps = twine isort mypy +extras = async 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 From a185820baaed4ecc081d8cbc0a185d9080a4e34f Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Wed, 7 May 2025 20:32:25 -0700 Subject: [PATCH 02/25] Remove httpx. --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 6c4a4c98..d0bdd860 100644 --- a/tox.ini +++ b/tox.ini @@ -24,7 +24,7 @@ envlist = usedevelop = true deps = docker - httpx + pytest-asyncio pytest-cov pytest-integration pretend From 15e4490b06f98d20a1ac74447d3ea505ff09e930 Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Tue, 3 Jun 2025 14:17:27 -0700 Subject: [PATCH 03/25] Update pyproject.toml to include extra async package. --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index c160562f..c8b29960 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,9 @@ dependencies = [ [project.urls] Homepage = "https://github.com/googlecloudplatform/functions-framework-python" +[project.optional-dependencies] +async = ["starlette>=0.37.0,<1.0.0"] + [project.scripts] ff = "functions_framework._cli:_cli" functions-framework = "functions_framework._cli:_cli" From 79f2b735604c9cab5b3b5580e8a6c2b3e42673d3 Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Tue, 3 Jun 2025 14:24:20 -0700 Subject: [PATCH 04/25] Update test deps. --- tox.ini | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index b7cabc15..90681c91 100644 --- a/tox.ini +++ b/tox.ini @@ -24,11 +24,13 @@ envlist = usedevelop = true deps = docker + httpx pytest-asyncio pytest-cov pytest-integration pretend -extras = async +extras = + async setenv = PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 windows-latest: PYTESTARGS = @@ -42,8 +44,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 ddc55d6d82f13e1155efa9d0762051b3f227fdd7 Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Tue, 3 Jun 2025 20:40:00 -0700 Subject: [PATCH 05/25] Improve test coverage. --- tests/test_aio.py | 191 ++++++++++++++++++ tests/test_decorator_functions.py | 42 ++++ .../decorators/async_decorator.py | 32 +++ 3 files changed, 265 insertions(+) create mode 100644 tests/test_aio.py diff --git a/tests/test_aio.py b/tests/test_aio.py new file mode 100644 index 00000000..dea3ac19 --- /dev/null +++ b/tests/test_aio.py @@ -0,0 +1,191 @@ +# 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 AsyncMock, Mock, call + +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_decorator_functions.py b/tests/test_decorator_functions.py index 7917b869..501434f2 100644 --- a/tests/test_decorator_functions.py +++ b/tests/test_decorator_functions.py @@ -75,3 +75,45 @@ def test_http_decorator(http_decorator_client): resp = http_decorator_client.post("/my_path", json={"mode": "path"}) assert resp.status_code == 200 assert resp.text == "/my_path" + + +def test_aio_sync_cloud_event_decorator(cloud_event_1_0): + """Test aio decorator with sync cloud event function.""" + source = TEST_FUNCTIONS_DIR / "decorators" / "async_decorator.py" + target = "function_cloud_event_sync" + + app = create_asgi_app(target, source) + client = StarletteTestClient(app) + + headers, data = ce_conversion.to_structured(cloud_event_1_0) + resp = client.post("/", headers=headers, data=data) + assert resp.status_code == 200 + assert resp.text == "OK" + + +def test_aio_sync_http_decorator(): + source = TEST_FUNCTIONS_DIR / "decorators" / "async_decorator.py" + target = "function_http_sync" + + app = create_asgi_app(target, source) + client = StarletteTestClient(app) + + resp = client.post("/my_path?mode=path") + assert resp.status_code == 200 + assert resp.text == "/my_path" + + resp = client.post("/other_path") + assert resp.status_code == 200 + assert resp.text == "sync response" + + +def test_aio_http_dict_response(): + source = TEST_FUNCTIONS_DIR / "decorators" / "async_decorator.py" + target = "function_http_dict_response" + + app = create_asgi_app(target, source) + client = StarletteTestClient(app) + + resp = client.post("/") + assert resp.status_code == 200 + assert resp.json() == {"message": "hello", "count": 42, "success": True} diff --git a/tests/test_functions/decorators/async_decorator.py b/tests/test_functions/decorators/async_decorator.py index b9fa33f4..0c0db7e4 100644 --- a/tests/test_functions/decorators/async_decorator.py +++ b/tests/test_functions/decorators/async_decorator.py @@ -64,3 +64,35 @@ async def function_http(request): 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} From 5d6734430494f174fd669328aded513472c9f1d8 Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Wed, 4 Jun 2025 09:33:32 -0700 Subject: [PATCH 06/25] Make linter happy. --- tests/test_aio.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/test_aio.py b/tests/test_aio.py index dea3ac19..24a4428f 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -63,8 +63,7 @@ def test_invalid_function_definition_missing_function_file(): create_asgi_app(target, source) assert re.match( - r"File .* that is expected to define function doesn't exist", str( - excinfo.value) + r"File .* that is expected to define function doesn't exist", str(excinfo.value) ) @@ -98,8 +97,7 @@ def test_asgi_background_event_not_supported(): 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) + 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" @@ -119,8 +117,7 @@ async def test_lazy_asgi_app(monkeypatch): 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) + 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) @@ -132,8 +129,7 @@ async def test_lazy_asgi_app(monkeypatch): 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 + actual_app.assert_called_once_with(scope, receive, send) # Should be called again @pytest.mark.asyncio From 8d5458b4b05c2a80ef349d2a07c5a2ac42cd1e49 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Fri, 6 Jun 2025 07:55:50 -0700 Subject: [PATCH 07/25] Fix test harness to support py37. --- conftest.py | 44 +++++++++++++++++++++++++++++ pyproject.toml | 25 +++++++++++----- tests/test_cloud_event_functions.py | 13 +++++++-- tests/test_decorator_functions.py | 15 ++++++++-- tests/test_functions.py | 14 +++++++-- tox.ini | 4 ++- 6 files changed, 101 insertions(+), 14 deletions(-) diff --git a/conftest.py b/conftest.py index 21572fda..f927c25e 100644 --- a/conftest.py +++ b/conftest.py @@ -42,3 +42,47 @@ def isolate_logging(): sys.stderr = sys.__stderr__ logging.shutdown() reload(logging) + + +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)" + ) + + 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 parameters + if isinstance(param_value, str) and ( + "async" in param_value or param_value.startswith("async_") + ): + skip_test = True + break + # Check if test is parametrized with create_asgi_app + if ( + hasattr(param_value, "__name__") + and param_value.__name__ == "create_asgi_app" + ): + skip_test = True + break + + # Check test file and function names for async-related test files + test_file = str(item.fspath) + test_name = item.name + + # Skip tests in async-specific test files + if "test_aio" in test_file: + skip_test = True + + # Skip tests that explicitly test async functionality + if "async" in test_name.lower() or "asgi" in test_name.lower(): + skip_test = True + + if skip_test: + item.add_marker(skip_async) diff --git a/pyproject.toml b/pyproject.toml index c8b29960..a402515e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,15 +3,13 @@ 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"} -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 = [ @@ -31,13 +29,14 @@ dependencies = [ "gunicorn>=22.0.0; platform_system!='Windows'", "cloudevents>=1.2.0,<2.0.0", "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"] +async = ["starlette>=0.37.0,<1.0.0; python_version>='3.8'"] [project.scripts] ff = "functions_framework._cli:_cli" @@ -58,3 +57,15 @@ functions_framework = ["py.typed"] [tool.setuptools.package-dir] "" = "src" + +[dependency-groups] +dev = [ + "black>=23.3.0", + "isort>=5.11.5", + "mypy>=1.4.1", + "pretend>=1.0.9", + "pytest>=7.4.4", + "pytest-asyncio>=0.21.2", + "pytest-cov>=4.1.0", + "pytest-integration>=0.2.3", +] diff --git a/tests/test_cloud_event_functions.py b/tests/test_cloud_event_functions.py index 20f2af66..2e7c281d 100644 --- a/tests/test_cloud_event_functions.py +++ b/tests/test_cloud_event_functions.py @@ -13,15 +13,24 @@ # limitations under the License. import json import pathlib +import sys import pytest from cloudevents import conversion as ce_conversion from cloudevents.http import CloudEvent -from starlette.testclient import TestClient as StarletteTestClient + +if sys.version_info >= (3, 8): + from starlette.testclient import TestClient as StarletteTestClient +else: + StarletteTestClient = None from functions_framework import create_app -from functions_framework.aio import create_asgi_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" diff --git a/tests/test_decorator_functions.py b/tests/test_decorator_functions.py index 501434f2..435aa815 100644 --- a/tests/test_decorator_functions.py +++ b/tests/test_decorator_functions.py @@ -12,15 +12,26 @@ # See the License for the specific language governing permissions and # limitations under the License. import pathlib +import sys import pytest from cloudevents import conversion as ce_conversion from cloudevents.http import CloudEvent -from starlette.testclient import TestClient as StarletteTestClient + +# 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 -from functions_framework.aio import create_asgi_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" diff --git a/tests/test_functions.py b/tests/test_functions.py index 145a86ab..b6c3eed0 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -15,17 +15,27 @@ import json import pathlib import re +import sys import time import pretend import pytest -from starlette.testclient import TestClient as StarletteTestClient +# 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 -from functions_framework.aio import create_asgi_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.cwd() / "tests" / "test_functions" diff --git a/tox.ini b/tox.ini index 90681c91..16849aa1 100644 --- a/tox.ini +++ b/tox.ini @@ -30,7 +30,8 @@ deps = pytest-integration pretend extras = - async + # Only include async extra for Python 3.8+ + py{38,39,310,311,312}: async setenv = PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 windows-latest: PYTESTARGS = @@ -44,6 +45,7 @@ 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 26d5828a8d4e9e7428d8a4959a39a71ff478a1ab Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Fri, 6 Jun 2025 13:09:45 -0700 Subject: [PATCH 08/25] Remove version filter in tox file. --- tox.ini | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 16849aa1..f8e78f8e 100644 --- a/tox.ini +++ b/tox.ini @@ -30,8 +30,7 @@ deps = pytest-integration pretend extras = - # Only include async extra for Python 3.8+ - py{38,39,310,311,312}: async + async setenv = PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 windows-latest: PYTESTARGS = From 4d49695367ae24184d58101826436e9343c73c9a Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Fri, 6 Jun 2025 13:57:18 -0700 Subject: [PATCH 09/25] Remove dependency-groups in pyproject.toml for now. --- pyproject.toml | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a402515e..ebc7ce37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,15 +57,3 @@ functions_framework = ["py.typed"] [tool.setuptools.package-dir] "" = "src" - -[dependency-groups] -dev = [ - "black>=23.3.0", - "isort>=5.11.5", - "mypy>=1.4.1", - "pretend>=1.0.9", - "pytest>=7.4.4", - "pytest-asyncio>=0.21.2", - "pytest-cov>=4.1.0", - "pytest-integration>=0.2.3", -] From e1fe361da40cd971dac114d4f436f59ff8ae40ec Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Fri, 6 Jun 2025 16:48:08 -0700 Subject: [PATCH 10/25] Use py3.8 compatible types. --- src/functions_framework/aio/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/functions_framework/aio/__init__.py b/src/functions_framework/aio/__init__.py index 5541d642..634eb048 100644 --- a/src/functions_framework/aio/__init__.py +++ b/src/functions_framework/aio/__init__.py @@ -17,7 +17,7 @@ import inspect import os -from typing import Any, Awaitable, Callable, Union +from typing import Any, Awaitable, Callable, Dict, Union from cloudevents.http import from_http from cloudevents.http.event import CloudEvent @@ -43,7 +43,7 @@ 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) + Dict[Any, Any], # Dict returns are wrapped in JSONResponse(result) tuple[Any, int], # Flask-style (content, status_code) supported None, # None raises HTTPException ] From c3f99bce7cc9c61be045831b52e82712deb3d1fa Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Fri, 6 Jun 2025 16:51:41 -0700 Subject: [PATCH 11/25] Fix more incompatibility with python38 --- src/functions_framework/aio/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/functions_framework/aio/__init__.py b/src/functions_framework/aio/__init__.py index 634eb048..90616206 100644 --- a/src/functions_framework/aio/__init__.py +++ b/src/functions_framework/aio/__init__.py @@ -17,7 +17,7 @@ import inspect import os -from typing import Any, Awaitable, Callable, Dict, Union +from typing import Any, Awaitable, Callable, Dict, Tuple, Union from cloudevents.http import from_http from cloudevents.http.event import CloudEvent @@ -44,7 +44,7 @@ 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 + Tuple[Any, int], # Flask-style (content, status_code) supported None, # None raises HTTPException ] From ca68963477ee5b9caa43e31fab0881ab26ff7881 Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Fri, 6 Jun 2025 17:02:50 -0700 Subject: [PATCH 12/25] Pin cloudevent sdk to python37 compatible version. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ebc7ce37..3a631b5d 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,<2.0.0", + "cloudevents>=1.2.0,<=1.11.0", # Must support python 3.7 "Werkzeug>=0.14,<4.0.0", "httpx>=0.24.1", ] From c6628c1ebf638133b67ab7b22f2fb5250e8e6615 Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Fri, 6 Jun 2025 17:05:25 -0700 Subject: [PATCH 13/25] Fix more py37 incompatibility. --- tests/test_aio.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_aio.py b/tests/test_aio.py index 24a4428f..cf69479a 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -17,7 +17,10 @@ import sys import tempfile -from unittest.mock import AsyncMock, Mock, call +from unittest.mock import Mock, call + +if sys.version_info >= (3, 8): + from unittest.mock import AsyncMock import pytest From 7db0c7918541278cc1107fda351a7b7aae12d440 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Mon, 9 Jun 2025 20:03:20 -0700 Subject: [PATCH 14/25] 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. --- conftest.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/conftest.py b/conftest.py index f927c25e..0684428b 100644 --- a/conftest.py +++ b/conftest.py @@ -44,6 +44,20 @@ def isolate_logging(): reload(logging) +# Safe to remove when we drop Python 3.7 support +def pytest_ignore_collect(path, config): + """Ignore async test files on Python 3.7 since Starlette requires Python 3.8+""" + if sys.version_info >= (3, 8): + return False + + # Skip test_aio.py entirely on Python 3.7 + if path.basename == "test_aio.py": + return True + + return False + + +# 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): @@ -76,7 +90,7 @@ def pytest_collection_modifyitems(config, items): test_file = str(item.fspath) test_name = item.name - # Skip tests in async-specific test files + # Skip tests in async-specific test files (backup for any not caught by ignore) if "test_aio" in test_file: skip_test = True From 8313ad7cac20097ff3692fc9f40f1a534d9f597a Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Mon, 9 Jun 2025 20:09:55 -0700 Subject: [PATCH 15/25] style: Apply black formatting to conftest.py --- conftest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/conftest.py b/conftest.py index 0684428b..a3c6abd5 100644 --- a/conftest.py +++ b/conftest.py @@ -49,11 +49,11 @@ def pytest_ignore_collect(path, config): """Ignore async test files on Python 3.7 since Starlette requires Python 3.8+""" if sys.version_info >= (3, 8): return False - + # Skip test_aio.py entirely on Python 3.7 if path.basename == "test_aio.py": return True - + return False From d6704f916196995cf388a12448f827075d841b2e Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Mon, 9 Jun 2025 20:22:33 -0700 Subject: [PATCH 16/25] 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/ --- conftest.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/conftest.py b/conftest.py index a3c6abd5..b5ad3738 100644 --- a/conftest.py +++ b/conftest.py @@ -45,16 +45,16 @@ def isolate_logging(): # Safe to remove when we drop Python 3.7 support -def pytest_ignore_collect(path, config): +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 False + return None # Let pytest decide (default behavior) # Skip test_aio.py entirely on Python 3.7 - if path.basename == "test_aio.py": + if collection_path.name == "test_aio.py": return True - return False + return None # Let pytest decide (default behavior) # Safe to remove when we drop Python 3.7 support From 9c4cceb43450d049a900cf1f7c8a9f7d4ff4ce21 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Mon, 9 Jun 2025 20:30:09 -0700 Subject: [PATCH 17/25] 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. --- conftest.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/conftest.py b/conftest.py index b5ad3738..8048a64a 100644 --- a/conftest.py +++ b/conftest.py @@ -78,11 +78,8 @@ def pytest_collection_modifyitems(config, items): ): skip_test = True break - # Check if test is parametrized with create_asgi_app - if ( - hasattr(param_value, "__name__") - and param_value.__name__ == "create_asgi_app" - ): + # Skip tests parametrized with None (create_asgi_app on Python 3.7) + if param_value is None: skip_test = True break From 7009b19444638c6c43cc1a09f29381565ed5257c Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Mon, 9 Jun 2025 20:48:32 -0700 Subject: [PATCH 18/25] 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. --- src/functions_framework/aio/__init__.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/functions_framework/aio/__init__.py b/src/functions_framework/aio/__init__.py index 90616206..832d6818 100644 --- a/src/functions_framework/aio/__init__.py +++ b/src/functions_framework/aio/__init__.py @@ -107,7 +107,10 @@ async def handler(request): if is_async: result = await function(request) else: - result = await asyncio.to_thread(function, request) + # 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): @@ -138,7 +141,10 @@ async def handler(request): if is_async: await function(event) else: - await asyncio.to_thread(function, event) + # 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 From f3933edbf252795d7fedf20da2e880e9cfc52ef6 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 10 Jun 2025 07:29:14 -0700 Subject: [PATCH 19/25] 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 --- conftest.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/conftest.py b/conftest.py index 8048a64a..86dead82 100644 --- a/conftest.py +++ b/conftest.py @@ -67,14 +67,17 @@ def pytest_collection_modifyitems(config, items): 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 parameters - if isinstance(param_value, str) and ( - "async" in param_value or param_value.startswith("async_") + # 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 @@ -87,12 +90,12 @@ def pytest_collection_modifyitems(config, items): test_file = str(item.fspath) test_name = item.name - # Skip tests in async-specific test files (backup for any not caught by ignore) - if "test_aio" in test_file: + # Skip tests in files with async-related keywords + if any(keyword in test_file.lower() for keyword in async_keywords): skip_test = True # Skip tests that explicitly test async functionality - if "async" in test_name.lower() or "asgi" in test_name.lower(): + if any(keyword in test_name.lower() for keyword in async_keywords): skip_test = True if skip_test: From 297cb96a7b9ff06ee8bb90cc6d32eccdb6ab116d Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 10 Jun 2025 09:49:17 -0700 Subject: [PATCH 20/25] 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 --- tests/test_functions.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/tests/test_functions.py b/tests/test_functions.py index b6c3eed0..9107dc68 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -101,7 +101,11 @@ def http_request_check_client(request): if not request.param.startswith("async_"): return create_app(target, source).test_client() app = create_asgi_app(target, source) - return StarletteTestClient(app, base_url="http://localhost") + return StarletteTestClient( + app, + # Override baseurl to use localhost instead of default http://testserver. + base_url="http://localhost", + ) @pytest.fixture(params=["main.py", "async_main.py"]) @@ -143,8 +147,6 @@ def http_method_check_client(request): app = create_asgi_app(target, source) return StarletteTestClient(app) - source = TEST_FUNCTIONS_DIR / "module_is_correct" / "main.py" - @pytest.fixture(params=["sync", "async"]) def module_is_correct_client(request): @@ -194,14 +196,20 @@ def test_http_function_executes_throw(http_trigger_client): def test_http_function_request_url_empty_path(http_request_check_client): - if isinstance(http_request_check_client, StarletteTestClient): - pytest.skip( - "This specific redirect test (empty path '' -> '/') is not " - " applicableto Starlette's default behavior." - ) - resp = http_request_check_client.post("", json={"mode": "url"}) - assert resp.status_code == 308 - assert resp.location == "http://localhost/" + # 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): From 41e73099f1c9c3140a01fa2ae05ee311612b424f Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 10 Jun 2025 11:20:02 -0700 Subject: [PATCH 21/25] 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. --- tox.ini | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index f8e78f8e..b5711075 100644 --- a/tox.ini +++ b/tox.ini @@ -34,6 +34,8 @@ extras = setenv = PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 windows-latest: PYTESTARGS = + # Python 3.7: Exclude aio module from coverage since it requires Python 3.8+ (Starlette dependency) + py37: PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 --cov-omit='*/aio/*' commands = pytest {env:PYTESTARGS} {posargs} [testenv:lint] @@ -44,7 +46,8 @@ deps = isort mypy build -extras = async +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 e7e6683ffe17d13836780452fe971e675eb62ee3 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 10 Jun 2025 11:22:24 -0700 Subject: [PATCH 22/25] fix: Simplify conftest.py. --- conftest.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/conftest.py b/conftest.py index 86dead82..f72314ed 100644 --- a/conftest.py +++ b/conftest.py @@ -48,13 +48,13 @@ def isolate_logging(): 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 # Let pytest decide (default behavior) + return None # Skip test_aio.py entirely on Python 3.7 if collection_path.name == "test_aio.py": return True - return None # Let pytest decide (default behavior) + return None # Safe to remove when we drop Python 3.7 support @@ -86,16 +86,8 @@ def pytest_collection_modifyitems(config, items): skip_test = True break - # Check test file and function names for async-related test files - test_file = str(item.fspath) - test_name = item.name - - # Skip tests in files with async-related keywords - if any(keyword in test_file.lower() for keyword in async_keywords): - skip_test = True - # Skip tests that explicitly test async functionality - if any(keyword in test_name.lower() for keyword in async_keywords): + if any(keyword in item.name.lower() for keyword in async_keywords): skip_test = True if skip_test: From c92984b5f5817d9484a8c98f7b4c86f9f26ff033 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 10 Jun 2025 11:24:12 -0700 Subject: [PATCH 23/25] 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. --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index b5711075..8255200b 100644 --- a/tox.ini +++ b/tox.ini @@ -35,7 +35,7 @@ setenv = PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 windows-latest: PYTESTARGS = # Python 3.7: Exclude aio module from coverage since it requires Python 3.8+ (Starlette dependency) - py37: PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 --cov-omit='*/aio/*' + py37-{ubuntu-22.04,macos-13,windows-latest}: PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 --cov-omit='*/aio/*' commands = pytest {env:PYTESTARGS} {posargs} [testenv:lint] From 1d9282205fb3427749c5baab1261ef4aec7730c7 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 10 Jun 2025 11:30:32 -0700 Subject: [PATCH 24/25] 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 --- tox.ini | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 8255200b..f569608f 100644 --- a/tox.ini +++ b/tox.ini @@ -33,9 +33,11 @@ extras = async setenv = PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 - windows-latest: PYTESTARGS = # Python 3.7: Exclude aio module from coverage since it requires Python 3.8+ (Starlette dependency) - py37-{ubuntu-22.04,macos-13,windows-latest}: PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 --cov-omit='*/aio/*' + py37-ubuntu-22.04: PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 --cov-omit='*/aio/*' + py37-macos-13: PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 --cov-omit='*/aio/*' + py37-windows-latest: PYTESTARGS = + windows-latest: PYTESTARGS = commands = pytest {env:PYTESTARGS} {posargs} [testenv:lint] From a387aa0501adf5ca0e0458c0bf87e22b382fcf87 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 10 Jun 2025 12:38:03 -0700 Subject: [PATCH 25/25] 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 ++++++++++ tox.ini | 6 +++--- 2 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 .coveragerc-py37 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/tox.ini b/tox.ini index f569608f..fd3e38a6 100644 --- a/tox.ini +++ b/tox.ini @@ -33,9 +33,9 @@ extras = async setenv = PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 - # Python 3.7: Exclude aio module from coverage since it requires Python 3.8+ (Starlette dependency) - py37-ubuntu-22.04: PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 --cov-omit='*/aio/*' - py37-macos-13: PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 --cov-omit='*/aio/*' + # 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}