diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 66099cc..010aabf 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -36,9 +36,10 @@ Django's ORM, and a **per-request** phase that records loads/accesses and emits ![phases.png](assets/architecture/phases.png) The two phases are decoupled: instrumentation makes the ORM *capable* of tracking, but -nothing is actually recorded unless the middleware has opened a request scope. Outside a -request (management commands, the shell, tests without the middleware) the patched code -checks `collector.active` / `_state.warn_unused` and quietly does nothing. +nothing is actually recorded unless a tracking scope has been opened — by the middleware +(per HTTP request) or by `warn_unused` (per call or per `with` block). With no scope open +(management commands, the shell, tests without either) the patched code checks +`collector.active` / `_state.warn_unused` and quietly does nothing. ## Phase 1 — Startup instrumentation @@ -116,8 +117,11 @@ reads of the cache are observable. ## Phase 2 — Per-request lifecycle -`EagleWarnUnusedMiddleware` opens and closes a tracking scope around each request. -Everything recorded in between lands in a thread-local `Collector`. +`EagleWarnUnusedMiddleware` opens and closes a tracking scope around each request; +`warn_unused` (`eagle/decorators.py`) does the same around a single function call or +`with` block, for code that runs outside the request cycle. Both are thin wrappers over +`begin_request()` / `end_request()`. Everything recorded in between lands in a +thread-local `Collector`. ![img_1.png](assets/architecture/lifecycle.png) @@ -210,6 +214,7 @@ subclass of `EagleWarning`), so you can route, filter, or escalate it with the s | `eagle/apps.py` | Startup entrypoint (`AppConfig.ready`); wires everything together. | | `eagle/config.py` | `is_enabled()` — reads `EAGLE_ENABLED`. | | `eagle/middleware.py` | Scopes tracking to a request; flushes warnings on response. | +| `eagle/decorators.py` | `warn_unused` — scopes tracking around a single call or `with` block (outside the request cycle). | | `eagle/sinks.py` | Public `mark_considered` / `may_access` escape hatches. | | `eagle/exceptions.py` | `EagleWarning` / `UnusedRelatedAccess` warning categories. | | `eagle/instrumentation/scope.py` | Decides which apps/models to instrument. | diff --git a/README.md b/README.md index 7df2bdd..5179bf4 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,33 @@ on . Queryset marked at /app/views.py:42. Fix it by dropping the unused `select_related` / `prefetch_related`, or tell eagle the access is legitimate (see below). +### Outside the request cycle — `warn_unused` + +The middleware only scopes tracking around HTTP requests. To get the same detection for code that runs elsewhere — a management command, a Celery task, a cron job, or any plain function — use `warn_unused` as a decorator: + +```python +from eagle import warn_unused + +@warn_unused +def refresh_eagles(): + # select_related("location") joins the location table... + for eagle in Eagle.objects.select_related("location"): + process(eagle.height) # ...but location is never read. +``` + +Or scope a single block by using the same name as a context manager: + +```python +from eagle import warn_unused + +with warn_unused(): + # select_related("location") joins the location table... + for eagle in Eagle.objects.select_related("location"): + process(eagle.height) # ...but location is never read. +``` + +`warn_unused` begins tracking before the scoped code runs and ends it on exit — exactly as the middleware does for a request — so the wasted join above emits the same `UnusedRelatedAccess` warning. Tracking always ends, even if the scoped code raises, so a failure never leaks tracking state onto the thread. The decorator form works on sync and async callables and preserves wrapper metadata (`__name__`, `__doc__`), and either form is a transparent passthrough when `EAGLE_ENABLED` is falsy. + ## Suppressing false positives eagle spots access by intercepting Django's relation descriptors, which fire on ordinary attribute access — so template rendering, conditional reads, and Python-level serializers (including DRF) are all tracked while they run. A warning is still a false positive in the following cases: @@ -213,6 +240,7 @@ Everything you need is exported from the top-level `eagle` package: | Name | Type | Purpose | | --- | --- | --- | | `EagleWarnUnusedMiddleware` | middleware | Scopes tracking per request; emits warnings on response. | +| `warn_unused` | decorator / context manager | Scopes tracking around a function call or `with` block; emits warnings on exit. | | `mark_considered` | function | Imperatively mark relations as accessed. | | `may_access` | decorator | Mark relations as accessed on normal return. | | `UnusedRelatedAccess` | warning | Category emitted for an unused eager load. | diff --git a/eagle/__init__.py b/eagle/__init__.py index 34d829a..7a79663 100644 --- a/eagle/__init__.py +++ b/eagle/__init__.py @@ -4,8 +4,10 @@ "UnusedRelatedAccess", "mark_considered", "may_access", + "warn_unused", ] +from eagle.decorators import warn_unused from eagle.exceptions import EagleWarning, UnusedRelatedAccess from eagle.middleware import EagleWarnUnusedMiddleware from eagle.sinks import mark_considered, may_access diff --git a/eagle/decorators.py b/eagle/decorators.py new file mode 100644 index 0000000..def380c --- /dev/null +++ b/eagle/decorators.py @@ -0,0 +1,171 @@ +import functools +import inspect +from collections.abc import Callable +from types import TracebackType +from typing import ParamSpec, TypeVar, overload + +from eagle.config import is_enabled +from eagle.unused import begin_request, end_request + +P = ParamSpec("P") +R = TypeVar("R") + + +def _wrap(fn: Callable[P, R]) -> Callable[P, R]: + """ + Wrap *fn* so Eagle's unused-relation tracking is scoped to each call. + + Begins tracking before *fn* runs and ends it in a ``finally`` so tracking + always ends even if *fn* raises. When ``EAGLE_ENABLED`` is falsy the wrapper + is a transparent passthrough. Coroutine functions are wrapped so tracking + spans the awaited call rather than ending the moment the coroutine is + created. + + Args: + fn: The synchronous or asynchronous callable to scope tracking around. + + Returns: + A wrapped callable that emits unused-relation warnings after each call. + """ + if inspect.iscoroutinefunction(fn): + + @functools.wraps(fn) + async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + if not is_enabled(): + return await fn(*args, **kwargs) + begin_request() + try: + return await fn(*args, **kwargs) + finally: + end_request() + + return async_wrapper + + @functools.wraps(fn) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + if not is_enabled(): + return fn(*args, **kwargs) + begin_request() + try: + return fn(*args, **kwargs) + finally: + end_request() + + return wrapper + + +class _WarnUnusedScope: + """ + Scope Eagle's unused-relation tracking to a ``with`` block or a call. + + Returned by ``warn_unused()`` (no arguments) so the same name can be used as + a context manager (``with warn_unused(): ...``) or as a parenthesised + decorator (``@warn_unused()``). When ``EAGLE_ENABLED`` is falsy entering the + scope is a no-op, so nothing is tracked and nothing is warned. + """ + + def __init__(self) -> None: + # Whether ``__enter__`` actually began tracking, so ``__exit__`` only + # ends a scope it started (and stays a no-op when Eagle is disabled). + self._active = False + + # PYI034 suggests ``Self``, unavailable from ``typing`` on the py310 target. + def __enter__(self) -> "_WarnUnusedScope": # noqa: PYI034 + """ + Begin tracking for the block, unless Eagle is disabled. + + Returns: + This scope instance. + """ + self._active = is_enabled() + if self._active: + begin_request() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> bool: + """ + End tracking, emitting warnings for relations loaded but never accessed. + + Tracking ends even when the block raises, so a failing block never leaks + an active collector into later work on the same thread. + + Args: + exc_type: Exception type raised in the block, if any. + exc: Exception instance raised in the block, if any. + tb: Traceback of the exception raised in the block, if any. + + Returns: + False, so any exception raised inside the block propagates. + """ + if self._active: + self._active = False + end_request() + return False + + def __call__(self, fn: Callable[P, R]) -> Callable[P, R]: + """ + Use the scope as a decorator, scoping tracking to each call of *fn*. + + Args: + fn: The synchronous or asynchronous callable to scope tracking around. + + Returns: + A wrapped callable that emits unused-relation warnings after each call. + """ + return _wrap(fn) + + +@overload +def warn_unused(fn: Callable[P, R]) -> Callable[P, R]: ... + + +@overload +def warn_unused(fn: None = ...) -> _WarnUnusedScope: ... + + +def warn_unused(fn: Callable[P, R] | None = None) -> Callable[P, R] | _WarnUnusedScope: + """ + Scope Eagle's unused-relation tracking to a single call or ``with`` block. + + Begins tracking before the scoped code runs and ends it afterwards -- exactly + as ``EagleWarnUnusedMiddleware`` does for a request/response cycle -- so any + relation eager-loaded with ``select_related``/``prefetch_related`` inside the + scope but never accessed surfaces as an ``UnusedRelatedAccess`` warning when + the scope ends. Use it to get request-style detection in code that runs + outside the request cycle: management commands, Celery tasks, or any plain + function, method, or block. + + Supports three forms:: + + @warn_unused + def task(): ... + + @warn_unused() + def task(): ... + + with warn_unused(): + ... + + Tracking always ends even if the scoped code raises, so a failure never + leaks an active collector into later work on the same thread. When + ``EAGLE_ENABLED`` is falsy the scope is a transparent passthrough. The + decorator forms work for both synchronous and asynchronous callables and + preserve wrapper metadata (``__name__``, ``__doc__``). + + Args: + fn: The callable to decorate when used as a bare ``@warn_unused``. Omit + it (passing nothing) to get a context manager / parenthesised + decorator instead. + + Returns: + The wrapped callable when *fn* is given, otherwise a scope usable as a + context manager or decorator. + """ + if fn is None: + return _WarnUnusedScope() + return _wrap(fn) diff --git a/tests/base.py b/tests/base.py index 33b2f84..677075e 100644 --- a/tests/base.py +++ b/tests/base.py @@ -27,13 +27,8 @@ class EagleWithLocation: @pytest.mark.django_db -class BaseRequestTest: - """Base for tests that drive the eagle detail endpoint; an unused eager load surfaces as an error.""" - - @pytest.fixture - def api_client(self) -> APIClient: - """Return a DRF test client for calling the eagle endpoints.""" - return APIClient() +class EagleGraphMixin: + """Supplies the ``eagle_graph`` fixture to any test class that eager-loads eagle relations.""" @pytest.fixture def eagle_graph(self, db: None) -> EagleGraph: @@ -49,6 +44,15 @@ def eagle_graph(self, db: None) -> EagleGraph: ) +class BaseRequestTest(EagleGraphMixin): + """Base for tests that drive the eagle detail endpoint; an unused eager load surfaces as an error.""" + + @pytest.fixture + def api_client(self) -> APIClient: + """Return a DRF test client for calling the eagle endpoints.""" + return APIClient() + + @pytest.mark.django_db class MayAccessHelperTestBase: """Base for tests of the may_access/mark_considered helpers run inside a tracking request.""" diff --git a/tests/test_warn_unused.py b/tests/test_warn_unused.py new file mode 100644 index 0000000..8d04160 --- /dev/null +++ b/tests/test_warn_unused.py @@ -0,0 +1,210 @@ +import asyncio + +import pytest +from django.test import override_settings + +import eagle +from eagle import UnusedRelatedAccess, unused, warn_unused +from test_project.models import Eagle +from tests.base import EagleGraph, EagleGraphMixin + + +class TestWarnUnusedSync(EagleGraphMixin): + """The decorator scopes tracking to a single synchronous call, like the middleware does for a request.""" + + def test_unused_eager_load_warns(self, eagle_graph: EagleGraph): + pk = eagle_graph.eagle.pk + + @warn_unused + def load_unused() -> None: + # Eager-load location but never read it: the join is wasted. + Eagle.objects.select_related("location").get(pk=pk) + + with pytest.raises(UnusedRelatedAccess) as exc_info: + load_unused() + assert 'select_related("location")' in str(exc_info.value) + + def test_accessed_eager_load_no_warning(self, eagle_graph: EagleGraph): + pk = eagle_graph.eagle.pk + + @warn_unused + def load_and_access() -> object: + eagle = Eagle.objects.select_related("location").get(pk=pk) + return eagle.location + + # The relation is read before the call returns, so ending the scope emits nothing. + assert load_and_access() == eagle_graph.location + + def test_collector_inactive_after_call(self, eagle_graph: EagleGraph): + pk = eagle_graph.eagle.pk + + @warn_unused + def load_and_access() -> None: + eagle = Eagle.objects.select_related("location").get(pk=pk) + _ = eagle.location + + load_and_access() + assert unused.is_active() is False + + +class TestWarnUnusedDisabled(EagleGraphMixin): + """When EAGLE_ENABLED is falsy the decorator is a transparent passthrough.""" + + @override_settings(EAGLE_ENABLED=False) + def test_disabled_is_passthrough_no_warning(self, eagle_graph: EagleGraph): + pk = eagle_graph.eagle.pk + + @warn_unused + def load_unused() -> str: + Eagle.objects.select_related("location").get(pk=pk) + return "done" + + assert load_unused() == "done" + + @override_settings(EAGLE_ENABLED=False) + def test_disabled_does_not_activate_collector(self, eagle_graph: EagleGraph): + pk = eagle_graph.eagle.pk + + @warn_unused + def load_unused() -> None: + Eagle.objects.select_related("location").get(pk=pk) + + load_unused() + assert unused.is_active() is False + + +class TestWarnUnusedExceptions: + """Tracking always ends, so a failing call never leaks an active collector onto the thread.""" + + def test_exception_propagates_and_no_leak(self): + class Boom(Exception): + pass + + @warn_unused + def explode() -> None: + raise Boom + + with pytest.raises(Boom): + explode() + assert unused.is_active() is False + + +class TestWarnUnusedMetadata: + """The wrapper preserves the wrapped callable's identifying metadata.""" + + def test_preserves_wrapper_metadata(self): + @warn_unused + def documented(x: int) -> int: + """Return x unchanged.""" + return x + + assert documented.__name__ == "documented" + assert documented.__doc__ == "Return x unchanged." + + +class TestWarnUnusedPublicExport: + """warn_unused is part of the public top-level API.""" + + def test_public_export(self): + assert eagle.warn_unused is warn_unused + + +class TestWarnUnusedAsyncNoDb: + """The async branch wraps the awaited call without touching the database.""" + + def test_async_disabled_passthrough(self): + @warn_unused + async def doubler(x: int) -> int: + return x * 2 + + with override_settings(EAGLE_ENABLED=False): + assert asyncio.run(doubler(3)) == 6 + assert unused.is_active() is False + + def test_async_enabled_no_loads_returns_value(self): + @warn_unused + async def producer() -> int: + return 7 + + # Tracking begins and ends around the await; with no loads, nothing is warned. + assert asyncio.run(producer()) == 7 + assert unused.is_active() is False + + +class TestWarnUnusedContextManager(EagleGraphMixin): + """warn_unused() scopes tracking to a `with` block, like the decorator does for a call.""" + + def test_unused_eager_load_warns(self, eagle_graph: EagleGraph): + pk = eagle_graph.eagle.pk + + with pytest.raises(UnusedRelatedAccess) as exc_info, warn_unused(): + # Eager-load location but never read it: the join is wasted. + Eagle.objects.select_related("location").get(pk=pk) + assert 'select_related("location")' in str(exc_info.value) + + def test_accessed_eager_load_no_warning(self, eagle_graph: EagleGraph): + pk = eagle_graph.eagle.pk + + with warn_unused(): + eagle = Eagle.objects.select_related("location").get(pk=pk) + # The relation is read before the block ends, so nothing is emitted. + assert eagle.location == eagle_graph.location + + def test_collector_inactive_after_block(self, eagle_graph: EagleGraph): + pk = eagle_graph.eagle.pk + + with warn_unused(): + eagle = Eagle.objects.select_related("location").get(pk=pk) + _ = eagle.location + + assert unused.is_active() is False + + +class TestWarnUnusedContextManagerDisabled(EagleGraphMixin): + """When EAGLE_ENABLED is falsy entering the block is a no-op.""" + + @override_settings(EAGLE_ENABLED=False) + def test_disabled_is_passthrough_no_warning(self, eagle_graph: EagleGraph): + pk = eagle_graph.eagle.pk + + with warn_unused(): + Eagle.objects.select_related("location").get(pk=pk) + + assert unused.is_active() is False + + +class TestWarnUnusedContextManagerExceptions(EagleGraphMixin): + """Tracking always ends, so a failing block never leaks an active collector onto the thread.""" + + def test_exception_propagates_and_no_leak(self): + class Boom(Exception): + pass + + with pytest.raises(Boom), warn_unused(): + raise Boom + + assert unused.is_active() is False + + +class TestWarnUnusedParenthesisedDecorator(EagleGraphMixin): + """warn_unused() also works as a parenthesised decorator, like the bare form.""" + + def test_unused_eager_load_warns(self, eagle_graph: EagleGraph): + pk = eagle_graph.eagle.pk + + @warn_unused() + def load_unused() -> None: + Eagle.objects.select_related("location").get(pk=pk) + + with pytest.raises(UnusedRelatedAccess) as exc_info: + load_unused() + assert 'select_related("location")' in str(exc_info.value) + + def test_preserves_wrapper_metadata(self): + @warn_unused() + def documented(x: int) -> int: + """Return x unchanged.""" + return x + + assert documented.__name__ == "documented" + assert documented.__doc__ == "Return x unchanged."