From 32da21e34fa23cd3605d8f68e21acd161a4546c4 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Wed, 12 Aug 2026 15:45:08 +0900 Subject: [PATCH 1/3] fix(django-cf): replace wsgi adapter in django-cf to wsgi in runtime-sdk --- packages/django-cf/README.md | 2 +- packages/django-cf/django_cf/__init__.py | 78 ++-------------- packages/django-cf/pyproject.toml | 1 + packages/django-cf/templates/d1/README.md | 2 +- .../tests/in_worker/test_in_worker.py | 64 ++++++++++++- .../tests/in_worker/worker/src/worker.py | 91 +++++++++++++++---- packages/django-cf/uv.lock | 15 ++- 7 files changed, 154 insertions(+), 99 deletions(-) diff --git a/packages/django-cf/README.md b/packages/django-cf/README.md index 705f6f23..77d85b54 100644 --- a/packages/django-cf/README.md +++ b/packages/django-cf/README.md @@ -67,7 +67,7 @@ Use Cloudflare D1, a serverless SQL database, as your Django application's datab from app.wsgi import application class Default(DjangoCF, WorkerEntrypoint): - async def get_app(self): + def get_app(self): return application ``` diff --git a/packages/django-cf/django_cf/__init__.py b/packages/django-cf/django_cf/__init__.py index 642675e2..a47979ef 100644 --- a/packages/django-cf/django_cf/__init__.py +++ b/packages/django-cf/django_cf/__init__.py @@ -1,78 +1,12 @@ import os -from io import BytesIO -async def handle_wsgi(request, app): - os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "false") - from js import URL, Object, Response, console - - url = URL.new(request.url) - assert url.protocol[-1] == ":" - scheme = url.protocol[:-1] - path = url.pathname - assert "?".startswith(url.search[0:1]) - query_string = url.search[1:] - method = str(request.method).upper() - - host = url.host.split(":")[0] - - wsgi_request = { - "REQUEST_METHOD": method, - "PATH_INFO": path, - "QUERY_STRING": query_string, - "SERVER_NAME": host, - "SERVER_PORT": url.port, - "SERVER_PROTOCOL": "HTTP/1.1", - "wsgi.input": BytesIO(b""), - "wsgi.errors": console.error, - "wsgi.version": (1, 0), - "wsgi.multithread": False, - "wsgi.multiprocess": False, - "wsgi.run_once": True, - "wsgi.url_scheme": scheme, - } - - if request.headers.get("content-type"): - wsgi_request["CONTENT_TYPE"] = request.headers.get("content-type") - - if request.headers.get("content-length"): - wsgi_request["CONTENT_LENGTH"] = request.headers.get("content-length") - - for header in request.headers.items(): - wsgi_request[f"HTTP_{header[0].upper().replace('-', '_')}"] = header[1] +async def handle_wsgi(request, app, env=None): + from workers import wsgi - if method in ["POST", "PUT", "PATCH"]: - body = (await request._js_request.arrayBuffer()).to_bytes() - wsgi_request["wsgi.input"] = BytesIO(body) - - def start_response(status_str, response_headers): - nonlocal status, headers - status = status_str - headers = response_headers - - try: - resp = app(wsgi_request, start_response) - except Exception as exc: - # library should always print or console log the exception, because a production django should not show end users errors - print("Caught exception while loading application:", exc.__str__()) - print(exc) - - raise exc - - status = resp.status_code - headers = resp.headers - - final_response = Response.new( - resp.content.decode("utf-8"), - headers=Object.fromEntries(headers.items()), - status=status, - ) - - for v in resp.cookies.values(): - value = str(v) - final_response.headers.set("Set-Cookie", value.replace("Set-Cookie: ", "", 1)) + os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "false") - return final_response + return await wsgi.fetch(app, request, env) class DjangoCF: @@ -80,7 +14,7 @@ def get_app(self): raise NotImplementedError("Please implement get_app in your django_cf worker") async def fetch(self, request): - return await handle_wsgi(request, self.get_app()) + return await handle_wsgi(request, self.get_app(), self.env) class DjangoCFDurableObject: @@ -96,4 +30,4 @@ def __init__(self, ctx, env): set_storage(self.ctx.storage.sql) def fetch(self, request): - return handle_wsgi(request, self.get_app()) + return handle_wsgi(request, self.get_app(), self.env) diff --git a/packages/django-cf/pyproject.toml b/packages/django-cf/pyproject.toml index c5b76274..a2b296af 100644 --- a/packages/django-cf/pyproject.toml +++ b/packages/django-cf/pyproject.toml @@ -10,6 +10,7 @@ authors = [ ] dependencies = [ 'sqlparse', + 'workers-runtime-sdk>=1.6.0', ] description = "django-cf is a package that integrates Django with Cloudflare products" readme = "README.md" diff --git a/packages/django-cf/templates/d1/README.md b/packages/django-cf/templates/d1/README.md index c30afde8..067a30e3 100644 --- a/packages/django-cf/templates/d1/README.md +++ b/packages/django-cf/templates/d1/README.md @@ -93,7 +93,7 @@ template-root/ from django_cf import DjangoCF class Default(DjangoCF, WorkerEntrypoint): - async def get_app(self): + def get_app(self): from app.wsgi import application return application ``` diff --git a/packages/django-cf/tests/in_worker/test_in_worker.py b/packages/django-cf/tests/in_worker/test_in_worker.py index fde74dcb..f7b4eec1 100644 --- a/packages/django-cf/tests/in_worker/test_in_worker.py +++ b/packages/django-cf/tests/in_worker/test_in_worker.py @@ -32,9 +32,9 @@ def compat_config(request: pytest.FixtureRequest) -> CompatConfig: register_in_worker_suites(globals(), IN_WORKER_SRC_DIR) -def test_wsgi_header_transformation(in_worker_server: str) -> None: +def test_django_wsgi_header_transformation(in_worker_server: str) -> None: response = requests.get( - f"{in_worker_server}/wsgi/headers", + f"{in_worker_server}/django/headers/", headers={ "cf-access-jwt-assertion": "jwt-token", "x-custom-header": "custom-value", @@ -50,9 +50,9 @@ def test_wsgi_header_transformation(in_worker_server: str) -> None: assert payload["content_type"] == "text/plain" -def test_wsgi_reads_request_body(in_worker_server: str) -> None: +def test_django_wsgi_reads_post_request_body(in_worker_server: str) -> None: response = requests.post( - f"{in_worker_server}/wsgi/body", + f"{in_worker_server}/django/body/", headers={"content-type": "text/plain"}, data=b"request-body", timeout=10, @@ -60,3 +60,59 @@ def test_wsgi_reads_request_body(in_worker_server: str) -> None: assert response.status_code == 200 assert response.text == "request-body" + + +def test_django_wsgi_preserves_binary_response(in_worker_server: str) -> None: + response = requests.get(f"{in_worker_server}/django/binary/", timeout=10) + + assert response.status_code == 200 + assert response.headers["content-type"] == "application/octet-stream" + assert response.content == bytes(range(256)) + + +def test_django_wsgi_streams_response(in_worker_server: str) -> None: + response = requests.get( + f"{in_worker_server}/django/stream/", stream=True, timeout=10 + ) + + assert response.status_code == 200 + assert "content-length" not in response.headers + assert response.content == b"".join(bytes([value]) * 1024 for value in range(5)) + + +def test_django_wsgi_preserves_multiple_cookies(in_worker_server: str) -> None: + response = requests.get(f"{in_worker_server}/django/cookies/", timeout=10) + + assert response.status_code == 200 + assert len(response.raw.headers.getlist("set-cookie")) == 2 + assert response.cookies["first"] == "1" + assert response.cookies["second"] == "2" + + +def test_django_wsgi_builds_request_metadata(in_worker_server: str) -> None: + response = requests.get( + f"{in_worker_server}/django/meta/%E6%9D%B1%E4%BA%AC/", + params=[("value", "first"), ("value", "second")], + timeout=10, + ) + + assert response.status_code == 200 + assert response.json() == { + "path": "/django/meta/東京/", + "segment": "東京", + "values": ["first", "second"], + "has_env": True, + "has_bucket": True, + } + + +def test_django_wsgi_reads_delete_request_body(in_worker_server: str) -> None: + response = requests.delete( + f"{in_worker_server}/django/body/", + data=b"\x00\xffrequest-body", + timeout=10, + ) + + assert response.status_code == 200 + assert response.headers["x-request-method"] == "DELETE" + assert response.content == b"\x00\xffrequest-body" diff --git a/packages/django-cf/tests/in_worker/worker/src/worker.py b/packages/django-cf/tests/in_worker/worker/src/worker.py index 962bc9cc..92c5de6e 100644 --- a/packages/django-cf/tests/in_worker/worker/src/worker.py +++ b/packages/django-cf/tests/in_worker/worker/src/worker.py @@ -2,6 +2,7 @@ import asyncio import contextlib +import functools import importlib.util import io from pathlib import Path @@ -10,7 +11,8 @@ import django import django.conf import pytest -from django.http import HttpResponse, JsonResponse +from django.http import HttpResponse, JsonResponse, StreamingHttpResponse +from django.urls import path from workers import Response, WorkerEntrypoint BASE_DIR = Path(__file__).parent @@ -49,25 +51,73 @@ django.setup() -from django_cf import handle_wsgi # noqa: E402 +from django_cf import DjangoCF # noqa: E402 -urlpatterns = [] +@functools.cache +def _django_wsgi_app(): + from django.core.wsgi import get_wsgi_application -def _wsgi_header_echo_app(environ, start_response): - payload = { - "cf_access": environ.get("HTTP_CF_ACCESS_JWT_ASSERTION"), - "custom": environ.get("HTTP_X_CUSTOM_HEADER"), - "content_type": environ.get("CONTENT_TYPE"), - "content_length": environ.get("CONTENT_LENGTH"), - } - return JsonResponse(payload) + return get_wsgi_application() -def _wsgi_body_echo_app(environ, start_response): - length = int(environ.get("CONTENT_LENGTH") or 0) - body = environ["wsgi.input"].read(length) - return HttpResponse(body, content_type="application/octet-stream") +def _django_binary_view(request): + return HttpResponse(bytes(range(256)), content_type="application/octet-stream") + + +def _django_streaming_view(request): + def chunks(): + for value in range(5): + yield bytes([value]) * 1024 + + return StreamingHttpResponse(chunks(), content_type="application/octet-stream") + + +def _django_cookies_view(request): + response = HttpResponse(b"cookies", content_type="text/plain") + response.set_cookie("first", "1") + response.set_cookie("second", "2") + return response + + +def _django_meta_view(request, segment): + env = request.META.get("workers.env") + return JsonResponse( + { + "path": request.path_info, + "segment": segment, + "values": request.GET.getlist("value"), + "has_env": env is not None, + "has_bucket": hasattr(env, "BUCKET"), + } + ) + + +def _django_body_view(request): + response = HttpResponse(request.body, content_type="application/octet-stream") + response["X-Request-Method"] = request.method + return response + + +def _django_headers_view(request): + return JsonResponse( + { + "cf_access": request.META.get("HTTP_CF_ACCESS_JWT_ASSERTION"), + "custom": request.META.get("HTTP_X_CUSTOM_HEADER"), + "content_type": request.META.get("CONTENT_TYPE"), + "content_length": request.META.get("CONTENT_LENGTH"), + } + ) + + +urlpatterns = [ + path("django/binary/", _django_binary_view), + path("django/stream/", _django_streaming_view), + path("django/cookies/", _django_cookies_view), + path("django/meta//", _django_meta_view), + path("django/body/", _django_body_view), + path("django/headers/", _django_headers_view), +] class ResultCollector: @@ -134,7 +184,10 @@ def env(self): return self._env -class Default(WorkerEntrypoint): +class Default(DjangoCF, WorkerEntrypoint): + def get_app(self): + return _django_wsgi_app() + async def fetch(self, request): path = urlparse(request.url).path @@ -143,10 +196,8 @@ async def fetch(self, request): return self._run_suite(suite_name) if path == "/health": return Response.json({"ok": True}) - if path == "/wsgi/headers": - return await handle_wsgi(request, _wsgi_header_echo_app) - if path == "/wsgi/body": - return await handle_wsgi(request, _wsgi_body_echo_app) + if path.startswith("/django/"): + return await super().fetch(request) return Response.json({"error": "not found"}, status=404) def _run_suite(self, suite_name): diff --git a/packages/django-cf/uv.lock b/packages/django-cf/uv.lock index 190ad2dd..d3a6f674 100644 --- a/packages/django-cf/uv.lock +++ b/packages/django-cf/uv.lock @@ -175,6 +175,7 @@ version = "0.2.10" source = { editable = "." } dependencies = [ { name = "sqlparse" }, + { name = "workers-runtime-sdk" }, ] [package.dev-dependencies] @@ -188,7 +189,10 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "sqlparse" }] +requires-dist = [ + { name = "sqlparse" }, + { name = "workers-runtime-sdk", specifier = ">=1.6.0" }, +] [package.metadata.requires-dev] dev = [ @@ -364,3 +368,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787 wheels = [ { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" }, ] + +[[package]] +name = "workers-runtime-sdk" +version = "1.6.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/e5/056ba82b12f353c3d22ed3ea026b58d8c8577f03eda254afcb66087c0ef7/workers_runtime_sdk-1.6.11.tar.gz", hash = "sha256:f8c01ba123b96efe596072f8042c41ffd09763270689cf46005caee053e6f9b7", size = 117369, upload-time = "2026-08-10T12:22:39.668Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/b1/4052839d147038eb72cf6d858698e0310ac81ef170f1df1ee70c23083643/workers_runtime_sdk-1.6.11-py3-none-any.whl", hash = "sha256:5045cca46f8a445567ae09bb704be15a58d53f53166bf069f048a46597246f28", size = 34373, upload-time = "2026-08-10T12:22:38.594Z" }, +] From 7acca2938c40ac0fc58f4d47c4685bf215d0b439 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Wed, 12 Aug 2026 15:50:07 +0900 Subject: [PATCH 2/3] chore: fix tests --- packages/django-cf/tests/e2e/test_workflows.py | 13 ++----------- .../django-cf/tests/in_worker/test_in_worker.py | 2 +- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/django-cf/tests/e2e/test_workflows.py b/packages/django-cf/tests/e2e/test_workflows.py index 51821260..22ef818f 100644 --- a/packages/django-cf/tests/e2e/test_workflows.py +++ b/packages/django-cf/tests/e2e/test_workflows.py @@ -4,7 +4,6 @@ They require running Cloudflare Workers via wrangler. """ -import pytest import requests @@ -161,16 +160,8 @@ def test_directory_operations_workflow(self, r2_web_server): assert "file1.txt" in result["files"] assert "file2.txt" in result["files"] - @pytest.mark.xfail( - reason="Framework limitation: django_cf/__init__.py:58 calls resp.content.decode('utf-8') " - "which fails for binary content. See handle_wsgi function." - ) def test_binary_file_workflow(self, r2_web_server): - """Test binary file upload and download. - - NOTE: This test documents a known framework limitation where binary - responses fail because handle_wsgi() tries to decode content as UTF-8. - """ + """Test binary file upload and download.""" base_url = r2_web_server.base_url test_path = "binary_test/image.bin" @@ -184,7 +175,7 @@ def test_binary_file_workflow(self, r2_web_server): response = requests.post(upload_url, files=files, data=data, timeout=10) assert response.status_code == 200 - # Download and verify - this fails due to UTF-8 decode in handle_wsgi + # Download and verify download_url = f"{base_url}/__r2_download__/" response = requests.get(download_url, params={"path": test_path}, timeout=10) assert response.status_code == 200 diff --git a/packages/django-cf/tests/in_worker/test_in_worker.py b/packages/django-cf/tests/in_worker/test_in_worker.py index f7b4eec1..0a01a35d 100644 --- a/packages/django-cf/tests/in_worker/test_in_worker.py +++ b/packages/django-cf/tests/in_worker/test_in_worker.py @@ -59,7 +59,7 @@ def test_django_wsgi_reads_post_request_body(in_worker_server: str) -> None: ) assert response.status_code == 200 - assert response.text == "request-body" + assert response.content == b"request-body" def test_django_wsgi_preserves_binary_response(in_worker_server: str) -> None: From 19cba874bb02785139629c95152ca4f13a7c883e Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Fri, 14 Aug 2026 11:28:02 +0900 Subject: [PATCH 3/3] chore: move workers import to the top level --- packages/django-cf/django_cf/__init__.py | 4 ++-- packages/django-cf/uv.lock | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/django-cf/django_cf/__init__.py b/packages/django-cf/django_cf/__init__.py index a47979ef..0ec4cb13 100644 --- a/packages/django-cf/django_cf/__init__.py +++ b/packages/django-cf/django_cf/__init__.py @@ -1,9 +1,9 @@ import os +from workers import wsgi -async def handle_wsgi(request, app, env=None): - from workers import wsgi +async def handle_wsgi(request, app, env=None): os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "false") return await wsgi.fetch(app, request, env) diff --git a/packages/django-cf/uv.lock b/packages/django-cf/uv.lock index d3a6f674..d3f41d8e 100644 --- a/packages/django-cf/uv.lock +++ b/packages/django-cf/uv.lock @@ -171,7 +171,7 @@ wheels = [ [[package]] name = "django-cf" -version = "0.2.10" +version = "0.2.12" source = { editable = "." } dependencies = [ { name = "sqlparse" },