Skip to content

Commit ef48e70

Browse files
authored
feat: auto-detect ASGI mode for @aio decorated functions (GoogleCloudPlatform#387)
## Summary - Implement automatic ASGI mode detection for functions decorated with `@aio.http` or `@aio.cloud_event` - The approach creates a Flask app first, loads the module within its context, then checks if ASGI is needed - This results in an unused Flask app for ASGI functions, but we accept this memory overhead as a trade-off - The `--asgi` CLI flag still works and skips the Flask app creation for optimization ## Implementation Details - Added `ASGI_FUNCTIONS` set to `_function_registry.py` to track functions that require ASGI - Updated `@aio.http` and `@aio.cloud_event` decorators to register functions in `ASGI_FUNCTIONS` - Modified `create_app()` to auto-detect ASGI requirements after module loading: 1. Always creates a Flask app first 2. Loads the user module within Flask app context 3. Checks if target function is in `ASGI_FUNCTIONS` registry 4. If ASGI is needed, delegates to `create_asgi_app_from_module()` - The `--asgi` CLI flag continues to work, bypassing Flask app creation entirely for performance ## Trade-offs - **Memory overhead**: ASGI functions will have an unused Flask app instance created during auto-detection - **Accepted trade-off**: This avoids loading modules twice which could cause side effects - **Optimization available**: Users can still use `--asgi` flag to skip Flask app creation entirely ## Test plan - [x] Added tests to verify decorators register functions in `ASGI_FUNCTIONS` - [x] Added CLI tests to verify auto-detection works for `@aio` decorated functions - [x] Added CLI tests to verify regular functions still use Flask/WSGI mode - [x] Added proper test isolation with registry cleanup fixtures - [x] All existing tests pass - [x] Linting passes
1 parent 82ba117 commit ef48e70

File tree

9 files changed

+170
-10
lines changed

9 files changed

+170
-10
lines changed

.coveragerc-py37

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,11 @@ omit =
1212

1313
[report]
1414
exclude_lines =
15-
# Have to re-enable the standard pragma
1615
pragma: no cover
17-
18-
# Don't complain about async-specific imports and code
1916
from functions_framework.aio import
2017
from functions_framework._http.asgi import
2118
from functions_framework._http.gunicorn import UvicornApplication
22-
23-
# Exclude async-specific classes and functions in execution_id.py
2419
class AsgiMiddleware:
25-
def set_execution_context_async
20+
def set_execution_context_async
21+
return create_asgi_app_from_module
22+
app = create_asgi_app\(target, source, signature_type\)

src/functions_framework/__init__.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,16 @@ def crash_handler(e):
327327

328328

329329
def create_app(target=None, source=None, signature_type=None):
330+
"""Create an app for the function.
331+
332+
Args:
333+
target: The name of the target function to invoke
334+
source: The source file containing the function
335+
signature_type: The signature type of the function
336+
337+
Returns:
338+
A Flask WSGI app or Starlette ASGI app depending on function decorators
339+
"""
330340
target = _function_registry.get_function_target(target)
331341
source = _function_registry.get_function_source(source)
332342

@@ -370,6 +380,7 @@ def handle_none(rv):
370380
setup_logging()
371381

372382
_app.wsgi_app = execution_id.WsgiMiddleware(_app.wsgi_app)
383+
373384
# Execute the module, within the application context
374385
with _app.app_context():
375386
try:
@@ -394,6 +405,23 @@ def function(*_args, **_kwargs):
394405
# command fails.
395406
raise e from None
396407

408+
use_asgi = target in _function_registry.ASGI_FUNCTIONS
409+
if use_asgi:
410+
# This function needs ASGI, delegate to create_asgi_app
411+
# Note: @aio decorators only register functions in ASGI_FUNCTIONS when the
412+
# module is imported. We can't know if a function uses @aio until after
413+
# we load the module.
414+
#
415+
# To avoid loading modules twice, we always create a Flask app first, load the
416+
# module within its context, then check if ASGI is needed. This results in an
417+
# unused Flask app for ASGI functions, but we accept this memory overhead as a
418+
# trade-off.
419+
from functions_framework.aio import create_asgi_app_from_module
420+
421+
return create_asgi_app_from_module(
422+
target, source, signature_type, source_module, spec
423+
)
424+
397425
# Get the configured function signature type
398426
signature_type = _function_registry.get_func_signature_type(target, signature_type)
399427

src/functions_framework/_cli.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import click
1818

19-
from functions_framework import create_app
19+
from functions_framework import _function_registry, create_app
2020
from functions_framework._http import create_server
2121

2222

@@ -39,11 +39,10 @@
3939
help="Use ASGI server for function execution",
4040
)
4141
def _cli(target, source, signature_type, host, port, debug, asgi):
42-
if asgi: # pragma: no cover
42+
if asgi:
4343
from functions_framework.aio import create_asgi_app
4444

4545
app = create_asgi_app(target, source, signature_type)
4646
else:
4747
app = create_app(target, source, signature_type)
48-
4948
create_server(app, debug).run(host, port)

