From 86b2267a16b6e62cb1b35ef96ee95ff8216173d4 Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Mon, 21 Jul 2025 15:38:24 -0700 Subject: [PATCH 01/11] feat: auto-detect ASGI mode for @aio decorated functions Functions decorated with @aio.http or @aio.cloud_event now automatically run in ASGI mode without requiring the --asgi flag. This improves the developer experience by removing the need to remember to pass the flag when using async decorators. --- src/functions_framework/_cli.py | 5 +- src/functions_framework/_function_registry.py | 4 ++ src/functions_framework/aio/__init__.py | 2 + tests/test_cli.py | 48 +++++++++++++++++++ tests/test_decorator_functions.py | 41 ++++++++++++++++ 5 files changed, 99 insertions(+), 1 deletion(-) diff --git a/src/functions_framework/_cli.py b/src/functions_framework/_cli.py index c2ba9f4b..ff60acca 100644 --- a/src/functions_framework/_cli.py +++ b/src/functions_framework/_cli.py @@ -16,7 +16,7 @@ import click -from functions_framework import create_app +from functions_framework import create_app, _function_registry from functions_framework._http import create_server @@ -39,6 +39,9 @@ help="Use ASGI server for function execution", ) def _cli(target, source, signature_type, host, port, debug, asgi): + if not asgi and target in _function_registry.ASGI_FUNCTIONS: + asgi = True + if asgi: # pragma: no cover from functions_framework.aio import create_asgi_app diff --git a/src/functions_framework/_function_registry.py b/src/functions_framework/_function_registry.py index 2214b5fd..1f08c794 100644 --- a/src/functions_framework/_function_registry.py +++ b/src/functions_framework/_function_registry.py @@ -40,6 +40,10 @@ # Keys are the user function name, values are the type of the function input INPUT_TYPE_MAP = {} +# ASGI_FUNCTIONS stores function names that require ASGI mode. +# Functions decorated with @aio.http or @aio.cloud_event are added here. +ASGI_FUNCTIONS = set() + def get_user_function(source, source_module, target): """Returns user function, raises exception for invalid function.""" diff --git a/src/functions_framework/aio/__init__.py b/src/functions_framework/aio/__init__.py index e30b5f99..799eaef1 100644 --- a/src/functions_framework/aio/__init__.py +++ b/src/functions_framework/aio/__init__.py @@ -69,6 +69,7 @@ def cloud_event(func: CloudEventFunction) -> CloudEventFunction: _function_registry.REGISTRY_MAP[func.__name__] = ( _function_registry.CLOUDEVENT_SIGNATURE_TYPE ) + _function_registry.ASGI_FUNCTIONS.add(func.__name__) if inspect.iscoroutinefunction(func): @functools.wraps(func) @@ -89,6 +90,7 @@ def http(func: HTTPFunction) -> HTTPFunction: _function_registry.REGISTRY_MAP[func.__name__] = ( _function_registry.HTTP_SIGNATURE_TYPE ) + _function_registry.ASGI_FUNCTIONS.add(func.__name__) if inspect.iscoroutinefunction(func): diff --git a/tests/test_cli.py b/tests/test_cli.py index 4e5a0a08..8603253e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -20,6 +20,8 @@ from click.testing import CliRunner import functions_framework +import functions_framework._function_registry as _function_registry +import functions_framework.aio from functions_framework._cli import _cli @@ -124,3 +126,49 @@ def test_asgi_cli(monkeypatch): assert result.exit_code == 0 assert create_asgi_app.calls == [pretend.call("foo", None, "http")] assert asgi_server.run.calls == [pretend.call("0.0.0.0", 8080)] + + +def test_auto_asgi_for_aio_decorated_functions(monkeypatch): + original_asgi_functions = _function_registry.ASGI_FUNCTIONS.copy() + _function_registry.ASGI_FUNCTIONS.clear() + _function_registry.ASGI_FUNCTIONS.add("my_aio_func") + + asgi_app = pretend.stub() + create_asgi_app = pretend.call_recorder(lambda *a, **k: asgi_app) + aio_module = pretend.stub(create_asgi_app=create_asgi_app) + monkeypatch.setitem(sys.modules, "functions_framework.aio", aio_module) + + asgi_server = pretend.stub(run=pretend.call_recorder(lambda host, port: None)) + create_server = pretend.call_recorder(lambda app, debug: asgi_server) + monkeypatch.setattr(functions_framework._cli, "create_server", create_server) + + runner = CliRunner() + result = runner.invoke(_cli, ["--target", "my_aio_func"]) + + assert create_asgi_app.calls == [pretend.call("my_aio_func", None, "http")] + assert asgi_server.run.calls == [pretend.call("0.0.0.0", 8080)] + + _function_registry.ASGI_FUNCTIONS.clear() + _function_registry.ASGI_FUNCTIONS.update(original_asgi_functions) + + +def test_no_auto_asgi_for_regular_functions(monkeypatch): + original_asgi_functions = _function_registry.ASGI_FUNCTIONS.copy() + _function_registry.ASGI_FUNCTIONS.clear() + + app = pretend.stub() + create_app = pretend.call_recorder(lambda *a, **k: app) + monkeypatch.setattr(functions_framework._cli, "create_app", create_app) + + flask_server = pretend.stub(run=pretend.call_recorder(lambda host, port: None)) + create_server = pretend.call_recorder(lambda app, debug: flask_server) + monkeypatch.setattr(functions_framework._cli, "create_server", create_server) + + runner = CliRunner() + result = runner.invoke(_cli, ["--target", "regular_func"]) + + assert create_app.calls == [pretend.call("regular_func", None, "http")] + assert flask_server.run.calls == [pretend.call("0.0.0.0", 8080)] + + _function_registry.ASGI_FUNCTIONS.clear() + _function_registry.ASGI_FUNCTIONS.update(original_asgi_functions) diff --git a/tests/test_decorator_functions.py b/tests/test_decorator_functions.py index 435aa815..204173e4 100644 --- a/tests/test_decorator_functions.py +++ b/tests/test_decorator_functions.py @@ -18,6 +18,7 @@ from cloudevents import conversion as ce_conversion from cloudevents.http import CloudEvent +import functions_framework._function_registry as registry # Conditional import for Starlette if sys.version_info >= (3, 8): @@ -128,3 +129,43 @@ def test_aio_http_dict_response(): resp = client.post("/") assert resp.status_code == 200 assert resp.json() == {"message": "hello", "count": 42, "success": True} + + +def test_aio_decorators_register_asgi_functions(): + """Test that @aio decorators add function names to ASGI_FUNCTIONS registry.""" + original_registry_map = registry.REGISTRY_MAP.copy() + original_asgi_functions = registry.ASGI_FUNCTIONS.copy() + registry.REGISTRY_MAP.clear() + registry.ASGI_FUNCTIONS.clear() + + from functions_framework.aio import http, cloud_event + + @http + async def test_http_func(request): + return "test" + + @cloud_event + async def test_cloud_event_func(event): + pass + + assert "test_http_func" in registry.ASGI_FUNCTIONS + assert "test_cloud_event_func" in registry.ASGI_FUNCTIONS + + assert registry.REGISTRY_MAP["test_http_func"] == "http" + assert registry.REGISTRY_MAP["test_cloud_event_func"] == "cloudevent" + + @http + def test_http_sync(request): + return "sync" + + @cloud_event + def test_cloud_event_sync(event): + pass + + assert "test_http_sync" in registry.ASGI_FUNCTIONS + assert "test_cloud_event_sync" in registry.ASGI_FUNCTIONS + + registry.REGISTRY_MAP.clear() + registry.REGISTRY_MAP.update(original_registry_map) + registry.ASGI_FUNCTIONS.clear() + registry.ASGI_FUNCTIONS.update(original_asgi_functions) From 473ed8f09bb56d70cf7d2a8278b4be0c74ab9a11 Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Mon, 21 Jul 2025 20:46:31 -0700 Subject: [PATCH 02/11] style: reformat --- src/functions_framework/_cli.py | 2 +- tests/test_cli.py | 4 ++-- tests/test_decorator_functions.py | 5 +++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/functions_framework/_cli.py b/src/functions_framework/_cli.py index ff60acca..ad77d4c8 100644 --- a/src/functions_framework/_cli.py +++ b/src/functions_framework/_cli.py @@ -16,7 +16,7 @@ import click -from functions_framework import create_app, _function_registry +from functions_framework import _function_registry, create_app from functions_framework._http import create_server diff --git a/tests/test_cli.py b/tests/test_cli.py index 8603253e..3daa129e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -147,7 +147,7 @@ def test_auto_asgi_for_aio_decorated_functions(monkeypatch): assert create_asgi_app.calls == [pretend.call("my_aio_func", None, "http")] assert asgi_server.run.calls == [pretend.call("0.0.0.0", 8080)] - + _function_registry.ASGI_FUNCTIONS.clear() _function_registry.ASGI_FUNCTIONS.update(original_asgi_functions) @@ -169,6 +169,6 @@ def test_no_auto_asgi_for_regular_functions(monkeypatch): assert create_app.calls == [pretend.call("regular_func", None, "http")] assert flask_server.run.calls == [pretend.call("0.0.0.0", 8080)] - + _function_registry.ASGI_FUNCTIONS.clear() _function_registry.ASGI_FUNCTIONS.update(original_asgi_functions) diff --git a/tests/test_decorator_functions.py b/tests/test_decorator_functions.py index 204173e4..62f0a584 100644 --- a/tests/test_decorator_functions.py +++ b/tests/test_decorator_functions.py @@ -18,6 +18,7 @@ from cloudevents import conversion as ce_conversion from cloudevents.http import CloudEvent + import functions_framework._function_registry as registry # Conditional import for Starlette @@ -138,7 +139,7 @@ def test_aio_decorators_register_asgi_functions(): registry.REGISTRY_MAP.clear() registry.ASGI_FUNCTIONS.clear() - from functions_framework.aio import http, cloud_event + from functions_framework.aio import cloud_event, http @http async def test_http_func(request): @@ -164,7 +165,7 @@ def test_cloud_event_sync(event): assert "test_http_sync" in registry.ASGI_FUNCTIONS assert "test_cloud_event_sync" in registry.ASGI_FUNCTIONS - + registry.REGISTRY_MAP.clear() registry.REGISTRY_MAP.update(original_registry_map) registry.ASGI_FUNCTIONS.clear() From f40604e011333e31d132dd00dc1c8bd3f8dffc47 Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Mon, 21 Jul 2025 22:05:44 -0700 Subject: [PATCH 03/11] fix: refactor tests --- tests/test_cli.py | 22 +++++++++++----------- tests/test_decorator_functions.py | 20 +++++++++++++------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 3daa129e..19802265 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -128,9 +128,17 @@ def test_asgi_cli(monkeypatch): assert asgi_server.run.calls == [pretend.call("0.0.0.0", 8080)] -def test_auto_asgi_for_aio_decorated_functions(monkeypatch): - original_asgi_functions = _function_registry.ASGI_FUNCTIONS.copy() +@pytest.fixture +def clean_registry(): + """Save and restore function registry state.""" + original_asgi = _function_registry.ASGI_FUNCTIONS.copy() _function_registry.ASGI_FUNCTIONS.clear() + yield + _function_registry.ASGI_FUNCTIONS.clear() + _function_registry.ASGI_FUNCTIONS.update(original_asgi) + + +def test_auto_asgi_for_aio_decorated_functions(monkeypatch, clean_registry): _function_registry.ASGI_FUNCTIONS.add("my_aio_func") asgi_app = pretend.stub() @@ -148,13 +156,8 @@ def test_auto_asgi_for_aio_decorated_functions(monkeypatch): assert create_asgi_app.calls == [pretend.call("my_aio_func", None, "http")] assert asgi_server.run.calls == [pretend.call("0.0.0.0", 8080)] - _function_registry.ASGI_FUNCTIONS.clear() - _function_registry.ASGI_FUNCTIONS.update(original_asgi_functions) - -def test_no_auto_asgi_for_regular_functions(monkeypatch): - original_asgi_functions = _function_registry.ASGI_FUNCTIONS.copy() - _function_registry.ASGI_FUNCTIONS.clear() +def test_no_auto_asgi_for_regular_functions(monkeypatch, clean_registry): app = pretend.stub() create_app = pretend.call_recorder(lambda *a, **k: app) @@ -169,6 +172,3 @@ def test_no_auto_asgi_for_regular_functions(monkeypatch): assert create_app.calls == [pretend.call("regular_func", None, "http")] assert flask_server.run.calls == [pretend.call("0.0.0.0", 8080)] - - _function_registry.ASGI_FUNCTIONS.clear() - _function_registry.ASGI_FUNCTIONS.update(original_asgi_functions) diff --git a/tests/test_decorator_functions.py b/tests/test_decorator_functions.py index 62f0a584..ed618e7e 100644 --- a/tests/test_decorator_functions.py +++ b/tests/test_decorator_functions.py @@ -132,13 +132,24 @@ def test_aio_http_dict_response(): assert resp.json() == {"message": "hello", "count": 42, "success": True} -def test_aio_decorators_register_asgi_functions(): - """Test that @aio decorators add function names to ASGI_FUNCTIONS registry.""" +@pytest.fixture +def clean_registry(): + """Save and restore registry state.""" original_registry_map = registry.REGISTRY_MAP.copy() original_asgi_functions = registry.ASGI_FUNCTIONS.copy() registry.REGISTRY_MAP.clear() registry.ASGI_FUNCTIONS.clear() + yield + + registry.REGISTRY_MAP.clear() + registry.REGISTRY_MAP.update(original_registry_map) + registry.ASGI_FUNCTIONS.clear() + registry.ASGI_FUNCTIONS.update(original_asgi_functions) + + +def test_aio_decorators_register_asgi_functions(clean_registry): + """Test that @aio decorators add function names to ASGI_FUNCTIONS registry.""" from functions_framework.aio import cloud_event, http @http @@ -165,8 +176,3 @@ def test_cloud_event_sync(event): assert "test_http_sync" in registry.ASGI_FUNCTIONS assert "test_cloud_event_sync" in registry.ASGI_FUNCTIONS - - registry.REGISTRY_MAP.clear() - registry.REGISTRY_MAP.update(original_registry_map) - registry.ASGI_FUNCTIONS.clear() - registry.ASGI_FUNCTIONS.update(original_asgi_functions) From a52cb057dbc8b5b4c1f88dc89417f41353e5f372 Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Tue, 22 Jul 2025 14:41:01 -0700 Subject: [PATCH 04/11] feat: better support use of aio decorator --- src/functions_framework/__init__.py | 28 +++++++++++++++++++++++++ src/functions_framework/_cli.py | 6 +----- src/functions_framework/aio/__init__.py | 28 +++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 22fbf44c..31169f4c 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -327,6 +327,16 @@ def crash_handler(e): def create_app(target=None, source=None, signature_type=None): + """Create an app for the function. + + Args: + target: The name of the target function to invoke + source: The source file containing the function + signature_type: The signature type of the function + + Returns: + A Flask WSGI app or Starlette ASGI app depending on function decorators + """ target = _function_registry.get_function_target(target) source = _function_registry.get_function_source(source) @@ -370,6 +380,7 @@ def handle_none(rv): setup_logging() _app.wsgi_app = execution_id.WsgiMiddleware(_app.wsgi_app) + # Execute the module, within the application context with _app.app_context(): try: @@ -394,6 +405,23 @@ def function(*_args, **_kwargs): # command fails. raise e from None + use_asgi = target in _function_registry.ASGI_FUNCTIONS + if use_asgi: + # This function needs ASGI, delegate to create_asgi_app + # Note: @aio decorators only register functions in ASGI_FUNCTIONS when the + # module is imported. We can't know if a function uses @aio until after + # we load the module. + # + # To avoid loading modules twice, we always create a Flask app first, load the + # module within its context, then check if ASGI is needed. This results in an + # unused Flask app for ASGI functions, but we accept this memory overhead as a + # trade-off. + from functions_framework.aio import create_asgi_app_from_module + + return create_asgi_app_from_module( + target, source, signature_type, source_module, spec + ) + # Get the configured function signature type signature_type = _function_registry.get_func_signature_type(target, signature_type) diff --git a/src/functions_framework/_cli.py b/src/functions_framework/_cli.py index ad77d4c8..48455ea6 100644 --- a/src/functions_framework/_cli.py +++ b/src/functions_framework/_cli.py @@ -39,14 +39,10 @@ help="Use ASGI server for function execution", ) def _cli(target, source, signature_type, host, port, debug, asgi): - if not asgi and target in _function_registry.ASGI_FUNCTIONS: - asgi = True - - if asgi: # pragma: no cover + if asgi: from functions_framework.aio import create_asgi_app app = create_asgi_app(target, source, signature_type) else: app = create_app(target, source, signature_type) - create_server(app, debug).run(host, port) diff --git a/src/functions_framework/aio/__init__.py b/src/functions_framework/aio/__init__.py index 72d06464..54fa6771 100644 --- a/src/functions_framework/aio/__init__.py +++ b/src/functions_framework/aio/__init__.py @@ -215,6 +215,29 @@ async def __call__(self, scope, receive, send): # Don't re-raise to prevent starlette from printing traceback again +def create_asgi_app_from_module(target, source, signature_type, source_module, spec): + """Create an ASGI application from an already-loaded module. + + Args: + target: The name of the target function to invoke + source: The source file containing the function + signature_type: The signature type of the function + source_module: The already-loaded module + spec: The module spec + + Returns: + A Starlette ASGI application instance + """ + enable_id_logging = _enable_execution_id_logging() + if enable_id_logging: + _configure_app_execution_id_logging() + + function = _function_registry.get_user_function(source, source_module, target) + signature_type = _function_registry.get_func_signature_type(target, signature_type) + + return _create_asgi_app_with_function(function, signature_type, enable_id_logging) + + def create_asgi_app(target=None, source=None, signature_type=None): """Create an ASGI application for the function. @@ -245,6 +268,11 @@ def create_asgi_app(target=None, source=None, signature_type=None): function = _function_registry.get_user_function(source, source_module, target) signature_type = _function_registry.get_func_signature_type(target, signature_type) + return _create_asgi_app_with_function(function, signature_type, enable_id_logging) + + +def _create_asgi_app_with_function(function, signature_type, enable_id_logging): + """Create an ASGI app with the given function and signature type.""" is_async = inspect.iscoroutinefunction(function) routes = [] if signature_type == _function_registry.HTTP_SIGNATURE_TYPE: From 87d2fbc730b1b16e031f96c2bae77f28310093bc Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Tue, 22 Jul 2025 14:54:43 -0700 Subject: [PATCH 05/11] fix: add better tests --- tests/test_cli.py | 69 +++++++++++++------------------ tests/test_decorator_functions.py | 33 +++++++-------- tests/test_functions.py | 2 +- 3 files changed, 45 insertions(+), 59 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 19802265..97b78543 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,12 +12,15 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os +import pathlib import sys import pretend import pytest from click.testing import CliRunner +from starlette.applications import Starlette import functions_framework import functions_framework._function_registry as _function_registry @@ -26,6 +29,20 @@ from functions_framework._cli import _cli +@pytest.fixture +def clean_registries(): + """Clean up both REGISTRY_MAP and ASGI_FUNCTIONS registries.""" + original_registry_map = _function_registry.REGISTRY_MAP.copy() + original_asgi = _function_registry.ASGI_FUNCTIONS.copy() + _function_registry.REGISTRY_MAP.clear() + _function_registry.ASGI_FUNCTIONS.clear() + yield + _function_registry.REGISTRY_MAP.clear() + _function_registry.REGISTRY_MAP.update(original_registry_map) + _function_registry.ASGI_FUNCTIONS.clear() + _function_registry.ASGI_FUNCTIONS.update(original_asgi) + + def test_cli_no_arguments(): runner = CliRunner() result = runner.invoke(_cli) @@ -128,47 +145,17 @@ def test_asgi_cli(monkeypatch): assert asgi_server.run.calls == [pretend.call("0.0.0.0", 8080)] -@pytest.fixture -def clean_registry(): - """Save and restore function registry state.""" - original_asgi = _function_registry.ASGI_FUNCTIONS.copy() - _function_registry.ASGI_FUNCTIONS.clear() - yield - _function_registry.ASGI_FUNCTIONS.clear() - _function_registry.ASGI_FUNCTIONS.update(original_asgi) - - -def test_auto_asgi_for_aio_decorated_functions(monkeypatch, clean_registry): - _function_registry.ASGI_FUNCTIONS.add("my_aio_func") - - asgi_app = pretend.stub() - create_asgi_app = pretend.call_recorder(lambda *a, **k: asgi_app) - aio_module = pretend.stub(create_asgi_app=create_asgi_app) - monkeypatch.setitem(sys.modules, "functions_framework.aio", aio_module) - - asgi_server = pretend.stub(run=pretend.call_recorder(lambda host, port: None)) - create_server = pretend.call_recorder(lambda app, debug: asgi_server) - monkeypatch.setattr(functions_framework._cli, "create_server", create_server) - - runner = CliRunner() - result = runner.invoke(_cli, ["--target", "my_aio_func"]) - - assert create_asgi_app.calls == [pretend.call("my_aio_func", None, "http")] - assert asgi_server.run.calls == [pretend.call("0.0.0.0", 8080)] - - -def test_no_auto_asgi_for_regular_functions(monkeypatch, clean_registry): +def test_cli_auto_detects_asgi_decorator(clean_registries): + """Test that CLI auto-detects @aio decorated functions without --asgi flag.""" + # Use the actual async_decorator.py test file which has @aio.http decorated functions + test_functions_dir = pathlib.Path(__file__).parent / "test_functions" / "decorators" + source = test_functions_dir / "async_decorator.py" - app = pretend.stub() - create_app = pretend.call_recorder(lambda *a, **k: app) - monkeypatch.setattr(functions_framework._cli, "create_app", create_app) - - flask_server = pretend.stub(run=pretend.call_recorder(lambda host, port: None)) - create_server = pretend.call_recorder(lambda app, debug: flask_server) - monkeypatch.setattr(functions_framework._cli, "create_server", create_server) + # Call create_app without any asgi flag - should auto-detect + app = functions_framework.create_app(target="function_http", source=str(source)) - runner = CliRunner() - result = runner.invoke(_cli, ["--target", "regular_func"]) + # Verify it created a Starlette app (ASGI) + assert isinstance(app, Starlette) - assert create_app.calls == [pretend.call("regular_func", None, "http")] - assert flask_server.run.calls == [pretend.call("0.0.0.0", 8080)] + # Verify the function was registered in ASGI_FUNCTIONS + assert "function_http" in _function_registry.ASGI_FUNCTIONS diff --git a/tests/test_decorator_functions.py b/tests/test_decorator_functions.py index ed618e7e..8d9b7cba 100644 --- a/tests/test_decorator_functions.py +++ b/tests/test_decorator_functions.py @@ -37,6 +37,21 @@ TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" + +@pytest.fixture +def clean_registries(): + """Clean up both REGISTRY_MAP and ASGI_FUNCTIONS registries.""" + original_registry_map = registry.REGISTRY_MAP.copy() + original_asgi = registry.ASGI_FUNCTIONS.copy() + registry.REGISTRY_MAP.clear() + registry.ASGI_FUNCTIONS.clear() + yield + registry.REGISTRY_MAP.clear() + registry.REGISTRY_MAP.update(original_registry_map) + registry.ASGI_FUNCTIONS.clear() + registry.ASGI_FUNCTIONS.update(original_asgi) + + # Python 3.5: ModuleNotFoundError does not exist try: _ModuleNotFoundError = ModuleNotFoundError @@ -132,23 +147,7 @@ def test_aio_http_dict_response(): assert resp.json() == {"message": "hello", "count": 42, "success": True} -@pytest.fixture -def clean_registry(): - """Save and restore registry state.""" - original_registry_map = registry.REGISTRY_MAP.copy() - original_asgi_functions = registry.ASGI_FUNCTIONS.copy() - registry.REGISTRY_MAP.clear() - registry.ASGI_FUNCTIONS.clear() - - yield - - registry.REGISTRY_MAP.clear() - registry.REGISTRY_MAP.update(original_registry_map) - registry.ASGI_FUNCTIONS.clear() - registry.ASGI_FUNCTIONS.update(original_asgi_functions) - - -def test_aio_decorators_register_asgi_functions(clean_registry): +def test_aio_decorators_register_asgi_functions(clean_registries): """Test that @aio decorators add function names to ASGI_FUNCTIONS registry.""" from functions_framework.aio import cloud_event, http diff --git a/tests/test_functions.py b/tests/test_functions.py index 534f4a88..bafb5e8b 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -495,7 +495,7 @@ def test_error_paths(http_trigger_client, path): def test_lazy_wsgi_app(monkeypatch, target, source, signature_type): actual_app_stub = pretend.stub() wsgi_app = pretend.call_recorder(lambda *a, **kw: actual_app_stub) - create_app = pretend.call_recorder(lambda *a: wsgi_app) + create_app = pretend.call_recorder(lambda *a, **kw: wsgi_app) monkeypatch.setattr(functions_framework, "create_app", create_app) # Test that it's lazy From 67b064cb2006ac1092547a549cb312e1b1b51790 Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Tue, 22 Jul 2025 15:27:56 -0700 Subject: [PATCH 06/11] fix: improve test coverage and fix flakey test --- src/functions_framework/aio/__init__.py | 2 +- tests/test_cli.py | 8 ++++++-- tests/test_decorator_functions.py | 4 ++-- tests/test_function_registry.py | 16 ++++++++++++++++ 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/functions_framework/aio/__init__.py b/src/functions_framework/aio/__init__.py index 54fa6771..a56fe942 100644 --- a/src/functions_framework/aio/__init__.py +++ b/src/functions_framework/aio/__init__.py @@ -229,7 +229,7 @@ def create_asgi_app_from_module(target, source, signature_type, source_module, s A Starlette ASGI application instance """ enable_id_logging = _enable_execution_id_logging() - if enable_id_logging: + if enable_id_logging: # pragma: no cover _configure_app_execution_id_logging() function = _function_registry.get_user_function(source, source_module, target) diff --git a/tests/test_cli.py b/tests/test_cli.py index 97b78543..75452f9d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -29,7 +29,7 @@ from functions_framework._cli import _cli -@pytest.fixture +@pytest.fixture(autouse=True) def clean_registries(): """Clean up both REGISTRY_MAP and ASGI_FUNCTIONS registries.""" original_registry_map = _function_registry.REGISTRY_MAP.copy() @@ -145,8 +145,12 @@ def test_asgi_cli(monkeypatch): assert asgi_server.run.calls == [pretend.call("0.0.0.0", 8080)] -def test_cli_auto_detects_asgi_decorator(clean_registries): +@pytest.mark.parametrize("log_execution_id", [None, "true"]) +def test_cli_auto_detects_asgi_decorator(monkeypatch, log_execution_id): """Test that CLI auto-detects @aio decorated functions without --asgi flag.""" + if log_execution_id: + monkeypatch.setenv("LOG_EXECUTION_ID", log_execution_id) + # Use the actual async_decorator.py test file which has @aio.http decorated functions test_functions_dir = pathlib.Path(__file__).parent / "test_functions" / "decorators" source = test_functions_dir / "async_decorator.py" diff --git a/tests/test_decorator_functions.py b/tests/test_decorator_functions.py index 8d9b7cba..3a6e5e99 100644 --- a/tests/test_decorator_functions.py +++ b/tests/test_decorator_functions.py @@ -38,7 +38,7 @@ TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" -@pytest.fixture +@pytest.fixture(autouse=True) def clean_registries(): """Clean up both REGISTRY_MAP and ASGI_FUNCTIONS registries.""" original_registry_map = registry.REGISTRY_MAP.copy() @@ -147,7 +147,7 @@ def test_aio_http_dict_response(): assert resp.json() == {"message": "hello", "count": 42, "success": True} -def test_aio_decorators_register_asgi_functions(clean_registries): +def test_aio_decorators_register_asgi_functions(): """Test that @aio decorators add function names to ASGI_FUNCTIONS registry.""" from functions_framework.aio import cloud_event, http diff --git a/tests/test_function_registry.py b/tests/test_function_registry.py index e3ae3c7e..5b517cdc 100644 --- a/tests/test_function_registry.py +++ b/tests/test_function_registry.py @@ -13,9 +13,25 @@ # limitations under the License. import os +import pytest + from functions_framework import _function_registry +@pytest.fixture(autouse=True) +def clean_registries(): + """Clean up both REGISTRY_MAP and ASGI_FUNCTIONS registries.""" + original_registry_map = _function_registry.REGISTRY_MAP.copy() + original_asgi = _function_registry.ASGI_FUNCTIONS.copy() + _function_registry.REGISTRY_MAP.clear() + _function_registry.ASGI_FUNCTIONS.clear() + yield + _function_registry.REGISTRY_MAP.clear() + _function_registry.REGISTRY_MAP.update(original_registry_map) + _function_registry.ASGI_FUNCTIONS.clear() + _function_registry.ASGI_FUNCTIONS.update(original_asgi) + + def test_get_function_signature(): test_cases = [ { From 318187611f257de55994f34f5595aaf223f31665 Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Tue, 22 Jul 2025 15:35:32 -0700 Subject: [PATCH 07/11] style: run formatter --- tests/test_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 75452f9d..17b9d1d2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -150,7 +150,7 @@ def test_cli_auto_detects_asgi_decorator(monkeypatch, log_execution_id): """Test that CLI auto-detects @aio decorated functions without --asgi flag.""" if log_execution_id: monkeypatch.setenv("LOG_EXECUTION_ID", log_execution_id) - + # Use the actual async_decorator.py test file which has @aio.http decorated functions test_functions_dir = pathlib.Path(__file__).parent / "test_functions" / "decorators" source = test_functions_dir / "async_decorator.py" From cf0eab0a34b5cae934bfb2f24fb22b9deaf89197 Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Tue, 22 Jul 2025 15:38:08 -0700 Subject: [PATCH 08/11] fix: conditoinal import for starlette --- tests/test_cli.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 17b9d1d2..e7126124 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -20,7 +20,6 @@ import pytest from click.testing import CliRunner -from starlette.applications import Starlette import functions_framework import functions_framework._function_registry as _function_registry @@ -28,6 +27,12 @@ from functions_framework._cli import _cli +# Conditional import for Starlette (Python 3.8+) +if sys.version_info >= (3, 8): + from starlette.applications import Starlette +else: + Starlette = None + @pytest.fixture(autouse=True) def clean_registries(): From f6756c6f83936f302d1a6dfe065bb2c6dec8df30 Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Tue, 22 Jul 2025 15:43:40 -0700 Subject: [PATCH 09/11] fix: remove unncessary parameterization --- tests/test_cli.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index e7126124..3d4a87ed 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -150,12 +150,8 @@ def test_asgi_cli(monkeypatch): assert asgi_server.run.calls == [pretend.call("0.0.0.0", 8080)] -@pytest.mark.parametrize("log_execution_id", [None, "true"]) -def test_cli_auto_detects_asgi_decorator(monkeypatch, log_execution_id): +def test_cli_auto_detects_asgi_decorator(): """Test that CLI auto-detects @aio decorated functions without --asgi flag.""" - if log_execution_id: - monkeypatch.setenv("LOG_EXECUTION_ID", log_execution_id) - # Use the actual async_decorator.py test file which has @aio.http decorated functions test_functions_dir = pathlib.Path(__file__).parent / "test_functions" / "decorators" source = test_functions_dir / "async_decorator.py" From 02096ea0006f7ca57b496e78617e4a6cc488425e Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Tue, 22 Jul 2025 17:35:34 -0700 Subject: [PATCH 10/11] fix: remove import that breaks py37 --- tests/test_cli.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 3d4a87ed..75c93f20 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -23,7 +23,6 @@ import functions_framework import functions_framework._function_registry as _function_registry -import functions_framework.aio from functions_framework._cli import _cli From 60180b0629c0141e0f6894808257edab90c5396d Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Tue, 22 Jul 2025 17:45:59 -0700 Subject: [PATCH 11/11] fix: expand coverage exclusion for py37 --- .coveragerc-py37 | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.coveragerc-py37 b/.coveragerc-py37 index b1c98d23..efb63fec 100644 --- a/.coveragerc-py37 +++ b/.coveragerc-py37 @@ -12,14 +12,11 @@ omit = [report] exclude_lines = - # Have to re-enable the standard pragma pragma: no cover - - # Don't complain about async-specific imports and code from functions_framework.aio import from functions_framework._http.asgi import from functions_framework._http.gunicorn import UvicornApplication - - # Exclude async-specific classes and functions in execution_id.py class AsgiMiddleware: - def set_execution_context_async \ No newline at end of file + def set_execution_context_async + return create_asgi_app_from_module + app = create_asgi_app\(target, source, signature_type\) \ No newline at end of file