diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 491b043..23d7258 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ repos: rev: "1.0.0" hooks: - id: pyrefly-check - additional_dependencies: ["django>=5.2"] + additional_dependencies: ["django>=5.2", "django-debug-toolbar>=4.0"] - repo: https://github.com/adamchainz/django-upgrade rev: "1.30.0" hooks: diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index cb8f4ed..361c879 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -60,6 +60,12 @@ Eagle only instruments code you own, so warnings only point at relations you can models libraries shipped as packages). - **`EAGLE_EXCLUDE_APPS`** — overrides both; a listed app is never instrumented. +The Debug Toolbar can opt back into the excluded apps: with +`EAGLE_DEBUG_TOOLBAR_INCLUDE_EXCLUDED_APPS = True`, `AppConfig.ready` also instruments +`get_excluded_models()` and registers their labels as *warn-suppressed* (via +`register_warn_suppressed_labels`), so those apps are tracked and shown in the panel but +never emit warnings — letting a migrating project see excluded-app waste without failing tests. + Proxy models are skipped (they share the concrete model's descriptors). The resulting model classes are stored in a module-level set by `register_tracked_models`; the rest of eagle calls `is_instrumented(model)` to decide whether to record anything for a given @@ -184,6 +190,7 @@ class _CollectorState: active: bool # is a request being tracked? loaded: dict[(model_label, cache_name), LoadedRelation] # kind + call-site consumed: set[(model_label, cache_name)] # relations that were accessed + loaded_counts: dict[(model_label, cache_name), int] # how many instances carried each load ``` - **Context-local** (a `contextvars.ContextVar`) so concurrent @@ -201,13 +208,20 @@ class _CollectorState: bare class name (resolved via the app registry when unambiguous). - **First write wins** for `loaded`, so the originally captured call-site survives repeated loads. +- **Counts live separately** in `loaded_counts`: because `loaded` is first-write-wins it cannot + also tally repeated loads, so each `_record_loaded` call increments a per-relation counter + (per-row for `select_related`, per-parent for `prefetch_related`). This fan-out feeds the + Debug Toolbar panel's cost estimates. `begin_request()` resets and activates it; `end_request()` reconciles and deactivates it. ## Emitting warnings -`end_request()` (`unused/tracker.py`) reconciles the two sets and emits one warning per -surviving relation: +`end_request()` (`unused/tracker.py`) reconciles the two sets via `collect_all_unused()` +(`unused/report.py`), which returns one structured `UnusedRelation` record per loaded-but-unread +relation, each tagged with `warn_ignored` (whether `EAGLE_WARN_UNUSED_IGNORE` suppresses it); +`end_request` emits one warning per record that is *not* `warn_ignored` (`collect_unused()` is +the same set already narrowed to that warning view): ![emit_warnings.png](assets/architecture/emit_warnings.png) @@ -219,6 +233,22 @@ The warning is a real Python `warnings.warn` with category `UnusedRelatedAccess` subclass of `EagleWarning`), so you can route, filter, or escalate it with the standard `warnings` machinery and pytest's `filterwarnings`. +### Stashing the report for later readers + +Before it stops the collector, `end_request` stashes the **full** report (every +loaded-but-unread relation, including the `warn_ignored` ones) in a context-local +(`_last_report`, read back via `get_last_report()`). `collector.stop()` then installs a fresh +empty state, so by the time later code runs the live collector is gone -- but the stash +survives. This is what lets the optional [Debug Toolbar panel](README.md#django-debug-toolbar-panel) +read a request's unused loads in its `generate_stats`, *after* the whole middleware stack +(including eagle's own middleware) has finished, regardless of middleware ordering. When the +panel instead runs while the collector is still live, it calls `collect_all_unused()` directly. + +Stashing the full set (rather than just the warning view) is deliberate: the panel shows a +*different* slice than the warnings. Warnings are `loaded − consumed − EAGLE_WARN_UNUSED_IGNORE`; +the panel is `loaded − consumed − EAGLE_DEBUG_TOOLBAR_IGNORE`, so warning-suppressed loads stay +visible in the panel (flagged via `warn_ignored`) while a project migrates them off. + ## Module map ![module_map.png](assets/architecture/module_map.png) @@ -226,18 +256,20 @@ subclass of `EagleWarning`), so you can route, filter, or escalate it with the s | Module | Responsibility | | --- | --- | | `eagle/apps.py` | Startup entrypoint (`AppConfig.ready`); wires everything together. | -| `eagle/config.py` | `is_enabled()` — reads `EAGLE_ENABLED`. | +| `eagle/config.py` | `is_enabled()` (reads `EAGLE_ENABLED`) and `include_excluded_apps_in_toolbar()` (reads `EAGLE_DEBUG_TOOLBAR_INCLUDE_EXCLUDED_APPS`). | | `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. | +| `eagle/instrumentation/scope.py` | Decides which apps/models to instrument (`get_first_party_models`); `get_excluded_models` yields the `EAGLE_EXCLUDE_APPS` models for optional toolbar-only profiling. | | `eagle/instrumentation/registry.py` | Set of instrumented model classes. | | `eagle/instrumentation/descriptors.py` | In-place descriptor swaps + tracking mixins. | | `eagle/instrumentation/models.py` | Applies descriptor patches across a model's fields. | | `eagle/instrumentation/query.py` | Patches `QuerySet`/`ModelIterable`/`prefetch_one_level`. | | `eagle/unused/state.py` | Thread-local `Collector` holding `loaded` / `consumed`. | | `eagle/unused/marker.py` | Records loaded/consumed and per-instance state. | -| `eagle/unused/tracker.py` | `begin_request` / `end_request`; emits warnings. | +| `eagle/unused/tracker.py` | `begin_request` / `end_request`; emits warnings and stashes the report. | +| `eagle/unused/report.py` | `collect_all_unused` builds the full `UnusedRelation` report (each tagged `warn_ignored`); `collect_unused` is the warning view; `_last_report` / `get_last_report` stash the full report for post-response readers. | | `eagle/unused/location.py` | Captures the user's call-site for a queryset. | -| `eagle/unused/ignore.py` | Applies `EAGLE_WARN_UNUSED_IGNORE` rules. | +| `eagle/unused/ignore.py` | Matches ignore rules; drives both the warning list `EAGLE_WARN_UNUSED_IGNORE` and the panel's `EAGLE_DEBUG_TOOLBAR_IGNORE`. | +| `eagle/panels.py` | Optional Django Debug Toolbar panel (`EagleUnusedLoadsPanel`): lists unused loads (including warning-suppressed, flagged) with heuristic cost estimates; filtered by `EAGLE_DEBUG_TOOLBAR_IGNORE`. | diff --git a/README.md b/README.md index 4504352..42affd3 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,69 @@ with warn_unused(): `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 into later work in the same context. 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. +## Django Debug Toolbar panel + +If you use [Django Debug Toolbar](https://django-debug-toolbar.readthedocs.io/), eagle ships an optional **Unused Eager Loads** panel that surfaces the same signal as an interactive panel rather than (or alongside) warnings. + +### Install + +```bash +pip install "django-eagle[debug-toolbar]" +``` + +### Configure + +Add the panel to `DEBUG_TOOLBAR_PANELS`, alongside the toolbar's own panels: + +```python +DEBUG_TOOLBAR_PANELS = [ + # ...the toolbar's default panels... + "eagle.panels.EagleUnusedLoadsPanel", +] +``` + +### What it shows + +For each request the panel lists every relation that was eager-loaded but never accessed -- the same `(model, relation)` signal eagle warns about -- with: + +- the **count** of unused loads (shown in the toolbar side bar, e.g. `3 unused`); +- each relation's **model, field, kind, and call site** (`file:line`); +- a per-row **cost estimate** -- `select_related` shows `1 JOIN · ~× cells`, `prefetch_related` shows `1 query · parents`; +- a header summary of the total estimated JOINs, extra queries, and wasted cells you would avoid by pruning the loads. + +> **These figures are heuristics, not measurements.** "Cells" is rows × extra columns of materialised-but-unused data; eagle does not measure query wall-clock time or bytes of memory. Use them to rank which loads are worth pruning, not as a profiler. + +The panel works whether or not `EagleWarnUnusedMiddleware` is installed, and regardless of its order relative to the toolbar's middleware. When `EAGLE_ENABLED` is falsy it renders a short "eagle is disabled" message. + +### Showing loads you've silenced — `EAGLE_DEBUG_TOOLBAR_IGNORE` + +The panel deliberately shows unused loads **even when they're silenced by `EAGLE_WARN_UNUSED_IGNORE`**. Those rows are flagged with a `⚠ suppressed` status and counted in the header (e.g. `12 unused · 8 currently warning-suppressed`). This makes the panel a migration tracker: the warning ignore list keeps your test suite green while you migrate modules off wasteful eager loads, and the panel still shows you everything that's left to do. + +To hide noise from the panel only — without touching warnings — use the separate `EAGLE_DEBUG_TOOLBAR_IGNORE` list. It has the same rule shape as `EAGLE_WARN_UNUSED_IGNORE` and defaults to empty (the panel shows everything): + +```python +EAGLE_DEBUG_TOOLBAR_IGNORE = [ + {"model": "Eagle", "field": "location"}, # hide this one row from the panel + {"location": "*/vendor/*"}, # hide everything built under these modules +] +``` + +The two lists are independent: `EAGLE_WARN_UNUSED_IGNORE` controls *warnings* (and so test failures), while `EAGLE_DEBUG_TOOLBAR_IGNORE` controls only what the *panel* displays. + +### Profiling excluded apps — `EAGLE_DEBUG_TOOLBAR_INCLUDE_EXCLUDED_APPS` + +Apps listed in `EAGLE_EXCLUDE_APPS` are normally skipped entirely — never instrumented, so they neither warn nor appear in the panel. That's ideal while you migrate a large codebase app-by-app, but it also hides how much those excluded apps still waste. + +Set this flag to have the toolbar profile them anyway: + +```python +EAGLE_DEBUG_TOOLBAR_INCLUDE_EXCLUDED_APPS = True +``` + +With it on, excluded apps are instrumented and their unused eager loads show up in the panel, flagged `⚠ suppressed` — but they **never emit warnings**, so an excluded app still can't fail your test suite. This turns the panel into a full migration backlog: every wasteful load, even in the apps you haven't migrated yet, while warnings stay scoped to the apps you've already adopted. + +It defaults to `False`. Turning it on instruments more models (a one-off startup cost), but like everything else in eagle it only does anything when `EAGLE_ENABLED` is on — i.e. dev/CI — so there's no production cost. + ## 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: diff --git a/eagle/apps.py b/eagle/apps.py index a65f5f1..c187135 100644 --- a/eagle/apps.py +++ b/eagle/apps.py @@ -11,8 +11,9 @@ class EagleAppConfig(AppConfig): def ready(self) -> None: """Wire up Eagle's ORM instrumentation once all apps have finished loading.""" # Deferred imports avoid AppRegistryNotReady when this module is imported before apps finish populating. - from eagle.config import is_enabled + from eagle.config import include_excluded_apps_in_toolbar, is_enabled from eagle.instrumentation import ( + get_excluded_models, get_first_party_models, make_contenttypes_eager, make_model_eager, @@ -41,6 +42,19 @@ def ready(self) -> None: for model in models: make_model_eager(model) + if include_excluded_apps_in_toolbar(): + # Profile EAGLE_EXCLUDE_APPS apps in the Debug Toolbar without ever warning about + # them: instrument and track them, but register their labels as warn-suppressed so + # ``end_request`` skips their warnings (excluded apps must never fail tests). + from eagle.unused.report import register_warn_suppressed_labels + + excluded_models = list(get_excluded_models()) + register_tracked_models(excluded_models) + register_warn_suppressed_labels(model._meta.label for model in excluded_models) + for model in excluded_models: + make_model_eager(model) + logger.debug("Profiling %d excluded-app model(s) in the Debug Toolbar.", len(excluded_models)) + logger.debug("Patching ORM.") patch_orm() logger.debug("ORM patched.") diff --git a/eagle/config.py b/eagle/config.py index a94f06d..234cd26 100644 --- a/eagle/config.py +++ b/eagle/config.py @@ -9,3 +9,16 @@ def is_enabled() -> bool: True when Eagle's instrumentation and warning emission should be active. """ return bool(getattr(settings, "EAGLE_ENABLED", False)) + + +def include_excluded_apps_in_toolbar() -> bool: + """ + Return True if the Debug Toolbar should also profile EAGLE_EXCLUDE_APPS apps. + + When truthy, Eagle instruments the excluded apps too so their unused eager loads show up in + the panel -- but their warnings stay suppressed, so they never fail tests. Defaults to False. + + Returns: + True when ``EAGLE_DEBUG_TOOLBAR_INCLUDE_EXCLUDED_APPS`` is set to a truthy value. + """ + return bool(getattr(settings, "EAGLE_DEBUG_TOOLBAR_INCLUDE_EXCLUDED_APPS", False)) diff --git a/eagle/instrumentation/__init__.py b/eagle/instrumentation/__init__.py index 8145483..52f046d 100644 --- a/eagle/instrumentation/__init__.py +++ b/eagle/instrumentation/__init__.py @@ -1,4 +1,5 @@ __all__ = [ + "get_excluded_models", "get_first_party_models", "is_instrumented", "make_contenttypes_eager", @@ -11,4 +12,4 @@ from eagle.instrumentation.models import make_model_eager from eagle.instrumentation.query import patch_orm from eagle.instrumentation.registry import is_instrumented, register_tracked_models -from eagle.instrumentation.scope import get_first_party_models +from eagle.instrumentation.scope import get_excluded_models, get_first_party_models diff --git a/eagle/instrumentation/scope.py b/eagle/instrumentation/scope.py index b8c1439..12afa63 100644 --- a/eagle/instrumentation/scope.py +++ b/eagle/instrumentation/scope.py @@ -67,14 +67,16 @@ def _matched_include_package(app_config: AppConfig, packages: tuple[str, ...]) - return None -def get_first_party_models() -> Iterator[type[Model]]: +def _iter_candidate_models() -> Iterator[tuple[type[Model], bool]]: """ - Yield all non-proxy models from first-party and explicitly included third-party apps. + Yield ``(model, is_excluded)`` for every non-proxy model Eagle could instrument. - Respects EAGLE_EXCLUDE_APPS to omit specific apps from instrumentation. + A model is a candidate when its app is first-party or matches + ``EAGLE_THIRD_PARTY_INCLUDE_APPS``; ``is_excluded`` reflects whether + ``EAGLE_EXCLUDE_APPS`` would normally drop it from instrumentation. Yields: - Django model classes eligible for Eagle tracking. + ``(model, is_excluded)`` pairs for each candidate model. """ roots = _dependency_roots() excluded = set(getattr(settings, "EAGLE_EXCLUDE_APPS", ())) @@ -85,9 +87,38 @@ def get_first_party_models() -> Iterator[type[Model]]: if not first_party and include_package is None: continue exclusion_key = app_config.label if first_party else f"{include_package}.{app_config.label}" - if exclusion_key in excluded: - continue + is_excluded = exclusion_key in excluded for model in app_config.get_models(): if model._meta.proxy: continue + yield model, is_excluded + + +def get_first_party_models() -> Iterator[type[Model]]: + """ + Yield all non-proxy models from first-party and explicitly included third-party apps. + + Respects EAGLE_EXCLUDE_APPS to omit specific apps from instrumentation. + + Yields: + Django model classes eligible for Eagle tracking. + """ + for model, is_excluded in _iter_candidate_models(): + if not is_excluded: + yield model + + +def get_excluded_models() -> Iterator[type[Model]]: + """ + Yield the non-proxy candidate models that EAGLE_EXCLUDE_APPS omits from instrumentation. + + These are normally skipped entirely. The Debug Toolbar can opt to instrument them anyway -- + without ever emitting warnings -- via ``EAGLE_DEBUG_TOOLBAR_INCLUDE_EXCLUDED_APPS``, so a + migrating project can still see what eager loads its excluded apps waste. + + Yields: + Django model classes from candidate apps that ``EAGLE_EXCLUDE_APPS`` excludes. + """ + for model, is_excluded in _iter_candidate_models(): + if is_excluded: yield model diff --git a/eagle/panels.py b/eagle/panels.py new file mode 100644 index 0000000..a26c874 --- /dev/null +++ b/eagle/panels.py @@ -0,0 +1,160 @@ +from debug_toolbar.panels import Panel +from django.conf import settings +from django.http import HttpRequest, HttpResponse +from django.utils.translation import gettext_lazy as _ +from django.utils.translation import ngettext + +from eagle import unused +from eagle.config import is_enabled +from eagle.unused import UnusedRelation +from eagle.unused.ignore import should_ignore + + +def _row_estimate(relation: UnusedRelation) -> str: + """ + Build the per-row cost estimate string shown in the panel for *relation*. + + Args: + relation: The unused relation to summarise. + + Returns: + For select_related, ``"1 JOIN"`` plus a ``"~× cells"`` materialisation + estimate when both are known; for prefetch_related, ``"1 query"`` plus the parent fan-out. + """ + if relation.kind == "select_related": + estimate = "1 JOIN" + if relation.columns is not None and relation.instances: + estimate = f"{estimate} · ~{relation.instances}×{relation.columns} cells" + return estimate + + estimate = "1 query" + if relation.instances: + estimate = f"{estimate} · {relation.instances} parents" + return estimate + + +def _toolbar_ignored(relation: UnusedRelation) -> bool: + """ + Return True if ``EAGLE_DEBUG_TOOLBAR_IGNORE`` hides *relation* from the panel. + + This is the panel's own ignore list, independent of ``EAGLE_WARN_UNUSED_IGNORE``; it defaults + to empty, so by default the panel shows every unused load (including warning-suppressed ones). + + Args: + relation: The unused relation being considered for display. + + Returns: + True when a configured toolbar ignore rule matches the relation. + """ + rules = getattr(settings, "EAGLE_DEBUG_TOOLBAR_IGNORE", []) + return should_ignore(relation.model_name, relation.cache_name, relation.location, rules) + + +def build_panel_stats(report: list[UnusedRelation], *, enabled: bool) -> dict: + """ + Turn an unused-relation report into Debug Toolbar template context with cost estimates. + + Args: + report: The relations to display (already filtered by the panel's own ignore list). + enabled: Whether Eagle is enabled; drives the panel's disabled message. + + Returns: + A context dict with the total ``count``, the number of shown rows currently + warning-suppressed (``suppressed``), a ``by_kind`` split, headline ``estimated`` tallies + (extra JOINs, extra queries, wasted cells), and a ``relations`` list of per-row dicts + (model, field, kind, location, instances, columns, estimate, warn_ignored). + """ + select_related = [relation for relation in report if relation.kind == "select_related"] + prefetch_related = [relation for relation in report if relation.kind == "prefetch_related"] + wasted_cells = sum(relation.instances * (relation.columns or 0) for relation in select_related) + + rows = [ + { + "model": relation.model_name, + "field": relation.cache_name, + "kind": relation.kind, + "location": relation.location, + "instances": relation.instances, + "columns": relation.columns, + "estimate": _row_estimate(relation), + "warn_ignored": relation.warn_ignored, + } + for relation in report + ] + + return { + "enabled": enabled, + "count": len(report), + "suppressed": sum(1 for relation in report if relation.warn_ignored), + "by_kind": {"select_related": len(select_related), "prefetch_related": len(prefetch_related)}, + "estimated": { + "extra_joins": len(select_related), + "extra_queries": len(prefetch_related), + "wasted_cells": wasted_cells, + }, + "relations": rows, + } + + +class EagleUnusedLoadsPanel(Panel): + """Debug Toolbar panel listing eager-loaded relations that were never accessed this request.""" + + title = _("Unused Eager Loads") + template = "eagle/panels/unused_loads.html" + + @property + def nav_subtitle(self) -> str: # pyrefly: ignore[bad-override] + """ + Return the side-bar subtitle: the unused-load count. + + Returns: + A localised ``" unused"`` string built from the recorded stats. + """ + count = self.get_stats().get("count", 0) + return ngettext("%d unused", "%d unused", count) % count + + def process_request(self, request: HttpRequest) -> HttpResponse: + """ + Open a tracking scope before the view runs, only if Eagle's middleware has not already. + + Args: + request: The incoming Django HTTP request. + + Returns: + The response produced by the rest of the middleware/view chain. + """ + # The panel owns the scope only in standalone use (no Eagle middleware). When the + # middleware is present it has already begun the request, so we leave it alone. + self._owns_scope = is_enabled() and not unused.is_active() + if self._owns_scope: + unused.begin_request() + return super().process_request(request) + + def generate_stats(self, request: HttpRequest, response: HttpResponse) -> None: + """ + Record the unused-loads stats after the response, reading the report order-independently. + + Reads the full report (including warning-suppressed relations) and applies only the + panel's own ``EAGLE_DEBUG_TOOLBAR_IGNORE`` filter, so suppressed loads stay visible. + + Args: + request: The Django HTTP request being processed. + response: The outgoing HTTP response. + """ + if not is_enabled(): + self.record_stats(build_panel_stats([], enabled=False)) + return + + if getattr(self, "_owns_scope", False) and unused.is_active(): + # Standalone: end the scope we opened (emits warnings + stashes), then read the stash. + unused.end_request() + report = unused.get_last_report() + elif unused.is_active(): + # Eagle's middleware runs outside us, so the collector is still live: read it directly. + report = unused.collect_all_unused() + else: + # Eagle's middleware ran inside us and already ended: read its stashed report. + report = unused.get_last_report() + + visible = [relation for relation in report if not _toolbar_ignored(relation)] + self.record_stats(build_panel_stats(visible, enabled=True)) diff --git a/eagle/templates/eagle/panels/unused_loads.html b/eagle/templates/eagle/panels/unused_loads.html new file mode 100644 index 0000000..ccc14f3 --- /dev/null +++ b/eagle/templates/eagle/panels/unused_loads.html @@ -0,0 +1,44 @@ +{% load i18n %} +{% if not enabled %} +

