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
15 changes: 10 additions & 5 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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. |
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,33 @@ on <Eagle instance>. 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:
Expand Down Expand Up @@ -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. |
Expand Down
2 changes: 2 additions & 0 deletions eagle/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
171 changes: 171 additions & 0 deletions eagle/decorators.py
Original file line number Diff line number Diff line change
@@ -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)
18 changes: 11 additions & 7 deletions tests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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."""
Expand Down
Loading
Loading