├── .editorconfig ├── .github ├── CODE_OF_CONDUCT.md ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature-request.yml │ └── issue.yml ├── SECURITY.md ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .typos.toml ├── CHANGELOG.rst ├── HISTORY.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── pyproject.toml ├── src └── django_perf_rec │ ├── __init__.py │ ├── api.py │ ├── cache.py │ ├── db.py │ ├── operation.py │ ├── py.typed │ ├── pytest_plugin.py │ ├── settings.py │ ├── sql.py │ ├── types.py │ ├── utils.py │ └── yaml.py ├── tests ├── __init__.py ├── settings.py ├── test_api.file_name.perf.yml ├── test_api.perf.yml ├── test_api.py ├── test_cache.py ├── test_db.py ├── test_operation.py ├── test_pytest_duplicate.perf.yml ├── test_pytest_duplicate.py ├── test_pytest_duplicate_other.perf.yml ├── test_pytest_duplicate_other.py ├── test_pytest_fixture_usage.perf.yml ├── test_pytest_fixture_usage.py ├── test_pytest_parametrize.perf.yml ├── test_pytest_parametrize.py ├── test_pytest_plugin.py ├── test_sql.py ├── test_utils.py ├── test_yaml.py ├── testapp │ ├── __init__.py │ └── models.py └── utils.py ├── tox.ini └── uv.lock /.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/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | This project follows [Django's Code of Conduct](https://www.djangoproject.com/conduct/). 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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.9.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 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | Please report security issues directly over email to me@adamj.eu 2 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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.9' 24 | - '3.10' 25 | - '3.11' 26 | - '3.12' 27 | - '3.13' 28 | 29 | steps: 30 | - uses: actions/checkout@v4 31 | 32 | - uses: actions/setup-python@v5 33 | with: 34 | python-version: ${{ matrix.python-version }} 35 | allow-prereleases: true 36 | 37 | - name: Install uv 38 | uses: astral-sh/setup-uv@v6 39 | with: 40 | enable-cache: true 41 | 42 | - name: Install dependencies 43 | run: uv pip install --system tox tox-uv 44 | 45 | - name: Run tox targets for ${{ matrix.python-version }} 46 | run: tox run -f py$(echo ${{ matrix.python-version }} | tr -d .) 47 | 48 | release: 49 | needs: [tests] 50 | if: success() && startsWith(github.ref, 'refs/tags/') 51 | runs-on: ubuntu-24.04 52 | environment: release 53 | 54 | permissions: 55 | contents: read 56 | id-token: write 57 | 58 | steps: 59 | - uses: actions/checkout@v4 60 | 61 | - uses: astral-sh/setup-uv@v6 62 | 63 | - name: Build 64 | run: uv build 65 | 66 | - uses: pypa/gh-action-pypi-publish@release/v1 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info/ 2 | *.pyc 3 | /.coverage 4 | /.coverage.* 5 | /.tox 6 | /build/ 7 | /dist/ 8 | -------------------------------------------------------------------------------- /.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: cef0300fd0fc4d2a87a85fa2093c6b283ea36f4b # frozen: v5.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: 6cb49915af2e93e61f5f0d0a82216e28ad5c7c18 # frozen: v1 21 | hooks: 22 | - id: typos 23 | - repo: https://github.com/tox-dev/pyproject-fmt 24 | rev: 8184a5b72f4a8fcd003b041ecb04c41a9f34fd2b # frozen: v2.6.0 25 | hooks: 26 | - id: pyproject-fmt 27 | - repo: https://github.com/tox-dev/tox-ini-fmt 28 | rev: e732f664aa3fd7b32cce3de8abbac43f4c3c375d # frozen: 1.5.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: 700530171ecf380bc829a64388f49d14ecd61c53 # frozen: 1.25.0 39 | hooks: 40 | - id: django-upgrade 41 | args: [--target-version, '4.2'] 42 | - repo: https://github.com/adamchainz/blacken-docs 43 | rev: 78a9dcbecf4f755f65d1f3dec556bc249d723600 # frozen: 1.19.1 44 | hooks: 45 | - id: blacken-docs 46 | additional_dependencies: 47 | - black==25.1.0 48 | - repo: https://github.com/astral-sh/ruff-pre-commit 49 | rev: d19233b89771be2d89273f163f5edc5a39bbc34a # frozen: v0.11.12 50 | hooks: 51 | - id: ruff-check 52 | args: [ --fix ] 53 | - id: ruff-format 54 | - repo: https://github.com/pre-commit/mirrors-mypy 55 | rev: 7010b10a09f65cd60a23c207349b539aa36dbec1 # frozen: v1.16.0 56 | hooks: 57 | - id: mypy 58 | additional_dependencies: 59 | - django-stubs==5.1.2 60 | - pytest==7.1.2 61 | - types-pyyaml 62 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Changelog 3 | ========= 4 | 5 | 4.28.0 (2025-02-06) 6 | ------------------- 7 | 8 | * Support Django 5.2. 9 | 10 | 4.27.0 (2024-10-29) 11 | ------------------- 12 | 13 | * Drop Django 3.2 to 4.1 support. 14 | 15 | * Drop Python 3.8 support. 16 | 17 | * Support Python 3.13. 18 | 19 | * Allow comments in ``ROLLBACK TO SAVEPOINT`` statements. 20 | 21 | Thanks to Corentin Smith in `PR #537 `__. 22 | 23 | 4.26.0 (2024-06-19) 24 | ------------------- 25 | 26 | * Support Django 5.1. 27 | 28 | * Optimize ``sql_fingerprint()`` a bit, yielding ~2% savings. 29 | 30 | 4.25.0 (2023-10-11) 31 | ------------------- 32 | 33 | * Support Django 5.0. 34 | 35 | 4.24.0 (2023-07-10) 36 | ------------------- 37 | 38 | * Drop Python 3.7 support. 39 | 40 | 4.23.0 (2023-06-14) 41 | ------------------- 42 | 43 | * Support Python 3.12. 44 | 45 | 4.22.3 (2023-05-30) 46 | ------------------- 47 | 48 | * Fetch the test name from pytest where possible, which pulls in its parametrized name. 49 | 50 | Thanks to Gert Van Gool in `PR #537 `__. 51 | 52 | 4.22.2 (2023-05-30) 53 | ------------------- 54 | 55 | * Avoid crashing when recording cache function calls that pass ``key`` or ``keys`` as a keyword argument, like ``cache.set(key="abc", value="def")``. 56 | 57 | Thanks to Benoît Casoetto in `PR #545 `__. 58 | 59 | 4.22.1 (2023-04-23) 60 | ------------------- 61 | 62 | * Fix compatibility with sqlparse 0.4.4+. 63 | sqlparse reverted a change made in 0.4.0 around ``IN``, so the old behaviour in django-perf-rec has been restored. 64 | Upgrade to avoid regenerating your performance record files. 65 | 66 | 4.22.0 (2023-02-25) 67 | ------------------- 68 | 69 | * Support Django 4.2. 70 | 71 | 4.21.0 (2022-06-05) 72 | ------------------- 73 | 74 | * Support Python 3.11. 75 | 76 | * Support Django 4.1. 77 | 78 | 4.20.1 (2022-05-18) 79 | ------------------- 80 | 81 | * Fix 'overwrite' mode to prevent file corruption. 82 | 83 | Thanks to Peter Law for the report in `Issue #468 `__. 84 | 85 | 4.20.0 (2022-05-10) 86 | ------------------- 87 | 88 | * Drop support for Django 2.2, 3.0, and 3.1. 89 | 90 | 4.19.0 (2022-05-10) 91 | ------------------- 92 | 93 | * Add new ``MODE`` option, ``'overwrite'``, which creates or updates missing 94 | records silently. 95 | 96 | Thanks to Peter Law in `PR #461 `__. 97 | 98 | 4.18.0 (2022-01-10) 99 | ------------------- 100 | 101 | * Drop Python 3.6 support. 102 | 103 | 4.17.0 (2021-10-05) 104 | ------------------- 105 | 106 | * Support Python 3.10. 107 | 108 | 4.16.0 (2021-09-28) 109 | ------------------- 110 | 111 | * Support Django 4.0. 112 | 113 | 4.15.0 (2021-08-20) 114 | ------------------- 115 | 116 | * Add type hints. 117 | 118 | 4.14.1 (2021-06-22) 119 | ------------------- 120 | 121 | * Support arbitrary collections of keys being passed to Django cache operations. 122 | Previously only mappings and sequences were supported, now sets and mapping 123 | views will also work. 124 | 125 | Thanks to Peter Law in 126 | `PR #378 `__. 127 | 128 | 4.14.0 (2021-06-02) 129 | ------------------- 130 | 131 | * Re-add simplification of SQL ``IN`` clauses to always use ``(...)``. This was 132 | done in 4.6.0 but accidentally reverted with the sqlparse upgrade in 4.8.0. 133 | 134 | Thanks to Dan Palmer for the report in 135 | `Issue #373 `__. 136 | 137 | 4.13.1 (2021-04-15) 138 | ------------------- 139 | 140 | * Fix SQL simplification for ``UPDATE`` queries without a ``WHERE`` clause. 141 | 142 | Thanks to Peter Law for the report in 143 | `Issue #360 `__. 144 | 145 | 4.13.0 (2021-03-27) 146 | ------------------- 147 | 148 | * Stop distributing tests to reduce package size. Tests are not intended to be 149 | run outside of the tox setup in the repository. Repackagers can use GitHub's 150 | tarballs per tag. 151 | 152 | * Add support for hiding some operations from being recorded, via a new 153 | ``capture_operation`` callable to ``record``. This is potentially useful where 154 | a different backend is used in testing than would be used in production and 155 | thus a portion of the operations in a context are not representative. 156 | 157 | Thanks to Peter Law in 158 | `PR #342 `__. 159 | 160 | 161 | 4.12.0 (2021-01-25) 162 | ------------------- 163 | 164 | * Support Django 3.2. 165 | 166 | 4.11.0 (2020-12-04) 167 | ------------------- 168 | 169 | * Drop Python 3.5 support. 170 | * Remove ORM patching. Now that only Python 3.6 is supported, the 171 | insertion-order of ``dict``\s should mean Django's ORM always provides 172 | deterministic queries. The two patches django-perf-rec made on the ORM have 173 | been removed, and the corresponding dependency on patchy. You may need to 174 | regenerate your performance record files. 175 | 176 | This fixes an issue where use of ``annotate()`` with dependencies between the 177 | annotations could cause a query error after django-perf-rec sorted the 178 | annotation names. 179 | 180 | Thanks to Gordon Wrigley for the report in 181 | `Issue #322 `__. 182 | 183 | 4.10.0 (2020-11-20) 184 | ------------------- 185 | 186 | * Correctly record calls to ``cache.get_or_set()``. 187 | 188 | Thanks to Peter Law for the report in 189 | `Issue #319 `__. 190 | 191 | 4.9.0 (2020-11-04) 192 | ------------------ 193 | 194 | * Support Python 3.9. 195 | * Allow recording of tracebacks alongside db queries or cache operations, 196 | selected via a function passed as ``capture_traceback`` to ``record()``. 197 | 198 | Thanks to Nadege Michel in 199 | `PR #299 `__. 200 | 201 | 4.8.0 (2020-10-10) 202 | ------------------ 203 | 204 | * Drop Django 2.0 and 2.1 support. 205 | * Upgrade for sqlparse 0.4.0+. This required changing how SQL lists of one 206 | element are simplified, e.g. ``IN (1)`` will now be simplified to ``IN (#)`` 207 | instead of ``IN (...)``. You should regenerate your performance record files 208 | to match. 209 | 210 | 4.7.0 (2020-06-15) 211 | ------------------ 212 | 213 | * Add Django 3.1 support. 214 | 215 | 4.6.1 (2020-05-21) 216 | ------------------ 217 | 218 | * Create YAML files as non-executable. This will not be applied to existing 219 | files, modify their permissions if necessary, or delete and recreate. 220 | 221 | Thanks to Peter Law for the report in `Issue #264 222 | `__. 223 | 224 | 4.6.0 (2020-05-20) 225 | ------------------ 226 | 227 | * Drop Django 1.11 support. Only Django 2.0+ is supported now. 228 | * Simplify SQL ``IN`` clauses to always use ``(...)``. Now ``x IN (1)`` and 229 | ``x IN (1,2)`` both simplify to ``x IN (...)``. 230 | 231 | Thanks to Dan Palmer in 232 | `PR #263 `__. 233 | 234 | 4.5.0 (2019-11-25) 235 | ------------------ 236 | 237 | * Update Python support to 3.5-3.8, as 3.4 has reached its end of life. 238 | * Converted setuptools metadata to configuration file. This meant removing the 239 | ``__version__`` attribute from the package. If you want to inspect the 240 | installed version, use 241 | ``importlib.metadata.version("django-perf-rec")`` 242 | (`docs `__ / 243 | `backport `__). 244 | * Fix ``Q()`` Patchy patch for Django 2.0+ with non-AND-ed ``Q()``'s. 245 | 246 | 4.4.0 (2019-05-09) 247 | ------------------ 248 | 249 | * Normalize SQL whitespace. This will change fingerprinted SQL in some cases. 250 | 251 | 4.3.0 (2019-04-26) 252 | ------------------ 253 | 254 | * Add support for Django 2.2. 255 | 256 | 4.2.0 (2019-04-13) 257 | ------------------ 258 | 259 | * Work with, and require, ``sqlparse`` > 0.3.0. 260 | 261 | 4.1.0 (2019-03-04) 262 | ------------------ 263 | 264 | * Fix a bug in automatic test record naming when two different modules had a 265 | test with the same class + name that ran one after another. 266 | * Fix Python 3.7 ``DeprecationWarning`` for ``collections.abc`` (Python 3.7 not 267 | officially supported yet). 268 | 269 | 4.0.0 (2019-02-01) 270 | ------------------ 271 | 272 | * Drop Python 2 support, only Python 3.4+ is supported now. 273 | * Drop Django 1.8, 1.9, and 1.10 support. Only Django 1.11+ is supported now. 274 | * Dropped requirements for ``kwargs-only`` and ``six``. 275 | 276 | 3.1.1 (2018-12-03) 277 | ------------------ 278 | 279 | * Fix to actually obey the ``HIDE_COLUMNS`` option. 280 | 281 | 3.1.0 (2018-12-02) 282 | ------------------ 283 | 284 | * Add the ``HIDE_COLUMNS`` option in settings to disable replacing column lists 285 | with ``...`` in all places. 286 | 287 | 3.0.0 (2018-07-17) 288 | ------------------ 289 | 290 | * Don't replace columns in ORDER BY, GROUP BY and HAVING clauses. 291 | 292 | 2.2.0 (2018-01-24) 293 | ------------------ 294 | 295 | * Use ``kwargs-only`` library rather than vendored copy. 296 | * Erase volatile part of PostgreSQL cursor name. 297 | 298 | 2.1.0 (2017-05-29) 299 | ------------------ 300 | 301 | * Exposed the automatic naming logic used in ``record()`` in two new functions 302 | ``get_perf_path()`` and ``get_record_name()``, in order to ease creation of 303 | test records from calls outside of tests. 304 | * Made the automatic test detection work when running under a Pytest fixture. 305 | * Stopped throwing warnings on Python 3. 306 | * Fixed loading empty performance record files. 307 | 308 | 2.0.1 (2017-03-02) 309 | ------------------ 310 | 311 | * Make cascaded delete queries deterministic on Django <1.10, with another 312 | Patchy patch to make it match the order from 1.10+. 313 | 314 | 2.0.0 (2017-02-09) 315 | ------------------ 316 | 317 | * Arguments to ``record`` must be passed as keyword arguments. 318 | * ``file_name`` is removed as an argument to ``record`` following its 319 | deprecation in release 1.1.0. 320 | 321 | 322 | 1.1.1 (2016-10-30) 323 | ------------------ 324 | 325 | * Fix django session keys not being fingerprinted. 326 | * Show diff when records don't match (when not on pytest). 327 | * Add new 'MODE' setting with three modes. This allows customization of the 328 | behaviour for missing performance records. The new ``'none'`` mode is 329 | particularly useful for CI servers as it makes tests fail if their 330 | corresponding performance records have not been committed. 331 | 332 | 1.1.0 (2016-10-26) 333 | ------------------ 334 | 335 | * Fix automatic filenames for tests in ``.pyc`` files. 336 | * Add the ``path`` argument to ``record`` which allows specifying a relative 337 | directory or filename to use. This deprecates the ``file_name`` argument, 338 | which will be removed in a future major release. For more info see the 339 | README. 340 | 341 | 1.0.4 (2016-10-23) 342 | ------------------ 343 | 344 | * Work with ``sqlparse`` 0.2.2 345 | 346 | 1.0.3 (2016-10-07) 347 | ------------------ 348 | 349 | * Stopped ``setup.py`` installing ``tests`` module. 350 | 351 | 1.0.2 (2016-09-23) 352 | ------------------ 353 | 354 | * Confirmed Django 1.8 and 1.10 support. 355 | 356 | 1.0.1 (2016-09-20) 357 | ------------------ 358 | 359 | * Fix ``install_requires`` in ``setup.py``. 360 | 361 | 1.0.0 (2016-09-19) 362 | ------------------ 363 | 364 | * Initial version with ``record()`` that can record database queries and cache 365 | operations and error if they change between test runs. 366 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | See https://github.com/adamchainz/django-perf-rec/blob/main/CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | **django-perf-rec** is like Django's ``assertNumQueries`` on steroids. It lets 21 | you track the individual queries and cache operations that occur in your code. 22 | Use it in your tests like so: 23 | 24 | .. code-block:: python 25 | 26 | def test_home(self): 27 | with django_perf_rec.record(): 28 | self.client.get("/") 29 | 30 | It then stores a YAML file alongside the test file that tracks the queries and 31 | operations, looking something like: 32 | 33 | .. code-block:: yaml 34 | 35 | MyTests.test_home: 36 | - cache|get: home_data.user_id.# 37 | - db: 'SELECT ... FROM myapp_table WHERE (myapp_table.id = #)' 38 | - db: 'SELECT ... FROM myapp_table WHERE (myapp_table.id = #)' 39 | 40 | When the test is run again, the new record will be compared with the one in the 41 | YAML file. If they are different, an assertion failure will be raised, failing 42 | the test. Magic! 43 | 44 | The queries and keys are 'fingerprinted', replacing information that seems 45 | variable with `#` and `...`. This is done to avoid spurious failures when e.g. 46 | primary keys are different, random data is used, new columns are added to 47 | tables, etc. 48 | 49 | If you check the YAML file in along with your tests, you'll have unbreakable 50 | performance with much better information about any regressions compared to 51 | ``assertNumQueries``. If you are fine with the changes from a failing test, 52 | just remove the file and rerun the test to regenerate it. 53 | 54 | For more information, see our `introductory blog 55 | post `_ that 56 | says a little more about why we made it. 57 | 58 | ---- 59 | 60 | **Are your tests slow?** 61 | Check out my book `Speed Up Your Django Tests `__ which covers loads of ways to write faster, more accurate tests. 62 | 63 | ---- 64 | 65 | Installation 66 | ============ 67 | 68 | Use **pip**: 69 | 70 | .. code-block:: bash 71 | 72 | python -m pip install django-perf-rec 73 | 74 | Requirements 75 | ============ 76 | 77 | Python 3.9 to 3.13 supported. 78 | 79 | Django 4.2 to 5.2 supported. 80 | 81 | API 82 | === 83 | 84 | ``record(record_name: str | None=None, path: str | None=None, capture_traceback: callable[[Operation], bool] | None=None, capture_operation: callable[[Operation], bool] | None=None)`` 85 | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- 86 | 87 | Return a context manager that will be used for a single performance test. 88 | 89 | The arguments must be passed as keyword arguments. 90 | 91 | ``path`` is the path to a directory or file in which to store the record. If it 92 | ends with ``'/'``, or is left as ``None``, the filename will be automatically 93 | determined by looking at the filename the calling code is in and replacing the 94 | ``.py[c]`` extension with ``.perf.yml``. If it points to a directory that 95 | doesn't exist, that directory will be created. 96 | 97 | ``record_name`` is the name of the record inside the performance file to use. 98 | If left as ``None``, the code assumes you are inside a Django ``TestCase`` and 99 | uses magic stack inspection to find that test case, and uses a name based upon 100 | the test case name + the test method name + an optional counter if you invoke 101 | ``record()`` multiple times inside the same test method. 102 | 103 | Whilst open, the context manager tracks all DB queries on all connections, and 104 | all cache operations on all defined caches. It names the connection/cache in 105 | the tracked operation it uses, except from for the ``default`` one. 106 | 107 | When the context manager exits, it will use the list of operations it has 108 | gathered. If the relevant file specified using ``path`` doesn't exist, or 109 | doesn't contain data for the specific ``record_name``, it will be created and 110 | saved and the test will pass with no assertions. However if the record **does** 111 | exist inside the file, the collected record will be compared with the original 112 | one, and if different, an ``AssertionError`` will be raised. When running on 113 | pytest, this will use its fancy assertion rewriting; in other test runners/uses 114 | the full diff will be attached to the message. 115 | 116 | Example: 117 | 118 | .. code-block:: python 119 | 120 | import django_perf_rec 121 | 122 | from app.models import Author 123 | 124 | 125 | class AuthorPerformanceTests(TestCase): 126 | def test_special_method(self): 127 | with django_perf_rec.record(): 128 | list(Author.objects.special_method()) 129 | 130 | 131 | ``capture_traceback``, if not ``None``, should be a function that takes one 132 | argument, the given DB or cache operation, and returns a ``bool`` indicating 133 | if a traceback should be captured for the operation (by default, they are not). 134 | Capturing tracebacks allows fine-grained debugging of code paths causing the 135 | operations. Be aware that records differing only by the presence of tracebacks 136 | will not match and cause an ``AssertionError`` to be raised, so it's not 137 | normally suitable to permanently record the tracebacks. 138 | 139 | For example, if you wanted to know what code paths query the table 140 | ``my_table``, you could use a ``capture_traceback`` function like so: 141 | 142 | .. code-block:: python 143 | 144 | def debug_sql_query(operation): 145 | return "my_tables" in operation.query 146 | 147 | 148 | def test_special_method(self): 149 | with django_perf_rec.record(capture_traceback=debug_sql_query): 150 | list(Author.objects.special_method()) 151 | 152 | The performance record here would include a standard Python traceback attached 153 | to each SQL query containing "my_table". 154 | 155 | 156 | ``capture_operation``, if not ``None``, should be a function that takes one 157 | argument, the given DB or cache operation, and returns a ``bool`` indicating if 158 | the operation should be recorded at all (by default, all operations are 159 | recorded). Not capturing some operations allows for hiding some code paths to be 160 | ignored in your tests, such as for ignoring database queries that would be 161 | replaced by an external service in production. 162 | 163 | For example, if you knew that in testing all queries to some table would be 164 | replaced in production with something else you could use a ``capture_operation`` 165 | function like so: 166 | 167 | .. code-block:: python 168 | 169 | def hide_my_tables(operation): 170 | return "my_tables" in operation.query 171 | 172 | 173 | def test_special_function(self): 174 | with django_perf_rec.record(capture_operation=hide_my_tables): 175 | list(Author.objects.all()) 176 | 177 | 178 | ``TestCaseMixin`` 179 | ----------------- 180 | 181 | A mixin class to be added to your custom ``TestCase`` subclass so you can use 182 | **django-perf-rec** across your codebase without needing to import it in each 183 | individual test file. It adds one method, ``record_performance()``, whose 184 | signature is the same as ``record()`` above. 185 | 186 | Example: 187 | 188 | .. code-block:: python 189 | 190 | # yplan/test.py 191 | from django.test import TestCase as OrigTestCase 192 | from django_perf_rec import TestCaseMixin 193 | 194 | 195 | class TestCase(TestCaseMixin, OrigTestCase): 196 | pass 197 | 198 | 199 | # app/tests/models/test_author.py 200 | from app.models import Author 201 | from yplan.test import TestCase 202 | 203 | 204 | class AuthorPerformanceTests(TestCase): 205 | def test_special_method(self): 206 | with self.record_performance(): 207 | list(Author.objects.special_method()) 208 | 209 | ``get_perf_path(file_path)`` 210 | ---------------------------- 211 | 212 | Encapsulates the logic used in ``record()`` to form ``path`` from the path of 213 | the file containing the currently running test, mostly swapping '.py' or '.pyc' 214 | for '.perf.yml'. You might want to use this when calling ``record()`` from 215 | somewhere other than inside a test (which causes the automatic inspection to 216 | fail), to match the same filename. 217 | 218 | ``get_record_name(test_name, class_name=None)`` 219 | ----------------------------------------------- 220 | 221 | Encapsulates the logic used in ``record()`` to form a ``record_name`` from 222 | details of the currently running test. You might want to use this when calling 223 | ``record()`` from somewhere other than inside a test (which causes the 224 | automatic inspection to fail), to match the same ``record_name``. 225 | 226 | Settings 227 | ======== 228 | 229 | Behaviour can be customized with a dictionary called ``PERF_REC`` in your 230 | Django settings, for example: 231 | 232 | .. code-block:: python 233 | 234 | PERF_REC = { 235 | "MODE": "once", 236 | } 237 | 238 | The possible keys to this dictionary are explained below. 239 | 240 | ``HIDE_COLUMNS`` 241 | ---------------- 242 | 243 | The ``HIDE_COLUMNS`` setting may be used to change the way **django-perf-rec** 244 | simplifies SQL in the recording files it makes. It takes a boolean: 245 | 246 | * ``True`` (default) causes column lists in queries to be collapsed, e.g. 247 | ``SELECT a, b, c FROM t`` becomes ``SELECT ... FROM t``. This is useful 248 | because selected columns often don't affect query time in typical 249 | Django applications, it makes the records easier to read, and they then don't 250 | need updating every time model fields are changed. 251 | * ``False`` stops the collapsing behaviour, causing all the columns to be 252 | output in the files. 253 | 254 | ``MODE`` 255 | -------- 256 | 257 | The ``MODE`` setting may be used to change the way **django-perf-rec** behaves 258 | when a performance record does not exist during a test run. 259 | 260 | * ``'once'`` (default) creates missing records silently. 261 | * ``'none'`` raises ``AssertionError`` when a record does not exist. You 262 | probably want to use this mode in CI, to ensure new tests fail if their 263 | corresponding performance records were not committed. 264 | * ``'all'`` creates missing records and then raises ``AssertionError``. 265 | * ``'overwrite'`` creates or updates records silently. 266 | 267 | Usage in Pytest 268 | =============== 269 | 270 | If you're using Pytest, you might want to call ``record()`` from within a 271 | Pytest fixture and have it automatically apply to all your tests. We have an 272 | example of this, see the file `test_pytest_fixture_usage.py 273 | `_ 274 | in the test suite. 275 | -------------------------------------------------------------------------------- /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.28.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.9" 21 | classifiers = [ 22 | "Development Status :: 5 - Production/Stable", 23 | "Framework :: Django :: 4.2", 24 | "Framework :: Django :: 5.0", 25 | "Framework :: Django :: 5.1", 26 | "Framework :: Django :: 5.2", 27 | "Intended Audience :: Developers", 28 | "Natural Language :: English", 29 | "Operating System :: OS Independent", 30 | "Programming Language :: Python :: 3 :: Only", 31 | "Programming Language :: Python :: 3.9", 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 :: Implementation :: CPython", 37 | "Typing :: Typed", 38 | ] 39 | dependencies = [ 40 | "django>=4.2", 41 | "pyyaml", 42 | "sqlparse>=0.4.4", 43 | ] 44 | 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" } 45 | entry-points.pytest11.django_perf_rec = "django_perf_rec.pytest_plugin" 46 | 47 | [dependency-groups] 48 | test = [ 49 | "pygments", 50 | "pytest", 51 | "pytest-django", 52 | "pytest-randomly", 53 | "pyyaml", 54 | "sqlparse", 55 | ] 56 | django42 = [ "django>=4.2a1,<5; python_version>='3.8'" ] 57 | django50 = [ "django>=5.0a1,<5.1; python_version>='3.10'" ] 58 | django51 = [ "django>=5.1a1,<5.2; python_version>='3.10'" ] 59 | django52 = [ "django>=5.2a1,<6; python_version>='3.10'" ] 60 | 61 | [tool.ruff] 62 | lint.select = [ 63 | # flake8-bugbear 64 | "B", 65 | # flake8-comprehensions 66 | "C4", 67 | # pycodestyle 68 | "E", 69 | # Pyflakes errors 70 | "F", 71 | # isort 72 | "I", 73 | # flake8-simplify 74 | "SIM", 75 | # flake8-tidy-imports 76 | "TID", 77 | # pyupgrade 78 | "UP", 79 | # Pyflakes warnings 80 | "W", 81 | ] 82 | lint.ignore = [ 83 | # flake8-bugbear opinionated rules 84 | "B9", 85 | # line-too-long 86 | "E501", 87 | # suppressible-exception 88 | "SIM105", 89 | # if-else-block-instead-of-if-exp 90 | "SIM108", 91 | ] 92 | lint.extend-safe-fixes = [ 93 | # non-pep585-annotation 94 | "UP006", 95 | ] 96 | lint.isort.required-imports = [ "from __future__ import annotations" ] 97 | 98 | [tool.pyproject-fmt] 99 | max_supported_python = "3.13" 100 | 101 | [tool.pytest.ini_options] 102 | addopts = """\ 103 | --strict-config 104 | --strict-markers 105 | --ds=tests.settings 106 | """ 107 | django_find_project = false 108 | xfail_strict = true 109 | 110 | [tool.mypy] 111 | enable_error_code = [ 112 | "ignore-without-code", 113 | "redundant-expr", 114 | "truthy-bool", 115 | ] 116 | mypy_path = "src/" 117 | namespace_packages = false 118 | plugins = [ 119 | "mypy_django_plugin.main", 120 | ] 121 | strict = true 122 | warn_unreachable = true 123 | 124 | [[tool.mypy.overrides]] 125 | module = "tests.*" 126 | allow_untyped_defs = true 127 | 128 | [tool.django-stubs] 129 | django_settings_module = "tests.settings" 130 | 131 | [tool.rstcheck] 132 | report_level = "ERROR" 133 | 134 | [tool.uv] 135 | conflicts = [ 136 | [ 137 | { group = "django42" }, 138 | { group = "django50" }, 139 | { group = "django51" }, 140 | { group = "django52" }, 141 | ], 142 | ] 143 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/django_perf_rec/api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from functools import cache 5 | from threading import local 6 | from types import TracebackType 7 | from typing import Callable 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 | -------------------------------------------------------------------------------- /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 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, Callable, 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/db.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import traceback 4 | from functools import wraps 5 | from types import MethodType, TracebackType 6 | from typing import Any, Callable, TypeVar, cast 7 | 8 | from django.db import DEFAULT_DB_ALIAS, connections 9 | 10 | from django_perf_rec.operation import AllSourceRecorder, BaseRecorder, Operation 11 | from django_perf_rec.settings import perf_rec_settings 12 | from django_perf_rec.sql import sql_fingerprint 13 | 14 | 15 | class DBOp(Operation): 16 | @property 17 | def name(self) -> str: 18 | name_parts = ["db"] 19 | if self.alias != DEFAULT_DB_ALIAS: 20 | name_parts.append(self.alias) 21 | return "|".join(name_parts) 22 | 23 | 24 | LastExecutedQuery = TypeVar("LastExecutedQuery", bound=Callable[..., str]) 25 | 26 | 27 | class DBRecorder(BaseRecorder): 28 | """ 29 | Monkey-patch-wraps a database connection to call 'callback' on every 30 | query it runs. 31 | """ 32 | 33 | def __enter__(self) -> None: 34 | """ 35 | When using the debug cursor wrapper, Django calls 36 | connection.ops.last_executed_query to get the SQL from the client 37 | library. Here we wrap this function on the connection to grab the SQL 38 | as it comes out. 39 | """ 40 | connection = connections[self.alias] 41 | self.orig_force_debug_cursor = connection.force_debug_cursor 42 | connection.force_debug_cursor = True 43 | 44 | def call_callback(func: LastExecutedQuery) -> LastExecutedQuery: 45 | alias = self.alias 46 | callback = self.callback 47 | 48 | @wraps(func) 49 | def inner(self: Any, *args: Any, **kwargs: Any) -> str: 50 | sql = func(*args, **kwargs) 51 | hide_columns = perf_rec_settings.HIDE_COLUMNS 52 | callback( 53 | DBOp( 54 | alias=alias, 55 | query=sql_fingerprint(sql, hide_columns=hide_columns), 56 | traceback=traceback.extract_stack(), 57 | ) 58 | ) 59 | return sql 60 | 61 | return cast(LastExecutedQuery, inner) 62 | 63 | self.orig_last_executed_query = connection.ops.last_executed_query 64 | connection.ops.last_executed_query = MethodType( # type: ignore [method-assign] 65 | call_callback(connection.ops.last_executed_query), connection.ops 66 | ) 67 | 68 | def __exit__( 69 | self, 70 | exc_type: type[BaseException] | None, 71 | exc_value: BaseException | None, 72 | exc_traceback: TracebackType | None, 73 | ) -> None: 74 | connection = connections[self.alias] 75 | connection.force_debug_cursor = self.orig_force_debug_cursor 76 | connection.ops.last_executed_query = ( # type: ignore [method-assign] 77 | self.orig_last_executed_query 78 | ) 79 | 80 | 81 | class AllDBRecorder(AllSourceRecorder): 82 | """ 83 | Launches DBRecorders on all database connections 84 | """ 85 | 86 | sources_setting = "DATABASES" 87 | recorder_class = DBRecorder 88 | -------------------------------------------------------------------------------- /src/django_perf_rec/operation.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from traceback import StackSummary 4 | from types import TracebackType 5 | from typing import Any, Callable 6 | 7 | from django.conf import settings 8 | 9 | from django_perf_rec.utils import sorted_names 10 | 11 | 12 | class Operation: 13 | def __init__( 14 | self, alias: str, query: str | list[str], traceback: StackSummary 15 | ) -> None: 16 | self.alias = alias 17 | self.query = query 18 | self.traceback = traceback 19 | 20 | def __eq__(self, other: Any) -> bool: 21 | return ( 22 | isinstance(other, type(self)) 23 | and self.alias == other.alias 24 | and self.query == other.query 25 | and self.traceback == other.traceback 26 | ) 27 | 28 | @property 29 | def name(self) -> str: 30 | raise TypeError("Needs implementing in subclass!") 31 | 32 | 33 | class BaseRecorder: 34 | def __init__(self, alias: str, callback: Callable[[Operation], None]) -> None: 35 | self.alias = alias 36 | self.callback = callback 37 | 38 | def __enter__(self) -> None: 39 | pass 40 | 41 | def __exit__( 42 | self, 43 | exc_type: type[BaseException] | None, 44 | exc_value: BaseException | None, 45 | exc_traceback: TracebackType | None, 46 | ) -> None: 47 | pass 48 | 49 | 50 | class AllSourceRecorder: 51 | """ 52 | Launches Recorders on all the active sources 53 | """ 54 | 55 | sources_setting: str 56 | recorder_class: type[BaseRecorder] 57 | 58 | def __init__(self, callback: Callable[[Operation], None]) -> None: 59 | self.callback = callback 60 | 61 | def __enter__(self) -> None: 62 | self.recorders = [] 63 | for name in sorted_names(getattr(settings, self.sources_setting).keys()): 64 | recorder = self.recorder_class(name, self.callback) 65 | recorder.__enter__() 66 | self.recorders.append(recorder) 67 | 68 | def __exit__( 69 | self, 70 | exc_type: type[BaseException] | None, 71 | exc_value: BaseException | None, 72 | exc_traceback: TracebackType | None, 73 | ) -> None: 74 | for recorder in reversed(self.recorders): 75 | recorder.__exit__(exc_type, exc_value, exc_traceback) 76 | self.recorders = [] 77 | -------------------------------------------------------------------------------- /src/django_perf_rec/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamchainz/django-perf-rec/edc3b07ff35be6ef814e9c741bd895621a5027a5/src/django_perf_rec/py.typed -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | parsed_query = sql_recursively_strip(parsed_queries[0]) 23 | sql_recursively_simplify(parsed_query, hide_columns=hide_columns) 24 | return str(parsed_query).strip() 25 | 26 | 27 | sql_deletable_tokens = frozenset( 28 | ( 29 | tokens.Number, 30 | tokens.Number.Float, 31 | tokens.Number.Integer, 32 | tokens.Number.Hexadecimal, 33 | tokens.String, 34 | tokens.String.Single, 35 | ) 36 | ) 37 | 38 | 39 | def sql_trim(node: Token, idx: int) -> None: 40 | tokens = node.tokens 41 | count = len(tokens) 42 | min_count = abs(idx) 43 | 44 | while count > min_count and tokens[idx].is_whitespace: 45 | tokens.pop(idx) 46 | count -= 1 47 | 48 | 49 | def sql_strip(node: Token) -> None: 50 | in_whitespace = False 51 | for token in node.tokens: 52 | if token.is_whitespace: 53 | token.value = "" if in_whitespace else " " 54 | in_whitespace = True 55 | else: 56 | in_whitespace = False 57 | 58 | 59 | def sql_recursively_strip(node: Token) -> Token: 60 | for sub_node in node.get_sublists(): 61 | sql_recursively_strip(sub_node) 62 | 63 | if isinstance(node, Comment): 64 | return node 65 | 66 | sql_strip(node) 67 | 68 | # strip duplicate whitespaces between parenthesis 69 | if isinstance(node, Parenthesis): 70 | sql_trim(node, 1) 71 | sql_trim(node, -2) 72 | 73 | return node 74 | 75 | 76 | def sql_recursively_simplify(node: Token, hide_columns: bool = True) -> None: 77 | # Erase which fields are being updated in an UPDATE 78 | if node.tokens[0].value == "UPDATE": 79 | i_set = [i for (i, t) in enumerate(node.tokens) if t.value == "SET"][0] 80 | where_indexes = [ 81 | i 82 | for (i, t) in enumerate(node.tokens) 83 | if t.is_group and t.tokens[0].value == "WHERE" 84 | ] 85 | if where_indexes: 86 | where_index = where_indexes[0] 87 | end = node.tokens[where_index:] 88 | else: 89 | end = [] 90 | middle = [Token(tokens.Punctuation, " ... ")] 91 | node.tokens = node.tokens[: i_set + 1] + middle + end 92 | 93 | # Ensure IN clauses with simple value in always simplify to "..." 94 | if node.tokens[0].value == "WHERE": 95 | in_token_indices = (i for i, t in enumerate(node.tokens) if t.value == "IN") 96 | for in_token_index in in_token_indices: 97 | parenthesis = next( 98 | t 99 | for t in node.tokens[in_token_index + 1 :] 100 | if isinstance(t, Parenthesis) 101 | ) 102 | if all( 103 | getattr(t, "ttype", "") in sql_deletable_tokens 104 | for t in parenthesis.tokens[1:-1] 105 | ): 106 | parenthesis.tokens[1:-1] = [Token(tokens.Punctuation, "...")] 107 | 108 | # Erase the names of savepoints since they are non-deteriministic 109 | if hasattr(node, "tokens"): 110 | # SAVEPOINT x 111 | if str(node.tokens[0]) == "SAVEPOINT": 112 | node.tokens[2].tokens[0].value = "`#`" 113 | return 114 | # RELEASE SAVEPOINT x 115 | elif len(node.tokens) >= 3 and node.tokens[2].value == "SAVEPOINT": 116 | node.tokens[4].tokens[0].value = "`#`" 117 | return 118 | # ROLLBACK TO SAVEPOINT X 119 | token_values = [getattr(t, "value", "") for t in node.tokens] 120 | if len(node.tokens) >= 7 and token_values[:6] == [ 121 | "ROLLBACK", 122 | " ", 123 | "TO", 124 | " ", 125 | "SAVEPOINT", 126 | " ", 127 | ]: 128 | node.tokens[6].tokens[0].value = "`#`" 129 | return 130 | 131 | # Erase volatile part of PG cursor name 132 | if node.tokens[0].value.startswith('"_django_curs_'): 133 | node.tokens[0].value = '"_django_curs_#"' 134 | 135 | prev_word_token: Any = None 136 | 137 | for token in node.tokens: 138 | ttype = getattr(token, "ttype", None) 139 | 140 | if ( 141 | hide_columns 142 | and isinstance(token, IdentifierList) 143 | and not ( 144 | prev_word_token 145 | and prev_word_token.is_keyword 146 | and prev_word_token.value.upper() in ("ORDER BY", "GROUP BY", "HAVING") 147 | ) 148 | ): 149 | token.tokens = [Token(tokens.Punctuation, "...")] 150 | elif hasattr(token, "tokens"): 151 | sql_recursively_simplify(token, hide_columns=hide_columns) 152 | elif ttype in sql_deletable_tokens or getattr(token, "value", None) == "NULL": 153 | token.value = "#" 154 | 155 | if not token.is_whitespace: 156 | prev_word_token = token 157 | -------------------------------------------------------------------------------- /src/django_perf_rec/types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Union 4 | 5 | PerformanceRecordItem = dict[str, Union[str, list[str]]] 6 | PerformanceRecord = list[PerformanceRecordItem] 7 | -------------------------------------------------------------------------------- /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 | if request.cls is not None: 87 | class_name = request.cls.__name__ 88 | else: 89 | class_name = None 90 | 91 | return TestDetails( 92 | file_path=request.fspath.strpath, 93 | class_name=class_name, 94 | test_name=request.node.name, 95 | ) 96 | 97 | 98 | def sorted_names(names: Iterable[str]) -> list[str]: 99 | """ 100 | Sort a list of names but keep the word 'default' first if it's there. 101 | """ 102 | names = list(names) 103 | 104 | have_default = False 105 | if "default" in names: 106 | names.remove("default") 107 | have_default = True 108 | 109 | sorted_names = sorted(names) 110 | 111 | if have_default: 112 | sorted_names = ["default"] + sorted_names 113 | 114 | return sorted_names 115 | 116 | 117 | def record_diff(old: PerformanceRecord, new: PerformanceRecord) -> str: 118 | """ 119 | Generate a human-readable diff of two performance records. 120 | """ 121 | return "\n".join( 122 | difflib.ndiff( 123 | [f"{k}: {v}" for op in old for k, v in op.items()], 124 | [f"{k}: {v}" for op in new for k, v in op.items()], 125 | ) 126 | ) 127 | -------------------------------------------------------------------------------- /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/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamchainz/django-perf-rec/edc3b07ff35be6ef814e9c741bd895621a5027a5/tests/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/test_api.file_name.perf.yml: -------------------------------------------------------------------------------- 1 | TestCaseMixinTests.test_record_performance_file_name: 2 | - cache|get: foo 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_duplicate.perf.yml: -------------------------------------------------------------------------------- 1 | test_duplicate_name: 2 | - db: 'SELECT #' 3 | -------------------------------------------------------------------------------- /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_pytest_duplicate_other.perf.yml: -------------------------------------------------------------------------------- 1 | test_duplicate_name: 2 | - db: 'SELECT #' 3 | - db: 'SELECT #' 4 | - db: 'SELECT #' 5 | -------------------------------------------------------------------------------- /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_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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | 28 | class SortedNamesTests(SimpleTestCase): 29 | def test_empty(self): 30 | assert sorted_names([]) == [] 31 | 32 | def test_just_default(self): 33 | assert sorted_names(["default"]) == ["default"] 34 | 35 | def test_just_something(self): 36 | assert sorted_names(["something"]) == ["something"] 37 | 38 | def test_does_sort(self): 39 | assert sorted_names(["b", "a"]) == ["a", "b"] 40 | 41 | def test_sort_keeps_default_first(self): 42 | assert sorted_names(["a", "default"]) == ["default", "a"] 43 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamchainz/django-perf-rec/edc3b07ff35be6ef814e9c741bd895621a5027a5/tests/testapp/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 Generator 8 | from contextlib import contextmanager 9 | from functools import wraps 10 | from typing import Any, Callable, 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 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | tox>=4.2 4 | env_list = 5 | py313-django{52, 51} 6 | py312-django{52, 51, 50, 42} 7 | py311-django{52, 51, 50, 42} 8 | py310-django{52, 51, 50, 42} 9 | py39-django{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 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 2 3 | requires-python = ">=3.9" 4 | resolution-markers = [ 5 | "python_full_version >= '3.10'", 6 | "python_full_version < '3.10'", 7 | ] 8 | conflicts = [[ 9 | { package = "django-perf-rec", group = "django42" }, 10 | { package = "django-perf-rec", group = "django50" }, 11 | { package = "django-perf-rec", group = "django51" }, 12 | { package = "django-perf-rec", group = "django52" }, 13 | ]] 14 | 15 | [[package]] 16 | name = "asgiref" 17 | version = "3.8.1" 18 | source = { registry = "https://pypi.org/simple" } 19 | dependencies = [ 20 | { name = "typing-extensions", marker = "python_full_version < '3.11'" }, 21 | ] 22 | sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186, upload-time = "2024-03-22T14:39:36.863Z" } 23 | wheels = [ 24 | { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828, upload-time = "2024-03-22T14:39:34.521Z" }, 25 | ] 26 | 27 | [[package]] 28 | name = "colorama" 29 | version = "0.4.6" 30 | source = { registry = "https://pypi.org/simple" } 31 | 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" } 32 | wheels = [ 33 | { 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" }, 34 | ] 35 | 36 | [[package]] 37 | name = "django" 38 | version = "4.2.22" 39 | source = { registry = "https://pypi.org/simple" } 40 | resolution-markers = [ 41 | "python_full_version < '3.10'", 42 | "python_full_version >= '3.10'", 43 | ] 44 | dependencies = [ 45 | { name = "asgiref" }, 46 | { name = "sqlparse" }, 47 | { name = "tzdata", marker = "sys_platform == 'win32'" }, 48 | ] 49 | sdist = { url = "https://files.pythonhosted.org/packages/56/9e/37b909fa1c5e46ddb42ec37ec600a575d25dc47a6de86a1fce782a4a44ad/django-4.2.22.tar.gz", hash = "sha256:e726764b094407c313adba5e2e866ab88f00436cad85c540a5bf76dc0a912c9e", size = 10427236, upload-time = "2025-06-04T13:53:41.332Z" } 50 | wheels = [ 51 | { url = "https://files.pythonhosted.org/packages/dc/17/6afcbac3575992b22fda1ae7fffcdff15ff24d1aff69b3d36876bc0a47eb/django-4.2.22-py3-none-any.whl", hash = "sha256:0a32773b5b7f4e774a155ee253ab24a841fed7e9e9061db08bf2ce9711da404d", size = 7993908, upload-time = "2025-06-04T13:53:33.923Z" }, 52 | ] 53 | 54 | [[package]] 55 | name = "django" 56 | version = "5.0.14" 57 | source = { registry = "https://pypi.org/simple" } 58 | resolution-markers = [ 59 | "python_full_version >= '3.10'", 60 | ] 61 | dependencies = [ 62 | { name = "asgiref", marker = "(python_full_version >= '3.10' 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-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-django51' and extra == 'group-15-django-perf-rec-django52')" }, 63 | { name = "sqlparse", marker = "(python_full_version >= '3.10' 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-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-django51' and extra == 'group-15-django-perf-rec-django52')" }, 64 | { name = "tzdata", marker = "(python_full_version >= '3.10' and sys_platform == 'win32' and extra == 'group-15-django-perf-rec-django50') or (python_full_version < '3.10' and extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django51') or (python_full_version < '3.10' and extra == 'group-15-django-perf-rec-django50' and extra == 'group-15-django-perf-rec-django52') or (python_full_version < '3.10' and extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52') or (sys_platform != 'win32' 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-django50' and extra == 'group-15-django-perf-rec-django52') or (sys_platform != 'win32' 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-django50' and extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52')" }, 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.10" 74 | source = { registry = "https://pypi.org/simple" } 75 | resolution-markers = [ 76 | "python_full_version >= '3.10'", 77 | ] 78 | dependencies = [ 79 | { name = "asgiref", marker = "(python_full_version >= '3.10' 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-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-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')" }, 80 | { name = "sqlparse", marker = "(python_full_version >= '3.10' 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-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-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')" }, 81 | { name = "tzdata", marker = "(python_full_version >= '3.10' and sys_platform == 'win32' and extra == 'group-15-django-perf-rec-django51') or (python_full_version < '3.10' and extra == 'group-15-django-perf-rec-django51' and extra == 'group-15-django-perf-rec-django52') or (sys_platform != 'win32' 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-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')" }, 82 | ] 83 | sdist = { url = "https://files.pythonhosted.org/packages/73/ca/1c724be89e603eb8b5587ea24c63a8c30094c8ff4d990780b5033ee15c40/django-5.1.10.tar.gz", hash = "sha256:73e5d191421d177803dbd5495d94bc7d06d156df9561f4eea9e11b4994c07137", size = 10714538, upload-time = "2025-06-04T13:53:18.805Z" } 84 | wheels = [ 85 | { url = "https://files.pythonhosted.org/packages/9e/fc/80dc741ba0acb3241aac1213d7272c573d52d8a62ec2c69e9b3bef1547f2/django-5.1.10-py3-none-any.whl", hash = "sha256:19c9b771e9cf4de91101861aadd2daaa159bcf10698ca909c5755c88e70ccb84", size = 8277457, upload-time = "2025-06-04T13:53:07.676Z" }, 86 | ] 87 | 88 | [[package]] 89 | name = "django" 90 | version = "5.2.2" 91 | source = { registry = "https://pypi.org/simple" } 92 | resolution-markers = [ 93 | "python_full_version >= '3.10'", 94 | ] 95 | dependencies = [ 96 | { name = "asgiref", marker = "python_full_version >= '3.10'" }, 97 | { name = "sqlparse", marker = "python_full_version >= '3.10'" }, 98 | { name = "tzdata", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, 99 | ] 100 | sdist = { url = "https://files.pythonhosted.org/packages/39/17/4567ee12bb84114c544d5c4a792e7226db517ac78f552111e9dc62d1de14/django-5.2.2.tar.gz", hash = "sha256:85852e517f84435e9b13421379cd6c43ef5b48a9c8b391d29a26f7900967e952", size = 10827542, upload-time = "2025-06-04T13:52:40.879Z" } 101 | wheels = [ 102 | { url = "https://files.pythonhosted.org/packages/b4/5c/5d00acab6c062b154e5a0f092938ae5a0c698dbc4362b68e23200960f32c/django-5.2.2-py3-none-any.whl", hash = "sha256:997ef2162d04ead6869551b22cde4e06da1f94cf595f4af3f3d3afeae1f3f6fe", size = 8302562, upload-time = "2025-06-04T13:52:33.14Z" }, 103 | ] 104 | 105 | [[package]] 106 | name = "django-perf-rec" 107 | version = "4.28.0" 108 | source = { editable = "." } 109 | dependencies = [ 110 | { name = "django", version = "4.2.22", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' or 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-django51' and extra == 'group-15-django-perf-rec-django52')" }, 111 | { name = "django", version = "5.0.14", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.10' 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-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-django51' and extra == 'group-15-django-perf-rec-django52')" }, 112 | { name = "django", version = "5.1.10", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.10' 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-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-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')" }, 113 | { name = "django", version = "5.2.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.10' 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-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-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-django51' and extra == 'group-15-django-perf-rec-django52')" }, 114 | { name = "pyyaml" }, 115 | { name = "sqlparse" }, 116 | ] 117 | 118 | [package.dev-dependencies] 119 | django42 = [ 120 | { name = "django", version = "4.2.22", source = { registry = "https://pypi.org/simple" } }, 121 | ] 122 | django50 = [ 123 | { name = "django", version = "5.0.14", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, 124 | ] 125 | django51 = [ 126 | { name = "django", version = "5.1.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, 127 | ] 128 | django52 = [ 129 | { name = "django", version = "5.2.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, 130 | ] 131 | test = [ 132 | { name = "pygments" }, 133 | { name = "pytest" }, 134 | { name = "pytest-django" }, 135 | { name = "pytest-randomly" }, 136 | { name = "pyyaml" }, 137 | { name = "sqlparse" }, 138 | ] 139 | 140 | [package.metadata] 141 | requires-dist = [ 142 | { name = "django", specifier = ">=4.2" }, 143 | { name = "pyyaml" }, 144 | { name = "sqlparse", specifier = ">=0.4.4" }, 145 | ] 146 | 147 | [package.metadata.requires-dev] 148 | django42 = [{ name = "django", marker = "python_full_version >= '3.8'", specifier = ">=4.2a1,<5" }] 149 | django50 = [{ name = "django", marker = "python_full_version >= '3.10'", specifier = ">=5.0a1,<5.1" }] 150 | django51 = [{ name = "django", marker = "python_full_version >= '3.10'", specifier = ">=5.1a1,<5.2" }] 151 | django52 = [{ name = "django", marker = "python_full_version >= '3.10'", specifier = ">=5.2a1,<6" }] 152 | test = [ 153 | { name = "pygments" }, 154 | { name = "pytest" }, 155 | { name = "pytest-django" }, 156 | { name = "pytest-randomly" }, 157 | { name = "pyyaml" }, 158 | { name = "sqlparse" }, 159 | ] 160 | 161 | [[package]] 162 | name = "exceptiongroup" 163 | version = "1.3.0" 164 | source = { registry = "https://pypi.org/simple" } 165 | dependencies = [ 166 | { name = "typing-extensions", marker = "python_full_version < '3.13' 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-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-django51' and extra == 'group-15-django-perf-rec-django52')" }, 167 | ] 168 | 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" } 169 | wheels = [ 170 | { 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" }, 171 | ] 172 | 173 | [[package]] 174 | name = "importlib-metadata" 175 | version = "8.7.0" 176 | source = { registry = "https://pypi.org/simple" } 177 | dependencies = [ 178 | { name = "zipp", marker = "python_full_version < '3.10' 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-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-django51' and extra == 'group-15-django-perf-rec-django52')" }, 179 | ] 180 | sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } 181 | wheels = [ 182 | { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, 183 | ] 184 | 185 | [[package]] 186 | name = "iniconfig" 187 | version = "2.1.0" 188 | source = { registry = "https://pypi.org/simple" } 189 | 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" } 190 | wheels = [ 191 | { 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" }, 192 | ] 193 | 194 | [[package]] 195 | name = "packaging" 196 | version = "25.0" 197 | source = { registry = "https://pypi.org/simple" } 198 | 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" } 199 | wheels = [ 200 | { 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" }, 201 | ] 202 | 203 | [[package]] 204 | name = "pluggy" 205 | version = "1.6.0" 206 | source = { registry = "https://pypi.org/simple" } 207 | 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" } 208 | wheels = [ 209 | { 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" }, 210 | ] 211 | 212 | [[package]] 213 | name = "pygments" 214 | version = "2.19.1" 215 | source = { registry = "https://pypi.org/simple" } 216 | sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } 217 | wheels = [ 218 | { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, 219 | ] 220 | 221 | [[package]] 222 | name = "pytest" 223 | version = "8.4.0" 224 | source = { registry = "https://pypi.org/simple" } 225 | dependencies = [ 226 | { 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-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-django51' and extra == 'group-15-django-perf-rec-django52')" }, 227 | { 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-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-django51' and extra == 'group-15-django-perf-rec-django52')" }, 228 | { name = "iniconfig" }, 229 | { name = "packaging" }, 230 | { name = "pluggy" }, 231 | { name = "pygments" }, 232 | { 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-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-django51' and extra == 'group-15-django-perf-rec-django52')" }, 233 | ] 234 | sdist = { url = "https://files.pythonhosted.org/packages/fb/aa/405082ce2749be5398045152251ac69c0f3578c7077efc53431303af97ce/pytest-8.4.0.tar.gz", hash = "sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6", size = 1515232, upload-time = "2025-06-02T17:36:30.03Z" } 235 | wheels = [ 236 | { url = "https://files.pythonhosted.org/packages/2f/de/afa024cbe022b1b318a3d224125aa24939e99b4ff6f22e0ba639a2eaee47/pytest-8.4.0-py3-none-any.whl", hash = "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e", size = 363797, upload-time = "2025-06-02T17:36:27.859Z" }, 237 | ] 238 | 239 | [[package]] 240 | name = "pytest-django" 241 | version = "4.11.1" 242 | source = { registry = "https://pypi.org/simple" } 243 | dependencies = [ 244 | { name = "pytest" }, 245 | ] 246 | 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" } 247 | wheels = [ 248 | { 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" }, 249 | ] 250 | 251 | [[package]] 252 | name = "pytest-randomly" 253 | version = "3.16.0" 254 | source = { registry = "https://pypi.org/simple" } 255 | dependencies = [ 256 | { name = "importlib-metadata", marker = "python_full_version < '3.10' 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-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-django51' and extra == 'group-15-django-perf-rec-django52')" }, 257 | { name = "pytest" }, 258 | ] 259 | 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" } 260 | wheels = [ 261 | { 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" }, 262 | ] 263 | 264 | [[package]] 265 | name = "pyyaml" 266 | version = "6.0.2" 267 | source = { registry = "https://pypi.org/simple" } 268 | 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" } 269 | wheels = [ 270 | { 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" }, 271 | { 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" }, 272 | { 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" }, 273 | { 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" }, 274 | { 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" }, 275 | { 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" }, 276 | { 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" }, 277 | { 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" }, 278 | { 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" }, 279 | { 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" }, 280 | { 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" }, 281 | { 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" }, 282 | { 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" }, 283 | { 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" }, 284 | { 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" }, 285 | { 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" }, 286 | { 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" }, 287 | { 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" }, 288 | { 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" }, 289 | { 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" }, 290 | { 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" }, 291 | { 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" }, 292 | { 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" }, 293 | { 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" }, 294 | { 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" }, 295 | { 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" }, 296 | { 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" }, 297 | { 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" }, 298 | { 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" }, 299 | { 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" }, 300 | { 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" }, 301 | { 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" }, 302 | { 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" }, 303 | { 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" }, 304 | { 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" }, 305 | { 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" }, 306 | { url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777, upload-time = "2024-08-06T20:33:25.896Z" }, 307 | { url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318, upload-time = "2024-08-06T20:33:27.212Z" }, 308 | { url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891, upload-time = "2024-08-06T20:33:28.974Z" }, 309 | { url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614, upload-time = "2024-08-06T20:33:34.157Z" }, 310 | { url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360, upload-time = "2024-08-06T20:33:35.84Z" }, 311 | { url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006, upload-time = "2024-08-06T20:33:37.501Z" }, 312 | { url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577, upload-time = "2024-08-06T20:33:39.389Z" }, 313 | { url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593, upload-time = "2024-08-06T20:33:46.63Z" }, 314 | { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312, upload-time = "2024-08-06T20:33:49.073Z" }, 315 | ] 316 | 317 | [[package]] 318 | name = "sqlparse" 319 | version = "0.5.3" 320 | source = { registry = "https://pypi.org/simple" } 321 | 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" } 322 | wheels = [ 323 | { 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" }, 324 | ] 325 | 326 | [[package]] 327 | name = "tomli" 328 | version = "2.2.1" 329 | source = { registry = "https://pypi.org/simple" } 330 | 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" } 331 | wheels = [ 332 | { 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" }, 333 | { 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" }, 334 | { 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" }, 335 | { 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" }, 336 | { 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" }, 337 | { 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" }, 338 | { 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" }, 339 | { 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" }, 340 | { 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" }, 341 | { 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" }, 342 | { 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" }, 343 | { 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" }, 344 | { 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" }, 345 | { 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" }, 346 | { 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" }, 347 | { 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" }, 348 | { 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" }, 349 | { 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" }, 350 | { 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" }, 351 | { 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" }, 352 | { 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" }, 353 | { 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" }, 354 | { 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" }, 355 | { 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" }, 356 | { 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" }, 357 | { 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" }, 358 | { 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" }, 359 | { 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" }, 360 | { 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" }, 361 | { 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" }, 362 | { 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" }, 363 | ] 364 | 365 | [[package]] 366 | name = "typing-extensions" 367 | version = "4.14.0" 368 | source = { registry = "https://pypi.org/simple" } 369 | sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" } 370 | wheels = [ 371 | { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" }, 372 | ] 373 | 374 | [[package]] 375 | name = "tzdata" 376 | version = "2025.2" 377 | source = { registry = "https://pypi.org/simple" } 378 | 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" } 379 | wheels = [ 380 | { 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" }, 381 | ] 382 | 383 | [[package]] 384 | name = "zipp" 385 | version = "3.22.0" 386 | source = { registry = "https://pypi.org/simple" } 387 | sdist = { url = "https://files.pythonhosted.org/packages/12/b6/7b3d16792fdf94f146bed92be90b4eb4563569eca91513c8609aebf0c167/zipp-3.22.0.tar.gz", hash = "sha256:dd2f28c3ce4bc67507bfd3781d21b7bb2be31103b51a4553ad7d90b84e57ace5", size = 25257, upload-time = "2025-05-26T14:46:32.217Z" } 388 | wheels = [ 389 | { url = "https://files.pythonhosted.org/packages/ad/da/f64669af4cae46f17b90798a827519ce3737d31dbafad65d391e49643dc4/zipp-3.22.0-py3-none-any.whl", hash = "sha256:fe208f65f2aca48b81f9e6fd8cf7b8b32c26375266b009b413d45306b6148343", size = 9796, upload-time = "2025-05-26T14:46:30.775Z" }, 390 | ] 391 | --------------------------------------------------------------------------------