Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ dist/
wheels/
*.egg-info
.python-version
.DS_Store

# Virtual environments
.venv
Expand Down
47 changes: 43 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
[build-system]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
requires = ["uv_build>=0.11,<0.12"]
build-backend = "uv_build"

[tool.setuptools.packages.find]
include = ["eagle*"]
[tool.uv.build-backend]
# eagle/ lives at the repo root (flat layout), not under src/.
module-name = "eagle"
module-root = ""

[project]
name = "django-eagle"
Expand All @@ -13,10 +15,47 @@ readme = "README.md"
license = "MIT"
license-files = ["LICENSE"]
requires-python = ">=3.10"
authors = [
{ name = "Xelix" },
{ name = "Cameron Hobbs", email = "cameron.hobbs@xelix.com" },
]
keywords = [
"django",
"orm",
"performance",
"select_related",
"prefetch_related",
"n+1",
"eager-loading",
"query-optimization",
]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Web Environment",
"Framework :: Django",
"Framework :: Django :: 5.2",
"Intended Audience :: Developers",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
Comment thread
mikicz marked this conversation as resolved.
"Programming Language :: Python :: 3.14",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Software Development :: Quality Assurance",
]
dependencies = [
"django>=5.2",
]

[project.urls]
Homepage = "https://github.com/xelixdev/django-eagle"
Repository = "https://github.com/xelixdev/django-eagle"
Issues = "https://github.com/xelixdev/django-eagle/issues"

[dependency-groups]
dev = [
"djangorestframework>=3.16.0",
Expand Down
30 changes: 24 additions & 6 deletions tests/test_concurrency_isolation.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,24 @@ def _run_async_and_capture(make_coro: Callable[[], Coroutine[Any, Any, None]]) -
return [w for w in caught if issubclass(w.category, UnusedRelatedAccess)]


def _close_context_connection() -> None:
"""
Force-close the database connection bound to the current thread or asyncio task.

Each interleaved request below runs in its own thread or task, and Django's
context-aware registry hands each one a separate connection to the shared in-memory
test database. ``connections.close_all()`` cannot reclaim those: the SQLite backend
makes ``close()`` a no-op for in-memory databases (closing the last handle would
destroy the data), so the extra connections would otherwise be finalized while still
open and raise ``ResourceWarning``. Closing the driver connection directly is safe
because the test's own connection keeps the shared in-memory database alive.
"""
connection = connections["default"]
if connection.connection is not None:
connection.connection.close()
connection.connection = None


@pytest.mark.django_db(transaction=True)
class TestAsyncRequestIsolation:
"""
Expand Down Expand Up @@ -75,7 +93,7 @@ async def request_a() -> None:
# Exiting here ends A's request while B's relation is still unconsumed.
a_finished.set()
finally:
connections.close_all()
_close_context_connection()

async def request_b() -> None:
try:
Expand All @@ -86,7 +104,7 @@ async def request_b() -> None:
await a_finished.wait() # Let A end first, with B's load outstanding.
_ = eagle.location # B reads its relation after A has ended.
finally:
connections.close_all()
_close_context_connection()

await asyncio.gather(request_a(), request_b())

Expand All @@ -110,7 +128,7 @@ async def request_a() -> None:
_ = eagle.location # A reads its relation, so A must not warn.
a_finished.set()
finally:
connections.close_all()
_close_context_connection()

async def request_b() -> None:
try:
Expand All @@ -121,7 +139,7 @@ async def request_b() -> None:
await a_finished.wait() # A runs (and resets a shared collector) before B ends.
# Exiting here ends B's request; its unused load must still be reported.
finally:
connections.close_all()
_close_context_connection()

await asyncio.gather(request_a(), request_b())

Expand Down Expand Up @@ -157,7 +175,7 @@ def request_a() -> None:
_ = loaded.location # Accessed, so thread A must not warn.
a_finished.set()
finally:
connections.close_all()
_close_context_connection()

def request_b() -> None:
try:
Expand All @@ -167,7 +185,7 @@ def request_b() -> None:
b_loaded.set()
a_finished.wait() # A's scope overlaps B's before B ends.
finally:
connections.close_all()
_close_context_connection()

thread_a = threading.Thread(target=request_a)
thread_b = threading.Thread(target=request_b)
Expand Down
Loading