Skip to content

Commit 268acf1

Browse files
authored
feat: add flag to run functions framework in asgi stack (#376)
* feat: add ASGI server support for async functions - Add uvicorn and uvicorn-worker to async optional dependencies - Refactor gunicorn.py with BaseGunicornApplication for shared config - Add UvicornApplication class for ASGI apps - Add StarletteApplication in asgi.py for development mode - Update HTTPServer to auto-detect Flask (WSGI) vs other (ASGI) apps - Add --gateway CLI flag to choose between wsgi and asgi - Update test_http.py to use Flask instance in tests * fix: apply black and isort formatting to source files * test: add comprehensive tests for ASGI server support - Add tests for HTTPServer ASGI/WSGI auto-detection - Add tests for StarletteApplication and UvicornApplication - Add tests for CLI --gateway flag functionality - Add integration tests for async functions with ASGI - Ensure 100% code coverage for new ASGI features - Apply black and isort formatting to test files * fix: skip async test files on Python 3.7 * fix: exclude async code from Python 3.7 coverage * fix: Install uvicorn on windows. * fix: unable to use reload in starlette. * fix: add missing endpoint parameter to Route constructors in ASGI * feat: add async conformance tests with ASGI gateway * fix: set UvicornWorker class before parent init and update tests * fix: update asgi tests to remove reload option * feat: add async-specific conformance tests for ASGI mode * fix: apply black formatting to async files * fix: disable validateMapping for CloudEvent tests in ASGI mode ASGI mode does not support automatic conversion from legacy events to CloudEvents, so validateMapping must be false for CloudEvent conformance tests. * fix: avoid mutating options dict in Gunicorn applications Create a copy of the options dict before modifying it to prevent side effects when the same options dict is reused elsewhere. This could cause issues with timeout tests. * fix: add pragma comments for Python 3.7 coverage and fix options handling - Add pragma: no cover comments for ASGI-specific code paths that won't execute in Python 3.7 - Fix options dict handling to use consistent variable names to avoid confusion * fix: revert to separate GunicornApplication and UvicornApplication classes Remove the BaseGunicornApplication abstraction as it was causing issues with the timeout mechanism. Each class now independently extends gunicorn.app.base.BaseApplication, which is cleaner and avoids the problems we were seeing with shared state and options handling. * refactor: restore GunicornApplication to match main branch Remove unnecessary changes to GunicornApplication class, keeping only the UvicornApplication addition for ASGI support. * chore: Untrack uv.lock * chore: rename confirmance test (asgi) github workflow * chore: cleanup .gitignore. * chore: clean up unncessary comments.
1 parent 49f6985 commit 268acf1

File tree

15 files changed

+544
-22
lines changed

15 files changed

+544
-22
lines changed

.coveragerc-py37

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,18 @@
44
# This file is only used by py37-* tox environments
55
omit =
66
*/functions_framework/aio/*
7+
*/functions_framework/_http/asgi.py
78
*/.tox/*
89
*/tests/*
910
*/venv/*
10-
*/.venv/*
11+
*/.venv/*
12+
13+
[report]
14+
exclude_lines =
15+
# Have to re-enable the standard pragma
16+
pragma: no cover
17+
18+
# Don't complain about async-specific imports and code
19+
from functions_framework.aio import
20+
from functions_framework._http.asgi import
21+
from functions_framework._http.gunicorn import UvicornApplication
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
name: Python Conformance CI (asgi)
2+
on:
3+
push:
4+
branches:
5+
- 'main'
6+
pull_request:
7+
8+
# Declare default permissions as read only.
9+
permissions: read-all
10+
11+
jobs:
12+
build:
13+
strategy:
14+
matrix:
15+
python: ['3.8', '3.9', '3.10', '3.11', '3.12']
16+
platform: [ubuntu-latest]
17+
runs-on: ${{ matrix.platform }}
18+
steps:
19+
- name: Harden Runner
20+
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
21+
with:
22+
disable-sudo: true
23+
egress-policy: block
24+
allowed-endpoints: >
25+
api.github.com:443
26+
files.pythonhosted.org:443
27+
github.com:443
28+
objects.githubusercontent.com:443
29+
proxy.golang.org:443
30+
pypi.org:443
31+
storage.googleapis.com:443
32+
33+
- name: Checkout code
34+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
35+
36+
- name: Setup Python
37+
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
38+
with:
39+
python-version: ${{ matrix.python }}
40+
41+
- name: Install the framework with async extras
42+
run: python -m pip install -e .[async]
43+
44+
- name: Setup Go
45+
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
46+
with:
47+
go-version: '1.24'
48+
49+
- name: Run HTTP conformance tests
50+
uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6
51+
with:
52+
functionType: 'http'
53+
useBuildpacks: false
54+
validateMapping: false
55+
cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http --signature-type http --gateway asgi'"
56+
57+
- name: Run CloudEvents conformance tests
58+
uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6
59+
with:
60+
functionType: 'cloudevent'
61+
useBuildpacks: false
62+
validateMapping: false
63+
cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event --signature-type cloudevent --gateway asgi'"
64+
65+
- name: Run HTTP conformance tests declarative
66+
uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6
67+
with:
68+
functionType: 'http'
69+
useBuildpacks: false
70+
validateMapping: false
71+
cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative --gateway asgi'"
72+
73+
- name: Run CloudEvents conformance tests declarative
74+
uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6
75+
with:
76+
functionType: 'cloudevent'
77+
useBuildpacks: false
78+
validateMapping: false
79+
cmd: "'functions-framework --source tests/conformance/async_main.py --target write_cloud_event_declarative --gateway asgi'"
80+
81+
- name: Run HTTP concurrency tests declarative
82+
uses: GoogleCloudPlatform/functions-framework-conformance/action@72a4f36b10f1c6435ab1a86a9ea24bda464cc262 # v1.8.6
83+
with:
84+
functionType: 'http'
85+
useBuildpacks: false
86+
validateConcurrency: true
87+
cmd: "'functions-framework --source tests/conformance/async_main.py --target write_http_declarative_concurrent --gateway asgi'"
88+
89+
# Note: Event (legacy) and Typed tests are not supported in ASGI mode
90+
# Note: validateMapping is set to false for CloudEvent tests because ASGI mode
91+
# does not support automatic conversion from legacy events to CloudEvents

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ dist/
1010
function_output.json
1111
serverlog_stderr.txt
1212
serverlog_stdout.txt
13+
venv/

conftest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ def pytest_ignore_collect(collection_path, config):
5050
if sys.version_info >= (3, 8):
5151
return None
5252

53-
# Skip test_aio.py entirely on Python 3.7
54-
if collection_path.name == "test_aio.py":
53+
# Skip test_aio.py and test_asgi.py entirely on Python 3.7
54+
if collection_path.name in ["test_aio.py", "test_asgi.py"]:
5555
return True
5656

5757
return None

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@ dependencies = [
3636
Homepage = "https://github.com/googlecloudplatform/functions-framework-python"
3737

3838
[project.optional-dependencies]
39-
async = ["starlette>=0.37.0,<1.0.0; python_version>='3.8'"]
39+
async = [
40+
"starlette>=0.37.0,<1.0.0; python_version>='3.8'",
41+
"uvicorn>=0.18.0,<1.0.0; python_version>='3.8'",
42+
"uvicorn-worker>=0.2.0,<1.0.0; python_version>='3.8'"
43+
]
4044

4145
[project.scripts]
4246
ff = "functions_framework._cli:_cli"

src/functions_framework/_cli.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,19 @@
3232
@click.option("--host", envvar="HOST", type=click.STRING, default="0.0.0.0")
3333
@click.option("--port", envvar="PORT", type=click.INT, default=8080)
3434
@click.option("--debug", envvar="DEBUG", is_flag=True)
35-
def _cli(target, source, signature_type, host, port, debug):
36-
app = create_app(target, source, signature_type)
35+
@click.option(
36+
"--gateway",
37+
envvar="GATEWAY",
38+
type=click.Choice(["wsgi", "asgi"]),
39+
default="wsgi",
40+
help="Server gateway interface type (wsgi for sync, asgi for async)",
41+
)
42+
def _cli(target, source, signature_type, host, port, debug, gateway):
43+
if gateway == "asgi": # pragma: no cover
44+
from functions_framework.aio import create_asgi_app
45+
46+
app = create_asgi_app(target, source, signature_type)
47+
else:
48+
app = create_app(target, source, signature_type)
49+
3750
create_server(app, debug).run(host, port)

src/functions_framework/_http/__init__.py

Lines changed: 27 additions & 10 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+
from flask import Flask
16+
1517
from functions_framework._http.flask import FlaskApplication
1618

1719

@@ -21,15 +23,30 @@ def __init__(self, app, debug, **options):
2123
self.debug = debug
2224
self.options = options
2325

24-
if self.debug:
25-
self.server_class = FlaskApplication
26-
else:
27-
try:
28-
from functions_framework._http.gunicorn import GunicornApplication
29-
30-
self.server_class = GunicornApplication
31-
except ImportError as e:
26+
if isinstance(app, Flask):
27+
if self.debug:
3228
self.server_class = FlaskApplication
29+
else:
30+
try:
31+
from functions_framework._http.gunicorn import GunicornApplication
32+
33+
self.server_class = GunicornApplication
34+
except ImportError as e:
35+
self.server_class = FlaskApplication
36+
else: # pragma: no cover
37+
if self.debug:
38+
from functions_framework._http.asgi import StarletteApplication
39+
40+
self.server_class = StarletteApplication
41+
else:
42+
try:
43+
from functions_framework._http.gunicorn import UvicornApplication
44+
45+
self.server_class = UvicornApplication
46+
except ImportError as e:
47+
from functions_framework._http.asgi import StarletteApplication
48+
49+
self.server_class = StarletteApplication
3350

3451
def run(self, host, port):
3552
http_server = self.server_class(
@@ -38,5 +55,5 @@ def run(self, host, port):
3855
http_server.run()
3956

4057

41-
def create_server(wsgi_app, debug, **options):
42-
return HTTPServer(wsgi_app, debug, **options)
58+
def create_server(app, debug, **options):
59+
return HTTPServer(app, debug, **options)

src/functions_framework/_http/asgi.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import uvicorn
16+
17+
18+
class StarletteApplication:
19+
"""A Starlette application that uses Uvicorn for direct serving (development mode)."""
20+
21+
def __init__(self, app, host, port, debug, **options):
22+
"""Initialize the Starlette application.
23+
24+
Args:
25+
app: The ASGI application to serve
26+
host: The host to bind to
27+
port: The port to bind to
28+
debug: Whether to run in debug mode
29+
**options: Additional options to pass to Uvicorn
30+
"""
31+
self.app = app
32+
self.host = host
33+
self.port = port
34+
self.debug = debug
35+
36+
self.options = {
37+
"log_level": "debug" if debug else "error",
38+
}
39+
self.options.update(options)
40+
41+
def run(self):
42+
"""Run the Uvicorn server directly."""
43+
uvicorn.run(self.app, host=self.host, port=int(self.port), **self.options)

src/functions_framework/_http/gunicorn.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,28 @@ class GThreadWorkerWithTimeoutSupport(ThreadWorker): # pragma: no cover
7070
def handle_request(self, req, conn):
7171
with ThreadingTimeout(TIMEOUT_SECONDS):
7272
super(GThreadWorkerWithTimeoutSupport, self).handle_request(req, conn)
73+
74+
75+
class UvicornApplication(gunicorn.app.base.BaseApplication):
76+
"""Gunicorn application for ASGI apps using Uvicorn workers."""
77+
78+
def __init__(self, app, host, port, debug, **options):
79+
self.options = {
80+
"bind": "%s:%s" % (host, port),
81+
"workers": int(os.environ.get("WORKERS", 1)),
82+
"worker_class": "uvicorn_worker.UvicornWorker",
83+
"timeout": int(os.environ.get("CLOUD_RUN_TIMEOUT_SECONDS", 0)),
84+
"loglevel": os.environ.get("GUNICORN_LOG_LEVEL", "error"),
85+
"limit_request_line": 0,
86+
}
87+
self.options.update(options)
88+
self.app = app
89+
90+
super().__init__()
91+
92+
def load_config(self):
93+
for key, value in self.options.items():
94+
self.cfg.set(key, value)
95+
96+
def load(self):
97+
return self.app

src/functions_framework/aio/__init__.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,14 +197,16 @@ def create_asgi_app(target=None, source=None, signature_type=None):
197197
routes.append(
198198
Route(
199199
"/{path:path}",
200-
http_handler,
200+
endpoint=http_handler,
201201
methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH"],
202202
)
203203
)
204204
elif signature_type == _function_registry.CLOUDEVENT_SIGNATURE_TYPE:
205205
cloudevent_handler = _cloudevent_func_wrapper(function, is_async)
206-
routes.append(Route("/{path:path}", cloudevent_handler, methods=["POST"]))
207-
routes.append(Route("/", cloudevent_handler, methods=["POST"]))
206+
routes.append(
207+
Route("/{path:path}", endpoint=cloudevent_handler, methods=["POST"])
208+
)
209+
routes.append(Route("/", endpoint=cloudevent_handler, methods=["POST"]))
208210
elif signature_type == _function_registry.TYPED_SIGNATURE_TYPE:
209211
raise FunctionsFrameworkException(
210212
f"ASGI server does not support typed events (signature type: '{signature_type}'). "

tests/conformance/async_main.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import asyncio
2+
import json
3+
4+
from cloudevents.http import to_json
5+
6+
import functions_framework.aio
7+
8+
filename = "function_output.json"
9+
10+
11+
class RawJson:
12+
data: dict
13+
14+
def __init__(self, data):
15+
self.data = data
16+
17+
@staticmethod
18+
def from_dict(obj: dict) -> "RawJson":
19+
return RawJson(obj)
20+
21+
def to_dict(self) -> dict:
22+
return self.data
23+
24+
25+
def _write_output(content):
26+
with open(filename, "w") as f:
27+
f.write(content)
28+
29+
30+
async def write_http(request):
31+
json_data = await request.json()
32+
_write_output(json.dumps(json_data))
33+
return "OK", 200
34+
35+
36+
async def write_cloud_event(cloud_event):
37+
_write_output(to_json(cloud_event).decode())
38+
39+
40+
@functions_framework.aio.http
41+
async def write_http_declarative(request):
42+
json_data = await request.json()
43+
_write_output(json.dumps(json_data))
44+
return "OK", 200
45+
46+
47+
@functions_framework.aio.cloud_event
48+
async def write_cloud_event_declarative(cloud_event):
49+
_write_output(to_json(cloud_event).decode())
50+
51+
52+
@functions_framework.aio.http
53+
async def write_http_declarative_concurrent(request):
54+
await asyncio.sleep(1)
55+
return "OK", 200
56+
57+
58+
# Note: Typed events are not supported in ASGI mode yet
59+
# Legacy event functions are also not supported in ASGI mode

0 commit comments

Comments
 (0)