src/functions_framework/_function_registry.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@
4040
# Keys are the user function name, values are the type of the function input
4141
INPUT_TYPE_MAP = {}
4242

43+
# ASGI_FUNCTIONS stores function names that require ASGI mode.
44+
# Functions decorated with @aio.http or @aio.cloud_event are added here.
45+
ASGI_FUNCTIONS = set()
46+
4347

4448
def get_user_function(source, source_module, target):
4549
"""Returns user function, raises exception for invalid function."""

src/functions_framework/aio/__init__.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def cloud_event(func: CloudEventFunction) -> CloudEventFunction:
6262
_function_registry.REGISTRY_MAP[func.__name__] = (
6363
_function_registry.CLOUDEVENT_SIGNATURE_TYPE
6464
)
65+
_function_registry.ASGI_FUNCTIONS.add(func.__name__)
6566
if inspect.iscoroutinefunction(func):
6667

6768
@functools.wraps(func)
@@ -82,6 +83,7 @@ def http(func: HTTPFunction) -> HTTPFunction:
8283
_function_registry.REGISTRY_MAP[func.__name__] = (
8384
_function_registry.HTTP_SIGNATURE_TYPE
8485
)
86+
_function_registry.ASGI_FUNCTIONS.add(func.__name__)
8587

8688
if inspect.iscoroutinefunction(func):
8789

@@ -213,6 +215,29 @@ async def __call__(self, scope, receive, send):
213215
# Don't re-raise to prevent starlette from printing traceback again
214216

215217

