diff --git a/.gitignore b/.gitignore index 176b487..e8f86c6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ dist/ wheels/ *.egg-info .python-version +.DS_Store # Virtual environments .venv diff --git a/pyproject.toml b/pyproject.toml index 2f1769b..f2b4d6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" @@ -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", + "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", diff --git a/tests/test_concurrency_isolation.py b/tests/test_concurrency_isolation.py index 4039101..4d3d7b4 100644 --- a/tests/test_concurrency_isolation.py +++ b/tests/test_concurrency_isolation.py @@ -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: """ @@ -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: @@ -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()) @@ -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: @@ -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()) @@ -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: @@ -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)