Skip to content
4 changes: 0 additions & 4 deletions packages/django-cf/django_cf/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import os

from workers import wsgi


async def handle_wsgi(request, app, env=None):
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "1")

return await wsgi.fetch(app, request, env)


Expand Down
32 changes: 32 additions & 0 deletions packages/django-cf/django_cf/db/_async_unsafe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Helper for removing Django's ``async_unsafe`` guard from backend methods."""

from django.utils import asyncio as _django_asyncio


def _async_unsafe_probe():
pass


# Capture async_unsafe function so we don't accidentally unwrap something else
_ASYNC_UNSAFE_WRAPPER_CODE = _django_asyncio.async_unsafe(_async_unsafe_probe).__code__


def _unwrap_async_unsafe(method):
"""
Unwrap Django's async_unsafe decorator to get the original method.

This is needed because Django does not allow calling database operations
from within an async context, but Python workers always run in an async context.

All the database backends that django-cf provides are async-safe so we need
to unwrap the async_unsafe decorator to allow calling database operations
from within an async context.
"""
method_code = getattr(method, "__code__", None)
is_async_unsafe = method_code is _ASYNC_UNSAFE_WRAPPER_CODE
wrapped = getattr(method, "__wrapped__", None)

if is_async_unsafe:
return wrapped

return method
13 changes: 13 additions & 0 deletions packages/django-cf/django_cf/db/base_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
OperationalError,
ProgrammingError,
)
from django.db.backends.base.base import BaseDatabaseWrapper
from django.db.backends.sqlite3.base import DatabaseWrapper as SQLiteDatabaseWrapper
from django.db.backends.sqlite3.client import DatabaseClient as SQLiteDatabaseClient
from django.db.backends.sqlite3.creation import (
Expand Down Expand Up @@ -43,6 +44,8 @@
)
from django.db.models.sql.compiler import SQLCompiler

from ._async_unsafe import _unwrap_async_unsafe


def replace_date_trunc_in_sql(sql):
"""Replace django_date_trunc and django_datetime_trunc function calls with SQLite equivalents."""
Expand Down Expand Up @@ -478,6 +481,16 @@ class CFDatabaseWrapper(SQLiteDatabaseWrapper):

transaction_modes = frozenset([])

connect = _unwrap_async_unsafe(BaseDatabaseWrapper.connect)
ensure_connection = _unwrap_async_unsafe(BaseDatabaseWrapper.ensure_connection)
cursor = _unwrap_async_unsafe(BaseDatabaseWrapper.cursor)
commit = _unwrap_async_unsafe(BaseDatabaseWrapper.commit)
rollback = _unwrap_async_unsafe(BaseDatabaseWrapper.rollback)
savepoint = _unwrap_async_unsafe(BaseDatabaseWrapper.savepoint)
savepoint_rollback = _unwrap_async_unsafe(BaseDatabaseWrapper.savepoint_rollback)
savepoint_commit = _unwrap_async_unsafe(BaseDatabaseWrapper.savepoint_commit)
clean_savepoints = _unwrap_async_unsafe(BaseDatabaseWrapper.clean_savepoints)

def get_compiler(self, default_using=None, using=None, **kwargs):
if using is None:
using = default_using
Expand Down
39 changes: 39 additions & 0 deletions packages/django-cf/tests/in_worker/worker/src/test_base_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
from unittest.mock import MagicMock, patch

import pytest
from django.db.backends.base.base import BaseDatabaseWrapper
from django.utils.asyncio import async_unsafe

from django_cf.db.base_engine import CFDatabaseWrapper, _unwrap_async_unsafe


class TestCFResult:
Expand Down Expand Up @@ -547,7 +551,42 @@ def test_bulk_insert_enabled(self):
assert features.can_return_columns_from_insert is True


class TestUnwrapAsyncUnsafe:
def test_removes_async_unsafe_guard(self):
def target():
return "called"

guarded = async_unsafe(target)

assert _unwrap_async_unsafe(guarded) is target

def test_returns_undecorated_method_unchanged(self):
def target():
return "called"

assert _unwrap_async_unsafe(target) is target


class TestCFDatabaseWrapper:
def test_connection_lifecycle_is_not_guarded_by_async_unsafe(self):
method_names = (
"connect",
"ensure_connection",
"cursor",
"commit",
"rollback",
"savepoint",
"savepoint_rollback",
"savepoint_commit",
"clean_savepoints",
)

for method_name in method_names:
guarded_method = getattr(BaseDatabaseWrapper, method_name)
assert getattr(CFDatabaseWrapper, method_name) is _unwrap_async_unsafe(
guarded_method
)

def test_get_database_version(self):
from django_cf.db.base_engine import CFDatabaseWrapper

Expand Down
2 changes: 0 additions & 2 deletions packages/django-cf/tests/in_worker/worker/src/worker.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# pyright: reportMissingImports=false

import os
from pathlib import Path
from urllib.parse import urlparse

Expand All @@ -14,7 +13,6 @@
from worker_durable_object import TestDurableObject # noqa: F401

BASE_DIR = Path(__file__).parent
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")


if not django.conf.settings.configured:
Expand Down
Loading