218+
def create_asgi_app_from_module(target, source, signature_type, source_module, spec):
219+
"""Create an ASGI application from an already-loaded module.
220+
221+
Args:
222+
target: The name of the target function to invoke
223+
source: The source file containing the function
224+
signature_type: The signature type of the function
225+
source_module: The already-loaded module
226+
spec: The module spec
227+
228+
Returns:
229+
A Starlette ASGI application instance
230+
"""
231+
enable_id_logging = _enable_execution_id_logging()
232+
if enable_id_logging: # pragma: no cover
233+
_configure_app_execution_id_logging()
234+
235+
function = _function_registry.get_user_function(source, source_module, target)
236+
signature_type = _function_registry.get_func_signature_type(target, signature_type)
237+
238+
return _create_asgi_app_with_function(function, signature_type, enable_id_logging)
239+
240+
216241
def create_asgi_app(target=None, source=None, signature_type=None):
217242
"""Create an ASGI application for the function.
218243
@@ -243,6 +268,11 @@ def create_asgi_app(target=None, source=None, signature_type=None):
243268
function = _function_registry.get_user_function(source, source_module, target)
244269
signature_type = _function_registry.get_func_signature_type(target, signature_type)
245270

271+
return _create_asgi_app_with_function(function, signature_type, enable_id_logging)
272+
273+
274+
def _create_asgi_app_with_function(function, signature_type, enable_id_logging):
275+
"""Create an ASGI app with the given function and signature type."""
246276
is_async = inspect.iscoroutinefunction(function)
247277
routes = []
248278
if signature_type == _function_registry.HTTP_SIGNATURE_TYPE:

tests/test_cli.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import os
16+
import pathlib
1517
import sys
1618

1719
import pretend
@@ -20,9 +22,30 @@
2022
from click.testing import CliRunner
2123

2224
import functions_framework
25+
import functions_framework._function_registry as _function_registry
2326

2427
from functions_framework._cli import _cli
2528

29+
# Conditional import for Starlette (Python 3.8+)
30+
if sys.version_info >= (3, 8):
31+
from starlette.applications import Starlette
32+
else:
33+
Starlette = None
34+
35+
36+
@pytest.fixture(autouse=True)
37+
def clean_registries():
38+
"""Clean up both REGISTRY_MAP and ASGI_FUNCTIONS registries."""
39+
original_registry_map = _function_registry.REGISTRY_MAP.copy()
40+
original_asgi = _function_registry.ASGI_FUNCTIONS.copy()
41+
_function_registry.REGISTRY_MAP.clear()
42+
_function_registry.ASGI_FUNCTIONS.clear()
43+
yield
44+
_function_registry.REGISTRY_MAP.clear()
45+
_function_registry.REGISTRY_MAP.update(original_registry_map)
46+
_function_registry.ASGI_FUNCTIONS.clear()
47+
_function_registry.ASGI_FUNCTIONS.update(original_asgi)
48+
2649

2750
def test_cli_no_arguments():
2851
runner = CliRunner()
@@ -124,3 +147,19 @@ def test_asgi_cli(monkeypatch):
124147
assert result.exit_code == 0
125148
assert create_asgi_app.calls == [pretend.call("foo", None, "http")]
126149
assert asgi_server.run.calls == [pretend.call("0.0.0.0", 8080)]
150+
151+
152+
def test_cli_auto_detects_asgi_decorator():
153+
"""Test that CLI auto-detects @aio decorated functions without --asgi flag."""
154+
# Use the actual async_decorator.py test file which has @aio.http decorated functions
155+
test_functions_dir = pathlib.Path(__file__).parent / "test_functions" / "decorators"
156+
source = test_functions_dir / "async_decorator.py"
157+
158+
# Call create_app without any asgi flag - should auto-detect
159+
app = functions_framework.create_app(target="function_http", source=str(source))
160+
161+
# Verify it created a Starlette app (ASGI)
162+
assert isinstance(app, Starlette)
163+
164+
# Verify the function was registered in ASGI_FUNCTIONS
165+
assert "function_http" in _function_registry.ASGI_FUNCTIONS

tests/test_decorator_functions.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
from cloudevents import conversion as ce_conversion
2020
from cloudevents.http import CloudEvent
2121

22+
import functions_framework._function_registry as registry
23+
2224
# Conditional import for Starlette
2325
if sys.version_info >= (3, 8):
2426
from starlette.testclient import TestClient as StarletteTestClient
@@ -35,6 +37,21 @@
3537

3638
TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions"
3739

40+
41+
@pytest.fixture(autouse=True)
42+
def clean_registries():
43+
"""Clean up both REGISTRY_MAP and ASGI_FUNCTIONS registries."""
44+
original_registry_map = registry.REGISTRY_MAP.copy()
45+
original_asgi = registry.ASGI_FUNCTIONS.copy()
46+
registry.REGISTRY_MAP.clear()
47+
registry.ASGI_FUNCTIONS.clear()
48+
yield
49+
registry.REGISTRY_MAP.clear()
50+
registry.REGISTRY_MAP.update(original_registry_map)
51+
registry.ASGI_FUNCTIONS.clear()
52+
registry.ASGI_FUNCTIONS.update(original_asgi)
53+
54+
3855
# Python 3.5: ModuleNotFoundError does not exist
3956
try:
4057
_ModuleNotFoundError = ModuleNotFoundError
@@ -128,3 +145,33 @@ def test_aio_http_dict_response():
128145
resp = client.post("/")
129146
assert resp.status_code == 200
130147
assert resp.json() == {"message": "hello", "count": 42, "success": True}
148+
149+
150+
def test_aio_decorators_register_asgi_functions():
151+
"""Test that @aio decorators add function names to ASGI_FUNCTIONS registry."""
152+
from functions_framework.aio import cloud_event, http
153+
154+
@http
155+
async def test_http_func(request):
156+
return "test"
157+
158+
@cloud_event
159+
async def test_cloud_event_func(event):
160+
pass
161+
162+
assert "test_http_func" in registry.ASGI_FUNCTIONS
163+
assert "test_cloud_event_func" in registry.ASGI_FUNCTIONS
164+
165+
assert registry.REGISTRY_MAP["test_http_func"] == "http"
166+
assert registry.REGISTRY_MAP["test_cloud_event_func"] == "cloudevent"
167+
168+
@http
169+
def test_http_sync(request):
170+
return "sync"
171+
172+
@cloud_event
173+
def test_cloud_event_sync(event):
174+
pass
175+
176+
assert "test_http_sync" in registry.ASGI_FUNCTIONS
177+
assert "test_cloud_event_sync" in registry.ASGI_FUNCTIONS

tests/test_function_registry.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,25 @@
1313
# limitations under the License.
1414
import os
1515

16+
import pytest
17+
1618
from functions_framework import _function_registry
1719

1820

21+
@pytest.fixture(autouse=True)
22+
def clean_registries():
23+
"""Clean up both REGISTRY_MAP and ASGI_FUNCTIONS registries."""
24+
original_registry_map = _function_registry.REGISTRY_MAP.copy()
25+
original_asgi = _function_registry.ASGI_FUNCTIONS.copy()
26+
_function_registry.REGISTRY_MAP.clear()
27+
_function_registry.ASGI_FUNCTIONS.clear()
28+
yield
29+
_function_registry.REGISTRY_MAP.clear()
30+
_function_registry.REGISTRY_MAP.update(original_registry_map)
31+
_function_registry.ASGI_FUNCTIONS.clear()
32+
_function_registry.ASGI_FUNCTIONS.update(original_asgi)
33+
34+
1935
def test_get_function_signature():
2036
test_cases = [
2137
{

tests/test_functions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -495,7 +495,7 @@ def test_error_paths(http_trigger_client, path):
495495
def test_lazy_wsgi_app(monkeypatch, target, source, signature_type):
496496
actual_app_stub = pretend.stub()
497497
wsgi_app = pretend.call_recorder(lambda *a, **kw: actual_app_stub)
498-
create_app = pretend.call_recorder(lambda *a: wsgi_app)
498+
create_app = pretend.call_recorder(lambda *a, **kw: wsgi_app)
499499
monkeypatch.setattr(functions_framework, "create_app", create_app)
500500

501501
# Test that it's lazy

0 commit comments

Comments
 (0)