{% blocktranslate %}eagle is disabled (EAGLE_ENABLED is falsy), so no eager loads were tracked for this request.{% endblocktranslate %}

+{% elif count == 0 %} +

{% translate "No unused eager loads 🎉" %}

+{% else %} +

+ {% blocktranslate count counter=count %}{{ counter }} unused eager load{% plural %}{{ counter }} unused eager loads{% endblocktranslate %} + — {{ by_kind.select_related }} select_related, {{ by_kind.prefetch_related }} prefetch_related +

+ {% if suppressed %} +

{% blocktranslate count counter=suppressed %}{{ counter }} of these is currently warning-suppressed by EAGLE_WARN_UNUSED_IGNORE — still wasteful, not yet migrated.{% plural %}{{ counter }} of these are currently warning-suppressed by EAGLE_WARN_UNUSED_IGNORE — still wasteful, not yet migrated.{% endblocktranslate %}

+ {% endif %} +

+ {% blocktranslate with joins=estimated.extra_joins queries=estimated.extra_queries cells=estimated.wasted_cells %}Estimated waste avoidable by pruning these loads: {{ joins }} JOIN(s), {{ queries }} extra query(ies), ~{{ cells }} wasted cells.{% endblocktranslate %} + {% translate "These are heuristic estimates, not measured time or memory." %} +

