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
19 changes: 17 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,22 @@ jobs:
uses: pre-commit/action@v3.0.1

- name: Test
run: uv run pytest -q --cov --cov-report=xml --cov-report=term-missing
# Invoked via `coverage run` (not `pytest --cov`) so tracing starts at interpreter
# startup: pytest's own config parsing eagerly imports the `filterwarnings` target
# ("error::eagle.exceptions.UnusedRelatedAccess") before pytest-cov would attach,
# which under `pytest --cov` silently drops that early import from measurement.
run: uv run coverage run -m pytest -q

- name: Coverage report
run: uv run coverage report -m

- name: Coverage XML
run: uv run coverage xml

- name: Generate coverage badge
run: uv run genbadge coverage -i coverage.xml -o assets/coverage.svg
run: |
uv run genbadge coverage -i coverage.xml -o assets/coverage.svg
[ -z "$(tail -c1 assets/coverage.svg)" ] || printf '\n' >> assets/coverage.svg

- name: Commit coverage badge
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
Expand All @@ -47,3 +59,6 @@ jobs:
git add assets/coverage.svg
git diff --staged --quiet || git commit -m "chore: update coverage badge"
git push

- name: Enforce minimum coverage
run: uv run coverage report --fail-under=95
2 changes: 1 addition & 1 deletion assets/coverage.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion eagle/instrumentation/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ def propagate_prefetch_location(instances: list[models.Model], child_queryset: A
child_queryset: The prefetch queryset that will execute against the related model.
cache_name: ORM cache key identifying which per-field location to look up; None skips propagation.
"""
if cache_name is None or not instances or child_queryset is None:
if cache_name is None or not instances or not isinstance(child_queryset, QuerySet):
return
if getattr(child_queryset, _EAGLE_LOCATION_ATTR, None) is not None:
return
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@ Issues = "https://github.com/xelixdev/django-eagle/issues"
[dependency-groups]
dev = [
"djangorestframework>=3.16.0",
"coverage>=7.6.0",
"factory-boy>=3.3.0",
"genbadge[coverage]>=1.1.3",
"pyrefly>=1.0.0",
"pytest>=9.0.3",
"pytest-cov>=5.0.0",
"pytest-django>=4.9.0",
]

Expand Down
50 changes: 48 additions & 2 deletions tests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,17 @@
from rest_framework.test import APIClient

from eagle import unused
from test_project.models import Climate, Eagle, Eaglet, Location
from tests.factories import ClimateFactory, EagleFactory, EagletFactory, LocationFactory
from excluded_app.models import Burrow
from test_project.models import Aerie, Climate, Eagle, Eaglet, Location, Sighting
from tests.factories import (
AerieFactory,
BurrowFactory,
ClimateFactory,
EagleFactory,
EagletFactory,
LocationFactory,
SightingFactory,
)


@dataclass(frozen=True)
Expand Down Expand Up @@ -53,6 +62,43 @@ def api_client(self) -> APIClient:
return APIClient()


@pytest.mark.django_db
class EagleFixtureMixin:
"""Supplies a plain ``eagle`` fixture to any test class that needs a single tracked instance."""

@pytest.fixture
def eagle(self) -> Eagle:
"""Create a single eagle via the default factory."""
return EagleFactory()


class AerieFixtureMixin(EagleFixtureMixin):
"""Supplies an ``aerie`` fixture: a non-nullable forward one-to-one owned by the eagle fixture."""

@pytest.fixture
def aerie(self, eagle: Eagle) -> Aerie:
"""Create an Aerie whose forward one-to-one points at the eagle fixture."""
return AerieFactory(eagle=eagle)


class BurrowFixtureMixin(EagleFixtureMixin):
"""Supplies a ``burrow`` fixture: a reverse one-to-one from excluded_app, owned by the eagle fixture."""

@pytest.fixture
def burrow(self, eagle: Eagle) -> Burrow:
"""Create a Burrow (an excluded_app model) whose one-to-one points at the eagle fixture."""
return BurrowFactory(eagle=eagle)


class SightingFixtureMixin(EagleFixtureMixin):
"""Supplies a ``sighting`` fixture: a GenericForeignKey pointing at the eagle fixture."""

@pytest.fixture
def sighting(self, eagle: Eagle) -> Sighting:
"""Create a Sighting whose content_object GenericForeignKey resolves to the eagle fixture."""
return SightingFactory(content_object=eagle)


@pytest.mark.django_db
class MayAccessHelperTestBase:
"""Base for tests of the may_access/mark_considered helpers run inside a tracking request."""
Expand Down
16 changes: 15 additions & 1 deletion tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from collision_app.models import Eagle as CollisionEagle
from excluded_app.models import Burrow
from test_project.models import Climate, Eagle, Eaglet, Location
from test_project.models import Aerie, Climate, Eagle, Eaglet, Location, Sighting


class ClimateFactory(factory.django.DjangoModelFactory):
Expand Down Expand Up @@ -45,6 +45,13 @@ class Meta:
eagle = factory.SubFactory(EagleFactory)


class AerieFactory(factory.django.DjangoModelFactory):
class Meta:
model = Aerie

eagle = factory.SubFactory(EagleFactory)


class BurrowFactory(factory.django.DjangoModelFactory):
class Meta:
model = Burrow
Expand All @@ -53,6 +60,13 @@ class Meta:
depth = 3


class SightingFactory(factory.django.DjangoModelFactory):
class Meta:
model = Sighting

content_object = factory.SubFactory(EagleFactory)


class CollisionEagleFactory(factory.django.DjangoModelFactory):
class Meta:
model = CollisionEagle
Expand Down
35 changes: 35 additions & 0 deletions tests/test_apps_ready.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from django.apps import apps as django_apps
from django.test import override_settings

import eagle.instrumentation as instrumentation


class TestEagleAppConfigReady:
def test_ready_is_noop_when_disabled(self, monkeypatch: object) -> None:
calls: list[str] = []
monkeypatch.setattr(instrumentation, "patch_orm", lambda: calls.append("patch_orm"))
monkeypatch.setattr(instrumentation, "register_tracked_models", lambda models: calls.append("register"))

with override_settings(EAGLE_ENABLED=False):
django_apps.get_app_config("eagle").ready()

assert calls == []

def test_ready_skips_contenttypes_eager_when_app_config_missing(self, monkeypatch: object) -> None:
calls: list[str] = []
monkeypatch.setattr(instrumentation, "make_contenttypes_eager", lambda: calls.append("contenttypes"))

original_get_app_config = django_apps.get_app_config

def fake_get_app_config(label: str) -> object:
if label == "contenttypes":
msg = "contenttypes"
raise LookupError(msg)
return original_get_app_config(label)

eagle_config = original_get_app_config("eagle")
monkeypatch.setattr(django_apps, "get_app_config", fake_get_app_config)

eagle_config.ready()

assert calls == []
131 changes: 131 additions & 0 deletions tests/test_descriptors_internals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
from types import SimpleNamespace

import pytest
from django.contrib.contenttypes.fields import GenericForeignKey

from eagle import UnusedRelatedAccess, unused, warn_unused
from eagle.instrumentation import descriptors
from test_project.models import Aerie, Eagle, Sighting
from tests.base import AerieFixtureMixin, EagleFixtureMixin, SightingFixtureMixin


class TestEagerPrefetchMixinBase:
def test_default_prefetch_cache_name_is_none(self) -> None:
assert descriptors.EagerPrefetchMixin()._prefetch_cache_name() is None


class TestLegacyGetPrefetchQueryset:
def test_delegates_to_super_and_returns_its_result(self) -> None:
class _LegacyBase:
def get_prefetch_queryset(self, instances: list, queryset: object = None) -> tuple:
return ("child-queryset", queryset)

class _LegacyManager(descriptors.EagerPrefetchMixin, _LegacyBase):
def _prefetch_cache_name(self) -> str:
return "legacy_cache"

result = _LegacyManager().get_prefetch_queryset([])

assert result == ("child-queryset", None)


class TestTrackingPrefetchCache(EagleFixtureMixin):
def test_getitem_marks_consumed_when_instance_is_tracked(self, eagle: Eagle) -> None:
unused.begin_request()
unused.init_state(eagle, location=None)
unused.mark_prefetched([eagle], "previous_locations")
cache = descriptors.TrackingPrefetchCache({"previous_locations": []}, _eagle_instance=eagle)

assert cache["previous_locations"] == []

unused.end_request()

def test_getitem_skips_marking_when_instance_untracked(self) -> None:
cache = descriptors.TrackingPrefetchCache({"key": "value"}, _eagle_instance=None)
assert cache["key"] == "value"


class TestPrefetchCacheDescriptor(EagleFixtureMixin):
def test_class_level_access_returns_descriptor_itself(self) -> None:
descriptor = Eagle.__dict__["_prefetched_objects_cache"]
assert descriptor.__get__(None, Eagle) is descriptor

def test_set_does_not_rewrap_existing_tracking_cache(self, eagle: Eagle) -> None:
descriptor = Eagle.__dict__["_prefetched_objects_cache"]
existing = descriptors.TrackingPrefetchCache({}, _eagle_instance=eagle)

descriptor.__set__(eagle, existing)

assert eagle.__dict__[descriptor._eagle_storage] is existing

def test_delete_missing_cache_raises_attribute_error(self, eagle: Eagle) -> None:
descriptor = Eagle.__dict__["_prefetched_objects_cache"]
with pytest.raises(AttributeError):
descriptor.__delete__(eagle)

def test_delete_existing_cache_removes_it(self, eagle: Eagle) -> None:
descriptor = Eagle.__dict__["_prefetched_objects_cache"]
descriptor.__set__(eagle, {})

descriptor.__delete__(eagle)

assert descriptor._eagle_storage not in eagle.__dict__


class TestCreateEagerRelatedManagerCacheNameFallback:
def test_falls_back_to_related_query_name_without_prefetch_cache_name(self) -> None:
class _StubRelatedManager:
field = SimpleNamespace(related_query_name=lambda: "stub_query_name")

manager_cls = descriptors.create_eager_related_manager(_StubRelatedManager)

assert manager_cls()._prefetch_cache_name() == "stub_query_name"


class TestForwardOneToOnePrefetch(AerieFixtureMixin):
def test_forward_o2o_prefetch_unused_warns(self, aerie: Aerie) -> None:
with pytest.raises(UnusedRelatedAccess) as exc_info, warn_unused():
Aerie.objects.prefetch_related("eagle").get(pk=aerie.pk)
assert 'prefetch_related("eagle")' in str(exc_info.value)

def test_forward_o2o_prefetch_accessed_no_warning(self, aerie: Aerie) -> None:
with warn_unused():
fetched = Aerie.objects.prefetch_related("eagle").get(pk=aerie.pk)
assert fetched.eagle == aerie.eagle


class TestMakeContenttypesEagerIdempotent:
def test_second_call_does_not_duplicate_registration(self) -> None:
before = len(descriptors._eager_mixins)
descriptors.make_contenttypes_eager()
assert len(descriptors._eager_mixins) == before


class TestLegacyGenericForeignKeyGetPrefetchQueryset:
def test_delegates_to_super_and_returns_its_result(self) -> None:
gfk_mixin = next(mixin for stock_class, mixin in descriptors._eager_mixins if stock_class is GenericForeignKey)

class _LegacyBase:
cache_name = "content_object"

def get_prefetch_queryset(self, instances: list, queryset: object = None) -> tuple:
return ("child-queryset", queryset)

class _LegacyGenericForeignKey(gfk_mixin, _LegacyBase):
pass

result = _LegacyGenericForeignKey().get_prefetch_queryset([])

assert result == ("child-queryset", None)


class TestGenericForeignKeyTracking(SightingFixtureMixin):
def test_prefetched_generic_foreign_key_unused_warns(self, sighting: Sighting) -> None:
with pytest.raises(UnusedRelatedAccess) as exc_info, warn_unused():
Sighting.objects.prefetch_related("content_object").get(pk=sighting.pk)
assert 'prefetch_related("content_object")' in str(exc_info.value)

def test_prefetched_generic_foreign_key_accessed_no_warning(self, sighting: Sighting) -> None:
with warn_unused():
fetched = Sighting.objects.prefetch_related("content_object").get(pk=sighting.pk)
assert fetched.content_object is not None
56 changes: 56 additions & 0 deletions tests/test_instrumentation_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from unittest.mock import Mock

from eagle.instrumentation import models
from test_project.models import Eagle


class TestMakeDescriptorEager:
def test_missing_attribute_is_a_noop(self) -> None:
models.make_descriptor_eager(object(), "does_not_exist")


class TestMakeRemoteFieldDescriptorEager:
def test_uninstrumented_related_model_is_skipped(self, monkeypatch: object) -> None:
calls: list[object] = []
monkeypatch.setattr(models, "make_descriptor_eager", lambda *a: calls.append(a))

models.make_remote_field_descriptor_eager(None, object, remote_field=Mock())

assert calls == []

def test_hidden_accessor_is_skipped(self, monkeypatch: object) -> None:
calls: list[object] = []
monkeypatch.setattr(models, "make_descriptor_eager", lambda *a: calls.append(a))
remote_field = Mock(get_accessor_name=Mock(return_value=None))

models.make_remote_field_descriptor_eager(None, Eagle, remote_field=remote_field)

remote_field.get_accessor_name.assert_called_once()
assert calls == []

def test_visible_accessor_is_made_eager(self, monkeypatch: object) -> None:
calls: list[object] = []
monkeypatch.setattr(models, "make_descriptor_eager", lambda *a: calls.append(a))
remote_field = Mock(get_accessor_name=Mock(return_value="visitors"))

models.make_remote_field_descriptor_eager(None, Eagle, remote_field=remote_field)

assert calls == [(Eagle, "visitors")]


class TestMakePrefetchCacheEager:
def test_second_call_is_idempotent(self) -> None:
models.make_prefetch_cache_eager(Eagle)
first_descriptor = Eagle.__dict__["_prefetched_objects_cache"]

models.make_prefetch_cache_eager(Eagle)

assert Eagle.__dict__["_prefetched_objects_cache"] is first_descriptor


class TestMakeModelEagerHiddenRelation:
def test_hidden_reverse_relation_is_skipped_without_error(self) -> None:
mates_rel = next(rel for rel in Eagle._meta.related_objects if rel.get_accessor_name() is None)
assert mates_rel.field.name == "mates"

models.make_model_eager(Eagle)
15 changes: 15 additions & 0 deletions tests/test_instrumentation_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from eagle.instrumentation import registry
from test_project.models import Eagle


class TestRegistryClear:
def test_clear_removes_all_registered_models(self) -> None:
original = set(registry._instrumented)
try:
assert registry.is_instrumented(Eagle) is True
registry.clear()
assert registry.is_instrumented(Eagle) is False
finally:
registry.register_tracked_models(original)

assert registry.is_instrumented(Eagle) is True
Loading
Loading