pax_global_header00006660000000000000000000000064147461107120014515gustar00rootroot0000000000000052 comment=61ceb7fb0b95be977fea0b9f2a5de9518905efad django-zeal-2.0.4/000077500000000000000000000000001474611071200137135ustar00rootroot00000000000000django-zeal-2.0.4/.github/000077500000000000000000000000001474611071200152535ustar00rootroot00000000000000django-zeal-2.0.4/.github/workflows/000077500000000000000000000000001474611071200173105ustar00rootroot00000000000000django-zeal-2.0.4/.github/workflows/benchmark.yaml000066400000000000000000000012031474611071200221220ustar00rootroot00000000000000name: Benchmark on: push: branches: - main pull_request: branches: - main # `workflow_dispatch` allows CodSpeed to trigger backtest # performance analysis in order to generate initial data. workflow_dispatch: jobs: benchmark: runs-on: ubuntu-latest name: Benchmark steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: 3.12 cache: "pip" - run: make ci - name: Run benchmarks uses: CodSpeedHQ/action@v3 with: token: ${{ secrets.CODSPEED_TOKEN }} run: pytest tests/ --codspeed django-zeal-2.0.4/.github/workflows/release-please.yaml000066400000000000000000000005071474611071200230650ustar00rootroot00000000000000on: push: branches: - main permissions: contents: write pull-requests: write name: release-please jobs: release-please: runs-on: ubuntu-latest steps: - uses: googleapis/release-please-action@v4 with: token: ${{ secrets.RELEASE_PLEASE_TOKEN }} release-type: python django-zeal-2.0.4/.github/workflows/release.yaml000066400000000000000000000011601474611071200216120ustar00rootroot00000000000000name: Publish to PyPI on: push: tags: - '*' jobs: publish: name: Build & publish runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/p/zealot permissions: id-token: write steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: 3.12 - name: Install pypa/build run: >- python3 -m pip install build --user - name: Build run: python3 -m build - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 django-zeal-2.0.4/.github/workflows/test.yaml000066400000000000000000000031101474611071200211460ustar00rootroot00000000000000name: Test on: push: pull_request: branches: - main jobs: test: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.9", "3.10", "3.11", "3.12"] django-version: ["4.2", "5.0"] exclude: # django 5 requires python >=3.10 - python-version: 3.9 django-version: 5.0 - python-version: 3.9 django-version: 5.1 name: Test (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' - run: make ci - run: pip install Django~=${{ matrix.django-version }} - run: make test test-django-prerelease: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.10", "3.11", "3.12"] name: Test Django prerelease (Python ${{ matrix.python-version }}) steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' - run: make ci - run: pip install --pre django - run: make test typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: 3.12 cache: 'pip' - run: make ci - run: make typecheck django-zeal-2.0.4/.gitignore000066400000000000000000000061331474611071200157060ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/latest/usage/project/#working-with-version-control .pdm.toml .pdm-python .pdm-build/ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ .ruff_cache/ .codspeed/django-zeal-2.0.4/.tool-versions000066400000000000000000000000161474611071200165340ustar00rootroot00000000000000python 3.9.19 django-zeal-2.0.4/CHANGELOG.md000066400000000000000000000151071474611071200155300ustar00rootroot00000000000000# Changelog ## [2.0.4](https://github.com/taobojlen/django-zeal/compare/v2.0.3...v2.0.4) (2025-01-26) ### Bug Fixes * handle empty apps list ([6aa6113](https://github.com/taobojlen/django-zeal/commit/6aa611367f8fffd59508c56384e7613611ddf39a)) ### Performance Improvements * optimize stack fetching ([#50](https://github.com/taobojlen/django-zeal/issues/50)) ([ea88ffd](https://github.com/taobojlen/django-zeal/commit/ea88ffd295cf914e1ca7ceb011578e30ba2c8044)) ## [2.0.3](https://github.com/taobojlen/django-zeal/compare/v2.0.2...v2.0.3) (2025-01-23) ### Bug Fixes * allow zeal_ignore even when zeal is disabled ([#46](https://github.com/taobojlen/django-zeal/issues/46)) ([a412208](https://github.com/taobojlen/django-zeal/commit/a41220879b7f8f985a5b2088b106f66c8e587418)) ## [2.0.2](https://github.com/taobojlen/django-zeal/compare/v2.0.1...v2.0.2) (2024-11-22) ### Bug Fixes * **#28:** prevent infinite recursion when custom __eq__ is used. ([#43](https://github.com/taobojlen/django-zeal/issues/43)) ([d157059](https://github.com/taobojlen/django-zeal/commit/d1570593bde02cd5f020fcbfb21350df03e43026)) (thanks @bradleyess!) ## [2.0.1](https://github.com/taobojlen/django-zeal/compare/v2.0.0...v2.0.1) (2024-11-13) ### Bug Fixes * use correct field name in forward many-to-many fields ([#39](https://github.com/taobojlen/django-zeal/issues/39)) ([3ada66f](https://github.com/taobojlen/django-zeal/commit/3ada66fa8f9b9c79acd9f2c45b35cb4967770e04)) ## [2.0.0](https://github.com/taobojlen/django-zeal/compare/v1.4.1...v2.0.0) (2024-11-12) ### Features * add app name to error messages ([#34](https://github.com/taobojlen/django-zeal/issues/34)) ([ad6fe5f](https://github.com/taobojlen/django-zeal/commit/ad6fe5f6599de26ee9adf30cd372cd3fcb7cded0)) * Add Django signal for zeal errors ([#31](https://github.com/taobojlen/django-zeal/issues/31)) ([1190ca7](https://github.com/taobojlen/django-zeal/commit/1190ca73b5e714c3ded3b979e0fb09935928abca)) (thanks @MaxTet1703!) * validate the allowlist ([#33](https://github.com/taobojlen/django-zeal/issues/33)) ([2aa1b42](https://github.com/taobojlen/django-zeal/commit/2aa1b42c3a441d6b66f52dd2ba70abc6ffe5efee)) ### Bug Fixes * handle related field names in allowlist validation ([#36](https://github.com/taobojlen/django-zeal/issues/36)) ([654eed6](https://github.com/taobojlen/django-zeal/commit/654eed692b9b6e0a17d5c5edc4ec74a2ae0783c9)) * use custom error class for validation errors ([#37](https://github.com/taobojlen/django-zeal/issues/37)) ([035ae35](https://github.com/taobojlen/django-zeal/commit/035ae3574e3bf29e1c24d896d5c3cd4100d1002b)) ### Miscellaneous Chores * make breaking change ([5eed8ec](https://github.com/taobojlen/django-zeal/commit/5eed8ec26e89f657e659d37acbf51c4ef8c4bed4)) ## [1.4.1](https://github.com/taobojlen/django-zeal/compare/v1.4.0...v1.4.1) (2024-09-22) ### Performance Improvements * don't load context in callstack ([#26](https://github.com/taobojlen/django-zeal/issues/26)) ([5ade002](https://github.com/taobojlen/django-zeal/commit/5ade0023be95173506167e5cd50388a8dbb5b5e9)) ## [1.4.0](https://github.com/taobojlen/django-zeal/compare/v1.3.0...v1.4.0) (2024-09-03) **NOTE**: In versions 1.1.0 - 1.3.0, there was a bug that caused `zeal` to be active in all code, even outside of a `zeal_context` block. That is fixed in 1.4.0. When updating, make sure that you have installed zeal correctly as per the README. ### Features * add async support to middleware ([#23](https://github.com/taobojlen/django-zeal/issues/23)) ([815bc16](https://github.com/taobojlen/django-zeal/commit/815bc1651e98a4519a42dfa088dcac4320350a1c)) ### Bug Fixes * only run zeal inside context ([#21](https://github.com/taobojlen/django-zeal/issues/21)) ([6c88fd2](https://github.com/taobojlen/django-zeal/commit/6c88fd247388cf58a3c2291917623b7e8094442b)) ## [1.3.0](https://github.com/taobojlen/django-zeal/compare/v1.2.0...v1.3.0) (2024-07-25) ### Features * add ZEAL_SHOW_ALL_CALLERS to aid in debugging ([#17](https://github.com/taobojlen/django-zeal/issues/17)) ([7fdaf36](https://github.com/taobojlen/django-zeal/commit/7fdaf36db50fed6dee0b0544205e71035c977541)) ## [1.2.0](https://github.com/taobojlen/django-zeal/compare/v1.1.0...v1.2.0) (2024-07-22) ### Features * use warnings instead of logging ([#15](https://github.com/taobojlen/django-zeal/issues/15)) ([df2c841](https://github.com/taobojlen/django-zeal/commit/df2c841b21fae664c14356d00a7a2f6ecbb7fd61)) ## [1.1.0](https://github.com/taobojlen/django-zeal/compare/v1.0.0...v1.1.0) (2024-07-20) ### Features * allow ignoring specific models/fields in zeal_ignore ([#13](https://github.com/taobojlen/django-zeal/issues/13)) ([e51413b](https://github.com/taobojlen/django-zeal/commit/e51413ba5fe4d9a3c34409863e9888d873ff84fa)) ## [1.0.0](https://github.com/taobojlen/zealot/compare/v0.2.3...v1.0.0) (2024-07-20) ### ⚠ BREAKING CHANGES This project has been renamed to `zeal`. To migrate, replace `zealot` with `zeal` in your project's requirements. In your Django settings, replace `ZEALOT_ALLOWLIST`, `ZEALOT_RAISE`, etc. with `ZEAL_ALLOWLIST`, `ZEAL_RAISE`, and so on. In your code, replace `from zealot import ...` with `from zeal import ...`. ### Miscellaneous Chores * rename to zeal ([cc429a2](https://github.com/taobojlen/zealot/commit/cc429a26bfede770db69429e8a11fc9e98fbb2a9)) ## [0.2.3](https://github.com/taobojlen/zeal/compare/v0.2.2...v0.2.3) (2024-07-18) ### Bug Fixes * ensure context is reset after leaving ([#8](https://github.com/taobojlen/zeal/issues/8)) ([f45cabb](https://github.com/taobojlen/zeal/commit/f45cabb2abcabce34cd5aed163f7f95c71256e2c)) ## [0.2.2](https://github.com/taobojlen/zeal/compare/v0.2.1...v0.2.2) (2024-07-15) ### Bug Fixes * don't alert from calls on different lines ([7f7bda7](https://github.com/taobojlen/zeal/commit/7f7bda709e5fff2e953ddac0277d684255732e7c)) ## [0.2.1](https://github.com/taobojlen/zeal/compare/v0.2.0...v0.2.1) (2024-07-08) ### Bug Fixes * zeal_ignore always takes precedence ([e61d060](https://github.com/taobojlen/zeal/commit/e61d060c74ed32193c2c86f1b7f20929a37402a1)) ## [0.2.0](https://github.com/taobojlen/zeal/compare/v0.1.2...v0.2.0) (2024-07-06) ### Features * add support for python 3.9 ([#2](https://github.com/taobojlen/zeal/issues/2)) ([44e5f41](https://github.com/taobojlen/zeal/commit/44e5f41fc247e98683a1dd283ae70322a32445d6)) ## 0.1.2 - 2024-07-06 ### Fixed - Handle empty querysets - Handle incorrectly-used `.prefetch_related()` when `.select_related()` should have been used - Don't raise an exception when using `.values(...).get()` ## 0.1.1 - 2024-07-05 ### Fixed - Ignore N+1s from singly-loaded records ## 0.1.0 - 2024-05-03 Initial release. django-zeal-2.0.4/LICENSE000066400000000000000000000020661474611071200147240ustar00rootroot00000000000000Copyright 2016 Joshua Carp Copyright 2024 Tao Bojlén Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. django-zeal-2.0.4/Makefile000066400000000000000000000010101474611071200153430ustar00rootroot00000000000000install-hooks: git config core.hooksPath hooks/ install: $(MAKE) install-hooks uv pip compile requirements-dev.in -o requirements-dev.txt && uv pip sync requirements-dev.txt ci: pip install -r requirements-dev.txt test: pytest -s --tb=native --random-order -m "not benchmark" $(ARGS) benchmark: pytest -s $(ARGS) --codspeed format-check: ruff format --check && ruff check format: ruff format && ruff check --fix typecheck: pyright . build: python -m build --installer uv publish: twine upload dist/* django-zeal-2.0.4/README.md000066400000000000000000000146721474611071200152040ustar00rootroot00000000000000# django-zeal Catch N+1 queries in your Django project. [![Static Badge](https://img.shields.io/badge/license-MIT-brightgreen)](https://github.com/taobojlen/django-zeal/blob/main/LICENSE) [![PyPI - Version](https://img.shields.io/pypi/v/django-zeal?color=lightgrey)](https://pypi.org/project/django-zeal/) 🔥 Battle-tested at [Cinder](https://www.cinder.co/) ## Features - Detects N+1s from missing prefetches and from use of `.defer()`/`.only()` - Friendly error messages like `N+1 detected on social.User.followers at myapp/views.py:25 in get_user` - Configurable thresholds - Allow-list - Well-tested - No dependencies ## Acknowledgements This library draws heavily from jmcarp's [nplusone](https://github.com/jmcarp/nplusone/). It's not a fork, but a lot of the central concepts and initial code came from nplusone. ## Installation First: ``` pip install django-zeal ``` Then, add zeal to your `INSTALLED_APPS` and `MIDDLEWARE`. ```python if DEBUG: INSTALLED_APPS.append("zeal") MIDDLEWARE.append("zeal.middleware.zeal_middleware") ``` This will detect N+1s that happen in web requests. To catch N+1s in more places, read on! > [!WARNING] > You probably don't want to run zeal in production: > there is significant overhead to detecting N+1s, and my benchmarks show that it > can make your code between 2.5x - 7x slower. ### Celery If you use Celery, you can configure this using [signals](https://docs.celeryq.dev/en/stable/userguide/signals.html): ```python from celery.signals import task_prerun, task_postrun from zeal import setup, teardown from django.conf import settings @task_prerun.connect() def setup_zeal(*args, **kwargs): setup() @task_postrun.connect() def teardown_zeal(*args, **kwargs): teardown() ``` ### Tests Django [runs tests with `DEBUG=False`](https://docs.djangoproject.com/en/5.0/topics/testing/overview/#other-test-conditions), so to run zeal in your tests, you'll first need to ensure it's added to your `INSTALLED_APPS` and `MIDDLEWARE`. You could do something like: ```python import sys TEST = "test" in sys.argv if DEBUG or TEST: INSTALLED_APPS.append("zeal") MIDDLEWARE.append("zeal.middleware.zeal_middleware") ``` This will enable zeal in any tests that go through your middleware. If you want to enable it in _all_ tests, you need to do a bit more work. If you use pytest, use a fixture in your `conftest.py`: ```python import pytest from zeal import zeal_context @pytest.fixture(scope="function", autouse=True) def use_zeal(): with zeal_context(): yield ``` If you use unittest, add custom test cases and inherit from these rather than directly from Django's test cases: ```python # In e.g. `myapp/testing/test_cases.py` from zeal import setup as zeal_setup, teardown as zeal_teardown import unittest from django.test import SimpleTestCase, TestCase, TransactionTestCase class ZealTestMixin(unittest.TestCase): def setUp(self, test): zeal_setup() super().setUp() def teardown(self) -> None: zeal_teardown() return super().teardown(test, err) class CustomSimpleTestCase(ZealTestMixin, SimpleTestCase): pass class CustomTestCase(ZealTestMixin, TestCase): pass class CustomTransactionTestCase(ZealTestMixin, TransactionTestCase): pass ``` ### Generic setup If you also want to detect N+1s in other places not covered here, you can use the `setup` and `teardown` functions, or the `zeal_context` context manager: ```python from zeal import setup, teardown, zeal_context def foo(): setup() try: # your code goes here finally: teardown() @zeal_context() def bar(): # your code goes here def baz(): with zeal_context(): # your code goes here ``` ## Configuration By default, any issues detected by zeal will raise a `ZealError`. If you'd rather log any detected N+1s as warnings, you can set: ```python ZEAL_RAISE = False ``` N+1s will be reported when the same query is executed twice. To configure this threshold, set the following in your Django settings. ```python ZEAL_NPLUSONE_THRESHOLD = 3 ``` To handle false positives, you can temporarily disable zeal in parts of your code using a context manager: ```python from zeal import zeal_ignore with zeal_ignore(): # code in this block will not log/raise zeal errors ``` If you only want to ignore a specific N+1, you can pass in a list of models/fields to ignore: ```python with zeal_ignore([{"model": "polls.Question", "field": "options"}]): # code in this block will ignore N+1s on Question.options ``` If you want to listen to N+1 exceptions globally and do something with them, you can listen to the Django signal that zeal emits: ```python from zeal.signals import nplusone_detected from django.dispatch import receiver @receiver(nplusone_detected) def handle_nplusone(sender, exception): # do something ``` Finally, if you want to ignore N+1 alerts from a specific model/field globally, you can add it to your settings: ```python ZEAL_ALLOWLIST = [ {"model": "polls.Question", "field": "options"}, # you can use fnmatch syntax in the model/field, too {"model": "polls.*", "field": "options"}, # if you don't pass in a field, all N+1s arising from the model will be ignored {"model": "polls.Question"}, ] ``` ## Debugging N+1s By default, zeal's alerts will tell you the line of your code that executed the same query multiple times. If you'd like to see the full call stack from each time the query was executed, you can set: ```python ZEAL_SHOW_ALL_CALLERS = True ``` in your settings. This will give you the full call stack from each time the query was executed. ## Comparison to nplusone zeal borrows heavily from [nplusone](https://github.com/jmcarp/nplusone), but has some differences: - zeal also detects N+1 caused by using `.only()` and `.defer()` - it lets you configure your own threshold for what constitutes an N+1 - it has slightly more helpful error messages that tell you where the N+1 occurred - nplusone patches the Django ORM even in production when it's not enabled. zeal does not! - nplusone appears to be abandoned at this point. - however, zeal only works with Django, whereas nplusone can also be used with SQLAlchemy. - zeal does not (yet) detect unused prefetches, but nplusone does. ## Contributing 1. First, install [uv](https://github.com/astral-sh/uv). 2. Create a virtual env using `uv venv` and activate it with `source .venv/bin/activate`. 3. Run `make install` to install dev dependencies. 4. To run tests, run `make test`. django-zeal-2.0.4/hooks/000077500000000000000000000000001474611071200150365ustar00rootroot00000000000000django-zeal-2.0.4/hooks/pre-commit000077500000000000000000000001011474611071200170300ustar00rootroot00000000000000#!/bin/sh make format-check || exit 1 make typecheck || exit 1 django-zeal-2.0.4/hooks/pre-push000077500000000000000000000000371474611071200165270ustar00rootroot00000000000000#!/bin/sh make test || exit 1 django-zeal-2.0.4/pyproject.toml000066400000000000000000000014461474611071200166340ustar00rootroot00000000000000[project] name = "django-zeal" version = "2.0.4" description = "Detect N+1s in your Django app" readme = "README.md" license = { file = "LICENSE" } requires-python = ">=3.9" [tool.setuptools] package-dir = { "" = "src" } [tool.setuptools.packages.find] where = ["src"] [tool.ruff] line-length = 79 [tool.ruff.lint] extend-select = [ "I", # isort "N", # naming "B", # bugbear "FIX", # disallow FIXME/TODO comments "F", # pyflakes "T20", # flake8-print "ERA", # commented-out code "UP", # pyupgrade ] [tool.pyright] include = ["src", "tests"] [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "djangoproject.settings" pythonpath = ["src", "tests"] testpaths = ["tests"] addopts = "--nomigrations" markers = ["nozeal: disable the auto-setup of zeal in a test"] django-zeal-2.0.4/requirements-dev.in000066400000000000000000000002441474611071200175420ustar00rootroot00000000000000Django~=4.2 pytest~=8.2.2 pytest-django~=4.8.0 factory-boy~=3.3.0 ruff~=0.5.0 django-stubs~=5.0 pyright build twine pytest-random-order pytest-mock pytest-codspeed django-zeal-2.0.4/requirements-dev.txt000066400000000000000000000050661474611071200177620ustar00rootroot00000000000000# This file was autogenerated by uv via the following command: # uv pip compile requirements-dev.in -o requirements-dev.txt asgiref==3.8.1 # via # django # django-stubs build==1.2.1 # via -r requirements-dev.in certifi==2024.6.2 # via requests cffi==1.17.1 # via pytest-codspeed charset-normalizer==3.3.2 # via requests django==4.2.13 # via # -r requirements-dev.in # django-stubs # django-stubs-ext django-stubs==5.0.2 # via -r requirements-dev.in django-stubs-ext==5.0.2 # via django-stubs docutils==0.21.2 # via readme-renderer factory-boy==3.3.0 # via -r requirements-dev.in faker==26.0.0 # via factory-boy filelock==3.16.1 # via pytest-codspeed idna==3.7 # via requests importlib-metadata==8.5.0 # via twine iniconfig==2.0.0 # via pytest jaraco-classes==3.4.0 # via keyring jaraco-context==5.3.0 # via keyring jaraco-functools==4.0.1 # via keyring keyring==25.2.1 # via twine markdown-it-py==3.0.0 # via rich mdurl==0.1.2 # via markdown-it-py more-itertools==10.3.0 # via # jaraco-classes # jaraco-functools nh3==0.2.17 # via readme-renderer nodeenv==1.9.1 # via pyright packaging==24.1 # via # build # pytest pkginfo==1.10.0 # via twine pluggy==1.5.0 # via pytest pycparser==2.22 # via cffi pygments==2.18.0 # via # readme-renderer # rich pyproject-hooks==1.1.0 # via build pyright==1.1.369 # via -r requirements-dev.in pytest==8.2.2 # via # -r requirements-dev.in # pytest-codspeed # pytest-django # pytest-mock # pytest-random-order pytest-codspeed==3.0.0 # via -r requirements-dev.in pytest-django==4.8.0 # via -r requirements-dev.in pytest-mock==3.14.0 # via -r requirements-dev.in pytest-random-order==1.1.1 # via -r requirements-dev.in python-dateutil==2.9.0.post0 # via faker readme-renderer==43.0 # via twine requests==2.32.3 # via # requests-toolbelt # twine requests-toolbelt==1.0.0 # via twine rfc3986==2.0.0 # via twine rich==13.9.4 # via # pytest-codspeed # twine ruff==0.5.0 # via -r requirements-dev.in setuptools==75.8.0 # via pytest-codspeed six==1.16.0 # via python-dateutil sqlparse==0.5.0 # via django twine==5.1.1 # via -r requirements-dev.in types-pyyaml==6.0.12.20240311 # via django-stubs typing-extensions==4.12.2 # via # django-stubs # django-stubs-ext urllib3==2.2.2 # via # requests # twine zipp==3.21.0 # via importlib-metadata django-zeal-2.0.4/src/000077500000000000000000000000001474611071200145025ustar00rootroot00000000000000django-zeal-2.0.4/src/zeal/000077500000000000000000000000001474611071200154355ustar00rootroot00000000000000django-zeal-2.0.4/src/zeal/__init__.py000066400000000000000000000003501474611071200175440ustar00rootroot00000000000000from .errors import NPlusOneError, ZealError from .listeners import setup, teardown, zeal_context, zeal_ignore __all__ = [ "ZealError", "NPlusOneError", "setup", "teardown", "zeal_context", "zeal_ignore", ] django-zeal-2.0.4/src/zeal/apps.py000066400000000000000000000003551474611071200167550ustar00rootroot00000000000000from django.apps import AppConfig from .patch import patch class ZealConfig(AppConfig): name = "zeal" def ready(self): from .constants import initialize_app_registry initialize_app_registry() patch() django-zeal-2.0.4/src/zeal/constants.py000066400000000000000000000013421474611071200200230ustar00rootroot00000000000000from typing import TYPE_CHECKING from django.apps import apps ALL_APPS = {} def initialize_app_registry(): if TYPE_CHECKING: # pyright is unhappy with model._meta.related_objects below, # so we need to skip this code path in type checking return for model in apps.get_models(): # Get direct fields fields = set( field.name for field in model._meta.get_fields(include_hidden=True) ) # Get reverse relations using related_objects reverse_fields = set( rel.get_accessor_name() for rel in model._meta.related_objects ) ALL_APPS[f"{model._meta.app_label}.{model.__name__}"] = ( fields | reverse_fields ) django-zeal-2.0.4/src/zeal/errors.py000066400000000000000000000001751474611071200173260ustar00rootroot00000000000000class ZealError(Exception): pass class NPlusOneError(ZealError): pass class ZealConfigError(ZealError): pass django-zeal-2.0.4/src/zeal/listeners.py000066400000000000000000000163051474611071200200240ustar00rootroot00000000000000import inspect import logging import warnings from abc import ABC, abstractmethod from collections import defaultdict from contextlib import contextmanager from contextvars import ContextVar, Token from dataclasses import dataclass, field from fnmatch import fnmatch from typing import Optional, TypedDict from django.conf import settings from django.db import models from zeal.util import get_caller, get_stack from .constants import ALL_APPS from .errors import NPlusOneError, ZealConfigError, ZealError from .signals import nplusone_detected class QuerySource(TypedDict): model: type[models.Model] field: str instance_key: Optional[str] # e.g. `User:123` # tuple of (model, field, caller) CountsKey = tuple[type[models.Model], str, str] class AllowListEntry(TypedDict): model: str field: Optional[str] def _validate_allowlist(allowlist: list[AllowListEntry]): for entry in allowlist: fnmatch_chars = "*?[]" # if this is an fnmatch, don't do anything if any(char in entry["model"] for char in fnmatch_chars): continue if not ALL_APPS: # zeal has not been initialized yet continue if entry["model"] not in ALL_APPS: raise ZealConfigError( f"Model '{entry['model']}' not found in installed Django models" ) if not entry["field"]: continue if any(char in entry["field"] for char in fnmatch_chars): continue if entry["field"] not in ALL_APPS[entry["model"]]: raise ZealConfigError( f"Field '{entry['field']}' not found on '{entry['model']}'" ) @dataclass class NPlusOneContext: enabled: bool = False calls: dict[CountsKey, list[list[inspect.FrameInfo]]] = field( default_factory=lambda: defaultdict(list) ) ignored: set[str] = field(default_factory=set) allowlist: list[AllowListEntry] = field(default_factory=list) _nplusone_context: ContextVar[NPlusOneContext] = ContextVar( "nplusone", default=NPlusOneContext(), ) logger = logging.getLogger("zeal") class Listener(ABC): @abstractmethod def notify(self, *args, **kwargs): ... @property @abstractmethod def error_class(self) -> type[ZealError]: ... @property def _allowlist(self) -> list[AllowListEntry]: if hasattr(settings, "ZEAL_ALLOWLIST"): settings_allowlist = settings.ZEAL_ALLOWLIST else: settings_allowlist = [] return [*settings_allowlist, *_nplusone_context.get().allowlist] def _alert( self, model: type[models.Model], field: str, message: str, calls: list[list[inspect.FrameInfo]], ): should_error = ( settings.ZEAL_RAISE if hasattr(settings, "ZEAL_RAISE") else True ) should_include_all_callers = ( settings.ZEAL_SHOW_ALL_CALLERS if hasattr(settings, "ZEAL_SHOW_ALL_CALLERS") else False ) is_allowlisted = False for entry in self._allowlist: model_match = fnmatch( f"{model._meta.app_label}.{model.__name__}", entry["model"] ) field_match = fnmatch(field, entry.get("field") or "*") if model_match and field_match: is_allowlisted = True break if is_allowlisted: return stack = get_stack() final_caller = get_caller(stack) if should_include_all_callers: message = f"{message} with calls:\n" for i, caller in enumerate(calls): message += f"CALL {i+1}:\n" for frame in caller: message += f" {frame.filename}:{frame.lineno} in {frame.function}\n" else: message = f"{message} at {final_caller.filename}:{final_caller.lineno} in {final_caller.function}" if should_error: raise self.error_class(message) else: warnings.warn_explicit( message, UserWarning, filename=final_caller.filename, lineno=final_caller.lineno, ) class NPlusOneListener(Listener): @property def error_class(self): return NPlusOneError def notify( self, model: type[models.Model], field: str, instance_key: Optional[str], ): context = _nplusone_context.get() if not context.enabled: return stack = get_stack() caller = get_caller(stack) key = (model, field, f"{caller.filename}:{caller.lineno}") context.calls[key].append(stack) count = len(context.calls[key]) if count >= self._threshold and instance_key not in context.ignored: message = f"N+1 detected on {model._meta.app_label}.{model.__name__}.{field}" self._alert(model, field, message, context.calls[key]) _nplusone_context.set(context) def ignore(self, instance_key: Optional[str]): """ Tells the listener to ignore N+1s arising from this instance. This is used when the given instance is singly-loaded, e.g. via `.first()` or `.get()`. This is to prevent false positives. """ context = _nplusone_context.get() if not instance_key: return context.ignored.add(instance_key) _nplusone_context.set(context) @property def _threshold(self) -> int: if hasattr(settings, "ZEAL_NPLUSONE_THRESHOLD"): return settings.ZEAL_NPLUSONE_THRESHOLD else: return 2 def _alert( self, model: type[models.Model], field: str, message: str, calls: list[list[inspect.FrameInfo]], ): super()._alert(model, field, message, calls) nplusone_detected.send( sender=self, exception=self.error_class(message), ) n_plus_one_listener = NPlusOneListener() def setup() -> Optional[Token]: # if we're already in an ignore-context, we don't want to override # it. context = _nplusone_context.get() if hasattr(settings, "ZEAL_ALLOWLIST"): _validate_allowlist(settings.ZEAL_ALLOWLIST) return _nplusone_context.set( NPlusOneContext(enabled=True, allowlist=context.allowlist) ) def teardown(token: Optional[Token] = None): if token: _nplusone_context.reset(token) else: _nplusone_context.set(NPlusOneContext()) @contextmanager def zeal_context(): token = setup() try: yield finally: teardown(token) @contextmanager def zeal_ignore(allowlist: Optional[list[AllowListEntry]] = None): old_context = _nplusone_context.get() if allowlist is None: allowlist = [{"model": "*", "field": "*"}] elif old_context.enabled: _validate_allowlist(allowlist) old_context = _nplusone_context.get() new_context = NPlusOneContext( enabled=old_context.enabled, calls=old_context.calls.copy(), ignored=old_context.ignored.copy(), allowlist=[*old_context.allowlist, *allowlist], ) token = _nplusone_context.set(new_context) try: yield finally: _nplusone_context.reset(token) django-zeal-2.0.4/src/zeal/middleware.py000066400000000000000000000011631474611071200201250ustar00rootroot00000000000000from asgiref.sync import iscoroutinefunction from django.utils.decorators import sync_and_async_middleware from .listeners import zeal_context @sync_and_async_middleware def zeal_middleware(get_response): if iscoroutinefunction(get_response): async def async_middleware(request): with zeal_context(): response = await get_response(request) return response return async_middleware else: def middleware(request): with zeal_context(): response = get_response(request) return response return middleware django-zeal-2.0.4/src/zeal/patch.py000066400000000000000000000252531474611071200171150ustar00rootroot00000000000000import functools import importlib import inspect from typing import Any, Callable, Optional, TypedDict, Union from django.db import models from django.db.models.fields.related_descriptors import ( ForwardManyToOneDescriptor, ReverseOneToOneDescriptor, create_forward_many_to_many_manager, create_reverse_many_to_one_manager, ) from django.db.models.query import QuerySet from django.db.models.query_utils import DeferredAttribute from zeal.util import is_single_query from .listeners import QuerySource, n_plus_one_listener class QuerysetContext(TypedDict): args: Optional[Any] kwargs: Optional[Any] # This is only used for many-to-many relations. It contains the call args # when `create_forward_many_to_many_manager` is called. manager_call_args: Optional[dict[str, Any]] # used by ReverseManyToOne. a django model instance. instance: Optional[models.Model] Parser = Callable[[QuerysetContext], QuerySource] def get_instance_key( instance: Union[models.Model, dict[str, Any]], ) -> Optional[str]: if isinstance(instance, models.Model): return f"{instance.__class__.__name__}:{instance.pk}" else: # when calling a queryset with `.values(...).get()`, the instance # we get here may be a dict. we don't handle that case formally, # so we return None to ignore that instance in our listeners. return None def patch_module_function(original, patched): module = importlib.import_module(original.__module__) setattr(module, original.__name__, patched) def patch_queryset_fetch_all( queryset: models.QuerySet, parser: Parser, context: QuerysetContext ): fetch_all = queryset._fetch_all @functools.wraps(fetch_all) def wrapper(*args, **kwargs): if queryset._result_cache is None: parsed = parser(context) n_plus_one_listener.notify( parsed["model"], parsed["field"], parsed["instance_key"], ) return fetch_all(*args, **kwargs) return wrapper def patch_queryset_function( queryset_func: Callable[..., models.QuerySet], parser: Parser, context: Optional[QuerysetContext] = None, ): if context is None: context = { "args": None, "kwargs": None, "manager_call_args": None, "instance": None, } @functools.wraps(queryset_func) def wrapper(*args, **kwargs): queryset = queryset_func(*args, **kwargs) # don't patch the same queryset more than once if ( hasattr(queryset, "__zeal_patched") and queryset.__zeal_patched # type: ignore ): return queryset if args and args != context.get("args"): context["args"] = args # When comparing kwargs, we must use id() rather than == because # __eq__ methods on model instances can trigger infinite recursion. if kwargs: existing_kwargs = context.get("kwargs") if existing_kwargs is None or any( id(v) != id(existing_kwargs.get(k)) for k, v in kwargs.items() ): context["kwargs"] = kwargs queryset._clone = patch_queryset_function( # type: ignore queryset._clone, # type: ignore parser, context=context, ) queryset._fetch_all = patch_queryset_fetch_all( queryset, parser, context ) queryset.__zeal_patched = True # type: ignore return queryset return wrapper def patch_forward_many_to_one_descriptor(): """ This also handles ForwardOneToOneDescriptor, which is a subclass of ForwardManyToOneDescriptor. """ def parser(context: QuerysetContext) -> QuerySource: assert "args" in context and context["args"] is not None descriptor = context["args"][0] if "kwargs" in context and context["kwargs"] is not None: instance = context["kwargs"]["instance"] instance_key = get_instance_key(instance) else: # `get_queryset` can in some cases be called without any # kwargs. In those cases, we ignore the instance. instance_key = None return { "model": descriptor.field.model, "field": descriptor.field.name, "instance_key": instance_key, } ForwardManyToOneDescriptor.get_queryset = patch_queryset_function( ForwardManyToOneDescriptor.get_queryset, parser=parser ) def parse_related_parts( model: type[models.Model], related_name: Optional[str], related_model: type[models.Model], ) -> tuple[type[models.Model], str]: field_name = related_name or f"{related_model._meta.model_name}_set" return (model, field_name) def patch_reverse_many_to_one_descriptor(): def parser(context: QuerysetContext) -> QuerySource: assert ( "manager_call_args" in context and context["manager_call_args"] is not None and "rel" in context["manager_call_args"] ) assert "instance" in context and context["instance"] is not None rel = context["manager_call_args"]["rel"] model, field = parse_related_parts( rel.model, rel.related_name, rel.related_model ) return { "model": model, "field": field, "instance_key": get_instance_key(context["instance"]), } def patched_create_reverse_many_to_one_manager(*args, **kwargs): manager_call_args = inspect.getcallargs( create_reverse_many_to_one_manager, *args, **kwargs ) manager = create_reverse_many_to_one_manager(*args, **kwargs) def patch_init_method(func): @functools.wraps(func) def wrapper(self, instance): self.get_queryset = patch_queryset_function( self.get_queryset, parser, context={ "args": None, "kwargs": None, "manager_call_args": manager_call_args, "instance": instance, }, ) return func(self, instance) return wrapper manager.__init__ = patch_init_method(manager.__init__) # type: ignore return manager patch_module_function( create_reverse_many_to_one_manager, patched_create_reverse_many_to_one_manager, ) def patch_reverse_one_to_one_descriptor(): def parser(context: QuerysetContext) -> QuerySource: assert "args" in context and context["args"] is not None descriptor = context["args"][0] field = descriptor.related.field if "kwargs" in context and context["kwargs"] is not None: instance = context["kwargs"]["instance"] instance_key = get_instance_key(instance) else: instance_key = None return { "model": field.related_model, "field": field.remote_field.name, "instance_key": instance_key, } ReverseOneToOneDescriptor.get_queryset = patch_queryset_function( ReverseOneToOneDescriptor.get_queryset, parser ) def patch_many_to_many_descriptor(): def parser(context: QuerysetContext) -> QuerySource: assert ( "manager_call_args" in context and context["manager_call_args"] is not None and "rel" in context["manager_call_args"] ) assert "args" in context and context["args"] is not None rel = context["manager_call_args"]["rel"] manager = context["args"][0] model = manager.instance.__class__ related_model = manager.target_field.related_model is_reverse = context["manager_call_args"]["reverse"] field_name = ( rel.related_name if is_reverse else manager.prefetch_cache_name ) model, field_name = parse_related_parts( model, field_name, related_model ) return { "model": model, "field": field_name, "instance_key": get_instance_key(manager.instance), } def patched_create_forward_many_to_many_manager(*args, **kwargs): manager_call_args = inspect.getcallargs( create_forward_many_to_many_manager, *args, **kwargs ) manager = create_forward_many_to_many_manager(*args, **kwargs) manager.get_queryset = patch_queryset_function( manager.get_queryset, parser, context={ "args": None, "kwargs": None, "manager_call_args": manager_call_args, "instance": None, }, ) return manager patch_module_function( create_forward_many_to_many_manager, patched_create_forward_many_to_many_manager, ) def patch_deferred_attribute(): def patched_check_parent_chain(func): @functools.wraps(func) def wrapper(self, instance, *args, **kwargs): result = func(self, instance, *args, **kwargs) if result is None: n_plus_one_listener.notify( instance.__class__, self.field.name, str(instance.pk) ) return result return wrapper DeferredAttribute._check_parent_chain = patched_check_parent_chain( # type: ignore DeferredAttribute._check_parent_chain # type: ignore ) def patch_global_queryset(): """ We patch `_fetch_all` and `.get()` on querysets to let us ignore singly-loaded instances. We don't want to trigger N+1 errors from such instances because of the high false positive rate. """ def patch_fetch_all(func): @functools.wraps(func) def wrapper(self, *args, **kwargs): should_ignore = ( is_single_query(self.query) and self._result_cache is None ) ret = func(self, *args, **kwargs) # call the original _fetch_all if should_ignore and len(self) > 0: n_plus_one_listener.ignore(get_instance_key(self[0])) return ret return wrapper QuerySet._fetch_all = patch_fetch_all(QuerySet._fetch_all) def patch_get(func): @functools.wraps(func) def wrapper(*args, **kwargs): ret = func(*args, **kwargs) n_plus_one_listener.ignore(get_instance_key(ret)) return ret return wrapper QuerySet.get = patch_get(QuerySet.get) def patch(): patch_forward_many_to_one_descriptor() patch_reverse_many_to_one_descriptor() patch_reverse_one_to_one_descriptor() patch_many_to_many_descriptor() patch_deferred_attribute() patch_global_queryset() django-zeal-2.0.4/src/zeal/signals.py000066400000000000000000000001011474611071200174370ustar00rootroot00000000000000from django.dispatch import Signal nplusone_detected = Signal() django-zeal-2.0.4/src/zeal/util.py000066400000000000000000000014761474611071200167740ustar00rootroot00000000000000import inspect from django.db.models.sql import Query PATTERNS = [ "site-packages", "zeal/listeners.py", "zeal/patch.py", "zeal/util.py", ] def get_stack() -> list[inspect.FrameInfo]: """ Returns the current call stack, excluding any code in site-packages or zeal. """ return [ frame for frame in inspect.stack(context=0)[1:] if not any(pattern in frame.filename for pattern in PATTERNS) ] def get_caller(stack: list[inspect.FrameInfo]) -> inspect.FrameInfo: """ Returns the filename and line number of the current caller, excluding any code in site-packages or zeal. """ return next(frame for frame in stack) def is_single_query(query: Query): return ( query.high_mark is not None and query.high_mark - query.low_mark == 1 ) django-zeal-2.0.4/tests/000077500000000000000000000000001474611071200150555ustar00rootroot00000000000000django-zeal-2.0.4/tests/__init__.py000066400000000000000000000000011474611071200171550ustar00rootroot00000000000000 django-zeal-2.0.4/tests/conftest.py000066400000000000000000000003411474611071200172520ustar00rootroot00000000000000import pytest from zeal import zeal_context @pytest.fixture(scope="function", autouse=True) def use_zeal(request): if "nozeal" in request.keywords: yield else: with zeal_context(): yield django-zeal-2.0.4/tests/djangoproject/000077500000000000000000000000001474611071200177065ustar00rootroot00000000000000django-zeal-2.0.4/tests/djangoproject/__init__.py000066400000000000000000000000001474611071200220050ustar00rootroot00000000000000django-zeal-2.0.4/tests/djangoproject/manage.py000077500000000000000000000012361474611071200215150ustar00rootroot00000000000000#!/usr/bin/env python """Django's command-line utility for administrative tasks.""" import os import sys def main(): """Run administrative tasks.""" os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoproject.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: raise ImportError( "Couldn't import Django. Are you sure it's installed and " "available on your PYTHONPATH environment variable? Did you " "forget to activate a virtual environment?" ) from exc execute_from_command_line(sys.argv) if __name__ == "__main__": main() django-zeal-2.0.4/tests/djangoproject/settings.py000066400000000000000000000010651474611071200221220ustar00rootroot00000000000000SECRET_KEY = 1 DEBUG = True USE_TZ = True TIME_ZONE = "UTC" INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "djangoproject.social", "zeal", ] MIDDLEWARE = ["zeal.middleware.zeal_middleware"] ROOT_URLCONF = "djangoproject.urls" DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:", } } DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" django-zeal-2.0.4/tests/djangoproject/social/000077500000000000000000000000001474611071200211605ustar00rootroot00000000000000django-zeal-2.0.4/tests/djangoproject/social/__init__.py000066400000000000000000000000001474611071200232570ustar00rootroot00000000000000django-zeal-2.0.4/tests/djangoproject/social/apps.py000066400000000000000000000002361474611071200224760ustar00rootroot00000000000000from django.apps import AppConfig class SocialConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "djangoproject.social" django-zeal-2.0.4/tests/djangoproject/social/models.py000066400000000000000000000016651474611071200230250ustar00rootroot00000000000000from django.db import models class User(models.Model): username = models.TextField() # user.followers and user.following are both ManyToManyDescriptor following = models.ManyToManyField("User", related_name="followers") # note that there's no related_name set here, because we want to # test that case too. blocked = models.ManyToManyField("user") followers: models.Manager["User"] user_set: models.Manager["User"] posts: models.Manager["Post"] profile: "Profile" class Profile(models.Model): # profile.user is ForwardOneToOne # user.profile is ReverseOneToOne user = models.OneToOneField(User, on_delete=models.CASCADE) display_name = models.TextField() class Post(models.Model): # post.author is ForwardManyToOne # user.posts is ReverseManyToOne author = models.ForeignKey( User, on_delete=models.CASCADE, related_name="posts" ) text = models.TextField() django-zeal-2.0.4/tests/djangoproject/social/views.py000066400000000000000000000012771474611071200226760ustar00rootroot00000000000000from django.http import HttpRequest, JsonResponse from .models import User def single_user_and_profile(request: HttpRequest, id: int): user = User.objects.get(id=id) return JsonResponse( data={ "username": user.username, "display_name": user.profile.display_name, } ) def all_users_and_profiles(request: HttpRequest): """ This view has an N+1. """ return JsonResponse( data={ "users": [ { "username": user.username, "display_name": user.profile.display_name, } for user in User.objects.all() ] } ) django-zeal-2.0.4/tests/djangoproject/urls.py000066400000000000000000000003341474611071200212450ustar00rootroot00000000000000from django.urls import path from .social.views import all_users_and_profiles, single_user_and_profile urlpatterns = [ path("users/", all_users_and_profiles), path("user//", single_user_and_profile), ] django-zeal-2.0.4/tests/factories.py000066400000000000000000000012761474611071200174140ustar00rootroot00000000000000from typing import Generic, TypeVar import factory from djangoproject.social.models import Post, Profile, User T = TypeVar("T") class BaseFactory(Generic[T], factory.django.DjangoModelFactory): @classmethod def create(cls, **kwargs) -> T: return super().create(**kwargs) class UserFactory(BaseFactory[User]): username = factory.Faker("user_name") class Meta: # type: ignore model = User class ProfileFactory(BaseFactory[Profile]): display_name = factory.Faker("name") class Meta: # type: ignore model = Profile class PostFactory(BaseFactory[Post]): text = factory.Faker("sentence") class Meta: # type: ignore model = Post django-zeal-2.0.4/tests/test_listeners.py000066400000000000000000000246531474611071200205100ustar00rootroot00000000000000import re import warnings import pytest from djangoproject.social.models import Post, User from zeal import NPlusOneError, zeal_context, zeal_ignore from zeal.errors import ZealConfigError from zeal.listeners import _nplusone_context, n_plus_one_listener from .factories import PostFactory, UserFactory pytestmark = pytest.mark.django_db def test_can_log_errors(settings, caplog): settings.ZEAL_RAISE = False [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") for user in User.objects.all(): _ = list(user.posts.all()) assert len(w) == 1 assert issubclass(w[0].category, UserWarning) assert re.search( r"N\+1 detected on social\.User\.posts at .*\/test_listeners\.py:24 in test_can_log_errors", str(w[0].message), ) def test_can_log_all_traces(settings): settings.ZEAL_SHOW_ALL_CALLERS = True settings.ZEAL_RAISE = False [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") for user in User.objects.all(): _ = list(user.posts.all()) assert len(w) == 1 assert issubclass(w[0].category, UserWarning) expected_lines = [ "N+1 detected on social.User.posts with calls:", "CALL 1:", "tests/test_listeners.py:42 in test_can_log_all_traces", "CALL 2:", "tests/test_listeners.py:42 in test_can_log_all_traces", ] for line in expected_lines: assert line in str(w[0].message) def test_errors_include_caller(): [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) with pytest.raises( NPlusOneError, match=r"N\+1 detected on social\.User\.posts at .*\/test_listeners\.py:65 in test_errors_include_caller", ): for user in User.objects.all(): _ = list(user.posts.all()) def test_can_exclude_with_allowlist(settings): settings.ZEAL_ALLOWLIST = [{"model": "social.User", "field": "posts"}] [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) # this will not raise, allow-listed for user in User.objects.all(): _ = list(user.posts.all()) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on social.Post.author") ): for post in Post.objects.all(): _ = post.author def test_can_use_fnmatch_pattern_in_allowlist_model(settings): settings.ZEAL_ALLOWLIST = [{"model": "social.U*"}] [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) # this will not raise, allow-listed for user in User.objects.all(): _ = list(user.posts.all()) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on social.Post.author") ): for post in Post.objects.all(): _ = post.author def test_can_use_fnmatch_pattern_in_allowlist_field(settings): settings.ZEAL_ALLOWLIST = [{"model": "social.User", "field": "p*st*"}] [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) # this will not raise, allow-listed for user in User.objects.all(): _ = list(user.posts.all()) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on social.Post.author") ): for post in Post.objects.all(): _ = post.author def test_ignore_context_takes_precedence(): """ If you're within a `zeal_ignore` context, then even if some later code adds a zeal context, then the ignore context should take precedence. """ with zeal_ignore(): with zeal_context(): [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) # this will not raise because we're in the zeal_ignore context for user in User.objects.all(): _ = list(user.posts.all()) def test_reverts_to_previous_state_when_leaving_zeal_ignore(): # we are currently in a zeal context initial_context = _nplusone_context.get() with zeal_ignore(): assert _nplusone_context.get().allowlist == [ {"model": "*", "field": "*"} ] assert _nplusone_context.get() == initial_context def test_resets_state_in_nested_context(): [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) # we're already in a zeal_context within each test, so let's set # some state. n_plus_one_listener.ignore("Test:1") n_plus_one_listener.notify(Post, "test_field", "Post:1") context = _nplusone_context.get() assert context.ignored == {"Test:1"} assert len(context.calls.values()) == 1 caller = list(context.calls.values())[0] with zeal_context(): # new context, fresh state context = _nplusone_context.get() assert context.ignored == set() assert list(context.calls.values()) == [] n_plus_one_listener.ignore("NestedTest:1") n_plus_one_listener.notify(Post, "nested_test_field", "Post:1") context = _nplusone_context.get() assert context.ignored == {"NestedTest:1"} assert len(list(context.calls.values())) == 1 # back outside the nested context, we're back to the old state context = _nplusone_context.get() assert context.ignored == {"Test:1"} assert list(context.calls.values()) == [caller] def test_can_ignore_specific_models(): [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) with zeal_ignore([{"model": "social.User", "field": "post*"}]): # this will not raise, allow-listed for user in User.objects.all(): _ = list(user.posts.all()) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on social.Post.author"), ): # this is *not* allowlisted for post in Post.objects.all(): _ = post.author # if we ignore another field, we still raise with zeal_ignore([{"model": "social.User", "field": "following"}]): with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on social.User.posts") ): for user in User.objects.all(): _ = list(user.posts.all()) # outside of the context, we're back to normal with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on social.User.posts") ): for user in User.objects.all(): _ = list(user.posts.all()) def test_context_specific_allowlist_merges(): [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) with zeal_ignore([{"model": "social.User", "field": "post*"}]): # this will not raise, allow-listed for user in User.objects.all(): _ = list(user.posts.all()) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on social.Post.author"), ): # this is *not* allowlisted for post in Post.objects.all(): _ = post.author with zeal_ignore([{"model": "social.Post", "field": "author"}]): for post in Post.objects.all(): _ = post.author # this is still allowlisted for user in User.objects.all(): _ = list(user.posts.all()) # other tests automatically run in a zeal context, so we need to disable # that here. @pytest.mark.nozeal def test_does_not_run_outside_of_context(): [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) # this should not raise since we are outside of a zeal context for user in User.objects.all(): _ = list(user.posts.all()) with zeal_context(), pytest.raises(NPlusOneError): # this should raise since we are inside a zeal context for user in User.objects.all(): _ = list(user.posts.all()) @pytest.mark.nozeal def test_validates_global_allowlist_model_name(settings): settings.ZEAL_ALLOWLIST = [{"model": "foo", "field": "*"}] with pytest.raises( ZealConfigError, match=re.escape("Model 'foo' not found in installed Django models"), ): with zeal_context(): pass @pytest.mark.nozeal def test_validates_global_allowlist_field_name(settings): settings.ZEAL_ALLOWLIST = [{"model": "social.User", "field": "foo"}] with pytest.raises( ZealConfigError, match=re.escape("Field 'foo' not found on 'social.User'"), ): with zeal_context(): pass @pytest.mark.nozeal def test_allows_fnmatch_in_global_allowlist(settings): settings.ZEAL_ALLOWLIST = [{"model": "social.U[sb]er", "field": "p?st"}] with zeal_context(): pass def test_validates_local_allowlist_model_name(): with pytest.raises( ZealConfigError, match=re.escape("Model 'foo' not found in installed Django models"), ): with zeal_ignore([{"model": "foo", "field": "*"}]): pass def test_validates_local_allowlist_field_name(): with pytest.raises( ZealConfigError, match=re.escape("Field 'foo' not found on 'social.User'"), ): with zeal_ignore([{"model": "social.User", "field": "foo"}]): pass def test_allows_fnmatch_in_local_allowlist(): with zeal_ignore([{"model": "social.U[sb]er", "field": "p?st"}]): pass def test_validates_related_name_field_names(): # User.following is a M2M field with related_name followers with zeal_ignore([{"model": "social.User", "field": "followers"}]): pass # User.blocked is a M2M field with an auto-generated related name (user_set) with zeal_ignore([{"model": "social.User", "field": "user_set"}]): pass @pytest.mark.nozeal def test_handles_zeal_ignore_when_disabled(): with zeal_ignore([{"model": "social.User", "field": "post"}]): pass django-zeal-2.0.4/tests/test_nplusones.py000066400000000000000000000431551474611071200205240ustar00rootroot00000000000000import re import pytest from django.db import connection from django.test.utils import CaptureQueriesContext from djangoproject.social.models import Post, Profile, User from zeal import NPlusOneError, zeal_context from zeal.listeners import zeal_ignore from .factories import PostFactory, ProfileFactory, UserFactory pytestmark = pytest.mark.django_db def test_detects_nplusone_in_forward_many_to_one(): [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on social.Post.author") ): for post in Post.objects.all(): _ = post.author.username for post in Post.objects.select_related("author").all(): _ = post.author.username def test_detects_nplusone_in_forward_many_to_one_iterator(): for _ in range(4): user = UserFactory.create() PostFactory.create(author=user) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on social.Post.author") ): for post in Post.objects.all().iterator(chunk_size=2): _ = post.author.username for post in Post.objects.select_related("author").iterator(chunk_size=2): _ = post.author.username def test_handles_prefetch_instead_of_select_related_in_forward_many_to_one(): user_1, user_2 = UserFactory.create_batch(2) PostFactory(author=user_1) PostFactory(author=user_2) with CaptureQueriesContext(connection) as ctx: # this should be a select_related! but we need to handle it even if someone # has accidentally used the wrong method. for post in Post.objects.prefetch_related("author").all(): _ = post.author.username assert len(ctx.captured_queries) == 2 def test_no_false_positive_when_loading_single_object_forward_many_to_one(): user = UserFactory.create() post_1, post_2 = PostFactory.create_batch(2, author=user) with zeal_context(), CaptureQueriesContext(connection) as ctx: post_1 = Post.objects.filter(pk=post_1.pk).first() post_2 = Post.objects.filter(pk=post_2.pk).first() assert post_1 is not None and post_2 is not None # queries on `post` should not raise an exception, because `post` was # singly-loaded _ = post_1.author _ = post_2.author assert len(ctx.captured_queries) == 4 with zeal_context(), CaptureQueriesContext(connection) as ctx: # same when using a slice to get a single record post_1 = Post.objects.filter(pk=post_1.pk).all()[0] post_2 = Post.objects.filter(pk=post_2.pk).all()[0] _ = post_1.author _ = post_2.author assert len(ctx.captured_queries) == 4 with zeal_context(), CaptureQueriesContext(connection) as ctx: # similarly, when using `.get()`, no N+1 error post_1 = Post.objects.get(pk=post_1.pk) post_2 = Post.objects.get(pk=post_2.pk) _ = post_1.author _ = post_2.author assert len(ctx.captured_queries) == 4 def test_detects_nplusone_in_reverse_many_to_one(): [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on social.User.posts") ): for user in User.objects.all(): _ = list(user.posts.all()) for user in User.objects.prefetch_related("posts").all(): _ = list(user.posts.all()) def test_detects_nplusone_in_reverse_many_to_one_iterator(): for _ in range(4): user = UserFactory.create() PostFactory.create(author=user) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on social.User.posts") ): for user in User.objects.all().iterator(chunk_size=2): _ = list(user.posts.all()) for user in User.objects.prefetch_related("posts").iterator(chunk_size=2): _ = list(user.posts.all()) def test_no_false_positive_when_calling_reverse_many_to_one_twice(): user = UserFactory.create() PostFactory.create(author=user) with zeal_context(), CaptureQueriesContext(connection) as ctx: queryset = user.posts.all() list(queryset) # evaluate queryset once list(queryset) # evalute again (cached) assert len(ctx.captured_queries) == 1 def test_detects_nplusone_in_forward_one_to_one(): [user_1, user_2] = UserFactory.create_batch(2) ProfileFactory.create(user=user_1) ProfileFactory.create(user=user_2) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on social.Profile.user") ): for profile in Profile.objects.all(): _ = profile.user.username for profile in Profile.objects.select_related("user").all(): _ = profile.user.username def test_detects_nplusone_in_forward_one_to_one_iterator(): for _ in range(4): user = UserFactory.create() ProfileFactory.create(user=user) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on social.Profile.user") ): for profile in Profile.objects.all().iterator(chunk_size=2): _ = profile.user.username for profile in Profile.objects.select_related("user").iterator( chunk_size=2 ): _ = profile.user.username def test_handles_prefetch_instead_of_select_related_in_forward_one_to_one(): user_1, user_2 = UserFactory.create_batch(2) ProfileFactory.create(user=user_1) ProfileFactory.create(user=user_2) with CaptureQueriesContext(connection) as ctx: # this should be a select_related! but we need to handle it even if someone # has accidentally used the wrong method. for profile in Profile.objects.prefetch_related("user").all(): _ = profile.user.username assert len(ctx.captured_queries) == 2 def test_no_false_positive_when_loading_single_object_forward_one_to_one(): user_1, user_2 = UserFactory.create_batch(2) profile_1 = ProfileFactory.create(user=user_1) profile_2 = ProfileFactory.create(user=user_2) with zeal_context(), CaptureQueriesContext(connection) as ctx: profile_1 = Profile.objects.filter(pk=profile_1.pk).first() profile_2 = Profile.objects.filter(pk=profile_2.pk).first() assert profile_1 is not None and profile_2 is not None _ = profile_1.user.username _ = profile_2.user.username assert len(ctx.captured_queries) == 4 with zeal_context(), CaptureQueriesContext(connection) as ctx: profile_1 = Profile.objects.filter(pk=profile_1.pk)[0] profile_2 = Profile.objects.filter(pk=profile_2.pk)[0] _ = profile_1.user.username _ = profile_2.user.username assert len(ctx.captured_queries) == 4 with zeal_context(), CaptureQueriesContext(connection) as ctx: profile_1 = Profile.objects.get(pk=profile_1.pk) profile_2 = Profile.objects.get(pk=profile_2.pk) _ = profile_1.user.username _ = profile_2.user.username assert len(ctx.captured_queries) == 4 def test_detects_nplusone_in_reverse_one_to_one(): [user_1, user_2] = UserFactory.create_batch(2) ProfileFactory.create(user=user_1) ProfileFactory.create(user=user_2) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on social.User.profile") ): for user in User.objects.all(): _ = user.profile.display_name for user in User.objects.select_related("profile").all(): _ = user.profile.display_name def test_detects_nplusone_in_reverse_one_to_one_iterator(): for _ in range(4): user = UserFactory.create() ProfileFactory.create(user=user) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on social.User.profile") ): for user in User.objects.all().iterator(chunk_size=2): _ = user.profile.display_name for user in User.objects.select_related("profile").iterator(chunk_size=2): _ = user.profile.display_name def test_handles_prefetch_instead_of_select_related_in_reverse_one_to_one(): [user_1, user_2] = UserFactory.create_batch(2) ProfileFactory.create(user=user_1) ProfileFactory.create(user=user_2) with CaptureQueriesContext(connection) as ctx: # this should be a select_related! but we need to handle it even if someone # has accidentally used the wrong method. for user in User.objects.prefetch_related("profile").all(): _ = user.profile.display_name assert len(ctx.captured_queries) == 2 def test_no_false_positive_when_loading_single_object_reverse_one_to_one(): user_1, user_2 = UserFactory.create_batch(2) ProfileFactory.create(user=user_1) ProfileFactory.create(user=user_2) with zeal_context(), CaptureQueriesContext(connection) as ctx: user_1 = User.objects.filter(pk=user_1.pk).first() user_2 = User.objects.filter(pk=user_2.pk).first() assert user_1 is not None and user_2 is not None _ = user_1.profile.display_name _ = user_2.profile.display_name assert len(ctx.captured_queries) == 4 with zeal_context(), CaptureQueriesContext(connection) as ctx: user_1 = User.objects.filter(pk=user_1.pk)[0] user_2 = User.objects.filter(pk=user_2.pk)[0] _ = user_1.profile.display_name _ = user_2.profile.display_name assert len(ctx.captured_queries) == 4 with zeal_context(), CaptureQueriesContext(connection) as ctx: user_1 = User.objects.get(pk=user_1.pk) user_2 = User.objects.get(pk=user_2.pk) _ = user_1.profile.display_name _ = user_2.profile.display_name assert len(ctx.captured_queries) == 4 def test_detects_nplusone_in_forward_many_to_many(): [user_1, user_2] = UserFactory.create_batch(2) user_1.following.add(user_2) user_2.following.add(user_1) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on social.User.following") ): for user in User.objects.all(): _ = list(user.following.all()) for user in User.objects.prefetch_related("following").all(): _ = list(user.following.all()) def test_detects_nplusone_in_forward_many_to_many_iterator(): influencer = UserFactory.create() users = UserFactory.create_batch(4) influencer.followers.set(users) # type: ignore with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on social.User.following") ): for user in User.objects.iterator(chunk_size=2): _ = list(user.following.all()) for user in User.objects.prefetch_related("following").iterator( chunk_size=2 ): _ = list(user.following.all()) def test_no_false_positive_when_loading_single_object_forward_many_to_many(): user_1, user_2 = UserFactory.create_batch(2) user_1.following.add(user_2) user_2.following.add(user_1) with zeal_context(), CaptureQueriesContext(connection) as ctx: _ = user_1.following.first().username _ = user_2.following.first().username assert len(ctx.captured_queries) == 2 with zeal_context(), CaptureQueriesContext(connection) as ctx: _ = user_1.following.all()[0].username _ = user_2.following.all()[0].username assert len(ctx.captured_queries) == 2 with zeal_context(), CaptureQueriesContext(connection) as ctx: _ = user_1.following.get(pk=user_2.pk).username _ = user_2.following.get(pk=user_1.pk).username assert len(ctx.captured_queries) == 2 def test_detects_nplusone_in_reverse_many_to_many(): [user_1, user_2] = UserFactory.create_batch(2) user_1.following.add(user_2) user_2.following.add(user_1) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on social.User.followers") ): for user in User.objects.all(): _ = list(user.followers.all()) for user in User.objects.prefetch_related("followers").all(): _ = list(user.followers.all()) def test_detects_nplusone_in_reverse_many_to_many_iterator(): follower = UserFactory.create() users = UserFactory.create_batch(4) follower.following.set(users) # type: ignore with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on social.User.followers") ): for user in User.objects.all().iterator(chunk_size=2): _ = list(user.followers.all()) for user in ( User.objects.prefetch_related("followers").all().iterator(chunk_size=2) ): _ = list(user.followers.all()) def test_no_false_positive_when_loading_single_object_reverse_many_to_many(): user_1, user_2 = UserFactory.create_batch(2) user_1.following.add(user_2) user_2.following.add(user_1) with zeal_context(), CaptureQueriesContext(connection) as ctx: _ = user_1.followers.first().username _ = user_2.followers.first().username assert len(ctx.captured_queries) == 2 with zeal_context(), CaptureQueriesContext(connection) as ctx: _ = user_1.followers.all()[0].username _ = user_2.followers.all()[0].username assert len(ctx.captured_queries) == 2 with zeal_context(), CaptureQueriesContext(connection) as ctx: _ = user_1.followers.get(pk=user_2.pk).username _ = user_2.followers.get(pk=user_1.pk).username assert len(ctx.captured_queries) == 2 def test_detects_nplusone_in_forward_many_to_many_with_no_related_name(): [user_1, user_2] = UserFactory.create_batch(2) user_1.blocked.add(user_2) user_2.blocked.add(user_1) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on social.User.blocked") ): for user in User.objects.all(): _ = list(user.blocked.all()) for user in User.objects.prefetch_related("blocked").all(): _ = list(user.blocked.all()) def test_detects_nplusone_in_reverse_many_to_many_with_no_related_name(): [user_1, user_2] = UserFactory.create_batch(2) user_1.blocked.add(user_2) user_2.blocked.add(user_1) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on social.User.user_set") ): for user in User.objects.all(): _ = list(user.user_set.all()) for user in User.objects.prefetch_related("user_set").all(): _ = list(user.user_set.all()) def test_detects_nplusone_due_to_deferred_fields(): [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on social.User.username") ): for post in ( Post.objects.all().select_related("author").only("author__id") ): _ = post.author.username for post in ( Post.objects.all().select_related("author").only("author__username") ): _ = post.author.username def test_detects_nplusone_due_to_deferred_fields_in_iterator(): for _ in range(4): user = UserFactory.create() PostFactory.create(author=user) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on social.User.username") ): for post in ( Post.objects.all() .select_related("author") .only("author__id") .iterator(chunk_size=2) ): _ = post.author.username for post in ( Post.objects.all() .select_related("author") .only("author__username") .iterator(chunk_size=2) ): _ = post.author.username def test_handles_prefetch_instead_of_select_related_with_deferred_fields(): [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) with CaptureQueriesContext(connection) as ctx: # this should be a select_related! but we need to handle it even if someone # has accidentally used the wrong method. for post in ( Post.objects.all() .prefetch_related("author") .only("author__username") ): _ = post.author.username assert len(ctx.captured_queries) == 2 def test_has_configurable_threshold(settings): settings.ZEAL_NPLUSONE_THRESHOLD = 3 [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) for post in Post.objects.all(): _ = post.author.username @zeal_ignore() def test_does_nothing_if_not_in_middleware(settings, client): settings.MIDDLEWARE = [] [user_1, user_2] = UserFactory.create_batch(2) ProfileFactory.create(user=user_1) ProfileFactory.create(user=user_2) # this does not raise an N+1 error even though the same # related field gets hit twice response = client.get(f"/user/{user_1.pk}/") assert response.status_code == 200 response = client.get(f"/user/{user_2.pk}/") assert response.status_code == 200 def test_works_in_web_requests(client): [user_1, user_2] = UserFactory.create_batch(2) ProfileFactory.create(user=user_1) ProfileFactory.create(user=user_2) with pytest.raises( NPlusOneError, match=re.escape(r"N+1 detected on social.User.profile") ): response = client.get("/users/") # but multiple requests work fine response = client.get(f"/user/{user_1.pk}/") assert response.status_code == 200 response = client.get(f"/user/{user_2.pk}/") assert response.status_code == 200 def test_ignores_calls_on_different_lines(): [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) # this should *not* raise an exception _a = list(user_1.posts.all()) _b = list(user_2.posts.all()) django-zeal-2.0.4/tests/test_patch.py000066400000000000000000000060711474611071200175710ustar00rootroot00000000000000import sys import pytest from django.db import models from djangoproject.social.models import User from tests.factories import UserFactory pytestmark = pytest.mark.django_db def test_handles_calling_queryset_many_times(): UserFactory.create() user = User.objects.prefetch_related("posts").all()[0] for _ in range(sys.getrecursionlimit() + 1): # this should *not* raise a recursion error list(user.posts.all()) def test_handles_empty_querysets(): User.objects.none().first() def test_handles_get_with_values(): user = UserFactory.create() User.objects.filter(pk=user.pk).values("username").get() class CustomEqualityModel(models.Model): """Model that implements custom equality checking using related fields""" name: models.CharField = models.CharField(max_length=100) relation: models.ForeignKey[ "CustomEqualityModel", "CustomEqualityModel" ] = models.ForeignKey( "self", null=True, on_delete=models.CASCADE, related_name="related" ) def __eq__(self, other: object) -> bool: if not isinstance(other, CustomEqualityModel): return NotImplemented # Explicitly access relation to trigger potential recursion my_rel = self.relation other_rel = other.relation return my_rel == other_rel and self.name == other.name class Meta: app_label = "social" def test_handles_custom_equality_with_relations(): """ Ensure model equality comparisons don't cause infinite recursion when __eq__ methods access related fields. This is important because Django's lazy loading could trigger repeated relation lookups during equality checks. """ # Create test instances base = CustomEqualityModel.objects.create(name="base") obj1 = CustomEqualityModel.objects.create(name="test1", relation=base) obj2 = CustomEqualityModel.objects.create(name="test1", relation=base) obj3 = CustomEqualityModel.objects.create(name="test2", relation=base) assert obj1 == obj1 # Same object assert obj1 == obj2 # Different objects, same values assert obj1 != obj3 # Different values result = CustomEqualityModel.objects.filter(name="test1").first() assert result is not None _ = result.relation def test_handles_nested_relation_equality(): """ Ensure deep relation traversal works correctly without infinite recursion. This is particularly important for models that compare relations in their equality checks, as each comparison could potentially trigger a chain of database lookups through the relationship tree. """ root = CustomEqualityModel.objects.create(name="root") middle = CustomEqualityModel.objects.create(name="middle", relation=root) leaf1 = CustomEqualityModel.objects.create(name="leaf", relation=middle) leaf2 = CustomEqualityModel.objects.create(name="leaf", relation=middle) assert leaf1 == leaf2 assert leaf1.relation == leaf2.relation result = CustomEqualityModel.objects.filter(name="leaf").first() assert result is not None _ = result.relation.relation django-zeal-2.0.4/tests/test_performance.py000066400000000000000000000043551474611071200207760ustar00rootroot00000000000000import pytest from djangoproject.social.models import Post, Profile, User from zeal import zeal_context, zeal_ignore from .factories import PostFactory, ProfileFactory, UserFactory pytestmark = [pytest.mark.nozeal, pytest.mark.django_db] def test_performance(benchmark): users = UserFactory.create_batch(10) # everyone follows everyone user_following_relations = [] for user in users: for followee in users: if user == followee: continue user_following_relations.append( User.following.through( from_user_id=user.id, to_user_id=followee.id ) ) User.following.through.objects.bulk_create(user_following_relations) # give everyone a profile for user in users: ProfileFactory(user=user) # everyone has 10 posts for user in users: PostFactory.create_batch(10, author=user) @benchmark def _run_benchmark(): with ( zeal_context(), zeal_ignore(), ): # Test forward & reverse many-to-one relationships (Post -> User, User -> Posts) posts = Post.objects.all() for post in posts: _ = post.author.username # forward many-to-one _ = list(post.author.posts.all()) # reverse many-to-one # Test forward & reverse one-to-one relationships (Profile -> User, User -> Profile) profiles = Profile.objects.all() for profile in profiles: _ = profile.user.username # forward one-to-one _ = profile.user.profile.display_name # reverse one-to-one # Test forward & reverse many-to-many relationships users = User.objects.all() for user in users: _ = list(user.following.all()) # forward many-to-many _ = list(user.followers.all()) # reverse many-to-many _ = list( user.blocked.all() ) # many-to-many without related_name # Test chained relationships for follower in user.followers.all(): _ = follower.profile.display_name _ = list(follower.posts.all()) django-zeal-2.0.4/tests/test_signals.py000066400000000000000000000023011474611071200201220ustar00rootroot00000000000000import warnings import pytest import pytest_django import pytest_mock from djangoproject.social import models from zeal import errors from zeal.listeners import n_plus_one_listener from . import factories pytestmark = pytest.mark.django_db def test_signal_send_message( monkeypatch: pytest.MonkeyPatch, mocker: pytest_mock.MockerFixture, settings: pytest_django.fixtures.SettingsWrapper, ): """Test signal send message after detecting N+1 query.""" settings.ZEAL_RAISE = False patched_signal = mocker.patch( "zeal.listeners.nplusone_detected.send", ) user_1, user_2 = factories.UserFactory.create_batch(2) factories.PostFactory.create(author=user_1) factories.PostFactory.create(author=user_2) with warnings.catch_warnings(record=True): warnings.simplefilter("always") _ = [post.author.username for post in models.Post.objects.all()] patched_signal.assert_called_once() sender = patched_signal.call_args[1]["sender"] assert sender == n_plus_one_listener exception = patched_signal.call_args[1]["exception"] assert isinstance(exception, errors.NPlusOneError) assert "N+1 detected on social.Post.author" in str(exception)