+ + + + + + + + + + + + + + {% for row in relations %} + + + + + + + + + + {% endfor %} + +
{% translate "Model" %}{% translate "Relation" %}{% translate "Kind" %}{% translate "Instances" %}{% translate "Estimate" %}{% translate "Status" %}{% translate "Location" %}
{{ row.model }}{{ row.field }}{{ row.kind }}{{ row.instances }}{{ row.estimate }}{% if row.warn_ignored %}⚠ {% translate "suppressed" %}{% else %}{% translate "active" %}{% endif %}{{ row.location|default:"—" }}
+{% endif %} diff --git a/eagle/unused/__init__.py b/eagle/unused/__init__.py index cc55c73..e0ef63f 100644 --- a/eagle/unused/__init__.py +++ b/eagle/unused/__init__.py @@ -1,7 +1,11 @@ __all__ = [ + "UnusedRelation", "begin_request", "capture_location", + "collect_all_unused", + "collect_unused", "end_request", + "get_last_report", "init_state", "is_active", "mark_considered_internal", @@ -22,4 +26,5 @@ mark_select_related, resolve_location, ) +from eagle.unused.report import UnusedRelation, collect_all_unused, collect_unused, get_last_report from eagle.unused.tracker import begin_request, end_request, is_active diff --git a/eagle/unused/ignore.py b/eagle/unused/ignore.py index cdd73a8..585477d 100644 --- a/eagle/unused/ignore.py +++ b/eagle/unused/ignore.py @@ -29,18 +29,27 @@ def _rule_matches(rule: Mapping[str, str], model_name: str, field_name: str, loc return True -def should_ignore(model_name: str, field_name: str, location: str | None) -> bool: +def should_ignore( + model_name: str, + field_name: str, + location: str | None, + rules: list[Mapping[str, str]] | None = None, +) -> bool: """ - Return True if any EAGLE_WARN_UNUSED_IGNORE rule suppresses this warning. + Return True if any ignore rule suppresses this relation. Args: model_name: Django model class name of the instance that loaded the relation. field_name: Relation cache key that was loaded but not consumed. location: Call-site string (``"file:line"``) recorded when the queryset was built, or None. + rules: The ignore rules to match against. Defaults to ``EAGLE_WARN_UNUSED_IGNORE`` when + None, so warning callers are unaffected; the Debug Toolbar panel passes its own + ``EAGLE_DEBUG_TOOLBAR_IGNORE`` list to filter the panel independently of warnings. Returns: - True if at least one configured rule matches all provided values. + True if at least one rule matches all provided values. """ - rules: list[Mapping[str, str]] = getattr(settings, "EAGLE_WARN_UNUSED_IGNORE", []) + if rules is None: + rules = getattr(settings, "EAGLE_WARN_UNUSED_IGNORE", []) return any(_rule_matches(rule, model_name, field_name, location) for rule in rules) diff --git a/eagle/unused/marker.py b/eagle/unused/marker.py index 4130a66..f21e965 100644 --- a/eagle/unused/marker.py +++ b/eagle/unused/marker.py @@ -19,13 +19,11 @@ def _record_loaded(model_label: str, cache_name: str, kind: str, location: str | if not collector.active: return - collector.loaded.setdefault( - (model_label, cache_name), - LoadedRelation( - kind=kind, - location=location, - ), - ) + key = (model_label, cache_name) + collector.loaded.setdefault(key, LoadedRelation(kind=kind, location=location)) + # Count every load, not just the first: ``loaded`` is first-write-wins, so the fan-out + # (how many instances carried this relation) is tracked here instead of inferred from it. + collector.loaded_counts[key] = collector.loaded_counts.get(key, 0) + 1 def _record_consumed(model_label: str, cache_name: str) -> None: diff --git a/eagle/unused/report.py b/eagle/unused/report.py new file mode 100644 index 0000000..a82696a --- /dev/null +++ b/eagle/unused/report.py @@ -0,0 +1,169 @@ +import contextvars +from collections.abc import Iterable +from dataclasses import dataclass + +from django.apps import apps + +from eagle.unused.ignore import should_ignore +from eagle.unused.state import collector + + +@dataclass(frozen=True) +class UnusedRelation: + """A single eager-loaded relation that was never accessed during the request.""" + + # ``app_label.ModelName`` -- the first segment of the collector key. + model_label: str + # Bare ``ModelName``, as used in warning messages and ignore rules. + model_name: str + # ORM cache key for the relation -- the second segment of the collector key. + cache_name: str + # ``"select_related"`` or ``"prefetch_related"``. + kind: str + # ``"file:line"`` where the queryset was built, or None when it could not be captured. + location: str | None + # How many instances carried this load (the fan-out). + instances: int + # Extra columns per row for select_related; None for prefetch_related or when unresolved. + columns: int | None + # Whether ``EAGLE_WARN_UNUSED_IGNORE`` suppresses the warning for this relation. The warning + # path skips these; the Debug Toolbar panel still shows them (flagged) so they stay visible. + warn_ignored: bool = False + + +# Holds the most recent request's full unused report (every loaded-but-unread relation, including +# warning-suppressed ones). Written by ``end_request`` before ``collector.stop()`` so a Debug +# Toolbar panel reading after the middleware has finished still sees the result, regardless of +# middleware ordering relative to the toolbar. +_last_report: contextvars.ContextVar[list[UnusedRelation]] = contextvars.ContextVar("eagle_last_report") + + +# Model labels (``app_label.ModelName``) instrumented only so the Debug Toolbar can profile them; +# their unused loads must never warn. Populated at startup by the app config when +# ``EAGLE_DEBUG_TOOLBAR_INCLUDE_EXCLUDED_APPS`` is on, and empty otherwise. +_warn_suppressed_labels: set[str] = set() + + +def register_warn_suppressed_labels(labels: Iterable[str]) -> None: + """ + Record model labels whose unused loads must never warn (only surface in the panel). + + Args: + labels: ``app_label.ModelName`` strings for the excluded-app models being profiled. + """ + _warn_suppressed_labels.update(labels) + + +def clear_warn_suppressed_labels() -> None: + """Forget all warn-suppressed labels; used in tests to reset between runs.""" + _warn_suppressed_labels.clear() + + +def _related_column_count(model_label: str, cache_name: str) -> int | None: + """ + Best-effort count of concrete columns on the model a forward relation points to. + + Resolves *cache_name* back to a field on the model named by *model_label* and returns + ``len(related_model._meta.concrete_fields)`` -- the extra per-row columns a select_related + join materialises. + + Args: + model_label: Django model label (``app_label.ModelName``) owning the relation. + cache_name: ORM cache key identifying the forward relation field. + + Returns: + The related model's concrete-field count, or None when *cache_name* maps to no relation + field carrying a related model (generic FKs, renamed caches, lookups that no longer + resolve) so the panel omits the estimate rather than guessing. + """ + try: + model = apps.get_model(model_label) + for field in model._meta.get_fields(): + if getattr(field, "cache_name", None) == cache_name and getattr(field, "related_model", None): + return len(field.related_model._meta.concrete_fields) + except (LookupError, AttributeError): + return None + return None + + +def collect_all_unused() -> list[UnusedRelation]: + """ + Return every relation loaded but never consumed in the current request, with no filtering. + + Each record is tagged with ``warn_ignored`` (whether ``EAGLE_WARN_UNUSED_IGNORE`` suppresses + its warning), so the warning path and the Debug Toolbar panel can filter the same data + differently. Reads the live collector without mutating it, so it is safe to call before + ``end_request``. When no request is being tracked the collector is empty, so the result is + an empty list. + + Returns: + One ``UnusedRelation`` per loaded-but-unread relation, ordered deterministically by + ``(model_label, cache_name)`` to match warning emission order. + """ + report: list[UnusedRelation] = [] + + for key, relation in sorted(collector.loaded.items()): + if key in collector.consumed: + continue + + model_label, cache_name = key + # Keys carry the full label (app_label.ModelName); ignore rules and warning messages + # speak in the bare class name, which is the segment after the final dot. + model_name = model_label.rsplit(".", 1)[-1] + + # Columns are only meaningful for the per-row join of a select_related. + columns = _related_column_count(model_label, cache_name) if relation.kind == "select_related" else None + + report.append( + UnusedRelation( + model_label=model_label, + model_name=model_name, + cache_name=cache_name, + kind=relation.kind, + location=relation.location, + instances=collector.loaded_counts.get(key, 0), + columns=columns, + warn_ignored=( + model_label in _warn_suppressed_labels or should_ignore(model_name, cache_name, relation.location) + ), + ) + ) + + return report + + +def collect_unused() -> list[UnusedRelation]: + """ + Return the relations that should warn: loaded but never consumed, ignore rules applied. + + This is the warning view of :func:`collect_all_unused` -- the same set minus the relations + suppressed by ``EAGLE_WARN_UNUSED_IGNORE``. + + Returns: + One ``UnusedRelation`` per surviving relation, ordered deterministically by + ``(model_label, cache_name)`` to match warning emission order. + """ + return [relation for relation in collect_all_unused() if not relation.warn_ignored] + + +def set_last_report(report: list[UnusedRelation]) -> None: + """ + Stash *report* as the most recent request's result so a panel can read it post-response. + + Args: + report: The full list of unused relations produced by the request that just ended. + """ + _last_report.set(report) + + +def get_last_report() -> list[UnusedRelation]: + """ + Return the most recently stashed unused report for this context, or an empty list. + + The stash holds the full report (every loaded-but-unread relation, including the + warning-suppressed ones), so a panel can apply its own filtering independently of warnings. + + Returns: + The list set by the last ``end_request`` in this context, or ``[]`` if none. + """ + return _last_report.get([]) diff --git a/eagle/unused/state.py b/eagle/unused/state.py index 25729f5..8d08311 100644 --- a/eagle/unused/state.py +++ b/eagle/unused/state.py @@ -23,6 +23,10 @@ class _CollectorState: active: bool = False loaded: dict[RelationKey, LoadedRelation] = field(default_factory=dict) consumed: set[RelationKey] = field(default_factory=set) + # How many instances carried each loaded relation (the fan-out): per-row for + # select_related, per-parent for prefetch_related. Tracked separately from ``loaded`` + # because that map is first-write-wins, so it cannot also count repeated loads. + loaded_counts: dict[RelationKey, int] = field(default_factory=dict) # Holds the active request's state. @@ -81,6 +85,16 @@ def consumed(self) -> set[RelationKey]: """ return self._current().consumed + @property + def loaded_counts(self) -> dict[RelationKey, int]: + """ + Per-relation instance counts (fan-out) for this request, keyed by ``(model_label, cache_name)``. + + Returns: + The mutable map of load counts for the current context. + """ + return self._current().loaded_counts + def start(self) -> None: """ Install a fresh, active state for a new request in the current context. diff --git a/eagle/unused/tracker.py b/eagle/unused/tracker.py index 31c3e65..6bb5563 100644 --- a/eagle/unused/tracker.py +++ b/eagle/unused/tracker.py @@ -2,8 +2,8 @@ from eagle.exceptions import UnusedRelatedAccess from eagle.logger import logger -from eagle.unused.ignore import should_ignore -from eagle.unused.state import LoadedRelation, collector +from eagle.unused.report import UnusedRelation, collect_all_unused, set_last_report +from eagle.unused.state import collector def is_active() -> bool: @@ -23,52 +23,41 @@ def begin_request() -> None: def end_request() -> None: - """Emit warnings for all loaded-but-not-consumed relations, then deactivate the collector.""" + """Emit warnings for all loaded-but-not-consumed relations, stash the report, then deactivate the collector.""" logger.debug("End request.") if not collector.active: return - for key, relation in sorted(collector.loaded.items()): - if key in collector.consumed: + report = collect_all_unused() + for relation in report: + # Warning-suppressed relations stay in the stashed report (so the panel can show them) + # but never warn -- this is what keeps ``EAGLE_WARN_UNUSED_IGNORE`` behaviour intact. + if relation.warn_ignored: continue + logger.debug("Found unused %s, %s, %s", relation.model_label, relation.cache_name, relation.location) + _emit_unused_warning(relation) - model_label, cache_name = key - # Keys carry the full label (app_label.ModelName); ignore rules and warning messages - # speak in the bare class name, which is the segment after the final dot. - model_name = model_label.rsplit(".", 1)[-1] - - if should_ignore( - model_name, - cache_name, - relation.location, - ): - logger.debug("Ignoring %s, %s, %s", model_label, cache_name, relation.location) - continue - - logger.debug("Found unused %s, %s, %s", model_label, cache_name, relation.location) - _emit_unused_warning( - model_name=model_name, - cache_name=cache_name, - relation=relation, - ) - + # Stash the full report before stopping: ``collector.stop()`` installs a fresh empty state, + # so a panel reading after the middleware has finished relies on this snapshot rather than + # the live collector (which is empty by then). + set_last_report(report) collector.stop() -def _emit_unused_warning(*, model_name: str, cache_name: str, relation: LoadedRelation) -> None: +def _emit_unused_warning(relation: UnusedRelation) -> None: """ Emit a single UnusedRelatedAccess warning with a descriptive message. Args: - model_name: Django model class name where the relation was loaded. - cache_name: ORM cache key for the relation. - relation: Snapshot of the loaded relation including kind and call-site location. + relation: The loaded-but-not-consumed relation to warn about, carrying its kind, + cache name, model name, and call-site location. """ location_suffix = f" Queryset marked at {relation.location}." if relation.location else "" warnings.warn( - f'{relation.kind}("{cache_name}") was loaded but never accessed on <{model_name} instance>.{location_suffix}', + f'{relation.kind}("{relation.cache_name}") was loaded but never accessed on ' + f"<{relation.model_name} instance>.{location_suffix}", category=UnusedRelatedAccess, stacklevel=2, ) diff --git a/pyproject.toml b/pyproject.toml index 2f1769b..4a054a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,11 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] include = ["eagle*"] +# Ship non-Python files (the Debug Toolbar panel template) inside the wheel/sdist; +# packages.find only collects Python modules, so data files must be listed explicitly. +[tool.setuptools.package-data] +eagle = ["templates/**/*.html"] + [project] name = "django-eagle" version = "0.1.0" @@ -17,8 +22,13 @@ dependencies = [ "django>=5.2", ] +[project.optional-dependencies] +# The Debug Toolbar panel is opt-in; DDT never becomes a core dependency. +debug-toolbar = ["django-debug-toolbar>=4.0"] + [dependency-groups] dev = [ + "django-debug-toolbar>=4.0", "djangorestframework>=3.16.0", "factory-boy>=3.3.0", "pyrefly>=1.0.0", diff --git a/tests/test_enabled.py b/tests/test_enabled.py index 80eb272..fbea491 100644 --- a/tests/test_enabled.py +++ b/tests/test_enabled.py @@ -4,7 +4,7 @@ from rest_framework.test import APIClient from eagle import UnusedRelatedAccess -from eagle.config import is_enabled +from eagle.config import include_excluded_apps_in_toolbar, is_enabled from tests.base import BaseRequestTest, EagleGraph @@ -22,6 +22,15 @@ def test_enabled_when_setting_true(self): assert is_enabled() is True +class TestIncludeExcludedAppsInToolbar: + def test_false_by_default(self): + assert include_excluded_apps_in_toolbar() is False + + @override_settings(EAGLE_DEBUG_TOOLBAR_INCLUDE_EXCLUDED_APPS=True) + def test_true_when_set(self): + assert include_excluded_apps_in_toolbar() is True + + class TestDisabledRequest(BaseRequestTest): @override_settings(EAGLE_ENABLED=False) def test_unused_eager_load_does_not_warn_when_disabled(self, api_client: APIClient, eagle_graph: EagleGraph): diff --git a/tests/test_panel.py b/tests/test_panel.py new file mode 100644 index 0000000..86e91fc --- /dev/null +++ b/tests/test_panel.py @@ -0,0 +1,232 @@ +import pytest +from django.http import HttpResponse +from django.test import RequestFactory, override_settings + +from eagle import UnusedRelatedAccess, unused +from eagle.panels import EagleUnusedLoadsPanel, _row_estimate, build_panel_stats +from eagle.unused import UnusedRelation +from test_project.models import Eagle, Location +from tests.factories import EagleFactory, LocationFactory + + +def _select_related(instances=10, columns=8, warn_ignored=False): + return UnusedRelation( + model_label="test_project.Eagle", + model_name="Eagle", + cache_name="location", + kind="select_related", + location="app/views.py:5", + instances=instances, + columns=columns, + warn_ignored=warn_ignored, + ) + + +def _prefetch_related(instances=4, warn_ignored=False): + return UnusedRelation( + model_label="test_project.Eagle", + model_name="Eagle", + cache_name="previous_locations", + kind="prefetch_related", + location="app/views.py:9", + instances=instances, + columns=None, + warn_ignored=warn_ignored, + ) + + +class TestRowEstimate: + """The per-row estimate string reflects the kind and the known magnitude.""" + + def test_select_related_with_magnitude(self): + assert _row_estimate(_select_related(instances=120, columns=8)) == "1 JOIN · ~120×8 cells" + + def test_select_related_without_columns(self): + assert _row_estimate(_select_related(instances=120, columns=None)) == "1 JOIN" + + def test_select_related_zero_instances(self): + assert _row_estimate(_select_related(instances=0, columns=8)) == "1 JOIN" + + def test_prefetch_related_with_parents(self): + assert _row_estimate(_prefetch_related(instances=120)) == "1 query · 120 parents" + + def test_prefetch_related_without_parents(self): + assert _row_estimate(_prefetch_related(instances=0)) == "1 query" + + +class TestBuildPanelStats: + """build_panel_stats aggregates a report into template context with headline estimates.""" + + def test_mixed_report(self): + stats = build_panel_stats( + [_select_related(instances=10, columns=8), _prefetch_related(instances=4)], enabled=True + ) + + assert stats["enabled"] is True + assert stats["count"] == 2 + assert stats["by_kind"] == {"select_related": 1, "prefetch_related": 1} + assert stats["estimated"] == {"extra_joins": 1, "extra_queries": 1, "wasted_cells": 80} + assert [row["estimate"] for row in stats["relations"]] == ["1 JOIN · ~10×8 cells", "1 query · 4 parents"] + assert stats["suppressed"] == 0 + assert [row["warn_ignored"] for row in stats["relations"]] == [False, False] + + def test_warning_suppressed_rows_are_counted_and_flagged(self): + stats = build_panel_stats([_select_related(warn_ignored=True), _prefetch_related()], enabled=True) + + assert stats["count"] == 2 + assert stats["suppressed"] == 1 + assert [row["warn_ignored"] for row in stats["relations"]] == [True, False] + + def test_disabled_shape(self): + stats = build_panel_stats([], enabled=False) + + assert stats["enabled"] is False + assert stats["count"] == 0 + assert stats["by_kind"] == {"select_related": 0, "prefetch_related": 0} + assert stats["estimated"] == {"extra_joins": 0, "extra_queries": 0, "wasted_cells": 0} + assert stats["relations"] == [] + + def test_empty_enabled_shape(self): + stats = build_panel_stats([], enabled=True) + + assert stats["enabled"] is True + assert stats["count"] == 0 + assert stats["relations"] == [] + + +class _StubStore: + """Minimal Debug Toolbar store: record_stats persists through here, but the panel reads toolbar.stats.""" + + def save_panel(self, request_id, panel_id, stats): + pass + + +class _StubToolbar: + """Just enough of the Debug Toolbar surface for a panel to record and read its own stats.""" + + def __init__(self, request): + self.stats = {} + self.store = _StubStore() + self.request_id = "test-request" + self.request = request + + +@pytest.mark.django_db +class TestPanelLifecycle: + """Driving process_request -> generate_stats records the request's unused loads on the panel.""" + + def _make_panel(self, get_response): + request = RequestFactory().get("/") + panel = EagleUnusedLoadsPanel(_StubToolbar(request), get_response) + return panel, request + + def test_standalone_unused_load_is_recorded(self): + EagleFactory(location=LocationFactory()) + + def get_response(request): + # No Eagle middleware in play, so the panel owns the scope; load but never read. + list(Eagle.objects.select_related("location")) + return HttpResponse() + + panel, request = self._make_panel(get_response) + panel.process_request(request) + with pytest.warns(UnusedRelatedAccess): + panel.generate_stats(request, HttpResponse()) + + stats = panel.get_stats() + assert stats["enabled"] is True + assert stats["count"] == 1 + assert stats["relations"][0]["field"] == "location" + assert stats["relations"][0]["columns"] == len(Location._meta.concrete_fields) + assert panel.nav_subtitle == "1 unused" + assert not unused.is_active() + + def test_middleware_outside_panel_reads_live_collector(self): + # Eagle's middleware wraps the toolbar: the scope is already open when the panel runs, + # and is still live at generate_stats, so the panel reads the collector directly. + EagleFactory(location=LocationFactory()) + unused.begin_request() + + def get_response(request): + list(Eagle.objects.select_related("location")) + return HttpResponse() + + panel, request = self._make_panel(get_response) + panel.process_request(request) + panel.generate_stats(request, HttpResponse()) + + assert panel.get_stats()["count"] == 1 + # The outer middleware -- not the panel -- ends the scope and emits the warning. + with pytest.warns(UnusedRelatedAccess): + unused.end_request() + assert not unused.is_active() + + def test_middleware_inside_panel_reads_stashed_report(self): + # Eagle's middleware runs inside the toolbar: by generate_stats the scope has already + # ended and stashed its report, so the panel reads the stash rather than a live collector. + EagleFactory(location=LocationFactory()) + unused.begin_request() + + panel, request = self._make_panel(lambda request: HttpResponse()) + panel.process_request(request) + + with pytest.warns(UnusedRelatedAccess): # noqa: PT031 + list(Eagle.objects.select_related("location")) + unused.end_request() + + panel.generate_stats(request, HttpResponse()) + + assert panel.get_stats()["count"] == 1 + assert not unused.is_active() + + @override_settings(EAGLE_WARN_UNUSED_IGNORE=[{"model": "Eagle", "field": "location"}]) + def test_warning_suppressed_load_still_shown_without_warning(self): + EagleFactory(location=LocationFactory()) + + def get_response(request): + list(Eagle.objects.select_related("location")) + return HttpResponse() + + panel, request = self._make_panel(get_response) + panel.process_request(request) + # The relation is warning-suppressed, so ending the scope emits nothing... + panel.generate_stats(request, HttpResponse()) + + # ...but the panel still surfaces it, flagged as suppressed. + stats = panel.get_stats() + assert stats["count"] == 1 + assert stats["suppressed"] == 1 + assert stats["relations"][0]["warn_ignored"] is True + assert not unused.is_active() + + @override_settings(EAGLE_DEBUG_TOOLBAR_IGNORE=[{"model": "Eagle", "field": "location"}]) + def test_toolbar_ignored_load_hidden_but_still_warns(self): + EagleFactory(location=LocationFactory()) + + def get_response(request): + list(Eagle.objects.select_related("location")) + return HttpResponse() + + panel, request = self._make_panel(get_response) + panel.process_request(request) + # The relation is NOT warning-suppressed, so the warning still fires... + with pytest.warns(UnusedRelatedAccess): + panel.generate_stats(request, HttpResponse()) + + # ...but the toolbar ignore list hides it from the panel. + stats = panel.get_stats() + assert stats["count"] == 0 + assert panel.nav_subtitle == "0 unused" + assert not unused.is_active() + + @override_settings(EAGLE_ENABLED=False) + def test_disabled_records_disabled_stats(self): + panel, request = self._make_panel(lambda request: HttpResponse()) + panel.process_request(request) + panel.generate_stats(request, HttpResponse()) + + stats = panel.get_stats() + assert stats["enabled"] is False + assert stats["count"] == 0 + assert panel.nav_subtitle == "0 unused" + assert not unused.is_active() diff --git a/tests/test_scope.py b/tests/test_scope.py index 26af5b8..3ed175e 100644 --- a/tests/test_scope.py +++ b/tests/test_scope.py @@ -2,6 +2,7 @@ from django.test import override_settings from eagle.instrumentation import scope +from excluded_app.models import Burrow from test_project.models import Eagle @@ -36,3 +37,20 @@ def test_exclude_wins_over_third_party_include(self): ) def test_bare_label_does_not_exclude_third_party(self): assert Group in self._instrumented_models() + + def _excluded_models(self) -> set: + return set(scope.get_excluded_models()) + + def test_excluded_app_model_yielded_by_get_excluded_models(self): + # excluded_app is in EAGLE_EXCLUDE_APPS (test settings), so its model is a candidate + # for toolbar-only profiling -- excluded from warnings, available to the panel. + assert Burrow in self._excluded_models() + assert Burrow not in self._instrumented_models() + + @override_settings(EAGLE_EXCLUDE_APPS=["test_project"]) + def test_excluded_first_party_app_moves_to_excluded_set(self): + assert Eagle in self._excluded_models() + assert Eagle not in self._instrumented_models() + + def test_excluded_set_omits_non_excluded_apps(self): + assert Eagle not in self._excluded_models() diff --git a/tests/test_unused_report.py b/tests/test_unused_report.py new file mode 100644 index 0000000..41c8bd5 --- /dev/null +++ b/tests/test_unused_report.py @@ -0,0 +1,165 @@ +import pytest +from django.test import override_settings + +from eagle import UnusedRelatedAccess, unused, warn_unused +from eagle.unused import collect_all_unused, collect_unused, get_last_report +from eagle.unused.report import ( + _related_column_count, + clear_warn_suppressed_labels, + register_warn_suppressed_labels, +) +from test_project.models import Eagle, Location +from tests.factories import EagleFactory, LocationFactory + + +@pytest.mark.django_db +class TestCollectUnused: + """collect_unused() reports loaded-but-unread relations with their magnitude, via real querysets.""" + + def test_select_related_reports_instances_and_columns(self): + location = LocationFactory() + for _ in range(3): + EagleFactory(location=location) + + # Load location on every eagle row but never read it, then inspect the live report + # before the scope ends and turns the survivor into a warning. + with pytest.warns(UnusedRelatedAccess), warn_unused(): # noqa: PT031 + list(Eagle.objects.select_related("location")) + report = collect_unused() + + assert len(report) == 1 + relation = report[0] + assert relation.kind == "select_related" + assert relation.model_name == "Eagle" + assert relation.cache_name == "location" + assert relation.instances == 3 + assert relation.columns == len(Location._meta.concrete_fields) + assert relation.location is not None + assert "test_unused_report.py" in relation.location + + def test_prefetch_related_reports_parent_fanout_and_no_columns(self): + location = LocationFactory() + for _ in range(2): + EagleFactory(location=location) + + with pytest.warns(UnusedRelatedAccess), warn_unused(): # noqa: PT031 + list(Eagle.objects.prefetch_related("previous_locations")) + report = collect_unused() + + assert len(report) == 1 + relation = report[0] + assert relation.kind == "prefetch_related" + assert relation.cache_name == "previous_locations" + assert relation.columns is None + assert relation.instances == 2 + + def test_consumed_relation_absent_from_report(self): + eagle = EagleFactory(location=LocationFactory()) + + with warn_unused(): + obj = Eagle.objects.select_related("location").get(pk=eagle.pk) + _ = obj.location + report = collect_unused() + + assert report == [] + + @override_settings(EAGLE_WARN_UNUSED_IGNORE=[{"model": "Eagle", "field": "location"}]) + def test_ignored_relation_absent_from_report(self): + EagleFactory(location=LocationFactory()) + + with warn_unused(): + list(Eagle.objects.select_related("location")) + report = collect_unused() + + assert report == [] + + def test_get_last_report_matches_emitted_records(self): + EagleFactory(location=LocationFactory()) + + with pytest.warns(UnusedRelatedAccess), warn_unused(): # noqa: PT031 + list(Eagle.objects.select_related("location")) + inside_scope = collect_unused() + + stashed = get_last_report() + assert stashed == inside_scope + assert len(stashed) == 1 + assert stashed[0].cache_name == "location" + + +@pytest.mark.django_db +class TestCollectAllUnused: + """collect_all_unused() is the full set (incl. warning-suppressed); collect_unused() drops the suppressed.""" + + def test_no_ignore_full_set_matches_warning_view(self): + EagleFactory(location=LocationFactory()) + + with pytest.warns(UnusedRelatedAccess), warn_unused(): # noqa: PT031 + list(Eagle.objects.select_related("location")) + full = collect_all_unused() + warning_view = collect_unused() + + assert len(full) == 1 + assert full[0].warn_ignored is False + assert full == warning_view + + @override_settings(EAGLE_WARN_UNUSED_IGNORE=[{"model": "Eagle", "field": "location"}]) + def test_warn_ignored_relation_kept_in_full_set_only(self): + EagleFactory(location=LocationFactory()) + + # No warning is emitted on scope exit because the relation is warning-suppressed. + with warn_unused(): + list(Eagle.objects.select_related("location")) + full = collect_all_unused() + warning_view = collect_unused() + + assert len(full) == 1 + assert full[0].cache_name == "location" + assert full[0].warn_ignored is True + assert warning_view == [] + + +@pytest.mark.django_db +class TestWarnSuppressedLabels: + """Models registered as warn-suppressed (e.g. profiled excluded apps) are reported but never warn.""" + + @pytest.fixture + def eagle_label_warn_suppressed(self): + register_warn_suppressed_labels(["test_project.Eagle"]) + yield + clear_warn_suppressed_labels() + + def test_warn_suppressed_model_flagged_and_not_warned(self, eagle_label_warn_suppressed): + EagleFactory(location=LocationFactory()) + + # No warning fires on scope exit because Eagle is registered as warn-suppressed; under + # filterwarnings=error a stray warning would fail this test instead of passing silently. + with warn_unused(): + list(Eagle.objects.select_related("location")) + full = collect_all_unused() + warning_view = collect_unused() + + assert len(full) == 1 + assert full[0].warn_ignored is True + assert warning_view == [] + + +class TestRelatedColumnCount: + """The best-effort column resolver counts a forward relation's columns and falls back to None.""" + + def test_forward_relation_counts_related_concrete_fields(self): + cache_name = Eagle._meta.get_field("location").cache_name + assert _related_column_count("test_project.Eagle", cache_name) == len(Location._meta.concrete_fields) + + def test_unknown_model_returns_none(self): + assert _related_column_count("test_project.DoesNotExist", "whatever") is None + + def test_unmatched_cache_name_returns_none(self): + assert _related_column_count("test_project.Eagle", "no_such_cache") is None + + +class TestCollectUnusedNoRequest: + """Outside any tracking scope the collector is empty, so the report is empty.""" + + def test_returns_empty_when_no_request_active(self): + assert not unused.is_active() + assert collect_unused() == []