├── tests
├── __init__.py
├── testapp
│ ├── __init__.py
│ └── models.py
├── test_pytest_duplicate.perf.yml
├── test_api.file_name.perf.yml
├── test_pytest_duplicate_other.perf.yml
├── test_pytest_fixture_usage.perf.yml
├── test_pytest_parametrize.perf.yml
├── test_pytest_duplicate.py
├── test_operation.py
├── test_pytest_parametrize.py
├── test_pytest_duplicate_other.py
├── test_pytest_plugin.py
├── test_pytest_fixture_usage.py
├── utils.py
├── test_utils.py
├── settings.py
├── test_api.perf.yml
├── test_yaml.py
├── test_db.py
├── test_cache.py
├── test_sql.py
└── test_api.py
├── src
└── django_perf_rec
│ ├── py.typed
│ ├── types.py
│ ├── pytest_plugin.py
│ ├── __init__.py
│ ├── settings.py
│ ├── operation.py
│ ├── yaml.py
│ ├── db.py
│ ├── utils.py
│ ├── sql.py
│ ├── cache.py
│ └── api.py
├── .github
├── ISSUE_TEMPLATE
│ ├── config.yml
│ ├── feature-request.yml
│ └── issue.yml
├── SECURITY.md
├── CODE_OF_CONDUCT.md
├── dependabot.yml
└── workflows
│ └── main.yml
├── HISTORY.rst
├── .gitignore
├── MANIFEST.in
├── .editorconfig
├── .typos.toml
├── tox.ini
├── LICENSE
├── .pre-commit-config.yaml
├── pyproject.toml
├── README.rst
├── CHANGELOG.rst
└── uv.lock
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/django_perf_rec/py.typed:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/testapp/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 |
--------------------------------------------------------------------------------
/tests/test_pytest_duplicate.perf.yml:
--------------------------------------------------------------------------------
1 | test_duplicate_name:
2 | - db: 'SELECT #'
3 |
--------------------------------------------------------------------------------
/.github/SECURITY.md:
--------------------------------------------------------------------------------
1 | Please report security issues directly over email to me@adamj.eu
2 |
--------------------------------------------------------------------------------
/HISTORY.rst:
--------------------------------------------------------------------------------
1 | See https://github.com/adamchainz/django-perf-rec/blob/main/CHANGELOG.rst
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.egg-info/
2 | *.pyc
3 | /.coverage
4 | /.coverage.*
5 | /.tox
6 | /build/
7 | /dist/
8 |
--------------------------------------------------------------------------------
/tests/test_api.file_name.perf.yml:
--------------------------------------------------------------------------------
1 | TestCaseMixinTests.test_record_performance_file_name:
2 | - cache|get: foo
3 |
--------------------------------------------------------------------------------
/.github/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | This project follows [Django's Code of Conduct](https://www.djangoproject.com/conduct/).
2 |
--------------------------------------------------------------------------------
/tests/test_pytest_duplicate_other.perf.yml:
--------------------------------------------------------------------------------
1 | test_duplicate_name:
2 | - db: 'SELECT #'
3 | - db: 'SELECT #'
4 | - db: 'SELECT #'
5 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | prune tests
2 | include CHANGELOG.rst
3 | include LICENSE
4 | include pyproject.toml
5 | include README.rst
6 | include src/*/py.typed
7 |
--------------------------------------------------------------------------------
/tests/test_pytest_fixture_usage.perf.yml:
--------------------------------------------------------------------------------
1 | test_auto_name:
2 | - db: 'SELECT #'
3 | test_auto_name_with_request:
4 | - db: 'SELECT #'
5 | test_build_name:
6 | - db: 'SELECT #'
7 |
--------------------------------------------------------------------------------
/src/django_perf_rec/types.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | PerformanceRecordItem = dict[str, str | list[str]]
4 | PerformanceRecord = list[PerformanceRecordItem]
5 |
--------------------------------------------------------------------------------
/src/django_perf_rec/pytest_plugin.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | in_pytest = False
4 |
5 |
6 | def pytest_configure() -> None:
7 | global in_pytest
8 | in_pytest = True
9 |
--------------------------------------------------------------------------------
/tests/test_pytest_parametrize.perf.yml:
--------------------------------------------------------------------------------
1 | test_with_parametrize[1337]:
2 | - db: 'SELECT #'
3 | test_with_parametrize[42]:
4 | - db: 'SELECT #'
5 | test_with_parametrize[73]:
6 | - db: 'SELECT #'
7 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: github-actions
4 | directory: "/"
5 | groups:
6 | "GitHub Actions":
7 | patterns:
8 | - "*"
9 | schedule:
10 | interval: monthly
11 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 |
3 | root = true
4 |
5 | [*]
6 | indent_style = space
7 | indent_size = 2
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 | charset = utf-8
11 | end_of_line = lf
12 |
13 | [*.py]
14 | indent_size = 4
15 |
16 | [Makefile]
17 | indent_style = tab
18 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature-request.yml:
--------------------------------------------------------------------------------
1 | name: Feature Request
2 | description: Request an enhancement or new feature.
3 | body:
4 | - type: textarea
5 | id: description
6 | attributes:
7 | label: Description
8 | description: Please describe your feature request with appropriate detail.
9 | validations:
10 | required: true
11 |
--------------------------------------------------------------------------------
/.typos.toml:
--------------------------------------------------------------------------------
1 | # Configuration file for 'typos' tool
2 | # https://github.com/crate-ci/typos
3 |
4 | [default]
5 | extend-ignore-re = [
6 | # Single line ignore comments
7 | "(?Rm)^.*(#|//)\\s*typos: ignore$",
8 | # Multi-line ignore comments
9 | "(?s)(#|//)\\s*typos: off.*?\\n\\s*(#|//)\\s*typos: on"
10 | ]
11 |
12 | [default.extend-words]
13 | Gool = "Gool"
14 |
--------------------------------------------------------------------------------
/tests/test_pytest_duplicate.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import pytest
4 |
5 | from django_perf_rec import record
6 | from tests.utils import run_query
7 |
8 | pytestmark = [pytest.mark.django_db(databases=("default", "second", "replica"))]
9 |
10 |
11 | def test_duplicate_name():
12 | with record():
13 | run_query("default", "SELECT 1337")
14 |
--------------------------------------------------------------------------------
/tests/test_operation.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from traceback import extract_stack
4 |
5 | import pytest
6 | from django.test import SimpleTestCase
7 |
8 | from django_perf_rec.operation import Operation
9 |
10 |
11 | class OperationTests(SimpleTestCase):
12 | def test_name(self):
13 | operation = Operation("hi", "world", extract_stack())
14 |
15 | with pytest.raises(TypeError):
16 | operation.name # noqa: B018
17 |
--------------------------------------------------------------------------------
/tests/test_pytest_parametrize.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import random
4 |
5 | import pytest
6 |
7 | from django_perf_rec import record
8 | from tests.utils import run_query
9 |
10 | pytestmark = [pytest.mark.django_db(databases=("default", "second"))]
11 |
12 | VALUES = [42, 73, 1337]
13 | random.shuffle(VALUES)
14 |
15 |
16 | @pytest.mark.parametrize("query_param", VALUES)
17 | def test_with_parametrize(request, query_param):
18 | with record():
19 | run_query("default", f"SELECT {query_param}")
20 |
--------------------------------------------------------------------------------
/src/django_perf_rec/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | try:
4 | import pytest
5 |
6 | _HAVE_PYTEST = True
7 | except ImportError:
8 | _HAVE_PYTEST = False
9 |
10 | if _HAVE_PYTEST:
11 | pytest.register_assert_rewrite("django_perf_rec.api")
12 |
13 | from django_perf_rec.api import (
14 | TestCaseMixin, # noqa: E402
15 | get_perf_path,
16 | get_record_name,
17 | record,
18 | )
19 |
20 | __all__ = [
21 | "TestCaseMixin",
22 | "get_record_name",
23 | "get_perf_path",
24 | "record",
25 | ]
26 |
--------------------------------------------------------------------------------
/tests/test_pytest_duplicate_other.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import pytest
4 | from django.test.utils import override_settings
5 |
6 | from django_perf_rec import record
7 | from tests.utils import run_query
8 |
9 | pytestmark = [pytest.mark.django_db(databases=("default", "second", "replica"))]
10 |
11 |
12 | @override_settings(PERF_REC={"MODE": "none"})
13 | def test_duplicate_name():
14 | with record():
15 | run_query("default", "SELECT 1337")
16 | run_query("default", "SELECT 4997")
17 | run_query("default", "SELECT 4998")
18 |
--------------------------------------------------------------------------------
/tests/test_pytest_plugin.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from django.test import SimpleTestCase
4 |
5 | from django_perf_rec import pytest_plugin
6 | from tests.utils import pretend_not_under_pytest
7 |
8 |
9 | class PytestPluginTests(SimpleTestCase):
10 | def test_in_pytest(self):
11 | # We always run our tests in pytest
12 | assert pytest_plugin.in_pytest
13 |
14 | def test_in_pytest_pretend(self):
15 | # The test helper should work to ignore it
16 | with pretend_not_under_pytest():
17 | assert not pytest_plugin.in_pytest
18 |
--------------------------------------------------------------------------------
/tests/testapp/models.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from django.db import models
4 |
5 |
6 | class Author(models.Model):
7 | name = models.CharField(max_length=32, unique=True)
8 | age = models.IntegerField()
9 |
10 |
11 | class Book(models.Model):
12 | title = models.CharField(max_length=128)
13 | author = models.ForeignKey(Author, on_delete=models.CASCADE)
14 |
15 |
16 | class Award(models.Model):
17 | name = models.CharField(max_length=128)
18 | author = models.ForeignKey(Author, on_delete=models.CASCADE)
19 |
20 |
21 | class Contract(models.Model):
22 | amount = models.IntegerField()
23 | author = models.ManyToManyField(Author)
24 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | requires =
3 | tox>=4.2
4 | env_list =
5 | py314-django{60, 52}
6 | py313-django{60, 52, 51}
7 | py312-django{60, 52, 51, 50, 42}
8 | py311-django{52, 51, 50, 42}
9 | py310-django{52, 51, 50, 42}
10 |
11 | [testenv]
12 | runner = uv-venv-lock-runner
13 | package = wheel
14 | wheel_build_env = .pkg
15 | set_env =
16 | PYTHONDEVMODE = 1
17 | commands =
18 | python \
19 | -W error::ResourceWarning \
20 | -W error::DeprecationWarning \
21 | -W error::PendingDeprecationWarning \
22 | -m pytest {posargs:tests}
23 | dependency_groups =
24 | test
25 | django42: django42
26 | django50: django50
27 | django51: django51
28 | django52: django52
29 | django60: django60
30 |
--------------------------------------------------------------------------------
/src/django_perf_rec/settings.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Any, Literal
4 |
5 | from django.conf import settings
6 |
7 |
8 | class Settings:
9 | defaults = {"HIDE_COLUMNS": True, "MODE": "once"}
10 |
11 | def get_setting(self, key: str) -> Any:
12 | try:
13 | return settings.PERF_REC[key]
14 | except (AttributeError, KeyError):
15 | return self.defaults.get(key, None)
16 |
17 | @property
18 | def HIDE_COLUMNS(self) -> bool:
19 | return bool(self.get_setting("HIDE_COLUMNS"))
20 |
21 | @property
22 | def MODE(self) -> Literal["all", "none", "once", "overwrite"]:
23 | value = self.get_setting("MODE")
24 | assert value in ("all", "none", "once", "overwrite")
25 | return value # type: ignore [no-any-return]
26 |
27 |
28 | perf_rec_settings = Settings()
29 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/issue.yml:
--------------------------------------------------------------------------------
1 | name: Issue
2 | description: File an issue
3 | body:
4 | - type: input
5 | id: python_version
6 | attributes:
7 | label: Python Version
8 | description: Which version of Python were you using?
9 | placeholder: 3.14.0
10 | validations:
11 | required: false
12 | - type: input
13 | id: django_version
14 | attributes:
15 | label: Django Version
16 | description: Which version of Django were you using?
17 | placeholder: 3.2.0
18 | validations:
19 | required: false
20 | - type: input
21 | id: package_version
22 | attributes:
23 | label: Package Version
24 | description: Which version of this package were you using? If not the latest version, please check this issue has not since been resolved.
25 | placeholder: 1.0.0
26 | validations:
27 | required: false
28 | - type: textarea
29 | id: description
30 | attributes:
31 | label: Description
32 | description: Please describe your issue.
33 | validations:
34 | required: true
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 YPlan, Adam Johnson
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/tests/test_pytest_fixture_usage.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import pytest
4 |
5 | from django_perf_rec import get_perf_path, get_record_name, record
6 | from tests.utils import run_query
7 |
8 | pytestmark = [pytest.mark.django_db(databases=("default", "second", "replica"))]
9 |
10 |
11 | @pytest.fixture
12 | def record_auto_name():
13 | with record():
14 | yield
15 |
16 |
17 | def test_auto_name(record_auto_name):
18 | run_query("default", "SELECT 1337")
19 |
20 |
21 | @pytest.fixture
22 | def record_auto_name_with_request(request):
23 | with record():
24 | yield
25 |
26 |
27 | def test_auto_name_with_request(record_auto_name_with_request):
28 | run_query("default", "SELECT 1337")
29 |
30 |
31 | @pytest.fixture
32 | def record_build_name(request):
33 | record_name = get_record_name(
34 | class_name=request.cls.__name__ if request.cls is not None else None,
35 | test_name=request.function.__name__,
36 | )
37 | path = get_perf_path(file_path=request.fspath.strpath)
38 | with record(record_name=record_name, path=path):
39 | yield
40 |
41 |
42 | def test_build_name(record_build_name):
43 | run_query("default", "SELECT 1337")
44 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | tags:
8 | - '**'
9 | pull_request:
10 |
11 | concurrency:
12 | group: ${{ github.head_ref || github.run_id }}
13 | cancel-in-progress: true
14 |
15 | jobs:
16 | tests:
17 | name: Python ${{ matrix.python-version }}
18 | runs-on: ubuntu-24.04
19 |
20 | strategy:
21 | matrix:
22 | python-version:
23 | - '3.10'
24 | - '3.11'
25 | - '3.12'
26 | - '3.13'
27 | - '3.14'
28 |
29 | steps:
30 | - uses: actions/checkout@v6
31 |
32 | - uses: actions/setup-python@v6
33 | with:
34 | python-version: ${{ matrix.python-version }}
35 | allow-prereleases: true
36 |
37 | - name: Install uv
38 | uses: astral-sh/setup-uv@v7
39 | with:
40 | enable-cache: true
41 |
42 | - name: Run tox targets for ${{ matrix.python-version }}
43 | run: uvx --with tox-uv tox run -f py$(echo ${{ matrix.python-version }} | tr -d .)
44 |
45 | release:
46 | needs: [tests]
47 | if: success() && startsWith(github.ref, 'refs/tags/')
48 | runs-on: ubuntu-24.04
49 | environment: release
50 |
51 | permissions:
52 | contents: read
53 | id-token: write
54 |
55 | steps:
56 | - uses: actions/checkout@v6
57 |
58 | - uses: astral-sh/setup-uv@v7
59 |
60 | - name: Build
61 | run: uv build
62 |
63 | - uses: pypa/gh-action-pypi-publish@release/v1
64 |
--------------------------------------------------------------------------------
/tests/utils.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import errno
4 | import os
5 | import shutil
6 | import traceback
7 | from collections.abc import Callable, Generator
8 | from contextlib import contextmanager
9 | from functools import wraps
10 | from typing import Any, TypeVar, cast
11 | from unittest import mock
12 |
13 | from django.db import connections
14 |
15 | from django_perf_rec import pytest_plugin
16 |
17 |
18 | def run_query(alias: str, sql: str, params: list[str] | None = None) -> None:
19 | with connections[alias].cursor() as cursor:
20 | cursor.execute(sql, params)
21 |
22 |
23 | @contextmanager
24 | def temporary_path(path: str) -> Generator[None]:
25 | ensure_path_does_not_exist(path)
26 | yield
27 | ensure_path_does_not_exist(path)
28 |
29 |
30 | def ensure_path_does_not_exist(path: str) -> None:
31 | if path.endswith("/"):
32 | shutil.rmtree(path, ignore_errors=True)
33 | else:
34 | try:
35 | os.unlink(path)
36 | except OSError as exc:
37 | if exc.errno != errno.ENOENT:
38 | raise
39 |
40 |
41 | @contextmanager
42 | def pretend_not_under_pytest() -> Generator[None]:
43 | orig = pytest_plugin.in_pytest
44 | pytest_plugin.in_pytest = False
45 | try:
46 | yield
47 | finally:
48 | pytest_plugin.in_pytest = orig
49 |
50 |
51 | TestFunc = TypeVar("TestFunc", bound=Callable[..., None])
52 |
53 |
54 | def override_extract_stack(func: TestFunc) -> TestFunc:
55 | @wraps(func)
56 | def wrapper(*args: Any, **kwargs: Any) -> None:
57 | summary = traceback.extract_stack()
58 | with mock.patch.object(traceback, "extract_stack", return_value=summary):
59 | func(*args, stack_summary=summary, **kwargs)
60 |
61 | return cast(TestFunc, wrapper)
62 |
--------------------------------------------------------------------------------
/tests/test_utils.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from django.test import SimpleTestCase
4 |
5 | from django_perf_rec.utils import TestDetails, current_test, sorted_names
6 |
7 |
8 | class CurrentTestTests(SimpleTestCase):
9 | def test_here(self):
10 | details = current_test()
11 | assert details.file_path == __file__
12 | assert details.class_name == "CurrentTestTests"
13 | assert details.test_name == "test_here"
14 |
15 | def test_twice_same(self):
16 | assert current_test() == current_test()
17 |
18 | def test_functional(self):
19 | def test_thats_functional() -> TestDetails:
20 | return current_test()
21 |
22 | details = test_thats_functional()
23 | assert details.file_path == __file__
24 | assert details.class_name is None
25 | assert details.test_name == "test_thats_functional"
26 |
27 | def test_request_local(self):
28 | def test_with_request() -> TestDetails:
29 | request = object() # noqa: F841
30 | return current_test()
31 |
32 | details = test_with_request()
33 | assert details.file_path == __file__
34 | assert details.class_name is None
35 | assert details.test_name == "test_with_request"
36 |
37 |
38 | class SortedNamesTests(SimpleTestCase):
39 | def test_empty(self):
40 | assert sorted_names([]) == []
41 |
42 | def test_just_default(self):
43 | assert sorted_names(["default"]) == ["default"]
44 |
45 | def test_just_something(self):
46 | assert sorted_names(["something"]) == ["something"]
47 |
48 | def test_does_sort(self):
49 | assert sorted_names(["b", "a"]) == ["a", "b"]
50 |
51 | def test_sort_keeps_default_first(self):
52 | assert sorted_names(["a", "default"]) == ["default", "a"]
53 |
--------------------------------------------------------------------------------
/tests/settings.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import os
4 | from typing import Any
5 |
6 | BASE_DIR = os.path.dirname(os.path.dirname(__file__))
7 |
8 | DEBUG = True
9 | TEMPLATE_DEBUG = DEBUG
10 |
11 | SECRET_KEY = "NOTASECRET"
12 |
13 | DATABASES = {
14 | "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"},
15 | "replica": {
16 | "ENGINE": "django.db.backends.sqlite3",
17 | "NAME": ":memory:",
18 | "TEST": {"MIRROR": "default"},
19 | },
20 | "second": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"},
21 | }
22 |
23 | CACHES = {
24 | "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"},
25 | "second": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"},
26 | }
27 |
28 | ALLOWED_HOSTS: list[str] = []
29 |
30 | INSTALLED_APPS = ["django.contrib.auth", "django.contrib.contenttypes", "tests.testapp"]
31 |
32 | MIDDLEWARE_CLASSES = (
33 | "django.middleware.common.CommonMiddleware",
34 | "django.middleware.csrf.CsrfViewMiddleware",
35 | "django.contrib.sessions.middleware.SessionMiddleware",
36 | "django.contrib.auth.middleware.AuthenticationMiddleware",
37 | "django.contrib.messages.middleware.MessageMiddleware",
38 | "django.middleware.clickjacking.XFrameOptionsMiddleware",
39 | )
40 |
41 | PERF_REC: dict[str, Any] = {}
42 |
43 | ROOT_URLCONF = "tests.urls"
44 | LANGUAGE_CODE = "en-us"
45 | TIME_ZONE = "UTC"
46 | USE_I18N = True
47 |
48 | TEMPLATES = [
49 | {
50 | "BACKEND": "django.template.backends.django.DjangoTemplates",
51 | "DIRS": [],
52 | "APP_DIRS": True,
53 | "OPTIONS": {
54 | "context_processors": [
55 | "django.template.context_processors.debug",
56 | "django.template.context_processors.request",
57 | "django.contrib.auth.context_processors.auth",
58 | "django.contrib.messages.context_processors.messages",
59 | ]
60 | },
61 | }
62 | ]
63 |
64 | USE_TZ = True
65 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | ci:
2 | autoupdate_schedule: monthly
3 |
4 | default_language_version:
5 | python: python3.13
6 |
7 | repos:
8 | - repo: https://github.com/pre-commit/pre-commit-hooks
9 | rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0
10 | hooks:
11 | - id: check-added-large-files
12 | - id: check-case-conflict
13 | - id: check-json
14 | - id: check-merge-conflict
15 | - id: check-symlinks
16 | - id: check-toml
17 | - id: end-of-file-fixer
18 | - id: trailing-whitespace
19 | - repo: https://github.com/crate-ci/typos
20 | rev: b04a3e939a8f2800a1dc330d7e569e7557879d41 # frozen: v1
21 | hooks:
22 | - id: typos
23 | - repo: https://github.com/tox-dev/pyproject-fmt
24 | rev: 68b1ed526e7533ac54a2e42874b99ae6c26807a2 # frozen: v2.11.0
25 | hooks:
26 | - id: pyproject-fmt
27 | - repo: https://github.com/tox-dev/tox-ini-fmt
28 | rev: be26ee0d710a48f7c1acc1291d84082036207bd3 # frozen: 1.7.0
29 | hooks:
30 | - id: tox-ini-fmt
31 | - repo: https://github.com/rstcheck/rstcheck
32 | rev: 27258fde1ee7d3b1e6a7bbc58f4c7b1dd0e719e5 # frozen: v6.2.5
33 | hooks:
34 | - id: rstcheck
35 | additional_dependencies:
36 | - tomli==2.0.1
37 | - repo: https://github.com/adamchainz/django-upgrade
38 | rev: 553731fe59437e0bd2cf18b10144116422bed259 # frozen: 1.29.1
39 | hooks:
40 | - id: django-upgrade
41 | - repo: https://github.com/adamchainz/blacken-docs
42 | rev: dda8db18cfc68df532abf33b185ecd12d5b7b326 # frozen: 1.20.0
43 | hooks:
44 | - id: blacken-docs
45 | additional_dependencies:
46 | - black==25.1.0
47 | - repo: https://github.com/astral-sh/ruff-pre-commit
48 | rev: aad66557af3b56ba6d4d69cd1b6cba87cef50cbb # frozen: v0.14.3
49 | hooks:
50 | - id: ruff-check
51 | args: [ --fix ]
52 | - id: ruff-format
53 | - repo: https://github.com/pre-commit/mirrors-mypy
54 | rev: 9f70dc58c23dfcca1b97af99eaeee3140a807c7e # frozen: v1.18.2
55 | hooks:
56 | - id: mypy
57 | additional_dependencies:
58 | - django-stubs==5.1.2
59 | - pytest==7.1.2
60 | - types-pyyaml
61 |
--------------------------------------------------------------------------------
/tests/test_api.perf.yml:
--------------------------------------------------------------------------------
1 | RecordTests.test_delete_on_cascade_called_twice:
2 | - db: DELETE FROM "testapp_book" WHERE "testapp_book"."author_id" IN (...)
3 | - db: DELETE FROM "testapp_award" WHERE "testapp_award"."author_id" IN (...)
4 | - db: DELETE FROM "testapp_contract_author" WHERE "testapp_contract_author"."author_id" IN (...)
5 | - db: DELETE FROM "testapp_author" WHERE "testapp_author"."id" IN (...)
6 | RecordTests.test_dependent_QuerySet_annotate:
7 | - db: SELECT "testapp_author"."id", "testapp_author"."name", "testapp_author"."age", UPPER("testapp_author"."name") AS "y", UPPER("testapp_author"."name") AS "x" FROM "testapp_author"
8 | RecordTests.test_get_or_set:
9 | - cache|get_or_set: foo
10 | RecordTests.test_multiple_cache_ops:
11 | - cache|set: foo
12 | - cache|second|get_many:
13 | - bar
14 | - foo
15 | - cache|delete: foo
16 | RecordTests.test_multiple_calls_in_same_function_are_different_records:
17 | - cache|get: foo
18 | RecordTests.test_multiple_calls_in_same_function_are_different_records.2:
19 | - cache|get: bar
20 | RecordTests.test_multiple_db_queries:
21 | - db: 'SELECT #'
22 | - db: 'SELECT #'
23 | RecordTests.test_non_deterministic_Q_query:
24 | - db: 'SELECT ... FROM "testapp_author" WHERE ("testapp_author"."age" = # AND "testapp_author"."name" = #)'
25 | RecordTests.test_non_deterministic_QuerySet_annotate:
26 | - db: SELECT ... FROM "testapp_author"
27 | RecordTests.test_non_deterministic_QuerySet_extra:
28 | - db: SELECT ... FROM "testapp_author"
29 | RecordTests.test_single_cache_op:
30 | - cache|get: foo
31 | RecordTests.test_single_db_query:
32 | - db: 'SELECT #'
33 | RecordTests.test_single_db_query_model:
34 | - db: SELECT ... FROM "testapp_author"
35 | RecordTests.test_single_db_query_model_with_columns:
36 | - db: SELECT "testapp_author"."id", "testapp_author"."name", "testapp_author"."age" FROM "testapp_author"
37 | RecordTests.test_single_db_query_with_filtering_negative: []
38 | RecordTests.test_single_db_query_with_filtering_positive:
39 | - db: 'SELECT #'
40 | TestCaseMixinTests.test_record_performance:
41 | - cache|get: foo
42 | custom:
43 | - cache|get: foo
44 | other:
45 | - cache|get: foo
46 | test_diff:
47 | - cache|get: foo
48 |
--------------------------------------------------------------------------------
/src/django_perf_rec/operation.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from collections.abc import Callable
4 | from traceback import StackSummary
5 | from types import TracebackType
6 | from typing import Any
7 |
8 | from django.conf import settings
9 |
10 | from django_perf_rec.utils import sorted_names
11 |
12 |
13 | class Operation:
14 | def __init__(
15 | self, alias: str, query: str | list[str], traceback: StackSummary
16 | ) -> None:
17 | self.alias = alias
18 | self.query = query
19 | self.traceback = traceback
20 |
21 | def __eq__(self, other: Any) -> bool:
22 | return (
23 | isinstance(other, type(self))
24 | and self.alias == other.alias
25 | and self.query == other.query
26 | and self.traceback == other.traceback
27 | )
28 |
29 | @property
30 | def name(self) -> str:
31 | raise TypeError("Needs implementing in subclass!")
32 |
33 |
34 | class BaseRecorder:
35 | def __init__(self, alias: str, callback: Callable[[Operation], None]) -> None:
36 | self.alias = alias
37 | self.callback = callback
38 |
39 | def __enter__(self) -> None:
40 | pass
41 |
42 | def __exit__(
43 | self,
44 | exc_type: type[BaseException] | None,
45 | exc_value: BaseException | None,
46 | exc_traceback: TracebackType | None,
47 | ) -> None:
48 | pass
49 |
50 |
51 | class AllSourceRecorder:
52 | """
53 | Launches Recorders on all the active sources
54 | """
55 |
56 | sources_setting: str
57 | recorder_class: type[BaseRecorder]
58 |
59 | def __init__(self, callback: Callable[[Operation], None]) -> None:
60 | self.callback = callback
61 |
62 | def __enter__(self) -> None:
63 | self.recorders = []
64 | for name in sorted_names(getattr(settings, self.sources_setting).keys()):
65 | recorder = self.recorder_class(name, self.callback)
66 | recorder.__enter__()
67 | self.recorders.append(recorder)
68 |
69 | def __exit__(
70 | self,
71 | exc_type: type[BaseException] | None,
72 | exc_value: BaseException | None,
73 | exc_traceback: TracebackType | None,
74 | ) -> None:
75 | for recorder in reversed(self.recorders):
76 | recorder.__exit__(exc_type, exc_value, exc_traceback)
77 | self.recorders = []
78 |
--------------------------------------------------------------------------------
/src/django_perf_rec/yaml.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import errno
4 | import os
5 | from typing import Any
6 |
7 | import yaml
8 | from django.core.files import locks
9 |
10 | from django_perf_rec.types import PerformanceRecord
11 |
12 |
13 | class KVFile:
14 | def __init__(self, file_name: str) -> None:
15 | self.file_name = file_name
16 | self.data = self.load(file_name)
17 |
18 | def __len__(self) -> int:
19 | return len(self.data)
20 |
21 | LOAD_CACHE: dict[str, dict[str, Any]] = {}
22 |
23 | @classmethod
24 | def load(cls, file_name: str) -> dict[str, PerformanceRecord]:
25 | if file_name not in cls.LOAD_CACHE:
26 | cls.LOAD_CACHE[file_name] = cls.load_file(file_name)
27 | return cls.LOAD_CACHE[file_name]
28 |
29 | @classmethod
30 | def load_file(cls, file_name: str) -> dict[str, PerformanceRecord]:
31 | try:
32 | with open(file_name) as fp:
33 | locks.lock(fp, locks.LOCK_EX)
34 | content = fp.read()
35 | except OSError as exc:
36 | if exc.errno == errno.ENOENT:
37 | content = "{}"
38 | else:
39 | raise
40 |
41 | data = yaml.safe_load(content)
42 |
43 | if data is None:
44 | return {}
45 | elif not isinstance(data, dict):
46 | raise TypeError(f"YAML content of {file_name} is not a dictionary")
47 |
48 | return data
49 |
50 | @classmethod
51 | def _clear_load_cache(cls) -> None:
52 | # Should really only be used in testing this class
53 | cls.LOAD_CACHE = {}
54 |
55 | def get(
56 | self, key: str, default: PerformanceRecord | None
57 | ) -> PerformanceRecord | None:
58 | return self.data.get(key, default)
59 |
60 | def set_and_save(self, key: str, value: PerformanceRecord) -> None:
61 | if self.data.get(key, object()) == value:
62 | return
63 |
64 | fd = os.open(self.file_name, os.O_RDWR | os.O_CREAT, mode=0o666)
65 | with os.fdopen(fd, "r+") as fp:
66 | locks.lock(fd, locks.LOCK_EX)
67 |
68 | data = yaml.safe_load(fp)
69 | if data is None:
70 | data = {}
71 |
72 | self.data[key] = value
73 | data[key] = value
74 |
75 | fp.seek(0)
76 | yaml.safe_dump(
77 | data, fp, default_flow_style=False, allow_unicode=True, width=10000
78 | )
79 | fp.truncate()
80 |
--------------------------------------------------------------------------------
/tests/test_yaml.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import shutil
4 | from tempfile import mkdtemp
5 |
6 | import pytest
7 | import yaml
8 | from django.test import SimpleTestCase
9 |
10 | from django_perf_rec.yaml import KVFile
11 |
12 |
13 | class KVFileTests(SimpleTestCase):
14 | def setUp(self):
15 | super().setUp()
16 | KVFile._clear_load_cache()
17 | self.temp_dir = mkdtemp()
18 |
19 | def tearDown(self):
20 | shutil.rmtree(self.temp_dir)
21 | super().tearDown()
22 |
23 | def test_load_no_permissions(self):
24 | with pytest.raises(IOError):
25 | KVFile("/")
26 |
27 | def test_load_non_existent_is_empty(self):
28 | kvf = KVFile(self.temp_dir + "/foo.yml")
29 | assert len(kvf) == 0
30 | assert kvf.get("foo", None) is None
31 |
32 | def test_load_existent(self):
33 | file_name = self.temp_dir + "/foo.yml"
34 | with open(file_name, "w") as fp:
35 | fp.write("foo: [{bar: baz}]")
36 |
37 | kvf = KVFile(file_name)
38 | assert len(kvf) == 1
39 | assert kvf.get("foo", None) == [{"bar": "baz"}]
40 |
41 | def test_load_empty(self):
42 | file_name = self.temp_dir + "/foo.yml"
43 | with open(file_name, "w") as fp:
44 | fp.write("")
45 |
46 | assert len(KVFile(file_name)) == 0
47 |
48 | def test_load_whitespace_empty(self):
49 | file_name = self.temp_dir + "/foo.yml"
50 | with open(file_name, "w") as fp:
51 | fp.write(" \n")
52 |
53 | assert len(KVFile(file_name)) == 0
54 |
55 | def test_load_non_dictionary(self):
56 | file_name = self.temp_dir + "/foo.yml"
57 | with open(file_name, "w") as fp:
58 | fp.write("[not, a, dictionary]")
59 |
60 | with pytest.raises(TypeError) as excinfo:
61 | KVFile(file_name)
62 | assert "not a dictionary" in str(excinfo.value)
63 |
64 | def test_get_after_set_same(self):
65 | kvf = KVFile(self.temp_dir + "/foo.yml")
66 | kvf.set_and_save("foo", [{"bar": "baz"}])
67 |
68 | assert len(kvf) == 1
69 | assert kvf.get("foo", None) == [{"bar": "baz"}]
70 |
71 | def test_load_second_same(self):
72 | kvf = KVFile(self.temp_dir + "/foo.yml")
73 | kvf.set_and_save("foo", [{"bar": "baz"}])
74 | kvf2 = KVFile(self.temp_dir + "/foo.yml")
75 |
76 | assert len(kvf2) == 1
77 | assert kvf2.get("foo", None) == [{"bar": "baz"}]
78 |
79 | def test_sets_dont_cause_append_duplication(self):
80 | file_name = self.temp_dir + "/foo.yml"
81 | kvf = KVFile(file_name)
82 | kvf.set_and_save("foo", [{"bar": "baz"}])
83 | kvf.set_and_save("foo2", [{"bar": "baz"}])
84 |
85 | with open(file_name) as fp:
86 | lines = fp.readlines()
87 | fp.seek(0)
88 | data = yaml.safe_load(fp)
89 |
90 | assert len(lines) == 4
91 | assert data == {
92 | "foo": [{"bar": "baz"}],
93 | "foo2": [{"bar": "baz"}],
94 | }
95 |
--------------------------------------------------------------------------------
/src/django_perf_rec/db.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import traceback
4 | from collections.abc import Callable
5 | from functools import wraps
6 | from types import MethodType, TracebackType
7 | from typing import Any, TypeVar, cast
8 |
9 | from django.db import DEFAULT_DB_ALIAS, connections
10 |
11 | from django_perf_rec.operation import AllSourceRecorder, BaseRecorder, Operation
12 | from django_perf_rec.settings import perf_rec_settings
13 | from django_perf_rec.sql import sql_fingerprint
14 |
15 |
16 | class DBOp(Operation):
17 | @property
18 | def name(self) -> str:
19 | name_parts = ["db"]
20 | if self.alias != DEFAULT_DB_ALIAS:
21 | name_parts.append(self.alias)
22 | return "|".join(name_parts)
23 |
24 |
25 | LastExecutedQuery = TypeVar("LastExecutedQuery", bound=Callable[..., str])
26 |
27 |
28 | class DBRecorder(BaseRecorder):
29 | """
30 | Monkey-patch-wraps a database connection to call 'callback' on every
31 | query it runs.
32 | """
33 |
34 | def __enter__(self) -> None:
35 | """
36 | When using the debug cursor wrapper, Django calls
37 | connection.ops.last_executed_query to get the SQL from the client
38 | library. Here we wrap this function on the connection to grab the SQL
39 | as it comes out.
40 | """
41 | connection = connections[self.alias]
42 | self.orig_force_debug_cursor = connection.force_debug_cursor
43 | connection.force_debug_cursor = True
44 |
45 | def call_callback(func: LastExecutedQuery) -> LastExecutedQuery:
46 | alias = self.alias
47 | callback = self.callback
48 |
49 | @wraps(func)
50 | def inner(self: Any, *args: Any, **kwargs: Any) -> str:
51 | sql = func(*args, **kwargs)
52 | hide_columns = perf_rec_settings.HIDE_COLUMNS
53 | callback(
54 | DBOp(
55 | alias=alias,
56 | query=sql_fingerprint(sql, hide_columns=hide_columns),
57 | traceback=traceback.extract_stack(),
58 | )
59 | )
60 | return sql
61 |
62 | return cast(LastExecutedQuery, inner)
63 |
64 | self.orig_last_executed_query = connection.ops.last_executed_query
65 | connection.ops.last_executed_query = MethodType( # type: ignore [method-assign]
66 | call_callback(connection.ops.last_executed_query), connection.ops
67 | )
68 |
69 | def __exit__(
70 | self,
71 | exc_type: type[BaseException] | None,
72 | exc_value: BaseException | None,
73 | exc_traceback: TracebackType | None,
74 | ) -> None:
75 | connection = connections[self.alias]
76 | connection.force_debug_cursor = self.orig_force_debug_cursor
77 | connection.ops.last_executed_query = ( # type: ignore [method-assign]
78 | self.orig_last_executed_query
79 | )
80 |
81 |
82 | class AllDBRecorder(AllSourceRecorder):
83 | """
84 | Launches DBRecorders on all database connections
85 | """
86 |
87 | sources_setting = "DATABASES"
88 | recorder_class = DBRecorder
89 |
--------------------------------------------------------------------------------
/tests/test_db.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from traceback import StackSummary, extract_stack
4 | from unittest import mock
5 |
6 | from django.test import SimpleTestCase, TestCase
7 |
8 | from django_perf_rec.db import AllDBRecorder, DBOp, DBRecorder
9 | from tests.utils import override_extract_stack, run_query
10 |
11 |
12 | class DBOpTests(SimpleTestCase):
13 | def test_create(self):
14 | op = DBOp("myalias", "SELECT 1", extract_stack())
15 | assert op.alias == "myalias"
16 | assert op.query == "SELECT 1"
17 | assert isinstance(op.traceback, StackSummary)
18 |
19 | def test_equal(self):
20 | summary = extract_stack()
21 | assert DBOp("foo", "bar", summary) == DBOp("foo", "bar", summary)
22 |
23 | def test_not_equal_alias(self):
24 | summary = extract_stack()
25 | assert DBOp("foo", "bar", summary) != DBOp("baz", "bar", summary)
26 |
27 | def test_not_equal_sql(self):
28 | summary = extract_stack()
29 | assert DBOp("foo", "bar", summary) != DBOp("foo", "baz", summary)
30 |
31 | def test_not_equal_traceback(self):
32 | assert DBOp("foo", "bar", extract_stack(limit=1)) != DBOp(
33 | "foo", "bar", extract_stack(limit=2)
34 | )
35 |
36 |
37 | class DBRecorderTests(TestCase):
38 | databases = {"default", "second", "replica"}
39 |
40 | @override_extract_stack
41 | def test_default(self, stack_summary):
42 | callback = mock.Mock()
43 | with DBRecorder("default", callback):
44 | run_query("default", "SELECT 1")
45 | callback.assert_called_once_with(DBOp("default", "SELECT #", stack_summary))
46 |
47 | @override_extract_stack
48 | def test_secondary(self, stack_summary):
49 | callback = mock.Mock()
50 | with DBRecorder("second", callback):
51 | run_query("second", "SELECT 1")
52 | callback.assert_called_once_with(DBOp("second", "SELECT #", stack_summary))
53 |
54 | @override_extract_stack
55 | def test_replica(self, stack_summary):
56 | callback = mock.Mock()
57 | with DBRecorder("replica", callback):
58 | run_query("replica", "SELECT 1")
59 | callback.assert_called_once_with(DBOp("replica", "SELECT #", stack_summary))
60 |
61 | def test_secondary_default_not_recorded(self):
62 | callback = mock.Mock()
63 | with DBRecorder("second", callback):
64 | run_query("default", "SELECT 1")
65 | assert len(callback.mock_calls) == 0
66 |
67 | def test_record_traceback(self):
68 | callback = mock.Mock()
69 | with DBRecorder("default", callback):
70 | run_query("default", "SELECT 1")
71 |
72 | assert len(callback.mock_calls) == 1
73 | assert "django_perf_rec/db.py" in str(
74 | callback.call_args_list[0][0][0].traceback
75 | )
76 |
77 |
78 | class AllDBRecorderTests(TestCase):
79 | databases = {"default", "second", "replica"}
80 |
81 | @override_extract_stack
82 | def test_records_all(self, stack_summary):
83 | callback = mock.Mock()
84 | with AllDBRecorder(callback):
85 | run_query("replica", "SELECT 1")
86 | run_query("default", "SELECT 2")
87 | run_query("second", "SELECT 3")
88 |
89 | assert callback.mock_calls == [
90 | mock.call(DBOp("replica", "SELECT #", stack_summary)),
91 | mock.call(DBOp("default", "SELECT #", stack_summary)),
92 | mock.call(DBOp("second", "SELECT #", stack_summary)),
93 | ]
94 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | build-backend = "setuptools.build_meta"
3 | requires = [
4 | "setuptools>=77",
5 | ]
6 |
7 | [project]
8 | name = "django-perf-rec"
9 | version = "4.31.0"
10 | description = "Keep detailed records of the performance of your Django code."
11 | readme = "README.rst"
12 | keywords = [
13 | "Django",
14 | ]
15 | license = "MIT"
16 | license-files = [ "LICENSE" ]
17 | authors = [
18 | { name = "Adam Johnson", email = "me@adamj.eu" },
19 | ]
20 | requires-python = ">=3.10"
21 | classifiers = [
22 | "Development Status :: 6 - Mature",
23 | "Framework :: Django :: 4.2",
24 | "Framework :: Django :: 5.0",
25 | "Framework :: Django :: 5.1",
26 | "Framework :: Django :: 5.2",
27 | "Framework :: Django :: 6.0",
28 | "Intended Audience :: Developers",
29 | "Natural Language :: English",
30 | "Operating System :: OS Independent",
31 | "Programming Language :: Python :: 3 :: Only",
32 | "Programming Language :: Python :: 3.10",
33 | "Programming Language :: Python :: 3.11",
34 | "Programming Language :: Python :: 3.12",
35 | "Programming Language :: Python :: 3.13",
36 | "Programming Language :: Python :: 3.14",
37 | "Programming Language :: Python :: Implementation :: CPython",
38 | "Typing :: Typed",
39 | ]
40 | dependencies = [
41 | "django>=4.2",
42 | "pyyaml",
43 | "sqlparse>=0.4.4",
44 | ]
45 | urls = { Changelog = "https://github.com/adamchainz/django-perf-rec/blob/main/CHANGELOG.rst", Funding = "https://adamj.eu/books/", Repository = "https://github.com/adamchainz/django-perf-rec" }
46 | entry-points.pytest11.django_perf_rec = "django_perf_rec.pytest_plugin"
47 |
48 | [dependency-groups]
49 | test = [
50 | "pygments",
51 | "pytest",
52 | "pytest-django",
53 | "pytest-randomly",
54 | "pyyaml",
55 | "sqlparse",
56 | ]
57 | django42 = [ "django>=4.2a1,<5; python_version>='3.8'" ]
58 | django50 = [ "django>=5a1,<5.1; python_version>='3.10'" ]
59 | django51 = [ "django>=5.1a1,<5.2; python_version>='3.10'" ]
60 | django52 = [ "django>=5.2a1,<6; python_version>='3.10'" ]
61 | django60 = [ "django>=6a1,<6.1; python_version>='3.12'" ]
62 |
63 | [tool.uv]
64 | conflicts = [
65 | [
66 | { group = "django42" },
67 | { group = "django50" },
68 | { group = "django51" },
69 | { group = "django52" },
70 | { group = "django60" },
71 | ],
72 | ]
73 |
74 | [tool.ruff]
75 | lint.select = [
76 | # flake8-bugbear
77 | "B",
78 | # flake8-comprehensions
79 | "C4",
80 | # pycodestyle
81 | "E",
82 | # Pyflakes errors
83 | "F",
84 | # isort
85 | "I",
86 | # flake8-simplify
87 | "SIM",
88 | # flake8-tidy-imports
89 | "TID",
90 | # pyupgrade
91 | "UP",
92 | # Pyflakes warnings
93 | "W",
94 | ]
95 | lint.ignore = [
96 | # flake8-bugbear opinionated rules
97 | "B9",
98 | # line-too-long
99 | "E501",
100 | # suppressible-exception
101 | "SIM105",
102 | # if-else-block-instead-of-if-exp
103 | "SIM108",
104 | ]
105 | lint.extend-safe-fixes = [
106 | # non-pep585-annotation
107 | "UP006",
108 | ]
109 | lint.isort.required-imports = [ "from __future__ import annotations" ]
110 |
111 | [tool.pyproject-fmt]
112 | max_supported_python = "3.14"
113 |
114 | [tool.pytest.ini_options]
115 | addopts = """\
116 | --strict-config
117 | --strict-markers
118 | --ds=tests.settings
119 | """
120 | django_find_project = false
121 | xfail_strict = true
122 |
123 | [tool.mypy]
124 | enable_error_code = [
125 | "ignore-without-code",
126 | "redundant-expr",
127 | "truthy-bool",
128 | ]
129 | mypy_path = "src/"
130 | namespace_packages = false
131 | plugins = [
132 | "mypy_django_plugin.main",
133 | ]
134 | strict = true
135 | warn_unreachable = true
136 |
137 | [[tool.mypy.overrides]]
138 | module = "tests.*"
139 | allow_untyped_defs = true
140 |
141 | [tool.django-stubs]
142 | django_settings_module = "tests.settings"
143 |
144 | [tool.rstcheck]
145 | report_level = "ERROR"
146 |
--------------------------------------------------------------------------------
/src/django_perf_rec/utils.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import difflib
4 | import inspect
5 | from collections.abc import Iterable
6 | from types import FrameType
7 | from typing import Any
8 |
9 | from django_perf_rec import _HAVE_PYTEST
10 | from django_perf_rec.types import PerformanceRecord
11 |
12 |
13 | class TestDetails:
14 | __slots__ = ("file_path", "class_name", "test_name")
15 | __test__ = False # tell pytest to ignore this class
16 |
17 | def __init__(self, file_path: str, class_name: str | None, test_name: str) -> None:
18 | self.file_path = file_path
19 | self.class_name = class_name
20 | self.test_name = test_name
21 |
22 | def __eq__(self, other: Any) -> bool:
23 | if not isinstance(other, TestDetails):
24 | return NotImplemented
25 | return (
26 | self.file_path == other.file_path
27 | and self.class_name == other.class_name
28 | and self.test_name == other.test_name
29 | )
30 |
31 |
32 | def current_test() -> TestDetails:
33 | """
34 | Use a little harmless stack inspection to determine the test that is
35 | currently running.
36 | """
37 | frame = inspect.currentframe()
38 | assert frame is not None
39 | try:
40 | while True:
41 | details = _get_details_from_pytest_request(
42 | frame
43 | ) or _get_details_from_test_function(frame)
44 |
45 | if details is not None:
46 | return details
47 |
48 | # Next frame
49 | frame = frame.f_back
50 | if frame is None:
51 | break
52 |
53 | raise RuntimeError("Could not automatically determine the test name.")
54 | finally:
55 | # Always delete frame references to help garbage collector
56 | del frame
57 |
58 |
59 | def _get_details_from_test_function(frame: FrameType) -> TestDetails | None:
60 | if not frame.f_code.co_name.startswith("test_"):
61 | return None
62 |
63 | file_path = frame.f_globals["__file__"]
64 |
65 | # May be a pytest function test so we can't assume 'self' exists
66 | its_self = frame.f_locals.get("self", None)
67 | class_name: str | None
68 | if its_self is None:
69 | class_name = None
70 | else:
71 | class_name = its_self.__class__.__name__
72 |
73 | test_name = frame.f_code.co_name
74 |
75 | return TestDetails(file_path=file_path, class_name=class_name, test_name=test_name)
76 |
77 |
78 | def _get_details_from_pytest_request(frame: FrameType) -> TestDetails | None:
79 | if not _HAVE_PYTEST:
80 | return None
81 |
82 | request = frame.f_locals.get("request", None)
83 | if request is None:
84 | return None
85 |
86 | try:
87 | cls = request.cls
88 | except AttributeError:
89 | # Doesn't look like a pytest request object
90 | return None
91 |
92 | if cls is not None:
93 | class_name = cls.__name__
94 | else:
95 | class_name = None
96 |
97 | return TestDetails(
98 | file_path=request.fspath.strpath,
99 | class_name=class_name,
100 | test_name=request.node.name,
101 | )
102 |
103 |
104 | def sorted_names(names: Iterable[str]) -> list[str]:
105 | """
106 | Sort a list of names but keep the word 'default' first if it's there.
107 | """
108 | names = list(names)
109 |
110 | have_default = False
111 | if "default" in names:
112 | names.remove("default")
113 | have_default = True
114 |
115 | sorted_names = sorted(names)
116 |
117 | if have_default:
118 | sorted_names = ["default"] + sorted_names
119 |
120 | return sorted_names
121 |
122 |
123 | def record_diff(old: PerformanceRecord, new: PerformanceRecord) -> str:
124 | """
125 | Generate a human-readable diff of two performance records.
126 | """
127 | return "\n".join(
128 | difflib.ndiff(
129 | [f"{k}: {v}" for op in old for k, v in op.items()],
130 | [f"{k}: {v}" for op in new for k, v in op.items()],
131 | )
132 | )
133 |
--------------------------------------------------------------------------------
/src/django_perf_rec/sql.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from functools import lru_cache
4 | from typing import Any
5 |
6 | from sqlparse import parse, tokens
7 | from sqlparse.sql import Comment, IdentifierList, Parenthesis, Token
8 |
9 |
10 | @lru_cache(maxsize=500)
11 | def sql_fingerprint(query: str, hide_columns: bool = True) -> str:
12 | """
13 | Simplify a query, taking away exact values and fields selected.
14 |
15 | Imperfect but better than super explicit, value-dependent queries.
16 | """
17 | parsed_queries = parse(query)
18 |
19 | if not parsed_queries:
20 | return ""
21 |
22 | fingerprinted_queries = []
23 | for parsed_query in parsed_queries:
24 | stripped_query = sql_recursively_strip(parsed_query)
25 | sql_recursively_simplify(stripped_query, hide_columns=hide_columns)
26 |
27 | fingerprinted_queries.append(stripped_query)
28 |
29 | return "".join([str(q) for q in fingerprinted_queries]).strip()
30 |
31 |
32 | sql_deletable_tokens = frozenset(
33 | (
34 | tokens.Number,
35 | tokens.Number.Float,
36 | tokens.Number.Integer,
37 | tokens.Number.Hexadecimal,
38 | tokens.String,
39 | tokens.String.Single,
40 | )
41 | )
42 |
43 |
44 | def sql_trim(node: Token, idx: int) -> None:
45 | tokens = node.tokens
46 | count = len(tokens)
47 | min_count = abs(idx)
48 |
49 | while count > min_count and tokens[idx].is_whitespace:
50 | tokens.pop(idx)
51 | count -= 1
52 |
53 |
54 | def sql_strip(node: Token) -> None:
55 | in_whitespace = False
56 | for token in node.tokens:
57 | if token.is_whitespace:
58 | token.value = "" if in_whitespace else " "
59 | in_whitespace = True
60 | else:
61 | in_whitespace = False
62 |
63 |
64 | def sql_recursively_strip(node: Token) -> Token:
65 | for sub_node in node.get_sublists():
66 | sql_recursively_strip(sub_node)
67 |
68 | if isinstance(node, Comment):
69 | return node
70 |
71 | sql_strip(node)
72 |
73 | # strip duplicate whitespaces between parenthesis
74 | if isinstance(node, Parenthesis):
75 | sql_trim(node, 1)
76 | sql_trim(node, -2)
77 |
78 | return node
79 |
80 |
81 | def sql_recursively_simplify(node: Token, hide_columns: bool = True) -> None:
82 | # Erase which fields are being updated in an UPDATE
83 | if node.tokens[0].value == "UPDATE":
84 | i_set = [i for (i, t) in enumerate(node.tokens) if t.value == "SET"][0]
85 | where_indexes = [
86 | i
87 | for (i, t) in enumerate(node.tokens)
88 | if t.is_group and t.tokens[0].value == "WHERE"
89 | ]
90 | if where_indexes:
91 | where_index = where_indexes[0]
92 | end = node.tokens[where_index:]
93 | else:
94 | end = []
95 | middle = [Token(tokens.Punctuation, " ... ")]
96 | node.tokens = node.tokens[: i_set + 1] + middle + end
97 |
98 | # Ensure IN clauses with simple value in always simplify to "..."
99 | if node.tokens[0].value == "WHERE":
100 | in_token_indices = (i for i, t in enumerate(node.tokens) if t.value == "IN")
101 | for in_token_index in in_token_indices:
102 | parenthesis = next(
103 | t
104 | for t in node.tokens[in_token_index + 1 :]
105 | if isinstance(t, Parenthesis)
106 | )
107 | if all(
108 | getattr(t, "ttype", "") in sql_deletable_tokens
109 | for t in parenthesis.tokens[1:-1]
110 | ):
111 | parenthesis.tokens[1:-1] = [Token(tokens.Punctuation, "...")]
112 |
113 | # Erase the names of savepoints since they are non-deteriministic
114 | if hasattr(node, "tokens"):
115 | # SAVEPOINT x
116 | if str(node.tokens[0]) == "SAVEPOINT":
117 | node.tokens[2].tokens[0].value = "`#`"
118 | return
119 | # RELEASE SAVEPOINT x
120 | elif len(node.tokens) >= 3 and node.tokens[2].value == "SAVEPOINT":
121 | node.tokens[4].tokens[0].value = "`#`"
122 | return
123 | # ROLLBACK TO SAVEPOINT X
124 | token_values = [getattr(t, "value", "") for t in node.tokens]
125 | if len(node.tokens) >= 7 and token_values[:6] == [
126 | "ROLLBACK",
127 | " ",
128 | "TO",
129 | " ",
130 | "SAVEPOINT",
131 | " ",
132 | ]:
133 | node.tokens[6].tokens[0].value = "`#`"
134 | return
135 |
136 | # Erase volatile part of PG cursor name
137 | if node.tokens[0].value.startswith('"_django_curs_'):
138 | node.tokens[0].value = '"_django_curs_#"'
139 |
140 | prev_word_token: Any = None
141 |
142 | for token in node.tokens:
143 | ttype = getattr(token, "ttype", None)
144 |
145 | if (
146 | hide_columns
147 | and isinstance(token, IdentifierList)
148 | and not (
149 | prev_word_token
150 | and prev_word_token.is_keyword
151 | and prev_word_token.value.upper() in ("ORDER BY", "GROUP BY", "HAVING")
152 | )
153 | ):
154 | token.tokens = [Token(tokens.Punctuation, "...")]
155 | elif hasattr(token, "tokens"):
156 | sql_recursively_simplify(token, hide_columns=hide_columns)
157 | elif ttype in sql_deletable_tokens or getattr(token, "value", None) == "NULL":
158 | token.value = "#"
159 |
160 | if not token.is_whitespace:
161 | prev_word_token = token
162 |
--------------------------------------------------------------------------------
/tests/test_cache.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from traceback import extract_stack
4 | from unittest import mock
5 |
6 | import pytest
7 | from django.core.cache import caches
8 | from django.test import SimpleTestCase, TestCase
9 |
10 | from django_perf_rec.cache import AllCacheRecorder, CacheOp, CacheRecorder
11 | from tests.utils import override_extract_stack
12 |
13 |
14 | class CacheOpTests(SimpleTestCase):
15 | def test_clean_key_integer(self):
16 | assert CacheOp.clean_key("foo1") == "foo#"
17 |
18 | def test_clean_key_uuid(self):
19 | assert CacheOp.clean_key("bdfc9986-d461-4a5e-bf98-8688993abcfb") == "#"
20 |
21 | def test_clean_key_random_hash(self):
22 | assert CacheOp.clean_key("abc123abc123abc123abc123abc12345") == "#"
23 |
24 | def test_clean_key_session_key_cache_backend(self):
25 | key = "django.contrib.sessions.cacheabcdefghijklmnopqrstuvwxyz012345"
26 | assert CacheOp.clean_key(key) == "django.contrib.sessions.cache#"
27 |
28 | def test_clean_key_session_key_cached_db_backend(self):
29 | key = "django.contrib.sessions.cached_db" + "abcdefghijklmnopqrstuvwxyz012345"
30 | assert CacheOp.clean_key(key) == "django.contrib.sessions.cached_db#"
31 |
32 | def test_key(self):
33 | summary = extract_stack()
34 | op = CacheOp("default", "foo", "bar", summary)
35 | assert op.alias == "default"
36 | assert op.operation == "foo"
37 | assert op.query == "bar"
38 | assert op.traceback == summary
39 |
40 | def test_keys(self):
41 | op = CacheOp("default", "foo", ["bar", "baz"], extract_stack())
42 | assert op.alias == "default"
43 | assert op.operation == "foo"
44 | assert op.query == ["bar", "baz"]
45 |
46 | def test_keys_frozenset(self):
47 | op = CacheOp("default", "foo", frozenset(["bar", "baz"]), extract_stack())
48 | assert op.alias == "default"
49 | assert op.operation == "foo"
50 | assert op.query == ["bar", "baz"]
51 |
52 | def test_keys_dict_keys(self):
53 | op = CacheOp("default", "foo", {"bar": "baz"}.keys(), extract_stack())
54 | assert op.alias == "default"
55 | assert op.operation == "foo"
56 | assert op.query == ["bar"]
57 |
58 | def test_invalid(self):
59 | with pytest.raises(ValueError):
60 | CacheOp("x", "foo", object(), extract_stack()) # type: ignore [arg-type]
61 |
62 | def test_equal(self):
63 | summary = extract_stack()
64 | assert CacheOp("x", "foo", "bar", summary) == CacheOp(
65 | "x", "foo", "bar", summary
66 | )
67 |
68 | def test_not_equal_alias(self):
69 | summary = extract_stack()
70 | assert CacheOp("x", "foo", "bar", summary) != CacheOp(
71 | "y", "foo", "bar", summary
72 | )
73 |
74 | def test_not_equal_operation(self):
75 | summary = extract_stack()
76 | assert CacheOp("x", "foo", "bar", summary) != CacheOp(
77 | "x", "bar", "bar", summary
78 | )
79 |
80 | def test_not_equal_keys(self):
81 | summary = extract_stack()
82 | assert CacheOp("x", "foo", ["bar"], summary) != CacheOp(
83 | "x", "foo", ["baz"], summary
84 | )
85 |
86 | def test_not_equal_traceback(self):
87 | assert CacheOp("x", "foo", "bar", extract_stack(limit=1)) != CacheOp(
88 | "x", "foo", "bar", extract_stack(limit=2)
89 | )
90 |
91 |
92 | class CacheRecorderTests(TestCase):
93 | @override_extract_stack
94 | def test_default(self, stack_summary):
95 | callback = mock.Mock()
96 | with CacheRecorder("default", callback):
97 | caches["default"].get("foo")
98 | callback.assert_called_once_with(
99 | CacheOp("default", "get", "foo", stack_summary)
100 | )
101 |
102 | @override_extract_stack
103 | def test_secondary(self, stack_summary):
104 | callback = mock.Mock()
105 | with CacheRecorder("second", callback):
106 | caches["second"].get("foo")
107 | callback.assert_called_once_with(CacheOp("second", "get", "foo", stack_summary))
108 |
109 | def test_secondary_default_not_recorded(self):
110 | callback = mock.Mock()
111 | with CacheRecorder("second", callback):
112 | caches["default"].get("foo")
113 | assert len(callback.mock_calls) == 0
114 |
115 | def test_record_traceback(self):
116 | callback = mock.Mock()
117 | with CacheRecorder("default", callback):
118 | caches["default"].get("foo")
119 |
120 | assert len(callback.mock_calls) == 1
121 | assert "django_perf_rec/cache.py" in str(
122 | callback.call_args_list[0][0][0].traceback
123 | )
124 |
125 |
126 | class AllCacheRecorderTests(TestCase):
127 | @override_extract_stack
128 | def test_records_all(self, stack_summary):
129 | callback = mock.Mock()
130 | with AllCacheRecorder(callback):
131 | caches["default"].get("foo")
132 | caches["default"].get(key="foo")
133 | caches["default"].get_many(keys=["foo"])
134 | caches["second"].set("bar", "baz")
135 | caches["default"].delete_many(["foo"])
136 |
137 | assert callback.mock_calls == [
138 | mock.call(CacheOp("default", "get", "foo", stack_summary)),
139 | mock.call(CacheOp("default", "get", "foo", stack_summary)),
140 | mock.call(CacheOp("default", "get_many", ["foo"], stack_summary)),
141 | mock.call(CacheOp("second", "set", "bar", stack_summary)),
142 | mock.call(CacheOp("default", "delete_many", ["foo"], stack_summary)),
143 | ]
144 |
--------------------------------------------------------------------------------
/src/django_perf_rec/cache.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import inspect
4 | import re
5 | import traceback
6 | from collections.abc import Callable, Collection
7 | from collections.abc import Collection as TypingCollection
8 | from functools import wraps
9 | from re import Pattern
10 | from types import MethodType, TracebackType
11 | from typing import Any, TypeVar, cast
12 |
13 | from django.core.cache import DEFAULT_CACHE_ALIAS, caches
14 |
15 | from django_perf_rec.operation import AllSourceRecorder, BaseRecorder, Operation
16 |
17 |
18 | class CacheOp(Operation):
19 | def __init__(
20 | self,
21 | alias: str,
22 | operation: str,
23 | key_or_keys: str | TypingCollection[str],
24 | traceback: traceback.StackSummary,
25 | ):
26 | self.alias = alias
27 | self.operation = operation
28 | cleaned_key_or_keys: str | TypingCollection[str]
29 | if isinstance(key_or_keys, str):
30 | cleaned_key_or_keys = self.clean_key(key_or_keys)
31 | elif isinstance(key_or_keys, Collection):
32 | cleaned_key_or_keys = sorted(self.clean_key(k) for k in key_or_keys)
33 | else:
34 | raise ValueError("key_or_keys must be a string or collection")
35 |
36 | super().__init__(alias, cleaned_key_or_keys, traceback)
37 |
38 | @classmethod
39 | def clean_key(cls, key: str) -> str:
40 | """
41 | Replace things that look like variables with a '#' so tests aren't
42 | affected by random variables
43 | """
44 | for var_re in cls.VARIABLE_RES:
45 | key = var_re.sub("#", key)
46 | return key
47 |
48 | VARIABLE_RES: tuple[Pattern[str], ...] = (
49 | # Django session keys for 'cache' backend
50 | re.compile(r"(?<=django\.contrib\.sessions\.cache)[0-9a-z]{32}\b"),
51 | # Django session keys for 'cached_db' backend
52 | re.compile(r"(?<=django\.contrib\.sessions\.cached_db)[0-9a-z]{32}\b"),
53 | # Long random hashes
54 | re.compile(r"\b[0-9a-f]{32}\b"),
55 | # UUIDs
56 | re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"),
57 | # Integers
58 | re.compile(r"\d+"),
59 | )
60 |
61 | def __eq__(self, other: Any) -> bool:
62 | return super().__eq__(other) and self.operation == other.operation
63 |
64 | @property
65 | def name(self) -> str:
66 | name_parts = ["cache"]
67 | if self.alias != DEFAULT_CACHE_ALIAS:
68 | name_parts.append(self.alias)
69 | name_parts.append(self.operation)
70 | return "|".join(name_parts)
71 |
72 |
73 | CacheFunc = TypeVar("CacheFunc", bound=Callable[..., Any])
74 |
75 |
76 | class CacheRecorder(BaseRecorder):
77 | """
78 | Monkey patches a cache class to call 'callback' on every operation it calls
79 | """
80 |
81 | def __enter__(self) -> None:
82 | cache = caches[self.alias]
83 |
84 | def call_callback(func: CacheFunc) -> CacheFunc:
85 | alias = self.alias
86 | callback = self.callback
87 |
88 | @wraps(func)
89 | def inner(self: Any, *args: Any, **kwargs: Any) -> Any:
90 | # Ignore operations from the cache class calling itself
91 |
92 | # Get the self of the parent via stack inspection
93 | frame = inspect.currentframe()
94 | assert frame is not None
95 | try:
96 | frame = frame.f_back
97 | is_internal_call = (
98 | frame is not None and frame.f_locals.get("self", None) is self
99 | )
100 | finally:
101 | # Always delete frame references to help garbage collector
102 | del frame
103 |
104 | if not is_internal_call:
105 | if args:
106 | key_or_keys = args[0]
107 | elif "key" in kwargs:
108 | key_or_keys = kwargs["key"]
109 | else:
110 | key_or_keys = kwargs["keys"]
111 | callback(
112 | CacheOp(
113 | alias=alias,
114 | operation=str(func.__name__),
115 | key_or_keys=key_or_keys,
116 | traceback=traceback.extract_stack(),
117 | )
118 | )
119 |
120 | return func(*args, **kwargs)
121 |
122 | return cast(CacheFunc, inner)
123 |
124 | self.orig_methods = {name: getattr(cache, name) for name in self.cache_methods}
125 | for name in self.cache_methods:
126 | orig_method = self.orig_methods[name]
127 | setattr(cache, name, MethodType(call_callback(orig_method), cache))
128 |
129 | def __exit__(
130 | self,
131 | exc_type: type[BaseException] | None,
132 | exc_value: BaseException | None,
133 | exc_traceback: TracebackType | None,
134 | ) -> None:
135 | cache = caches[self.alias]
136 | for name in self.cache_methods:
137 | setattr(cache, name, self.orig_methods[name])
138 | del self.orig_methods
139 |
140 | cache_methods = (
141 | "add",
142 | "decr",
143 | "delete",
144 | "delete_many",
145 | "get",
146 | "get_many",
147 | "get_or_set",
148 | "incr",
149 | "set",
150 | "set_many",
151 | )
152 |
153 |
154 | class AllCacheRecorder(AllSourceRecorder):
155 | """
156 | Launches CacheRecorders on all the active caches
157 | """
158 |
159 | sources_setting = "CACHES"
160 | recorder_class = CacheRecorder
161 |
--------------------------------------------------------------------------------
/src/django_perf_rec/api.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import os
4 | from collections.abc import Callable
5 | from functools import cache
6 | from threading import local
7 | from types import TracebackType
8 |
9 | from django_perf_rec import pytest_plugin
10 | from django_perf_rec.cache import AllCacheRecorder
11 | from django_perf_rec.db import AllDBRecorder
12 | from django_perf_rec.operation import Operation
13 | from django_perf_rec.settings import perf_rec_settings
14 | from django_perf_rec.types import PerformanceRecordItem
15 | from django_perf_rec.utils import TestDetails, current_test, record_diff
16 | from django_perf_rec.yaml import KVFile
17 |
18 |
19 | def get_perf_path(file_path: str) -> str:
20 | if file_path.endswith(".py"):
21 | perf_path = file_path[: -len(".py")] + ".perf.yml"
22 | elif file_path.endswith(".pyc"):
23 | perf_path = file_path[: -len(".pyc")] + ".perf.yml"
24 | else:
25 | perf_path = file_path + ".perf.yml"
26 | return perf_path
27 |
28 |
29 | record_current = local()
30 |
31 |
32 | def get_record_name(
33 | test_name: str,
34 | class_name: str | None = None,
35 | file_name: str = "",
36 | ) -> str:
37 | if class_name:
38 | record_name = f"{class_name}.{test_name}"
39 | else:
40 | record_name = test_name
41 |
42 | # Multiple calls inside the same test should end up suffixing with .2, .3 etc.
43 | record_spec = (file_name, record_name)
44 | if getattr(record_current, "record_spec", None) == record_spec:
45 | record_current.counter += 1
46 | record_name = record_name + f".{record_current.counter}"
47 | else:
48 | record_current.record_spec = record_spec
49 | record_current.counter = 1
50 |
51 | return record_name
52 |
53 |
54 | class PerformanceRecorder:
55 | def __init__(
56 | self,
57 | file_name: str,
58 | record_name: str,
59 | capture_traceback: Callable[[Operation], bool] | None,
60 | capture_operation: Callable[[Operation], bool] | None,
61 | ) -> None:
62 | self.file_name = file_name
63 | self.record_name = record_name
64 |
65 | self.record: list[PerformanceRecordItem] = []
66 | self.db_recorder = AllDBRecorder(self.on_op)
67 | self.cache_recorder = AllCacheRecorder(self.on_op)
68 | self.capture_operation = capture_operation
69 | self.capture_traceback = capture_traceback
70 |
71 | def __enter__(self) -> None:
72 | self.db_recorder.__enter__()
73 | self.cache_recorder.__enter__()
74 | self.load_recordings()
75 |
76 | def __exit__(
77 | self,
78 | exc_type: type[BaseException] | None,
79 | exc_value: BaseException | None,
80 | exc_traceback: TracebackType | None,
81 | ) -> None:
82 | self.cache_recorder.__exit__(exc_type, exc_value, exc_traceback)
83 | self.db_recorder.__exit__(exc_type, exc_value, exc_traceback)
84 |
85 | if exc_type is None:
86 | self.save_or_assert()
87 |
88 | def on_op(self, op: Operation) -> None:
89 | record = {op.name: op.query}
90 |
91 | if self.capture_operation and not self.capture_operation(op):
92 | return
93 |
94 | if self.capture_traceback and self.capture_traceback(op):
95 | record["traceback"] = op.traceback.format()
96 |
97 | self.record.append(record)
98 |
99 | def load_recordings(self) -> None:
100 | self.records_file = KVFile(self.file_name)
101 |
102 | def save_or_assert(self) -> None:
103 | orig_record = self.records_file.get(self.record_name, None)
104 | if perf_rec_settings.MODE == "none":
105 | assert orig_record is not None, (
106 | f"Original performance record does not exist for {self.record_name}"
107 | )
108 |
109 | if orig_record is not None and perf_rec_settings.MODE != "overwrite":
110 | msg = f"Performance record did not match for {self.record_name}"
111 | if not pytest_plugin.in_pytest:
112 | msg += f"\n{record_diff(orig_record, self.record)}"
113 | assert self.record == orig_record, msg
114 |
115 | self.records_file.set_and_save(self.record_name, self.record)
116 |
117 | if perf_rec_settings.MODE == "all":
118 | assert orig_record is not None, (
119 | f"Original performance record did not exist for {self.record_name}"
120 | )
121 |
122 |
123 | def record(
124 | *,
125 | record_name: str | None = None,
126 | path: str | None = None,
127 | capture_traceback: Callable[[Operation], bool] | None = None,
128 | capture_operation: Callable[[Operation], bool] | None = None,
129 | ) -> PerformanceRecorder:
130 | @cache
131 | def get_test_details() -> TestDetails:
132 | return current_test()
133 |
134 | if path is None or path.endswith("/"):
135 | file_name = get_perf_path(get_test_details().file_path)
136 | else:
137 | file_name = path
138 |
139 | if path is not None and path.endswith("/"):
140 | if not os.path.isabs(path):
141 | directory = os.path.join(
142 | os.path.dirname(get_test_details().file_path), path
143 | )
144 | if not os.path.exists(directory):
145 | os.makedirs(directory)
146 | else:
147 | directory = path
148 |
149 | file_name = os.path.join(directory, os.path.basename(file_name))
150 |
151 | if record_name is None:
152 | record_name = get_record_name(
153 | test_name=get_test_details().test_name,
154 | class_name=get_test_details().class_name,
155 | file_name=file_name,
156 | )
157 |
158 | return PerformanceRecorder(
159 | file_name,
160 | record_name,
161 | capture_traceback,
162 | capture_operation,
163 | )
164 |
165 |
166 | class TestCaseMixin:
167 | """
168 | Adds record_performance() method to TestCase class it's mixed into
169 | for easy import-free use.
170 | """
171 |
172 | def record_performance(
173 | self, *, record_name: str | None = None, path: str | None = None
174 | ) -> PerformanceRecorder:
175 | return record(record_name=record_name, path=path)
176 |
--------------------------------------------------------------------------------
/tests/test_sql.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from django_perf_rec.sql import sql_fingerprint
4 |
5 |
6 | def test_empty():
7 | assert sql_fingerprint("") == ""
8 | assert sql_fingerprint("\n\n \n") == ""
9 |
10 |
11 | def test_select():
12 | assert sql_fingerprint("SELECT `f1`, `f2` FROM `b`") == "SELECT ... FROM `b`"
13 |
14 |
15 | def test_select_show_columns(settings):
16 | assert (
17 | sql_fingerprint("SELECT `f1`, `f2` FROM `b`", hide_columns=False)
18 | == "SELECT `f1`, `f2` FROM `b`"
19 | )
20 |
21 |
22 | def test_select_limit(settings):
23 | assert (
24 | sql_fingerprint("SELECT `f1`, `f2` FROM `b` LIMIT 12", hide_columns=False)
25 | == "SELECT `f1`, `f2` FROM `b` LIMIT #"
26 | )
27 |
28 |
29 | def test_select_coalesce_show_columns(settings):
30 | assert (
31 | sql_fingerprint(
32 | (
33 | "SELECT `table`.`f1`, COALESCE(table.f2->>'a', table.f2->>'b', "
34 | + "'default') FROM `table`"
35 | ),
36 | hide_columns=False,
37 | )
38 | == "SELECT `table`.`f1`, COALESCE(table.f2->>#, table.f2->>#, #) FROM `table`"
39 | )
40 |
41 |
42 | def test_select_where():
43 | assert (
44 | sql_fingerprint(
45 | "SELECT DISTINCT `table`.`field` FROM `table` WHERE `table`.`id` = 1"
46 | )
47 | == "SELECT DISTINCT `table`.`field` FROM `table` WHERE `table`.`id` = #"
48 | )
49 |
50 |
51 | def test_select_where_show_columns(settings):
52 | assert (
53 | sql_fingerprint(
54 | "SELECT DISTINCT `table`.`field` FROM `table` WHERE `table`.`id` = 1",
55 | hide_columns=False,
56 | )
57 | == "SELECT DISTINCT `table`.`field` FROM `table` WHERE `table`.`id` = #"
58 | )
59 |
60 |
61 | def test_select_comment():
62 | assert (
63 | sql_fingerprint("SELECT /* comment */ `f1`, `f2` FROM `b`")
64 | == "SELECT /* comment */ ... FROM `b`"
65 | )
66 |
67 |
68 | def test_select_comment_show_columns(settings):
69 | assert (
70 | sql_fingerprint("SELECT /* comment */ `f1`, `f2` FROM `b`", hide_columns=False)
71 | == "SELECT /* comment */ `f1`, `f2` FROM `b`"
72 | )
73 |
74 |
75 | def test_select_join():
76 | assert (
77 | sql_fingerprint(
78 | "SELECT f1, f2 FROM a INNER JOIN b ON (a.b_id = b.id) WHERE a.f2 = 1"
79 | )
80 | == "SELECT ... FROM a INNER JOIN b ON (a.b_id = b.id) WHERE a.f2 = #"
81 | )
82 |
83 |
84 | def test_select_join_show_columns(settings):
85 | assert (
86 | sql_fingerprint(
87 | "SELECT f1, f2 FROM a INNER JOIN b ON (a.b_id = b.id) WHERE a.f2 = 1",
88 | hide_columns=False,
89 | )
90 | == "SELECT f1, f2 FROM a INNER JOIN b ON (a.b_id = b.id) WHERE a.f2 = #"
91 | )
92 |
93 |
94 | def test_select_order_by():
95 | assert (
96 | sql_fingerprint("SELECT f1, f2 FROM a ORDER BY f3")
97 | == "SELECT ... FROM a ORDER BY f3"
98 | )
99 |
100 |
101 | def test_select_order_by_limit():
102 | assert (
103 | sql_fingerprint("SELECT f1, f2 FROM a ORDER BY f3 LIMIT 12")
104 | == "SELECT ... FROM a ORDER BY f3 LIMIT #"
105 | )
106 |
107 |
108 | def test_select_order_by_show_columns(settings):
109 | assert (
110 | sql_fingerprint("SELECT f1, f2 FROM a ORDER BY f3", hide_columns=False)
111 | == "SELECT f1, f2 FROM a ORDER BY f3"
112 | )
113 |
114 |
115 | def test_select_order_by_multiple():
116 | assert (
117 | sql_fingerprint("SELECT f1, f2 FROM a ORDER BY f3, f4")
118 | == "SELECT ... FROM a ORDER BY f3, f4"
119 | )
120 |
121 |
122 | def test_select_group_by():
123 | assert (
124 | sql_fingerprint("SELECT f1, f2 FROM a GROUP BY f1")
125 | == "SELECT ... FROM a GROUP BY f1"
126 | )
127 |
128 |
129 | def test_select_group_by_show_columns(settings):
130 | assert (
131 | sql_fingerprint("SELECT f1, f2 FROM a GROUP BY f1", hide_columns=False)
132 | == "SELECT f1, f2 FROM a GROUP BY f1"
133 | )
134 |
135 |
136 | def test_select_group_by_multiple():
137 | assert (
138 | sql_fingerprint("SELECT f1, f2 FROM a GROUP BY f1, f2")
139 | == "SELECT ... FROM a GROUP BY f1, f2"
140 | )
141 |
142 |
143 | def test_select_group_by_having():
144 | assert (
145 | sql_fingerprint("SELECT f1, f2 FROM a GROUP BY f1 HAVING f1 > 21")
146 | == "SELECT ... FROM a GROUP BY f1 HAVING f1 > #"
147 | )
148 |
149 |
150 | def test_select_group_by_having_show_columns(settings):
151 | assert (
152 | sql_fingerprint(
153 | "SELECT f1, f2 FROM a GROUP BY f1 HAVING f1 > 21", hide_columns=False
154 | )
155 | == "SELECT f1, f2 FROM a GROUP BY f1 HAVING f1 > #"
156 | )
157 |
158 |
159 | def test_select_group_by_having_multiple():
160 | assert (
161 | sql_fingerprint("SELECT f1, f2 FROM a GROUP BY f1 HAVING f1 > 21, f2 < 42")
162 | == "SELECT ... FROM a GROUP BY f1 HAVING f1 > #, f2 < #"
163 | )
164 |
165 |
166 | def test_insert():
167 | assert (
168 | sql_fingerprint("INSERT INTO `table` (`f1`, `f2`) VALUES ('v1', 2)")
169 | == "INSERT INTO `table` (...) VALUES (...)"
170 | )
171 |
172 |
173 | def test_insert_show_columns(settings):
174 | assert (
175 | sql_fingerprint(
176 | "INSERT INTO `table` (`f1`, `f2`) VALUES ('v1', 2)", hide_columns=False
177 | )
178 | == "INSERT INTO `table` (`f1`, `f2`) VALUES (#, #)"
179 | )
180 |
181 |
182 | def test_update():
183 | assert (
184 | sql_fingerprint("UPDATE `table` SET `foo` = 'bar' WHERE `table`.`id` = 1")
185 | == "UPDATE `table` SET ... WHERE `table`.`id` = #"
186 | )
187 |
188 |
189 | def test_update_no_where():
190 | assert (
191 | sql_fingerprint("UPDATE `table` SET `foo` = 'bar'") == "UPDATE `table` SET ..."
192 | )
193 |
194 |
195 | def test_declare_cursor():
196 | assert (
197 | sql_fingerprint(
198 | 'DECLARE "_django_curs_140239496394496_1300" NO SCROLL CURSOR WITHOUT'
199 | )
200 | == 'DECLARE "_django_curs_#" NO SCROLL CURSOR WITHOUT'
201 | )
202 |
203 |
204 | def test_savepoint():
205 | assert sql_fingerprint("SAVEPOINT `s140323809662784_x54`") == "SAVEPOINT `#`"
206 |
207 |
208 | def test_rollback_to_savepoint():
209 | assert (
210 | sql_fingerprint("ROLLBACK TO SAVEPOINT `s140323809662784_x54`")
211 | == "ROLLBACK TO SAVEPOINT `#`"
212 | )
213 |
214 |
215 | def test_rollback_to_savepoint_with_comment():
216 | assert (
217 | sql_fingerprint(
218 | "ROLLBACK TO SAVEPOINT `s139987847644992_x3209` /* this is a comment */"
219 | )
220 | == "ROLLBACK TO SAVEPOINT `#` /* this is a comment */"
221 | )
222 |
223 |
224 | def test_release_savepoint():
225 | assert (
226 | sql_fingerprint("RELEASE SAVEPOINT `s140699855320896_x17`")
227 | == "RELEASE SAVEPOINT `#`"
228 | )
229 |
230 |
231 | def test_null_value():
232 | assert (
233 | sql_fingerprint(
234 | "SELECT `f1`, `f2` FROM `b` WHERE `b`.`name` IS NULL", hide_columns=False
235 | )
236 | == "SELECT `f1`, `f2` FROM `b` WHERE `b`.`name` IS #"
237 | )
238 |
239 |
240 | def test_strip_duplicate_whitespaces():
241 | assert (
242 | sql_fingerprint(
243 | "SELECT `f1`, `f2` FROM `b` WHERE `b`.`f1` IS NULL LIMIT 12 "
244 | )
245 | == "SELECT ... FROM `b` WHERE `b`.`f1` IS # LIMIT #"
246 | )
247 |
248 |
249 | def test_strip_duplicate_whitespaces_recursive():
250 | assert (
251 | sql_fingerprint(
252 | "SELECT `f1`, `f2`, ( COALESCE(b.f3->>'en', b.f3->>'fr', '')) "
253 | "FROM `b` WHERE (`b`.`f1` IS NULL OR ( EXISTS COUNT(1) )) LIMIT 12 ",
254 | hide_columns=False,
255 | )
256 | == "SELECT `f1`, `f2`, (COALESCE(b.f3->>#, b.f3->>#, #)) "
257 | "FROM `b` WHERE (`b`.`f1` IS # OR (EXISTS COUNT(#))) LIMIT #"
258 | )
259 |
260 |
261 | def test_strip_newlines():
262 | assert (
263 | sql_fingerprint("SELECT `f1`, `f2`\nFROM `b`\n LIMIT 12\n\n")
264 | == "SELECT ... FROM `b` LIMIT #"
265 | )
266 |
267 |
268 | def test_strip_raw_query():
269 | assert sql_fingerprint(
270 | """
271 | SELECT 'f1'
272 | , 'f2'
273 | , 'f3'
274 | FROM "table_a" WHERE "table_a"."f1" = 1 OR (
275 | "table_a"."type" = 'A' AND
276 | EXISTS (
277 | SELECT "table_b"."id"
278 | FROM "table_b"
279 | WHERE "table_b"."id" = 1
280 | ) = true)
281 | """
282 | ) == (
283 | 'SELECT ... FROM "table_a" WHERE "table_a"."f1" = # OR '
284 | + '("table_a"."type" = # AND EXISTS (SELECT "table_b"."id" FROM '
285 | + '"table_b" WHERE "table_b"."id" = # ) = true)'
286 | )
287 |
288 |
289 | def test_in_single_value():
290 | assert (
291 | sql_fingerprint("SELECT `f1`, `f2` FROM `b` WHERE `x` IN (1)")
292 | == "SELECT ... FROM `b` WHERE `x` IN (...)"
293 | )
294 |
295 |
296 | def test_in_multiple_values():
297 | assert (
298 | sql_fingerprint("SELECT `f1`, `f2` FROM `b` WHERE `x` IN (1, 2, 3)")
299 | == "SELECT ... FROM `b` WHERE `x` IN (...)"
300 | )
301 |
302 |
303 | def test_in_multiple_clauses():
304 | assert (
305 | sql_fingerprint(
306 | "SELECT `f1`, `f2` FROM `b` WHERE `x` IN (1, 2, 3) AND `y` IN (4, 5, 6)"
307 | )
308 | == "SELECT ... FROM `b` WHERE `x` IN (...) AND `y` IN (...)"
309 | )
310 |
311 |
312 | def test_in_multiple_values_and_clause():
313 | assert (
314 | sql_fingerprint(
315 | "SELECT `f1`, `f2` FROM `b` WHERE `x` IN (1, 2, 3) AND (`y` = 1 OR `y` = 2)"
316 | )
317 | == "SELECT ... FROM `b` WHERE `x` IN (...) AND (`y` = # OR `y` = #)"
318 | )
319 |
320 |
321 | def test_in_subquery():
322 | assert (
323 | sql_fingerprint("SELECT `f1`, `f2` FROM `b` WHERE `x` IN (SELECT 1)")
324 | == "SELECT ... FROM `b` WHERE `x` IN (SELECT #)"
325 | )
326 |
327 |
328 | def test_multiple_queries():
329 | assert (
330 | sql_fingerprint(
331 | "SELECT set_config('flag1', true), set_config('flag2', true); UPDATE user SET username = 'username'"
332 | )
333 | == "SELECT ...; UPDATE user SET ..."
334 | )
335 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | ===============
2 | django-perf-rec
3 | ===============
4 |
5 | .. image:: https://img.shields.io/github/actions/workflow/status/adamchainz/django-perf-rec/main.yml.svg?branch=main&style=for-the-badge
6 | :target: https://github.com/adamchainz/django-perf-rec/actions?workflow=CI
7 |
8 | .. image:: https://img.shields.io/pypi/v/django-perf-rec.svg?style=for-the-badge
9 | :target: https://pypi.org/project/django-perf-rec/
10 |
11 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg?style=for-the-badge
12 | :target: https://github.com/psf/black
13 |
14 | .. image:: https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white&style=for-the-badge
15 | :target: https://github.com/pre-commit/pre-commit
16 | :alt: pre-commit
17 |
18 | Keep detailed records of the performance of your Django code.
19 |
20 | ----
21 |
22 | **Note (2025-08-01):**
23 | My new package `inline-snapshot-django `__ is a faster and more convenient alternative to **django-perf-rec**.
24 | As a consequence, django-perf-rec is now in maintenance mode, and will not receive significant new features.
25 |
26 | ----
27 |
28 | **django-perf-rec** is like Django's ``assertNumQueries`` on steroids. It lets
29 | you track the individual queries and cache operations that occur in your code.
30 | Use it in your tests like so:
31 |
32 | .. code-block:: python
33 |
34 | def test_home(self):
35 | with django_perf_rec.record():
36 | self.client.get("/")
37 |
38 | It then stores a YAML file alongside the test file that tracks the queries and
39 | operations, looking something like:
40 |
41 | .. code-block:: yaml
42 |
43 | MyTests.test_home:
44 | - cache|get: home_data.user_id.#
45 | - db: 'SELECT ... FROM myapp_table WHERE (myapp_table.id = #)'
46 | - db: 'SELECT ... FROM myapp_table WHERE (myapp_table.id = #)'
47 |
48 | When the test is run again, the new record will be compared with the one in the
49 | YAML file. If they are different, an assertion failure will be raised, failing
50 | the test. Magic!
51 |
52 | The queries and keys are 'fingerprinted', replacing information that seems
53 | variable with `#` and `...`. This is done to avoid spurious failures when e.g.
54 | primary keys are different, random data is used, new columns are added to
55 | tables, etc.
56 |
57 | If you check the YAML file in along with your tests, you'll have unbreakable
58 | performance with much better information about any regressions compared to
59 | ``assertNumQueries``. If you are fine with the changes from a failing test,
60 | just remove the file and rerun the test to regenerate it.
61 |
62 | For more information, see our `introductory blog
63 | post `_ that
64 | says a little more about why we made it.
65 |
66 | ----
67 |
68 | **Are your tests slow?**
69 | Check out my book `Speed Up Your Django Tests `__ which covers loads of ways to write faster, more accurate tests.
70 |
71 | ----
72 |
73 | Installation
74 | ============
75 |
76 | Use **pip**:
77 |
78 | .. code-block:: bash
79 |
80 | python -m pip install django-perf-rec
81 |
82 | Requirements
83 | ============
84 |
85 | Python 3.10 to 3.14 supported.
86 |
87 | Django 4.2 to 6.0 supported.
88 |
89 | API
90 | ===
91 |
92 | ``record(record_name: str | None=None, path: str | None=None, capture_traceback: callable[[Operation], bool] | None=None, capture_operation: callable[[Operation], bool] | None=None)``
93 | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
94 |
95 | Return a context manager that will be used for a single performance test.
96 |
97 | The arguments must be passed as keyword arguments.
98 |
99 | ``path`` is the path to a directory or file in which to store the record. If it
100 | ends with ``'/'``, or is left as ``None``, the filename will be automatically
101 | determined by looking at the filename the calling code is in and replacing the
102 | ``.py[c]`` extension with ``.perf.yml``. If it points to a directory that
103 | doesn't exist, that directory will be created.
104 |
105 | ``record_name`` is the name of the record inside the performance file to use.
106 | If left as ``None``, the code assumes you are inside a Django ``TestCase`` and
107 | uses magic stack inspection to find that test case, and uses a name based upon
108 | the test case name + the test method name + an optional counter if you invoke
109 | ``record()`` multiple times inside the same test method.
110 |
111 | Whilst open, the context manager tracks all DB queries on all connections, and
112 | all cache operations on all defined caches. It names the connection/cache in
113 | the tracked operation it uses, except from for the ``default`` one.
114 |
115 | When the context manager exits, it will use the list of operations it has
116 | gathered. If the relevant file specified using ``path`` doesn't exist, or
117 | doesn't contain data for the specific ``record_name``, it will be created and
118 | saved and the test will pass with no assertions. However if the record **does**
119 | exist inside the file, the collected record will be compared with the original
120 | one, and if different, an ``AssertionError`` will be raised. When running on
121 | pytest, this will use its fancy assertion rewriting; in other test runners/uses
122 | the full diff will be attached to the message.
123 |
124 | Example:
125 |
126 | .. code-block:: python
127 |
128 | import django_perf_rec
129 |
130 | from app.models import Author
131 |
132 |
133 | class AuthorPerformanceTests(TestCase):
134 | def test_special_method(self):
135 | with django_perf_rec.record():
136 | list(Author.objects.special_method())
137 |
138 |
139 | ``capture_traceback``, if not ``None``, should be a function that takes one
140 | argument, the given DB or cache operation, and returns a ``bool`` indicating
141 | if a traceback should be captured for the operation (by default, they are not).
142 | Capturing tracebacks allows fine-grained debugging of code paths causing the
143 | operations. Be aware that records differing only by the presence of tracebacks
144 | will not match and cause an ``AssertionError`` to be raised, so it's not
145 | normally suitable to permanently record the tracebacks.
146 |
147 | For example, if you wanted to know what code paths query the table
148 | ``my_table``, you could use a ``capture_traceback`` function like so:
149 |
150 | .. code-block:: python
151 |
152 | def debug_sql_query(operation):
153 | return "my_tables" in operation.query
154 |
155 |
156 | def test_special_method(self):
157 | with django_perf_rec.record(capture_traceback=debug_sql_query):
158 | list(Author.objects.special_method())
159 |
160 | The performance record here would include a standard Python traceback attached
161 | to each SQL query containing "my_table".
162 |
163 |
164 | ``capture_operation``, if not ``None``, should be a function that takes one
165 | argument, the given DB or cache operation, and returns a ``bool`` indicating if
166 | the operation should be recorded at all (by default, all operations are
167 | recorded). Not capturing some operations allows for hiding some code paths to be
168 | ignored in your tests, such as for ignoring database queries that would be
169 | replaced by an external service in production.
170 |
171 | For example, if you knew that in testing all queries to some table would be
172 | replaced in production with something else you could use a ``capture_operation``
173 | function like so:
174 |
175 | .. code-block:: python
176 |
177 | def hide_my_tables(operation):
178 | return "my_tables" in operation.query
179 |
180 |
181 | def test_special_function(self):
182 | with django_perf_rec.record(capture_operation=hide_my_tables):
183 | list(Author.objects.all())
184 |
185 |
186 | ``TestCaseMixin``
187 | -----------------
188 |
189 | A mixin class to be added to your custom ``TestCase`` subclass so you can use
190 | **django-perf-rec** across your codebase without needing to import it in each
191 | individual test file. It adds one method, ``record_performance()``, whose
192 | signature is the same as ``record()`` above.
193 |
194 | Example:
195 |
196 | .. code-block:: python
197 |
198 | # yplan/test.py
199 | from django.test import TestCase as OrigTestCase
200 | from django_perf_rec import TestCaseMixin
201 |
202 |
203 | class TestCase(TestCaseMixin, OrigTestCase):
204 | pass
205 |
206 |
207 | # app/tests/models/test_author.py
208 | from app.models import Author
209 | from yplan.test import TestCase
210 |
211 |
212 | class AuthorPerformanceTests(TestCase):
213 | def test_special_method(self):
214 | with self.record_performance():
215 | list(Author.objects.special_method())
216 |
217 | ``get_perf_path(file_path)``
218 | ----------------------------
219 |
220 | Encapsulates the logic used in ``record()`` to form ``path`` from the path of
221 | the file containing the currently running test, mostly swapping '.py' or '.pyc'
222 | for '.perf.yml'. You might want to use this when calling ``record()`` from
223 | somewhere other than inside a test (which causes the automatic inspection to
224 | fail), to match the same filename.
225 |
226 | ``get_record_name(test_name, class_name=None)``
227 | -----------------------------------------------
228 |
229 | Encapsulates the logic used in ``record()`` to form a ``record_name`` from
230 | details of the currently running test. You might want to use this when calling
231 | ``record()`` from somewhere other than inside a test (which causes the
232 | automatic inspection to fail), to match the same ``record_name``.
233 |
234 | Settings
235 | ========
236 |
237 | Behaviour can be customized with a dictionary called ``PERF_REC`` in your
238 | Django settings, for example:
239 |
240 | .. code-block:: python
241 |
242 | PERF_REC = {
243 | "MODE": "once",
244 | }
245 |
246 | The possible keys to this dictionary are explained below.
247 |
248 | ``HIDE_COLUMNS``
249 | ----------------
250 |
251 | The ``HIDE_COLUMNS`` setting may be used to change the way **django-perf-rec**
252 | simplifies SQL in the recording files it makes. It takes a boolean:
253 |
254 | * ``True`` (default) causes column lists in queries to be collapsed, e.g.
255 | ``SELECT a, b, c FROM t`` becomes ``SELECT ... FROM t``. This is useful
256 | because selected columns often don't affect query time in typical
257 | Django applications, it makes the records easier to read, and they then don't
258 | need updating every time model fields are changed.
259 | * ``False`` stops the collapsing behaviour, causing all the columns to be
260 | output in the files.
261 |
262 | ``MODE``
263 | --------
264 |
265 | The ``MODE`` setting may be used to change the way **django-perf-rec** behaves
266 | when a performance record does not exist during a test run.
267 |
268 | * ``'once'`` (default) creates missing records silently.
269 | * ``'none'`` raises ``AssertionError`` when a record does not exist. You
270 | probably want to use this mode in CI, to ensure new tests fail if their
271 | corresponding performance records were not committed.
272 | * ``'all'`` creates missing records and then raises ``AssertionError``.
273 | * ``'overwrite'`` creates or updates records silently.
274 |
275 | Usage in Pytest
276 | ===============
277 |
278 | If you're using Pytest, you might want to call ``record()`` from within a
279 | Pytest fixture and have it automatically apply to all your tests. We have an
280 | example of this, see the file `test_pytest_fixture_usage.py
281 | `_
282 | in the test suite.
283 |
--------------------------------------------------------------------------------
/tests/test_api.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import os
4 |
5 | import pytest
6 | import yaml
7 | from django.core.cache import caches
8 | from django.db.models import F, Q
9 | from django.db.models.functions import Upper
10 | from django.test import SimpleTestCase, TestCase, override_settings
11 |
12 | from django_perf_rec import TestCaseMixin, get_perf_path, get_record_name, record
13 | from tests.testapp.models import Author
14 | from tests.utils import pretend_not_under_pytest, run_query, temporary_path
15 |
16 | FILE_DIR = os.path.dirname(__file__)
17 |
18 |
19 | class RecordTests(TestCase):
20 | def test_single_db_query(self):
21 | with record():
22 | run_query("default", "SELECT 1337")
23 |
24 | def test_single_db_query_with_traceback(self):
25 | with pretend_not_under_pytest():
26 | with pytest.raises(AssertionError) as excinfo:
27 |
28 | def capture_traceback(operation):
29 | return True
30 |
31 | with record(
32 | record_name="RecordTests.test_single_db_query",
33 | capture_traceback=capture_traceback,
34 | ):
35 | run_query("default", "SELECT 1337")
36 |
37 | msg = str(excinfo.value)
38 | assert (
39 | "Performance record did not match for RecordTests.test_single_db_query"
40 | in msg
41 | )
42 | assert "+ traceback:" in msg
43 | assert "in test_single_db_query_with_traceback" in msg
44 |
45 | def test_single_db_query_with_filtering_negative(self):
46 | def no_capture_operation(operation):
47 | return False
48 |
49 | with record(capture_operation=no_capture_operation):
50 | run_query("default", "SELECT 1337")
51 |
52 | def test_single_db_query_with_filtering_positive(self):
53 | def capture_operation(operation):
54 | return True
55 |
56 | with record(capture_operation=capture_operation):
57 | run_query("default", "SELECT 1338")
58 |
59 | def test_single_db_query_model(self):
60 | with record():
61 | list(Author.objects.all())
62 |
63 | @override_settings(PERF_REC={"HIDE_COLUMNS": False})
64 | def test_single_db_query_model_with_columns(self):
65 | with record():
66 | list(Author.objects.all())
67 |
68 | def test_multiple_db_queries(self):
69 | with record():
70 | run_query("default", "SELECT 1337")
71 | run_query("default", "SELECT 4949")
72 |
73 | def test_non_deterministic_QuerySet_annotate(self):
74 | with record():
75 | list(Author.objects.annotate(x=Upper("name"), y=Upper("name")))
76 |
77 | @override_settings(PERF_REC={"HIDE_COLUMNS": False})
78 | def test_dependent_QuerySet_annotate(self):
79 | with record():
80 | list(Author.objects.annotate(y=Upper("name"), x=F("y")))
81 |
82 | def test_non_deterministic_QuerySet_extra(self):
83 | with record():
84 | list(Author.objects.extra(select={"x": "1", "y": "1"}))
85 |
86 | def test_non_deterministic_Q_query(self):
87 | with record():
88 | list(Author.objects.filter(Q(name="foo", age=1)))
89 |
90 | def test_single_cache_op(self):
91 | with record():
92 | caches["default"].get("foo")
93 |
94 | def test_get_or_set(self):
95 | with record():
96 | caches["default"].get_or_set("foo", 42)
97 |
98 | def test_single_cache_op_with_traceback(self):
99 | with pretend_not_under_pytest():
100 | with pytest.raises(AssertionError) as excinfo:
101 |
102 | def capture_traceback(operation):
103 | return True
104 |
105 | with record(
106 | record_name="RecordTests.test_single_cache_op",
107 | capture_traceback=capture_traceback,
108 | ):
109 | caches["default"].get("foo")
110 |
111 | msg = str(excinfo.value)
112 | assert "+ traceback:" in msg
113 | assert "in test_single_cache_op_with_traceback" in msg
114 |
115 | def test_multiple_cache_ops(self):
116 | with record():
117 | caches["default"].set("foo", "bar")
118 | caches["second"].get_many(["foo", "bar"])
119 | caches["default"].delete("foo")
120 |
121 | def test_multiple_calls_in_same_function_are_different_records(self):
122 | with record():
123 | caches["default"].get("foo")
124 |
125 | with record():
126 | caches["default"].get("bar")
127 |
128 | def test_custom_name(self):
129 | with record(record_name="custom"):
130 | caches["default"].get("foo")
131 |
132 | def test_custom_name_multiple_calls(self):
133 | with record(record_name="custom"):
134 | caches["default"].get("foo")
135 |
136 | with pytest.raises(AssertionError) as excinfo, record(record_name="custom"):
137 | caches["default"].get("bar")
138 |
139 | assert "Performance record did not match" in str(excinfo.value)
140 |
141 | def test_diff(self):
142 | with pretend_not_under_pytest():
143 | with record(record_name="test_diff"):
144 | caches["default"].get("foo")
145 |
146 | with (
147 | pytest.raises(AssertionError) as excinfo,
148 | record(record_name="test_diff"),
149 | ):
150 | caches["default"].get("bar")
151 |
152 | msg = str(excinfo.value)
153 | assert "- cache|get: foo\n" in msg
154 | assert "+ cache|get: bar\n" in msg
155 |
156 | def test_path_pointing_to_filename(self):
157 | with temporary_path("custom.perf.yml"):
158 | with record(path="custom.perf.yml"):
159 | caches["default"].get("foo")
160 |
161 | assert os.path.exists("custom.perf.yml")
162 |
163 | def test_path_pointing_to_filename_record_twice(self):
164 | with temporary_path("custom.perf.yml"):
165 | with record(path="custom.perf.yml"):
166 | caches["default"].get("foo")
167 |
168 | with record(path="custom.perf.yml"):
169 | caches["default"].get("foo")
170 |
171 | def test_path_pointing_to_dir(self):
172 | temp_dir = os.path.join(FILE_DIR, "perf_files/")
173 | with temporary_path(temp_dir):
174 | with record(path="perf_files/"):
175 | caches["default"].get("foo")
176 |
177 | full_path = os.path.join(FILE_DIR, "perf_files", "test_api.perf.yml")
178 | assert os.path.exists(full_path)
179 |
180 | def test_custom_nested_path(self):
181 | temp_dir = os.path.join(FILE_DIR, "perf_files/")
182 | with temporary_path(temp_dir):
183 | with record(path="perf_files/api/"):
184 | caches["default"].get("foo")
185 |
186 | full_path = os.path.join(FILE_DIR, "perf_files", "api", "test_api.perf.yml")
187 | assert os.path.exists(full_path)
188 |
189 | @override_settings(PERF_REC={"MODE": "once"})
190 | def test_mode_once(self):
191 | temp_dir = os.path.join(FILE_DIR, "perf_files/")
192 | with temporary_path(temp_dir):
193 | with record(path="perf_files/api/", record_name="test_mode_once"):
194 | caches["default"].get("foo")
195 |
196 | full_path = os.path.join(FILE_DIR, "perf_files", "api", "test_api.perf.yml")
197 | assert os.path.exists(full_path)
198 |
199 | with (
200 | pytest.raises(AssertionError) as excinfo,
201 | record(path="perf_files/api/", record_name="test_mode_once"),
202 | ):
203 | caches["default"].get("bar")
204 |
205 | message = str(excinfo.value)
206 | assert "Performance record did not match for test_mode_once" in message
207 |
208 | @override_settings(PERF_REC={"MODE": "none"})
209 | def test_mode_none(self):
210 | temp_dir = os.path.join(FILE_DIR, "perf_files/")
211 | with temporary_path(temp_dir):
212 | with (
213 | pytest.raises(AssertionError) as excinfo,
214 | record(path="perf_files/api/"),
215 | ):
216 | caches["default"].get("foo")
217 |
218 | assert "Original performance record does not exist" in str(excinfo.value)
219 |
220 | full_path = os.path.join(FILE_DIR, "perf_files", "api", "test_api.perf.yml")
221 | assert not os.path.exists(full_path)
222 |
223 | @override_settings(PERF_REC={"MODE": "all"})
224 | def test_mode_all(self):
225 | temp_dir = os.path.join(FILE_DIR, "perf_files/")
226 | with temporary_path(temp_dir):
227 | with (
228 | pytest.raises(AssertionError) as excinfo,
229 | record(path="perf_files/api/"),
230 | ):
231 | caches["default"].get("foo")
232 |
233 | assert "Original performance record did not exist" in str(excinfo.value)
234 |
235 | full_path = os.path.join(FILE_DIR, "perf_files", "api", "test_api.perf.yml")
236 | assert os.path.exists(full_path)
237 |
238 | @override_settings(PERF_REC={"MODE": "overwrite"})
239 | def test_mode_overwrite(self):
240 | temp_dir = os.path.join(FILE_DIR, "perf_files/")
241 | with temporary_path(temp_dir):
242 | with record(path="perf_files/api/", record_name="test_mode_overwrite"):
243 | caches["default"].get("foo")
244 | caches["default"].get("bar")
245 |
246 | full_path = os.path.join(FILE_DIR, "perf_files", "api", "test_api.perf.yml")
247 | assert os.path.exists(full_path)
248 |
249 | with record(path="perf_files/api/", record_name="test_mode_overwrite"):
250 | caches["default"].get("baz")
251 |
252 | full_path = os.path.join(FILE_DIR, "perf_files", "api", "test_api.perf.yml")
253 | with open(full_path) as f:
254 | data = yaml.safe_load(f.read())
255 |
256 | assert data == {"test_mode_overwrite": [{"cache|get": "baz"}]}
257 |
258 | def test_delete_on_cascade_called_twice(self):
259 | arthur = Author.objects.create(name="Arthur", age=42)
260 | with record():
261 | arthur.delete()
262 |
263 |
264 | class GetPerfPathTests(SimpleTestCase):
265 | def test_py_file(self):
266 | assert get_perf_path("foo.py") == "foo.perf.yml"
267 |
268 | def test_pyc_file(self):
269 | assert get_perf_path("foo.pyc") == "foo.perf.yml"
270 |
271 | def test_unknown_file(self):
272 | assert get_perf_path("foo.plob") == "foo.plob.perf.yml"
273 |
274 |
275 | class GetRecordNameTests(SimpleTestCase):
276 | def test_class_and_test(self):
277 | assert (
278 | get_record_name(class_name="FooTests", test_name="test_bar")
279 | == "FooTests.test_bar"
280 | )
281 |
282 | def test_just_test(self):
283 | assert get_record_name(test_name="test_baz") == "test_baz"
284 |
285 | def test_multiple_calls(self):
286 | assert get_record_name(test_name="test_qux") == "test_qux"
287 | assert get_record_name(test_name="test_qux") == "test_qux.2"
288 |
289 | def test_multiple_calls_from_different_files(self):
290 | assert get_record_name(test_name="test_qux", file_name="foo.py") == "test_qux"
291 |
292 | assert get_record_name(test_name="test_qux", file_name="foo2.py") == "test_qux"
293 |
294 | assert get_record_name(test_name="test_qux", file_name="foo.py") == "test_qux"
295 |
296 | assert get_record_name(test_name="test_qux", file_name="foo.py") == "test_qux.2"
297 |
298 |
299 | class TestCaseMixinTests(TestCaseMixin, TestCase):
300 | def test_record_performance(self):
301 | with self.record_performance():
302 | caches["default"].get("foo")
303 |
304 | def test_record_performance_record_name(self):
305 | with self.record_performance(record_name="other"):
306 | caches["default"].get("foo")
307 |
308 | def test_record_performance_file_name(self):
309 | perf_name = __file__.replace(".py", ".file_name.perf.yml")
310 | with self.record_performance(path=perf_name):
311 | caches["default"].get("foo")
312 |
--------------------------------------------------------------------------------
/CHANGELOG.rst:
--------------------------------------------------------------------------------
1 | =========
2 | Changelog
3 | =========
4 |
5 | * Drop Python 3.9 support.
6 |
7 | 4.31.0 (2025-09-18)
8 | -------------------
9 |
10 | * Support Django 6.0.
11 |
12 | 4.30.0 (2025-09-08)
13 | -------------------
14 |
15 | * Support Python 3.14.
16 |
17 | 4.29.0 (2025-08-01)
18 | -------------------
19 |
20 | * Support fingerprinting SQL strings containing multiple queries.
21 |
22 | Thanks to q0w in `PR #669 `__.
23 |
24 | * Fix test name detection when a test function contains a variable called ``request``.
25 |
26 | Thanks to Konstantin Alekseev for the initial patch in `PR #659 `__.
27 |
28 | 4.28.0 (2025-02-06)
29 | -------------------
30 |
31 | * Support Django 5.2.
32 |
33 | 4.27.0 (2024-10-29)
34 | -------------------
35 |
36 | * Drop Django 3.2 to 4.1 support.
37 |
38 | * Drop Python 3.8 support.
39 |
40 | * Support Python 3.13.
41 |
42 | * Allow comments in ``ROLLBACK TO SAVEPOINT`` statements.
43 |
44 | Thanks to Corentin Smith in `PR #537 `__.
45 |
46 | 4.26.0 (2024-06-19)
47 | -------------------
48 |
49 | * Support Django 5.1.
50 |
51 | * Optimize ``sql_fingerprint()`` a bit, yielding ~2% savings.
52 |
53 | 4.25.0 (2023-10-11)
54 | -------------------
55 |
56 | * Support Django 5.0.
57 |
58 | 4.24.0 (2023-07-10)
59 | -------------------
60 |
61 | * Drop Python 3.7 support.
62 |
63 | 4.23.0 (2023-06-14)
64 | -------------------
65 |
66 | * Support Python 3.12.
67 |
68 | 4.22.3 (2023-05-30)
69 | -------------------
70 |
71 | * Fetch the test name from pytest where possible, which pulls in its parametrized name.
72 |
73 | Thanks to Gert Van Gool in `PR #537 `__.
74 |
75 | 4.22.2 (2023-05-30)
76 | -------------------
77 |
78 | * Avoid crashing when recording cache function calls that pass ``key`` or ``keys`` as a keyword argument, like ``cache.set(key="abc", value="def")``.
79 |
80 | Thanks to Benoît Casoetto in `PR #545 `__.
81 |
82 | 4.22.1 (2023-04-23)
83 | -------------------
84 |
85 | * Fix compatibility with sqlparse 0.4.4+.
86 | sqlparse reverted a change made in 0.4.0 around ``IN``, so the old behaviour in django-perf-rec has been restored.
87 | Upgrade to avoid regenerating your performance record files.
88 |
89 | 4.22.0 (2023-02-25)
90 | -------------------
91 |
92 | * Support Django 4.2.
93 |
94 | 4.21.0 (2022-06-05)
95 | -------------------
96 |
97 | * Support Python 3.11.
98 |
99 | * Support Django 4.1.
100 |
101 | 4.20.1 (2022-05-18)
102 | -------------------
103 |
104 | * Fix 'overwrite' mode to prevent file corruption.
105 |
106 | Thanks to Peter Law for the report in `Issue #468 `__.
107 |
108 | 4.20.0 (2022-05-10)
109 | -------------------
110 |
111 | * Drop support for Django 2.2, 3.0, and 3.1.
112 |
113 | 4.19.0 (2022-05-10)
114 | -------------------
115 |
116 | * Add new ``MODE`` option, ``'overwrite'``, which creates or updates missing
117 | records silently.
118 |
119 | Thanks to Peter Law in `PR #461 `__.
120 |
121 | 4.18.0 (2022-01-10)
122 | -------------------
123 |
124 | * Drop Python 3.6 support.
125 |
126 | 4.17.0 (2021-10-05)
127 | -------------------
128 |
129 | * Support Python 3.10.
130 |
131 | 4.16.0 (2021-09-28)
132 | -------------------
133 |
134 | * Support Django 4.0.
135 |
136 | 4.15.0 (2021-08-20)
137 | -------------------
138 |
139 | * Add type hints.
140 |
141 | 4.14.1 (2021-06-22)
142 | -------------------
143 |
144 | * Support arbitrary collections of keys being passed to Django cache operations.
145 | Previously only mappings and sequences were supported, now sets and mapping
146 | views will also work.
147 |
148 | Thanks to Peter Law in
149 | `PR #378 `__.
150 |
151 | 4.14.0 (2021-06-02)
152 | -------------------
153 |
154 | * Re-add simplification of SQL ``IN`` clauses to always use ``(...)``. This was
155 | done in 4.6.0 but accidentally reverted with the sqlparse upgrade in 4.8.0.
156 |
157 | Thanks to Dan Palmer for the report in
158 | `Issue #373 `__.
159 |
160 | 4.13.1 (2021-04-15)
161 | -------------------
162 |
163 | * Fix SQL simplification for ``UPDATE`` queries without a ``WHERE`` clause.
164 |
165 | Thanks to Peter Law for the report in
166 | `Issue #360 `__.
167 |
168 | 4.13.0 (2021-03-27)
169 | -------------------
170 |
171 | * Stop distributing tests to reduce package size. Tests are not intended to be
172 | run outside of the tox setup in the repository. Repackagers can use GitHub's
173 | tarballs per tag.
174 |
175 | * Add support for hiding some operations from being recorded, via a new
176 | ``capture_operation`` callable to ``record``. This is potentially useful where
177 | a different backend is used in testing than would be used in production and
178 | thus a portion of the operations in a context are not representative.
179 |
180 | Thanks to Peter Law in
181 | `PR #342 `__.
182 |
183 |
184 | 4.12.0 (2021-01-25)
185 | -------------------
186 |
187 | * Support Django 3.2.
188 |
189 | 4.11.0 (2020-12-04)
190 | -------------------
191 |
192 | * Drop Python 3.5 support.
193 | * Remove ORM patching. Now that only Python 3.6 is supported, the
194 | insertion-order of ``dict``\s should mean Django's ORM always provides
195 | deterministic queries. The two patches django-perf-rec made on the ORM have
196 | been removed, and the corresponding dependency on patchy. You may need to
197 | regenerate your performance record files.
198 |
199 | This fixes an issue where use of ``annotate()`` with dependencies between the
200 | annotations could cause a query error after django-perf-rec sorted the
201 | annotation names.
202 |
203 | Thanks to Gordon Wrigley for the report in
204 | `Issue #322 `__.
205 |
206 | 4.10.0 (2020-11-20)
207 | -------------------
208 |
209 | * Correctly record calls to ``cache.get_or_set()``.
210 |
211 | Thanks to Peter Law for the report in
212 | `Issue #319 `__.
213 |
214 | 4.9.0 (2020-11-04)
215 | ------------------
216 |
217 | * Support Python 3.9.
218 | * Allow recording of tracebacks alongside db queries or cache operations,
219 | selected via a function passed as ``capture_traceback`` to ``record()``.
220 |
221 | Thanks to Nadege Michel in
222 | `PR #299 `__.
223 |
224 | 4.8.0 (2020-10-10)
225 | ------------------
226 |
227 | * Drop Django 2.0 and 2.1 support.
228 | * Upgrade for sqlparse 0.4.0+. This required changing how SQL lists of one
229 | element are simplified, e.g. ``IN (1)`` will now be simplified to ``IN (#)``
230 | instead of ``IN (...)``. You should regenerate your performance record files
231 | to match.
232 |
233 | 4.7.0 (2020-06-15)
234 | ------------------
235 |
236 | * Add Django 3.1 support.
237 |
238 | 4.6.1 (2020-05-21)
239 | ------------------
240 |
241 | * Create YAML files as non-executable. This will not be applied to existing
242 | files, modify their permissions if necessary, or delete and recreate.
243 |
244 | Thanks to Peter Law for the report in `Issue #264
245 | `__.
246 |
247 | 4.6.0 (2020-05-20)
248 | ------------------
249 |
250 | * Drop Django 1.11 support. Only Django 2.0+ is supported now.
251 | * Simplify SQL ``IN`` clauses to always use ``(...)``. Now ``x IN (1)`` and
252 | ``x IN (1,2)`` both simplify to ``x IN (...)``.
253 |
254 | Thanks to Dan Palmer in
255 | `PR #263 `__.
256 |
257 | 4.5.0 (2019-11-25)
258 | ------------------
259 |
260 | * Update Python support to 3.5-3.8, as 3.4 has reached its end of life.
261 | * Converted setuptools metadata to configuration file. This meant removing the
262 | ``__version__`` attribute from the package. If you want to inspect the
263 | installed version, use
264 | ``importlib.metadata.version("django-perf-rec")``
265 | (`docs `__ /
266 | `backport `__).
267 | * Fix ``Q()`` Patchy patch for Django 2.0+ with non-AND-ed ``Q()``'s.
268 |
269 | 4.4.0 (2019-05-09)
270 | ------------------
271 |
272 | * Normalize SQL whitespace. This will change fingerprinted SQL in some cases.
273 |
274 | 4.3.0 (2019-04-26)
275 | ------------------
276 |
277 | * Add support for Django 2.2.
278 |
279 | 4.2.0 (2019-04-13)
280 | ------------------
281 |
282 | * Work with, and require, ``sqlparse`` > 0.3.0.
283 |
284 | 4.1.0 (2019-03-04)
285 | ------------------
286 |
287 | * Fix a bug in automatic test record naming when two different modules had a
288 | test with the same class + name that ran one after another.
289 | * Fix Python 3.7 ``DeprecationWarning`` for ``collections.abc`` (Python 3.7 not
290 | officially supported yet).
291 |
292 | 4.0.0 (2019-02-01)
293 | ------------------
294 |
295 | * Drop Python 2 support, only Python 3.4+ is supported now.
296 | * Drop Django 1.8, 1.9, and 1.10 support. Only Django 1.11+ is supported now.
297 | * Dropped requirements for ``kwargs-only`` and ``six``.
298 |
299 | 3.1.1 (2018-12-03)
300 | ------------------
301 |
302 | * Fix to actually obey the ``HIDE_COLUMNS`` option.
303 |
304 | 3.1.0 (2018-12-02)
305 | ------------------
306 |
307 | * Add the ``HIDE_COLUMNS`` option in settings to disable replacing column lists
308 | with ``...`` in all places.
309 |
310 | 3.0.0 (2018-07-17)
311 | ------------------
312 |
313 | * Don't replace columns in ORDER BY, GROUP BY and HAVING clauses.
314 |
315 | 2.2.0 (2018-01-24)
316 | ------------------
317 |
318 | * Use ``kwargs-only`` library rather than vendored copy.
319 | * Erase volatile part of PostgreSQL cursor name.
320 |
321 | 2.1.0 (2017-05-29)
322 | ------------------
323 |
324 | * Exposed the automatic naming logic used in ``record()`` in two new functions
325 | ``get_perf_path()`` and ``get_record_name()``, in order to ease creation of
326 | test records from calls outside of tests.
327 | * Made the automatic test detection work when running under a Pytest fixture.
328 | * Stopped throwing warnings on Python 3.
329 | * Fixed loading empty performance record files.
330 |
331 | 2.0.1 (2017-03-02)
332 | ------------------
333 |
334 | * Make cascaded delete queries deterministic on Django <1.10, with another
335 | Patchy patch to make it match the order from 1.10+.
336 |
337 | 2.0.0 (2017-02-09)
338 | ------------------
339 |
340 | * Arguments to ``record`` must be passed as keyword arguments.
341 | * ``file_name`` is removed as an argument to ``record`` following its
342 | deprecation in release 1.1.0.
343 |
344 |
345 | 1.1.1 (2016-10-30)
346 | ------------------
347 |
348 | * Fix django session keys not being fingerprinted.
349 | * Show diff when records don't match (when not on pytest).
350 | * Add new 'MODE' setting with three modes. This allows customization of the
351 | behaviour for missing performance records. The new ``'none'`` mode is
352 | particularly useful for CI servers as it makes tests fail if their
353 | corresponding performance records have not been committed.
354 |
355 | 1.1.0 (2016-10-26)
356 | ------------------
357 |
358 | * Fix automatic filenames for tests in ``.pyc`` files.
359 | * Add the ``path`` argument to ``record`` which allows specifying a relative
360 | directory or filename to use. This deprecates the ``file_name`` argument,
361 | which will be removed in a future major release. For more info see the
362 | README.
363 |
364 | 1.0.4 (2016-10-23)
365 | ------------------
366 |
367 | * Work with ``sqlparse`` 0.2.2
368 |
369 | 1.0.3 (2016-10-07)
370 | ------------------
371 |
372 | * Stopped ``setup.py`` installing ``tests`` module.
373 |
374 | 1.0.2 (2016-09-23)
375 | ------------------
376 |
377 | * Confirmed Django 1.8 and 1.10 support.
378 |
379 | 1.0.1 (2016-09-20)
380 | ------------------
381 |
382 | * Fix ``install_requires`` in ``setup.py``.
383 |
384 | 1.0.0 (2016-09-19)
385 | ------------------
386 |
387 | * Initial version with ``record()`` that can record database queries and cache
388 | operations and error if they change between test runs.
389 |
--------------------------------------------------------------------------------
/uv.lock:
--------------------------------------------------------------------------------
1 | version = 1
2 | revision = 3
3 | requires-python = ">=3.10"
4 | resolution-markers = [
5 | "python_full_version >= '3.12' and extra != 'group-15-django-perf-rec-django42' and extra != 'group-15-django-perf-rec-django50' and extra != 'group-15-django-perf-rec-django51' and extra != 'group-15-django-perf-rec-django52' and extra == 'group-15-django-perf-rec-django60'",
6 | "python_full_version < '3.12' and extra != 'group-15-django-perf-rec-django42' and extra != 'group-15-django-perf-rec-django50' and extra != 'group-15-django-perf-rec-django51' and extra != 'group-15-django-perf-rec-django52' and extra == 'group-15-django-perf-rec-django60'",
7 | "extra != 'group-15-django-perf-rec-django42' and extra != 'group-15-django-perf-rec-django50' and extra != 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52' and extra != 'group-15-django-perf-rec-django60'",
8 | "extra != 'group-15-django-perf-rec-django42' and extra != 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django51' and extra != 'group-15-django-perf-rec-django52' and extra != 'group-15-django-perf-rec-django60'",
9 | "extra != 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django50' and extra != 'group-15-django-perf-rec-django51' and extra != 'group-15-django-perf-rec-django52' and extra != 'group-15-django-perf-rec-django60'",
10 | "extra == 'group-15-django-perf-rec-django42' and extra != 'group-15-django-perf-rec-django50' and extra != 'group-15-django-perf-rec-django51' and extra != 'group-15-django-perf-rec-django52' and extra != 'group-15-django-perf-rec-django60'",
11 | "python_full_version >= '3.12' and extra != 'group-15-django-perf-rec-django42' and extra != 'group-15-django-perf-rec-django50' and extra != 'group-15-django-perf-rec-django51' and extra != 'group-15-django-perf-rec-django52' and extra != 'group-15-django-perf-rec-django60'",
12 | "python_full_version < '3.12' and extra != 'group-15-django-perf-rec-django42' and extra != 'group-15-django-perf-rec-django50' and extra != 'group-15-django-perf-rec-django51' and extra != 'group-15-django-perf-rec-django52' and extra != 'group-15-django-perf-rec-django60'",
13 | ]
14 | conflicts = [[
15 | { package = "django-perf-rec", group = "django42" },
16 | { package = "django-perf-rec", group = "django50" },
17 | { package = "django-perf-rec", group = "django51" },
18 | { package = "django-perf-rec", group = "django52" },
19 | { package = "django-perf-rec", group = "django60" },
20 | ]]
21 |
22 | [[package]]
23 | name = "asgiref"
24 | version = "3.9.1"
25 | source = { registry = "https://pypi.org/simple" }
26 | dependencies = [
27 | { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django50') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django52' and extra == 'group-15-django-perf-rec-django60')" },
28 | ]
29 | sdist = { url = "https://files.pythonhosted.org/packages/90/61/0aa957eec22ff70b830b22ff91f825e70e1ef732c06666a805730f28b36b/asgiref-3.9.1.tar.gz", hash = "sha256:a5ab6582236218e5ef1648f242fd9f10626cfd4de8dc377db215d5d5098e3142", size = 36870, upload-time = "2025-07-08T09:07:43.344Z" }
30 | wheels = [
31 | { url = "https://files.pythonhosted.org/packages/7c/3c/0464dcada90d5da0e71018c04a140ad6349558afb30b3051b4264cc5b965/asgiref-3.9.1-py3-none-any.whl", hash = "sha256:f3bba7092a48005b5f5bacd747d36ee4a5a61f4a269a6df590b43144355ebd2c", size = 23790, upload-time = "2025-07-08T09:07:41.548Z" },
32 | ]
33 |
34 | [[package]]
35 | name = "colorama"
36 | version = "0.4.6"
37 | source = { registry = "https://pypi.org/simple" }
38 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
39 | wheels = [
40 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
41 | ]
42 |
43 | [[package]]
44 | name = "django"
45 | version = "4.2.23"
46 | source = { registry = "https://pypi.org/simple" }
47 | dependencies = [
48 | { name = "asgiref", marker = "extra == 'group-15-django-perf-rec-django42' or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django52' and extra == 'group-15-django-perf-rec-django60')" },
49 | { name = "sqlparse", marker = "extra == 'group-15-django-perf-rec-django42' or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django52' and extra == 'group-15-django-perf-rec-django60')" },
50 | { name = "tzdata", marker = "(sys_platform == 'win32' and extra == 'group-15-django-perf-rec-django42') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django50') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django52' and extra == 'group-15-django-perf-rec-django60')" },
51 | ]
52 | sdist = { url = "https://files.pythonhosted.org/packages/b5/20/02242739714eb4e53933d6c0fe2c57f41feb449955b0aa39fc2da82b8f3c/django-4.2.23.tar.gz", hash = "sha256:42fdeaba6e6449d88d4f66de47871015097dc6f1b87910db00a91946295cfae4", size = 10448384, upload-time = "2025-06-10T10:06:34.574Z" }
53 | wheels = [
54 | { url = "https://files.pythonhosted.org/packages/cb/44/314e8e4612bd122dd0424c88b44730af68eafbee88cc887a86586b7a1f2a/django-4.2.23-py3-none-any.whl", hash = "sha256:dafbfaf52c2f289bd65f4ab935791cb4fb9a198f2a5ba9faf35d7338a77e9803", size = 7993904, upload-time = "2025-06-10T10:06:28.092Z" },
55 | ]
56 |
57 | [[package]]
58 | name = "django"
59 | version = "5.0.14"
60 | source = { registry = "https://pypi.org/simple" }
61 | dependencies = [
62 | { name = "asgiref", marker = "extra == 'group-15-django-perf-rec-django50' or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django52' and extra == 'group-15-django-perf-rec-django60')" },
63 | { name = "sqlparse", marker = "extra == 'group-15-django-perf-rec-django50' or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django52' and extra == 'group-15-django-perf-rec-django60')" },
64 | { name = "tzdata", marker = "(sys_platform == 'win32' and extra == 'group-15-django-perf-rec-django50') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django50') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django52' and extra == 'group-15-django-perf-rec-django60')" },
65 | ]
66 | sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/cc0205045386b5be8eecb15a95f290383d103f0db5f7e34f93dcc340d5b0/Django-5.0.14.tar.gz", hash = "sha256:29019a5763dbd48da1720d687c3522ef40d1c61be6fb2fad27ed79e9f655bc11", size = 10644306, upload-time = "2025-04-02T11:24:41.396Z" }
67 | wheels = [
68 | { url = "https://files.pythonhosted.org/packages/c0/93/eabde8789f41910845567ebbff5aacd52fd80e54c934ce15b83d5f552d2c/Django-5.0.14-py3-none-any.whl", hash = "sha256:e762bef8629ee704de215ebbd32062b84f4e56327eed412e5544f6f6eb1dfd74", size = 8185934, upload-time = "2025-04-02T11:24:36.888Z" },
69 | ]
70 |
71 | [[package]]
72 | name = "django"
73 | version = "5.1.11"
74 | source = { registry = "https://pypi.org/simple" }
75 | dependencies = [
76 | { name = "asgiref", marker = "extra == 'group-15-django-perf-rec-django51' or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django50') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django52' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django42' and extra != 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django42' and extra != 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django60')" },
77 | { name = "sqlparse", marker = "extra == 'group-15-django-perf-rec-django51' or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django50') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django52' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django42' and extra != 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django42' and extra != 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django60')" },
78 | { name = "tzdata", marker = "(sys_platform == 'win32' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django50') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django52' and extra == 'group-15-django-perf-rec-django60')" },
79 | ]
80 | sdist = { url = "https://files.pythonhosted.org/packages/83/80/bf0f9b0aa434fca2b46fc6a31c39b08ea714b87a0a72a16566f053fb05a8/django-5.1.11.tar.gz", hash = "sha256:3bcdbd40e4d4623b5e04f59c28834323f3086df583058e65ebce99f9982385ce", size = 10734926, upload-time = "2025-06-10T10:12:48.229Z" }
81 | wheels = [
82 | { url = "https://files.pythonhosted.org/packages/59/91/2972ce330c6c0bd5b3200d4c2ad5cbf47eecff5243220c5a56444d3267a0/django-5.1.11-py3-none-any.whl", hash = "sha256:e48091f364007068728aca938e7450fbfe3f2217079bfd2b8af45122585acf64", size = 8277453, upload-time = "2025-06-10T10:12:42.236Z" },
83 | ]
84 |
85 | [[package]]
86 | name = "django"
87 | version = "5.2.5"
88 | source = { registry = "https://pypi.org/simple" }
89 | resolution-markers = [
90 | "python_full_version < '3.12' and extra != 'group-15-django-perf-rec-django42' and extra != 'group-15-django-perf-rec-django50' and extra != 'group-15-django-perf-rec-django51' and extra != 'group-15-django-perf-rec-django52' and extra == 'group-15-django-perf-rec-django60'",
91 | "extra != 'group-15-django-perf-rec-django42' and extra != 'group-15-django-perf-rec-django50' and extra != 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52' and extra != 'group-15-django-perf-rec-django60'",
92 | "python_full_version < '3.12' and extra != 'group-15-django-perf-rec-django42' and extra != 'group-15-django-perf-rec-django50' and extra != 'group-15-django-perf-rec-django51' and extra != 'group-15-django-perf-rec-django52' and extra != 'group-15-django-perf-rec-django60'",
93 | ]
94 | dependencies = [
95 | { name = "asgiref", marker = "(python_full_version < '3.12' and extra != 'group-15-django-perf-rec-django42' and extra != 'group-15-django-perf-rec-django50' and extra != 'group-15-django-perf-rec-django51') or extra == 'group-15-django-perf-rec-django52' or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django50') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django60')" },
96 | { name = "sqlparse", marker = "(python_full_version < '3.12' and extra != 'group-15-django-perf-rec-django42' and extra != 'group-15-django-perf-rec-django50' and extra != 'group-15-django-perf-rec-django51') or extra == 'group-15-django-perf-rec-django52' or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django50') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django60')" },
97 | { name = "tzdata", marker = "(python_full_version < '3.12' and sys_platform == 'win32' and extra != 'group-15-django-perf-rec-django42' and extra != 'group-15-django-perf-rec-django50' and extra != 'group-15-django-perf-rec-django51') or (sys_platform == 'win32' and extra == 'group-15-django-perf-rec-django52') or (sys_platform != 'win32' and extra == 'group-15-django-perf-rec-django52' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django50') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django60')" },
98 | ]
99 | sdist = { url = "https://files.pythonhosted.org/packages/62/9b/779f853c3d2d58b9e08346061ff3e331cdec3fe3f53aae509e256412a593/django-5.2.5.tar.gz", hash = "sha256:0745b25681b129a77aae3d4f6549b62d3913d74407831abaa0d9021a03954bae", size = 10859748, upload-time = "2025-08-06T08:26:29.978Z" }
100 | wheels = [
101 | { url = "https://files.pythonhosted.org/packages/9d/6e/98a1d23648e0085bb5825326af17612ecd8fc76be0ce96ea4dc35e17b926/django-5.2.5-py3-none-any.whl", hash = "sha256:2b2ada0ee8a5ff743a40e2b9820d1f8e24c11bac9ae6469cd548f0057ea6ddcd", size = 8302999, upload-time = "2025-08-06T08:26:23.562Z" },
102 | ]
103 |
104 | [[package]]
105 | name = "django"
106 | version = "6.0a1"
107 | source = { registry = "https://pypi.org/simple" }
108 | resolution-markers = [
109 | "python_full_version >= '3.12'",
110 | ]
111 | dependencies = [
112 | { name = "asgiref", marker = "(python_full_version >= '3.12' and extra != 'group-15-django-perf-rec-django42' and extra != 'group-15-django-perf-rec-django50' and extra != 'group-15-django-perf-rec-django51' and extra != 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django50') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django52' and extra == 'group-15-django-perf-rec-django60')" },
113 | { name = "sqlparse", marker = "(python_full_version >= '3.12' and extra != 'group-15-django-perf-rec-django42' and extra != 'group-15-django-perf-rec-django50' and extra != 'group-15-django-perf-rec-django51' and extra != 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django50') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django52' and extra == 'group-15-django-perf-rec-django60')" },
114 | { name = "tzdata", marker = "(python_full_version >= '3.12' and sys_platform == 'win32' and extra != 'group-15-django-perf-rec-django42' and extra != 'group-15-django-perf-rec-django50' and extra != 'group-15-django-perf-rec-django51' and extra != 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django50') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django52' and extra == 'group-15-django-perf-rec-django60')" },
115 | ]
116 | sdist = { url = "https://files.pythonhosted.org/packages/91/12/a219b5c2a0c4377f58859f5b68f14449f36ea6668bc20816ad51bef3b5d0/django-6.0a1.tar.gz", hash = "sha256:0195dd90d63d2249079f610779dff5e72d51a5feda82222645e6e941c95b3992", size = 11171245, upload-time = "2025-09-17T19:05:35.202Z" }
117 | wheels = [
118 | { url = "https://files.pythonhosted.org/packages/f1/fd/92e699f92520168df34dce1d5e88322e2db3dcc2815c52fd7926eea608a6/django-6.0a1-py3-none-any.whl", hash = "sha256:8bc2b47de56a446ad9995a36c9bfeda15f5be413c65d8bde34871d5f778d9172", size = 8333170, upload-time = "2025-09-17T19:05:31.635Z" },
119 | ]
120 |
121 | [[package]]
122 | name = "django-perf-rec"
123 | version = "4.31.0"
124 | source = { editable = "." }
125 | dependencies = [
126 | { name = "django", version = "4.2.23", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-15-django-perf-rec-django42' or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django52' and extra == 'group-15-django-perf-rec-django60')" },
127 | { name = "django", version = "5.0.14", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-15-django-perf-rec-django50' or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django52' and extra == 'group-15-django-perf-rec-django60')" },
128 | { name = "django", version = "5.1.11", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-15-django-perf-rec-django51' or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django50') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django52' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django42' and extra != 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django42' and extra != 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django60')" },
129 | { name = "django", version = "5.2.5", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and extra != 'group-15-django-perf-rec-django42' and extra != 'group-15-django-perf-rec-django50' and extra != 'group-15-django-perf-rec-django51') or extra == 'group-15-django-perf-rec-django52' or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django50') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django60')" },
130 | { name = "django", version = "6.0a1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and extra != 'group-15-django-perf-rec-django42' and extra != 'group-15-django-perf-rec-django50' and extra != 'group-15-django-perf-rec-django51' and extra != 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django50') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django52' and extra == 'group-15-django-perf-rec-django60')" },
131 | { name = "pyyaml" },
132 | { name = "sqlparse" },
133 | ]
134 |
135 | [package.dev-dependencies]
136 | django42 = [
137 | { name = "django", version = "4.2.23", source = { registry = "https://pypi.org/simple" } },
138 | ]
139 | django50 = [
140 | { name = "django", version = "5.0.14", source = { registry = "https://pypi.org/simple" } },
141 | ]
142 | django51 = [
143 | { name = "django", version = "5.1.11", source = { registry = "https://pypi.org/simple" } },
144 | ]
145 | django52 = [
146 | { name = "django", version = "5.2.5", source = { registry = "https://pypi.org/simple" } },
147 | ]
148 | django60 = [
149 | { name = "django", version = "6.0a1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
150 | ]
151 | test = [
152 | { name = "pygments" },
153 | { name = "pytest" },
154 | { name = "pytest-django" },
155 | { name = "pytest-randomly" },
156 | { name = "pyyaml" },
157 | { name = "sqlparse" },
158 | ]
159 |
160 | [package.metadata]
161 | requires-dist = [
162 | { name = "django", specifier = ">=4.2" },
163 | { name = "pyyaml" },
164 | { name = "sqlparse", specifier = ">=0.4.4" },
165 | ]
166 |
167 | [package.metadata.requires-dev]
168 | django42 = [{ name = "django", marker = "python_full_version >= '3.8'", specifier = ">=4.2a1,<5" }]
169 | django50 = [{ name = "django", marker = "python_full_version >= '3.10'", specifier = ">=5a1,<5.1" }]
170 | django51 = [{ name = "django", marker = "python_full_version >= '3.10'", specifier = ">=5.1a1,<5.2" }]
171 | django52 = [{ name = "django", marker = "python_full_version >= '3.10'", specifier = ">=5.2a1,<6" }]
172 | django60 = [{ name = "django", marker = "python_full_version >= '3.12'", specifier = ">=6a1,<6.1" }]
173 | test = [
174 | { name = "pygments" },
175 | { name = "pytest" },
176 | { name = "pytest-django" },
177 | { name = "pytest-randomly" },
178 | { name = "pyyaml" },
179 | { name = "sqlparse" },
180 | ]
181 |
182 | [[package]]
183 | name = "exceptiongroup"
184 | version = "1.3.0"
185 | source = { registry = "https://pypi.org/simple" }
186 | dependencies = [
187 | { name = "typing-extensions", marker = "python_full_version < '3.12' or (python_full_version == '3.12.*' and extra == 'group-15-django-perf-rec-django42') or (python_full_version == '3.12.*' and extra == 'group-15-django-perf-rec-django50') or (python_full_version == '3.12.*' and extra == 'group-15-django-perf-rec-django51') or (python_full_version == '3.12.*' and extra == 'group-15-django-perf-rec-django52') or (python_full_version >= '3.13' and extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django50') or (python_full_version >= '3.13' and extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django51') or (python_full_version >= '3.13' and extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django52') or (python_full_version >= '3.13' and extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django60') or (python_full_version >= '3.13' and extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django51') or (python_full_version >= '3.13' and extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django52') or (python_full_version >= '3.13' and extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django60') or (python_full_version >= '3.13' and extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52') or (python_full_version >= '3.13' and extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django60') or (python_full_version >= '3.13' and extra == 'group-15-django-perf-rec-django52' and extra == 'group-15-django-perf-rec-django60')" },
188 | ]
189 | sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
190 | wheels = [
191 | { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" },
192 | ]
193 |
194 | [[package]]
195 | name = "iniconfig"
196 | version = "2.1.0"
197 | source = { registry = "https://pypi.org/simple" }
198 | sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
199 | wheels = [
200 | { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
201 | ]
202 |
203 | [[package]]
204 | name = "packaging"
205 | version = "25.0"
206 | source = { registry = "https://pypi.org/simple" }
207 | sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
208 | wheels = [
209 | { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
210 | ]
211 |
212 | [[package]]
213 | name = "pluggy"
214 | version = "1.6.0"
215 | source = { registry = "https://pypi.org/simple" }
216 | sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
217 | wheels = [
218 | { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
219 | ]
220 |
221 | [[package]]
222 | name = "pygments"
223 | version = "2.19.2"
224 | source = { registry = "https://pypi.org/simple" }
225 | sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
226 | wheels = [
227 | { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
228 | ]
229 |
230 | [[package]]
231 | name = "pytest"
232 | version = "8.4.1"
233 | source = { registry = "https://pypi.org/simple" }
234 | dependencies = [
235 | { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django50') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django52' and extra == 'group-15-django-perf-rec-django60')" },
236 | { name = "exceptiongroup", marker = "python_full_version < '3.11' or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django50') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django52' and extra == 'group-15-django-perf-rec-django60')" },
237 | { name = "iniconfig" },
238 | { name = "packaging" },
239 | { name = "pluggy" },
240 | { name = "pygments" },
241 | { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django50') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django42' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django51') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52') or (extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django60') or (extra == 'group-15-django-perf-rec-django52' and extra == 'group-15-django-perf-rec-django60')" },
242 | ]
243 | sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" }
244 | wheels = [
245 | { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" },
246 | ]
247 |
248 | [[package]]
249 | name = "pytest-django"
250 | version = "4.11.1"
251 | source = { registry = "https://pypi.org/simple" }
252 | dependencies = [
253 | { name = "pytest" },
254 | ]
255 | sdist = { url = "https://files.pythonhosted.org/packages/b1/fb/55d580352db26eb3d59ad50c64321ddfe228d3d8ac107db05387a2fadf3a/pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991", size = 86202, upload-time = "2025-04-03T18:56:09.338Z" }
256 | wheels = [
257 | { url = "https://files.pythonhosted.org/packages/be/ac/bd0608d229ec808e51a21044f3f2f27b9a37e7a0ebaca7247882e67876af/pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10", size = 25281, upload-time = "2025-04-03T18:56:07.678Z" },
258 | ]
259 |
260 | [[package]]
261 | name = "pytest-randomly"
262 | version = "3.16.0"
263 | source = { registry = "https://pypi.org/simple" }
264 | dependencies = [
265 | { name = "pytest" },
266 | ]
267 | sdist = { url = "https://files.pythonhosted.org/packages/c0/68/d221ed7f4a2a49a664da721b8e87b52af6dd317af2a6cb51549cf17ac4b8/pytest_randomly-3.16.0.tar.gz", hash = "sha256:11bf4d23a26484de7860d82f726c0629837cf4064b79157bd18ec9d41d7feb26", size = 13367, upload-time = "2024-10-25T15:45:34.274Z" }
268 | wheels = [
269 | { url = "https://files.pythonhosted.org/packages/22/70/b31577d7c46d8e2f9baccfed5067dd8475262a2331ffb0bfdf19361c9bde/pytest_randomly-3.16.0-py3-none-any.whl", hash = "sha256:8633d332635a1a0983d3bba19342196807f6afb17c3eef78e02c2f85dade45d6", size = 8396, upload-time = "2024-10-25T15:45:32.78Z" },
270 | ]
271 |
272 | [[package]]
273 | name = "pyyaml"
274 | version = "6.0.2"
275 | source = { registry = "https://pypi.org/simple" }
276 | sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" }
277 | wheels = [
278 | { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" },
279 | { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload-time = "2024-08-06T20:31:42.173Z" },
280 | { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload-time = "2024-08-06T20:31:44.263Z" },
281 | { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload-time = "2024-08-06T20:31:50.199Z" },
282 | { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload-time = "2024-08-06T20:31:52.292Z" },
283 | { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload-time = "2024-08-06T20:31:53.836Z" },
284 | { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload-time = "2024-08-06T20:31:55.565Z" },
285 | { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload-time = "2024-08-06T20:31:56.914Z" },
286 | { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload-time = "2024-08-06T20:31:58.304Z" },
287 | { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" },
288 | { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" },
289 | { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" },
290 | { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" },
291 | { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" },
292 | { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" },
293 | { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" },
294 | { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" },
295 | { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" },
296 | { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" },
297 | { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" },
298 | { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" },
299 | { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" },
300 | { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" },
301 | { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" },
302 | { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" },
303 | { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" },
304 | { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" },
305 | { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" },
306 | { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" },
307 | { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" },
308 | { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" },
309 | { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" },
310 | { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" },
311 | { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" },
312 | { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" },
313 | { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
314 | ]
315 |
316 | [[package]]
317 | name = "sqlparse"
318 | version = "0.5.3"
319 | source = { registry = "https://pypi.org/simple" }
320 | sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload-time = "2024-12-10T12:05:30.728Z" }
321 | wheels = [
322 | { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" },
323 | ]
324 |
325 | [[package]]
326 | name = "tomli"
327 | version = "2.2.1"
328 | source = { registry = "https://pypi.org/simple" }
329 | sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" }
330 | wheels = [
331 | { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" },
332 | { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" },
333 | { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" },
334 | { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" },
335 | { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" },
336 | { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" },
337 | { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" },
338 | { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" },
339 | { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" },
340 | { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" },
341 | { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" },
342 | { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" },
343 | { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" },
344 | { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" },
345 | { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" },
346 | { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" },
347 | { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" },
348 | { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" },
349 | { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" },
350 | { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" },
351 | { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" },
352 | { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" },
353 | { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" },
354 | { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" },
355 | { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" },
356 | { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" },
357 | { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" },
358 | { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" },
359 | { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" },
360 | { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" },
361 | { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" },
362 | ]
363 |
364 | [[package]]
365 | name = "typing-extensions"
366 | version = "4.14.1"
367 | source = { registry = "https://pypi.org/simple" }
368 | sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" }
369 | wheels = [
370 | { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" },
371 | ]
372 |
373 | [[package]]
374 | name = "tzdata"
375 | version = "2025.2"
376 | source = { registry = "https://pypi.org/simple" }
377 | sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
378 | wheels = [
379 | { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
380 | ]
381 |
--------------------------------------------------------------------------------