├── tests ├── __init__.py ├── apps │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ ├── apps.py │ ├── models.py │ └── fixtures │ │ └── data.json ├── conftest.py ├── settings.py ├── urls.py └── test_main.py ├── AUTHORS.md ├── query_counter ├── __init__.py ├── middleware.py ├── settings.py ├── apps.py └── decorators.py ├── .github └── workflows │ └── check.yml ├── CHANGELOG.md ├── LICENSE ├── pyproject.toml ├── README.md └── uv.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Authors & Contributors 2 | 3 | - Oleg Smedyuk [https://github.com/conformist-mw](https://github.com/conformist-mw) 4 | -------------------------------------------------------------------------------- /tests/apps/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AppsConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'apps' 7 | -------------------------------------------------------------------------------- /query_counter/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | import django 3 | if django.VERSION < (3, 2): 4 | default_app_config = 'query_counter.apps.DjangoQueryCounterConfig' 5 | except ImportError: 6 | ... 7 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.core.management import call_command 3 | 4 | 5 | @pytest.fixture(scope='session') 6 | def django_db_setup(django_db_setup, django_db_blocker): 7 | with django_db_blocker.unblock(): 8 | call_command('loaddata', 'data.json') 9 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | DEBUG = True 2 | USE_TZ = False 3 | SECRET_KEY = 'key' 4 | INSTALLED_APPS = ['apps'] 5 | ROOT_URLCONF = 'urls' 6 | DATABASES = { 7 | 'default': { 8 | 'ENGINE': 'django.db.backends.sqlite3', 9 | 'NAME': ':memory:', 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /query_counter/middleware.py: -------------------------------------------------------------------------------- 1 | from .decorators import queries_counter 2 | 3 | 4 | class DjangoQueryCounterMiddleware: 5 | 6 | def __init__(self, get_response): 7 | self.get_response = get_response 8 | 9 | @queries_counter 10 | def __call__(self, request): 11 | return self.get_response(request) 12 | -------------------------------------------------------------------------------- /query_counter/settings.py: -------------------------------------------------------------------------------- 1 | DEFAULTS = { 2 | 'DQC_SLOWEST_COUNT': 5, 3 | 'DQC_DUPLICATED_COUNT': 10, 4 | 'DQC_TABULATE_FMT': 'pretty', 5 | 'DQC_SLOW_THRESHOLD': 1, # seconds 6 | 'DQC_INDENT_SQL': True, 7 | 'DQC_PYGMENTS_STYLE': 'tango', 8 | 'DQC_PRINT_ALL_QUERIES': False, 9 | 'DQC_COUNT_QTY_MAP': { 10 | 5: 'green', 11 | 10: 'white', 12 | 20: 'yellow', 13 | 30: 'red', 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /query_counter/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.conf import settings 3 | 4 | from .settings import DEFAULTS 5 | 6 | 7 | class DjangoQueryCounterConfig(AppConfig): 8 | name = 'query_counter' 9 | verbose_name = 'Django Query Counter' 10 | 11 | def ready(self): 12 | 13 | for attr, value in DEFAULTS.items(): 14 | if not hasattr(settings, attr): 15 | setattr(settings, attr, value) 16 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: check 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | python -m pip install tox tox-gh-actions 24 | - name: Test with tox 25 | run: tox 26 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from apps.models import Parent 2 | from django.http import HttpRequest, HttpResponse 3 | from django.urls import path 4 | 5 | 6 | def index(_: HttpRequest) -> HttpResponse: 7 | lines = [] 8 | for parent in Parent.objects.all(): 9 | lines.append(f'
  • {parent}
  • ') 16 | return HttpResponse(''.join([''])) 17 | 18 | 19 | urlpatterns = [path('', index)] 20 | -------------------------------------------------------------------------------- /tests/apps/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Model(models.Model): 5 | class Meta: 6 | abstract = True 7 | 8 | def __str__(self) -> str: 9 | return f'{self._meta.model_name} - {self.id} - {self.name}' 10 | 11 | 12 | class Parent(Model): 13 | name = models.CharField(max_length=10) 14 | 15 | 16 | class Child(Model): 17 | parent = models.ForeignKey( 18 | Parent, on_delete=models.CASCADE, related_name='children', 19 | ) 20 | name = models.CharField(max_length=10) 21 | 22 | 23 | class Grandson(Model): 24 | child = models.ForeignKey( 25 | Child, on_delete=models.CASCADE, related_name='grandchildren', 26 | ) 27 | name = models.CharField(max_length=10) 28 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | def test_view(db, client, django_assert_num_queries): 2 | with django_assert_num_queries(13): 3 | response = client.get('') 4 | assert response.status_code == 200 5 | 6 | 7 | def test_view_with_decorator( 8 | db, client, django_assert_num_queries, capsys, settings, 9 | ): 10 | settings.INSTALLED_APPS.append('query_counter') 11 | settings.MIDDLEWARE.append('query_counter.middleware.DjangoQueryCounterMiddleware') 12 | 13 | with django_assert_num_queries(13): 14 | response = client.get('') 15 | assert response.status_code == 200 16 | 17 | out, err = capsys.readouterr() 18 | assert '9\x1b[0m: SELECT "apps_grandson"."id"' in out 19 | assert 'Target: / urls.index' in out 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.4.0 4 | 5 | - integrate github actions 6 | - fix py38 support 7 | 8 | ## 0.3.1 9 | 10 | - fix an unclosed curly bracket 11 | - introduce .gitignore 12 | 13 | ## 0.3.0 14 | 15 | - prettify markdown files 16 | - add smoke tests using tox 17 | 18 | ## 0.2.2 19 | 20 | - fix decorator type in README.md (Thanks @MichaelAquilina) 21 | 22 | ## 0.2.1 23 | 24 | - fix `DEFAULTS` settings typo 25 | - suppress Django default_app_config warning 26 | 27 | ## 0.2.0 28 | 29 | - fix the view/func name after the sql stats table 30 | - move stats table to the bottom 31 | - introduce CHANGELOG.md (Thanks @DmytroLitvinov) 32 | 33 | ## 0.1.0 34 | 35 | - add base implementation of the decorator/middleware 36 | - introduce setup.py to publish on PyPI 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Oleg Smedyuk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/apps/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | import django.db.models.deletion 2 | from django.db import migrations, models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | initial = True 8 | 9 | dependencies = [] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name='Child', 14 | fields=[ 15 | ( 16 | 'id', 17 | models.BigAutoField( 18 | auto_created=True, 19 | primary_key=True, 20 | serialize=False, 21 | verbose_name='ID', 22 | ), 23 | ), 24 | ('name', models.CharField(max_length=10)), 25 | ], 26 | ), 27 | migrations.CreateModel( 28 | name='Parent', 29 | fields=[ 30 | ( 31 | 'id', 32 | models.BigAutoField( 33 | auto_created=True, 34 | primary_key=True, 35 | serialize=False, 36 | verbose_name='ID', 37 | ), 38 | ), 39 | ('name', models.CharField(max_length=10)), 40 | ], 41 | ), 42 | migrations.CreateModel( 43 | name='Grandson', 44 | fields=[ 45 | ( 46 | 'id', 47 | models.BigAutoField( 48 | auto_created=True, 49 | primary_key=True, 50 | serialize=False, 51 | verbose_name='ID', 52 | ), 53 | ), 54 | ('name', models.CharField(max_length=10)), 55 | ( 56 | 'child', 57 | models.ForeignKey( 58 | on_delete=django.db.models.deletion.CASCADE, 59 | related_name='grandchildren', 60 | to='apps.child', 61 | ), 62 | ), 63 | ], 64 | ), 65 | migrations.AddField( 66 | model_name='child', 67 | name='parent', 68 | field=models.ForeignKey( 69 | on_delete=django.db.models.deletion.CASCADE, 70 | related_name='children', 71 | to='apps.parent', 72 | ), 73 | ), 74 | ] 75 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [tool.hatch.build.targets.sdist] 6 | include = ["query_counter"] 7 | 8 | [tool.hatch.build.targets.wheel] 9 | packages = ["query_counter"] 10 | 11 | [project] 12 | name = "django-query-counter" 13 | version = "0.4.1" 14 | description = "Debug tool to print SQL queries count to the console" 15 | authors = [{name = "Oleg Smedyuk", email = "oleg.smedyuk@gmail.com"}] 16 | license = "MIT" 17 | keywords = [ 18 | "django", 19 | "sql", 20 | "query", 21 | "count", 22 | "management", 23 | "commands", 24 | ] 25 | readme = "README.md" 26 | requires-python = ">= 3.9" 27 | 28 | dependencies = [ 29 | "tabulate" 30 | ] 31 | 32 | classifiers = [ 33 | "Development Status :: 4 - Beta", 34 | "Environment :: Web Environment", 35 | "Intended Audience :: Developers", 36 | "License :: OSI Approved :: MIT License", 37 | "Operating System :: OS Independent", 38 | "Topic :: Utilities", 39 | "Framework :: Django", 40 | "Framework :: Django :: 3.2", 41 | "Framework :: Django :: 4.0", 42 | "Framework :: Django :: 4.1", 43 | "Framework :: Django :: 4.2", 44 | "Framework :: Django :: 5.0", 45 | "Framework :: Django :: 5.1", 46 | "Framework :: Django :: 5.2", 47 | "Programming Language :: Python", 48 | "Programming Language :: Python :: 3 :: Only", 49 | "Programming Language :: Python :: 3.9", 50 | "Programming Language :: Python :: 3.10", 51 | "Programming Language :: Python :: 3.11", 52 | "Programming Language :: Python :: 3.12", 53 | "Programming Language :: Python :: 3.13", 54 | "Programming Language :: Python :: Implementation :: CPython", 55 | ] 56 | 57 | [project.urls] 58 | Repository = "https://github.com/conformist-mw/django-query-counter" 59 | Changelog = "https://github.com/conformist-mw/django-query-counter/blob/master/CHANGELOG.md" 60 | 61 | [dependency-groups] 62 | dev = [ 63 | "django", 64 | "pytest", 65 | "pytest-django", 66 | "ruff", 67 | "tox", 68 | ] 69 | 70 | [tool.tox] 71 | legacy_tox_ini = """ 72 | [tox] 73 | requires = 74 | tox>=4.5 75 | env_list = 76 | lint 77 | py313-django{51} 78 | py312-django{51, 50, 42} 79 | py311-django{51, 50, 42, 41} 80 | py310-django{51, 50, 42, 41, 40, 32} 81 | py39-django{42, 41, 40, 32} 82 | py38-django{42, 41, 40, 32} 83 | 84 | [testenv] 85 | deps = 86 | pytest>=7 87 | pytest-django 88 | django32: Django>=3.2,<3.3 89 | django40: Django>=4.0,<4.1 90 | django41: Django>=4.1,<4.2 91 | django42: Django>=4.2,<5.0 92 | django50: Django>=5.0,<5.1 93 | django51: Django>=5.1,<5.2 94 | set_env = 95 | PYTHONDEVMODE = 1 96 | commands = pytest -Wa -r {posargs:.} 97 | 98 | [gh-actions] 99 | python = 100 | 3.8: py38 101 | 3.9: py39 102 | 3.10: py310 103 | 3.11: py311 104 | 3.12: py312 105 | 3.13: py313 106 | 107 | [gh-actions:env] 108 | DJANGO = 109 | 3.2: dj32 110 | 4.0: dj40 111 | 4.1: dj41 112 | 4.2: dj42 113 | 5.0: dj50 114 | 5.1: dj51 115 | 116 | [testenv:lint] 117 | description = run linters 118 | skip_install = true 119 | deps = 120 | ruff 121 | commands = ruff check {posargs:query_counter} 122 | """ 123 | 124 | [tool.ruff] 125 | line-length = 80 126 | fix = false 127 | [tool.ruff.lint] 128 | select = [ 129 | "E", # pycodestyle 130 | "F", # pyflakes 131 | "I", # isort 132 | "A", # flake8-builtins 133 | "COM", # flake8-commas 134 | "T20", # flake8-print 135 | "Q", # flake8-quotes 136 | "BLE", # flake8-blind-except 137 | "N", # pep8-naming 138 | ] 139 | 140 | [tool.ruff.lint.flake8-quotes] 141 | inline-quotes = "single" 142 | 143 | [tool.pytest.ini_options] 144 | DJANGO_SETTINGS_MODULE = "settings" 145 | pythonpath = "tests" 146 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Queries Count 2 | 3 | [![check](https://github.com/conformist-mw/django-query-counter/actions/workflows/check.yml/badge.svg)](https://github.com/conformist-mw/django-query-counter/actions/workflows/check.yml) 4 | [![PyPI version](https://badge.fury.io/py/django-query-counter.svg)](https://badge.fury.io/py/django-query-counter) 5 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-query-counter) 6 | ![PyPI - Versions from Framework Classifiers](https://img.shields.io/pypi/frameworkversions/django/django-query-counter) 7 | 8 | 9 | The difference between this project and all the others like it is that I needed 10 | to debug management command in Django, but all the others only provided middleware, 11 | which did not solve my problem. 12 | 13 | ## Example output 14 | 15 | ![duplicates-main-example](https://user-images.githubusercontent.com/13550539/117552176-89c30b80-b052-11eb-80b9-7eb32435d116.png) 16 | 17 | The basic idea is to count duplicate queries, like django-debug-toolbar does, 18 | and output them. The number of duplicated queries and the color of the theme 19 | can be specified in the settings. It is also possible to output all requests 20 | at once (counted if they are duplicated). 21 | 22 | ## Content 23 | 24 | - [Installation](#installation) 25 | - [Usage](#usage) 26 | - [Available settings](#available-settings) 27 | - [Additional screenshots](#additional-screenshots) 28 | - [Contribute](#contribute) 29 | 30 | ## Installation 31 | 32 | It is enough to install the package and apply the decorator to the desired 33 | management command or view. 34 | 35 | ```shell 36 | pip install django-query-counter 37 | ``` 38 | 39 | Please take note that the colored and reformatted SQL output depicted in 40 | the readme screenshots may not be achieved unless the specified additional 41 | packages are installed (which maybe already installed): 42 | 43 | - colorize requires [pygments](https://pypi.org/project/Pygments/) 44 | 45 | ```shell 46 | pip install Pygments 47 | ``` 48 | 49 | - reformat requires [sqlparse](https://pypi.org/project/sqlparse/) 50 | 51 | ```shell 52 | pip install sqlparse 53 | ``` 54 | 55 | ## Usage 56 | 57 | The project can be used in two ways: 58 | 59 | Import the decorator and apply it where you need to know the number of queries 60 | to the database. 61 | 62 | - management command: 63 | 64 | ```python 65 | from django.core.management.base import BaseCommand 66 | from query_counter.decorators import queries_counter 67 | 68 | class Command(BaseCommand): 69 | 70 | @queries_counter 71 | def handle(self, *args, **options): 72 | pass 73 | ``` 74 | 75 | - function-based views 76 | 77 | ```python 78 | from query_counter.decorators import queries_counter 79 | 80 | 81 | @queries_counter 82 | def index(request): 83 | pass 84 | ``` 85 | 86 | - class-based views: 87 | 88 | ```python 89 | from django.utils.decorators import method_decorator 90 | from query_counter.decorators import queries_counter 91 | 92 | 93 | @method_decorator(queries_counter, name='dispatch') 94 | class IndexView(View): 95 | pass 96 | ``` 97 | 98 | - specifying middleware in settings for all views at once. 99 | 100 | ```python 101 | MIDDLEWARE = [ 102 | 'query_counter.middleware.DjangoQueryCounterMiddleware', 103 | ] 104 | ``` 105 | 106 | ### Available settings 107 | 108 | It is possible to override the default settings. To do this, you need to 109 | include the app to the INSTALLED_APPS: 110 | 111 | ```python 112 | INSTALLED_APPS = [ 113 | ..., 114 | 'query_counter', 115 | ... 116 | ] 117 | ``` 118 | 119 | Default settings: 120 | 121 | ```python 122 | { 123 | 'DQC_SLOWEST_COUNT': 5, 124 | 'DQC_TABULATE_FMT': 'pretty', 125 | 'DQC_SLOW_THRESHOLD': 1, # seconds 126 | 'DQC_INDENT_SQL': True, 127 | 'DQC_PYGMENTS_STYLE': 'tango', 128 | 'DQC_PRINT_ALL_QUERIES': False, 129 | 'DQC_COUNT_QTY_MAP': { 130 | 5: 'green', 131 | 10: 'white', 132 | 20: 'yellow', 133 | 30: 'red', 134 | }, 135 | } 136 | ``` 137 | 138 | Feel free to override any of them. 139 | 140 | Tabulate tables formats you can find [here](https://github.com/astanin/python-tabulate#table-format). 141 | Pygments styles available [here](https://pygments.org/demo/). 142 | 143 | ### Additional screenshots 144 | 145 | ![good_example](https://user-images.githubusercontent.com/13550539/117552177-8a5ba200-b052-11eb-8b6b-e66521aebdd6.png) 146 | ![yellow_example](https://user-images.githubusercontent.com/13550539/117552179-8af43880-b052-11eb-85ca-65df4eca3ea7.png) 147 | 148 | ### Contribute 149 | 150 | Feel free to open an issue to report of any bugs. Bug fixes and features are 151 | welcome! Be sure to add yourself to the AUTHORS.md if you provide PR. 152 | -------------------------------------------------------------------------------- /query_counter/decorators.py: -------------------------------------------------------------------------------- 1 | import re 2 | import time 3 | from collections import Counter 4 | from operator import itemgetter 5 | from typing import Dict, List 6 | 7 | from django.conf import settings 8 | from django.db import connection 9 | from django.utils import termcolors 10 | from tabulate import tabulate 11 | 12 | from .settings import DEFAULTS 13 | 14 | 15 | def _get_value(key): 16 | """" 17 | Try to get value from django.conf.settings otherwise default 18 | """ 19 | return getattr(settings, key, DEFAULTS[key]) 20 | 21 | 22 | def _print(lines, sep: str = '\n') -> None: 23 | print(f'{sep}'.join(lines)) # noqa: T201 24 | 25 | 26 | def colorize(string: str, color: str = 'white') -> str: 27 | return { 28 | 'yellow': termcolors.make_style(opts='bold', fg='yellow'), 29 | 'red': termcolors.make_style(opts='bold', fg='red'), 30 | 'white': termcolors.make_style(opts='bold', fg='white'), 31 | 'green': termcolors.make_style(opts='bold', fg='green'), 32 | }[color](string) 33 | 34 | 35 | def get_color_by(count): 36 | for _count, color in _get_value('DQC_COUNT_QTY_MAP').items(): 37 | if count <= _count: 38 | return _get_value('DQC_COUNT_QTY_MAP')[_count] 39 | return color 40 | 41 | 42 | def highlight(sql): 43 | try: 44 | import pygments 45 | from pygments.formatters import TerminalTrueColorFormatter 46 | from pygments.lexers import SqlLexer 47 | except ImportError: 48 | pygments = None 49 | 50 | try: 51 | import sqlparse 52 | except ImportError: 53 | sqlparse = None 54 | 55 | if sqlparse: 56 | sql = sqlparse.format(sql, reindent=_get_value('DQC_INDENT_SQL')) 57 | 58 | if pygments: 59 | sql = pygments.highlight( 60 | sql, 61 | SqlLexer(), 62 | TerminalTrueColorFormatter(style=_get_value('DQC_PYGMENTS_STYLE')), 63 | ) 64 | return sql 65 | 66 | 67 | class QueryLogger: 68 | 69 | SQL_STATEMENTS = ('SELECT', 'INSERT', 'UPDATE', 'DELETE') 70 | 71 | def __init__(self): 72 | self.queries = [] 73 | self.duplicates = {} 74 | self.slowest = {} 75 | self.counted = None 76 | self.start = time.perf_counter() 77 | 78 | def __call__(self, execute, sql, params, many, context): 79 | stripped_sql = re.sub(r'\(%s.*\)', '(%s, ..., %s)', sql) 80 | current_query = {'sql': stripped_sql, 'params': params, 'many': many} 81 | start = time.monotonic() 82 | execute(sql, params, many, context) 83 | duration = time.monotonic() - start 84 | current_query['duration'] = duration 85 | self.queries.append(current_query) 86 | 87 | def do_count(self) -> Counter: 88 | return Counter([ 89 | q['sql'].split()[0] 90 | for q in self.queries if q['sql'].startswith(self.SQL_STATEMENTS) 91 | ]) 92 | 93 | def count_duplicated(self) -> Dict[str, int]: 94 | return { 95 | query: count 96 | for query, count 97 | in Counter([q['sql'] for q in self.queries]).most_common() 98 | if count > 1 99 | } 100 | 101 | def get_slowest(self) -> Dict[str, float]: 102 | return { 103 | q['sql']: q['duration'] 104 | for q in sorted( 105 | self.queries, 106 | key=itemgetter('duration'), 107 | reverse=True, 108 | )[:_get_value('DQC_SLOWEST_COUNT')] 109 | if q['duration'] > _get_value('DQC_SLOW_THRESHOLD') 110 | } 111 | 112 | def count(self) -> None: 113 | self.elapsed = time.perf_counter() - self.start 114 | self.counted = self.do_count() 115 | self.slowest = self.get_slowest() 116 | self.duplicates = self.count_duplicated() 117 | 118 | def collect_stats(self): 119 | stats = [(stmt, self.counted[stmt]) for stmt in self.SQL_STATEMENTS] 120 | stats.extend( 121 | [ 122 | ('duplicates', sum(self.duplicates.values())), 123 | ('total', len(self.queries)), 124 | ('duration', '{:.2f}'.format(self.elapsed)), 125 | ], 126 | ) 127 | return stats 128 | 129 | def print_stats(self): 130 | lines_to_print = [] 131 | print_all = _get_value('DQC_PRINT_ALL_QUERIES') 132 | if print_all: 133 | lines_to_print.extend(self.generate_all_queries_lines()) 134 | stats = self.collect_stats() 135 | table = self.get_table(stats) 136 | if not print_all: 137 | lines_to_print.extend(self.generate_detailed_lines()) 138 | lines_to_print.append( 139 | colorize(table, get_color_by(sum(self.duplicates.values()))), 140 | ) 141 | 142 | _print(lines_to_print) 143 | 144 | def get_table(self, stats): 145 | return tabulate( 146 | [[value for _, value in stats]], 147 | headers=[h.capitalize() for h, _ in stats], 148 | tablefmt=_get_value('DQC_TABULATE_FMT'), 149 | ) 150 | 151 | def generate_all_queries_lines(self) -> List[str]: 152 | lines = [] 153 | for query, count in Counter( 154 | [q['sql'] for q in self.queries], 155 | ).most_common(): 156 | lines.append( 157 | f'{colorize(str(count), color="yellow")}: {highlight(query)}', 158 | ) 159 | return lines 160 | 161 | def generate_detailed_lines(self) -> List[str]: 162 | lines = [] 163 | if self.duplicates: 164 | lines.append(colorize('Duplicate queries:')) 165 | for index, (query, count) in enumerate(self.duplicates.items()): 166 | if index >= _get_value('DQC_DUPLICATED_COUNT'): 167 | break 168 | lines.append(f'{colorize(count, "yellow")}: {highlight(query)}') 169 | if self.slowest: 170 | lines.append(colorize('Slowest queries:')) 171 | for query, duration in self.slowest.items(): 172 | duration = f'{duration:.2f}' 173 | lines.append(f'{colorize(duration, "red")}: {highlight(query)}') 174 | return lines 175 | 176 | 177 | def queries_counter(func): 178 | def inner_func(*args, **kwargs): 179 | func_info = ['Target:'] 180 | query_logger = QueryLogger() 181 | with connection.execute_wrapper(query_logger): 182 | result = func(*args, **kwargs) 183 | query_logger.count() 184 | try: 185 | if len(args) == 1: 186 | cmd, = args 187 | func_info.append(cmd.__module__) 188 | elif len(args) > 1: 189 | _, request, *_ = args 190 | if request.path: 191 | func_info.append(request.path) 192 | if request.resolver_match: 193 | func_info.append(request.resolver_match._func_path) 194 | except ValueError: 195 | func_info.append(func.__qualname__) 196 | query_logger.print_stats() 197 | _print(func_info, sep=' ') 198 | return result 199 | return inner_func 200 | -------------------------------------------------------------------------------- /tests/apps/fixtures/data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "apps.parent", 4 | "pk": 1, 5 | "fields": { 6 | "name": "David" 7 | } 8 | }, 9 | { 10 | "model": "apps.parent", 11 | "pk": 2, 12 | "fields": { 13 | "name": "Mark" 14 | } 15 | }, 16 | { 17 | "model": "apps.parent", 18 | "pk": 3, 19 | "fields": { 20 | "name": "Kevin" 21 | } 22 | }, 23 | { 24 | "model": "apps.child", 25 | "pk": 1, 26 | "fields": { 27 | "parent": 1, 28 | "name": "Michael" 29 | } 30 | }, 31 | { 32 | "model": "apps.child", 33 | "pk": 2, 34 | "fields": { 35 | "parent": 1, 36 | "name": "Paul" 37 | } 38 | }, 39 | { 40 | "model": "apps.child", 41 | "pk": 3, 42 | "fields": { 43 | "parent": 1, 44 | "name": "Mitchell" 45 | } 46 | }, 47 | { 48 | "model": "apps.child", 49 | "pk": 4, 50 | "fields": { 51 | "parent": 2, 52 | "name": "Gary" 53 | } 54 | }, 55 | { 56 | "model": "apps.child", 57 | "pk": 5, 58 | "fields": { 59 | "parent": 2, 60 | "name": "Mark" 61 | } 62 | }, 63 | { 64 | "model": "apps.child", 65 | "pk": 6, 66 | "fields": { 67 | "parent": 2, 68 | "name": "Robert" 69 | } 70 | }, 71 | { 72 | "model": "apps.child", 73 | "pk": 7, 74 | "fields": { 75 | "parent": 3, 76 | "name": "Carl" 77 | } 78 | }, 79 | { 80 | "model": "apps.child", 81 | "pk": 8, 82 | "fields": { 83 | "parent": 3, 84 | "name": "Franklin" 85 | } 86 | }, 87 | { 88 | "model": "apps.child", 89 | "pk": 9, 90 | "fields": { 91 | "parent": 3, 92 | "name": "Steven" 93 | } 94 | }, 95 | { 96 | "model": "apps.grandson", 97 | "pk": 1, 98 | "fields": { 99 | "child": 1, 100 | "name": "Eric" 101 | } 102 | }, 103 | { 104 | "model": "apps.grandson", 105 | "pk": 2, 106 | "fields": { 107 | "child": 1, 108 | "name": "Paul" 109 | } 110 | }, 111 | { 112 | "model": "apps.grandson", 113 | "pk": 3, 114 | "fields": { 115 | "child": 1, 116 | "name": "Vincent" 117 | } 118 | }, 119 | { 120 | "model": "apps.grandson", 121 | "pk": 4, 122 | "fields": { 123 | "child": 1, 124 | "name": "Christopher" 125 | } 126 | }, 127 | { 128 | "model": "apps.grandson", 129 | "pk": 5, 130 | "fields": { 131 | "child": 1, 132 | "name": "Matthew" 133 | } 134 | }, 135 | { 136 | "model": "apps.grandson", 137 | "pk": 6, 138 | "fields": { 139 | "child": 2, 140 | "name": "Nicholas" 141 | } 142 | }, 143 | { 144 | "model": "apps.grandson", 145 | "pk": 7, 146 | "fields": { 147 | "child": 2, 148 | "name": "Ryan" 149 | } 150 | }, 151 | { 152 | "model": "apps.grandson", 153 | "pk": 8, 154 | "fields": { 155 | "child": 2, 156 | "name": "Seth" 157 | } 158 | }, 159 | { 160 | "model": "apps.grandson", 161 | "pk": 9, 162 | "fields": { 163 | "child": 2, 164 | "name": "Andrew" 165 | } 166 | }, 167 | { 168 | "model": "apps.grandson", 169 | "pk": 10, 170 | "fields": { 171 | "child": 2, 172 | "name": "Noah" 173 | } 174 | }, 175 | { 176 | "model": "apps.grandson", 177 | "pk": 11, 178 | "fields": { 179 | "child": 3, 180 | "name": "Wesley" 181 | } 182 | }, 183 | { 184 | "model": "apps.grandson", 185 | "pk": 12, 186 | "fields": { 187 | "child": 3, 188 | "name": "Riley" 189 | } 190 | }, 191 | { 192 | "model": "apps.grandson", 193 | "pk": 13, 194 | "fields": { 195 | "child": 3, 196 | "name": "Derek" 197 | } 198 | }, 199 | { 200 | "model": "apps.grandson", 201 | "pk": 14, 202 | "fields": { 203 | "child": 3, 204 | "name": "Jacob" 205 | } 206 | }, 207 | { 208 | "model": "apps.grandson", 209 | "pk": 15, 210 | "fields": { 211 | "child": 3, 212 | "name": "William" 213 | } 214 | }, 215 | { 216 | "model": "apps.grandson", 217 | "pk": 16, 218 | "fields": { 219 | "child": 4, 220 | "name": "Douglas" 221 | } 222 | }, 223 | { 224 | "model": "apps.grandson", 225 | "pk": 17, 226 | "fields": { 227 | "child": 4, 228 | "name": "Mitchell" 229 | } 230 | }, 231 | { 232 | "model": "apps.grandson", 233 | "pk": 18, 234 | "fields": { 235 | "child": 4, 236 | "name": "Daniel" 237 | } 238 | }, 239 | { 240 | "model": "apps.grandson", 241 | "pk": 19, 242 | "fields": { 243 | "child": 4, 244 | "name": "Shawn" 245 | } 246 | }, 247 | { 248 | "model": "apps.grandson", 249 | "pk": 20, 250 | "fields": { 251 | "child": 4, 252 | "name": "Michael" 253 | } 254 | }, 255 | { 256 | "model": "apps.grandson", 257 | "pk": 21, 258 | "fields": { 259 | "child": 5, 260 | "name": "Eric" 261 | } 262 | }, 263 | { 264 | "model": "apps.grandson", 265 | "pk": 22, 266 | "fields": { 267 | "child": 5, 268 | "name": "Manuel" 269 | } 270 | }, 271 | { 272 | "model": "apps.grandson", 273 | "pk": 23, 274 | "fields": { 275 | "child": 5, 276 | "name": "Jonathan" 277 | } 278 | }, 279 | { 280 | "model": "apps.grandson", 281 | "pk": 24, 282 | "fields": { 283 | "child": 5, 284 | "name": "Ronald" 285 | } 286 | }, 287 | { 288 | "model": "apps.grandson", 289 | "pk": 25, 290 | "fields": { 291 | "child": 5, 292 | "name": "Harry" 293 | } 294 | }, 295 | { 296 | "model": "apps.grandson", 297 | "pk": 26, 298 | "fields": { 299 | "child": 6, 300 | "name": "Shawn" 301 | } 302 | }, 303 | { 304 | "model": "apps.grandson", 305 | "pk": 27, 306 | "fields": { 307 | "child": 6, 308 | "name": "Malik" 309 | } 310 | }, 311 | { 312 | "model": "apps.grandson", 313 | "pk": 28, 314 | "fields": { 315 | "child": 6, 316 | "name": "Gerald" 317 | } 318 | }, 319 | { 320 | "model": "apps.grandson", 321 | "pk": 29, 322 | "fields": { 323 | "child": 6, 324 | "name": "Lee" 325 | } 326 | }, 327 | { 328 | "model": "apps.grandson", 329 | "pk": 30, 330 | "fields": { 331 | "child": 6, 332 | "name": "Brian" 333 | } 334 | }, 335 | { 336 | "model": "apps.grandson", 337 | "pk": 31, 338 | "fields": { 339 | "child": 7, 340 | "name": "Daniel" 341 | } 342 | }, 343 | { 344 | "model": "apps.grandson", 345 | "pk": 32, 346 | "fields": { 347 | "child": 7, 348 | "name": "Dustin" 349 | } 350 | }, 351 | { 352 | "model": "apps.grandson", 353 | "pk": 33, 354 | "fields": { 355 | "child": 7, 356 | "name": "Michael" 357 | } 358 | }, 359 | { 360 | "model": "apps.grandson", 361 | "pk": 34, 362 | "fields": { 363 | "child": 7, 364 | "name": "Jeffery" 365 | } 366 | }, 367 | { 368 | "model": "apps.grandson", 369 | "pk": 35, 370 | "fields": { 371 | "child": 7, 372 | "name": "Alejandro" 373 | } 374 | }, 375 | { 376 | "model": "apps.grandson", 377 | "pk": 36, 378 | "fields": { 379 | "child": 8, 380 | "name": "Christopher" 381 | } 382 | }, 383 | { 384 | "model": "apps.grandson", 385 | "pk": 37, 386 | "fields": { 387 | "child": 8, 388 | "name": "Nathan" 389 | } 390 | }, 391 | { 392 | "model": "apps.grandson", 393 | "pk": 38, 394 | "fields": { 395 | "child": 8, 396 | "name": "Douglas" 397 | } 398 | }, 399 | { 400 | "model": "apps.grandson", 401 | "pk": 39, 402 | "fields": { 403 | "child": 8, 404 | "name": "Daniel" 405 | } 406 | }, 407 | { 408 | "model": "apps.grandson", 409 | "pk": 40, 410 | "fields": { 411 | "child": 8, 412 | "name": "Angel" 413 | } 414 | }, 415 | { 416 | "model": "apps.grandson", 417 | "pk": 41, 418 | "fields": { 419 | "child": 9, 420 | "name": "Andrew" 421 | } 422 | }, 423 | { 424 | "model": "apps.grandson", 425 | "pk": 42, 426 | "fields": { 427 | "child": 9, 428 | "name": "Jorge" 429 | } 430 | }, 431 | { 432 | "model": "apps.grandson", 433 | "pk": 43, 434 | "fields": { 435 | "child": 9, 436 | "name": "Taylor" 437 | } 438 | }, 439 | { 440 | "model": "apps.grandson", 441 | "pk": 44, 442 | "fields": { 443 | "child": 9, 444 | "name": "Daniel" 445 | } 446 | }, 447 | { 448 | "model": "apps.grandson", 449 | "pk": 45, 450 | "fields": { 451 | "child": 9, 452 | "name": "Henry" 453 | } 454 | } 455 | ] 456 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 1 3 | requires-python = ">=3.9" 4 | resolution-markers = [ 5 | "python_full_version >= '3.10'", 6 | "python_full_version < '3.10'", 7 | ] 8 | 9 | [[package]] 10 | name = "asgiref" 11 | version = "3.8.1" 12 | source = { registry = "https://pypi.org/simple" } 13 | dependencies = [ 14 | { name = "typing-extensions", marker = "python_full_version < '3.11'" }, 15 | ] 16 | sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186 } 17 | wheels = [ 18 | { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828 }, 19 | ] 20 | 21 | [[package]] 22 | name = "cachetools" 23 | version = "5.5.2" 24 | source = { registry = "https://pypi.org/simple" } 25 | sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380 } 26 | wheels = [ 27 | { url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080 }, 28 | ] 29 | 30 | [[package]] 31 | name = "chardet" 32 | version = "5.2.0" 33 | source = { registry = "https://pypi.org/simple" } 34 | sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618 } 35 | wheels = [ 36 | { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385 }, 37 | ] 38 | 39 | [[package]] 40 | name = "colorama" 41 | version = "0.4.6" 42 | source = { registry = "https://pypi.org/simple" } 43 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 44 | wheels = [ 45 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, 46 | ] 47 | 48 | [[package]] 49 | name = "distlib" 50 | version = "0.3.9" 51 | source = { registry = "https://pypi.org/simple" } 52 | sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 } 53 | wheels = [ 54 | { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, 55 | ] 56 | 57 | [[package]] 58 | name = "django" 59 | version = "4.2.20" 60 | source = { registry = "https://pypi.org/simple" } 61 | resolution-markers = [ 62 | "python_full_version < '3.10'", 63 | ] 64 | dependencies = [ 65 | { name = "asgiref", marker = "python_full_version < '3.10'" }, 66 | { name = "sqlparse", marker = "python_full_version < '3.10'" }, 67 | { name = "tzdata", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, 68 | ] 69 | sdist = { url = "https://files.pythonhosted.org/packages/0a/dd/33d2a11713f6b78493273a32d99bb449f2d93663ed4ec15a8b890d44ba04/Django-4.2.20.tar.gz", hash = "sha256:92bac5b4432a64532abb73b2ac27203f485e40225d2640a7fbef2b62b876e789", size = 10432686 } 70 | wheels = [ 71 | { url = "https://files.pythonhosted.org/packages/b3/5d/7571ba1c288ead056dda7adad46b25cbf64790576f095565282e996138b1/Django-4.2.20-py3-none-any.whl", hash = "sha256:213381b6e4405f5c8703fffc29cd719efdf189dec60c67c04f76272b3dc845b9", size = 7993584 }, 72 | ] 73 | 74 | [[package]] 75 | name = "django" 76 | version = "5.1.7" 77 | source = { registry = "https://pypi.org/simple" } 78 | resolution-markers = [ 79 | "python_full_version >= '3.10'", 80 | ] 81 | dependencies = [ 82 | { name = "asgiref", marker = "python_full_version >= '3.10'" }, 83 | { name = "sqlparse", marker = "python_full_version >= '3.10'" }, 84 | { name = "tzdata", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, 85 | ] 86 | sdist = { url = "https://files.pythonhosted.org/packages/5f/57/11186e493ddc5a5e92cc7924a6363f7d4c2b645f7d7cb04a26a63f9bfb8b/Django-5.1.7.tar.gz", hash = "sha256:30de4ee43a98e5d3da36a9002f287ff400b43ca51791920bfb35f6917bfe041c", size = 10716510 } 87 | wheels = [ 88 | { url = "https://files.pythonhosted.org/packages/ba/0f/7e042df3d462d39ae01b27a09ee76653692442bc3701fbfa6cb38e12889d/Django-5.1.7-py3-none-any.whl", hash = "sha256:1323617cb624add820cb9611cdcc788312d250824f92ca6048fda8625514af2b", size = 8276912 }, 89 | ] 90 | 91 | [[package]] 92 | name = "django-query-counter" 93 | version = "0.4.1" 94 | source = { editable = "." } 95 | dependencies = [ 96 | { name = "tabulate" }, 97 | ] 98 | 99 | [package.dev-dependencies] 100 | dev = [ 101 | { name = "django", version = "4.2.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, 102 | { name = "django", version = "5.1.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, 103 | { name = "pytest" }, 104 | { name = "pytest-django" }, 105 | { name = "ruff" }, 106 | { name = "tox" }, 107 | ] 108 | 109 | [package.metadata] 110 | requires-dist = [{ name = "tabulate" }] 111 | 112 | [package.metadata.requires-dev] 113 | dev = [ 114 | { name = "django" }, 115 | { name = "pytest" }, 116 | { name = "pytest-django" }, 117 | { name = "ruff" }, 118 | { name = "tox" }, 119 | ] 120 | 121 | [[package]] 122 | name = "exceptiongroup" 123 | version = "1.2.2" 124 | source = { registry = "https://pypi.org/simple" } 125 | sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } 126 | wheels = [ 127 | { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, 128 | ] 129 | 130 | [[package]] 131 | name = "filelock" 132 | version = "3.18.0" 133 | source = { registry = "https://pypi.org/simple" } 134 | sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075 } 135 | wheels = [ 136 | { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215 }, 137 | ] 138 | 139 | [[package]] 140 | name = "iniconfig" 141 | version = "2.0.0" 142 | source = { registry = "https://pypi.org/simple" } 143 | sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } 144 | wheels = [ 145 | { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, 146 | ] 147 | 148 | [[package]] 149 | name = "packaging" 150 | version = "24.2" 151 | source = { registry = "https://pypi.org/simple" } 152 | sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } 153 | wheels = [ 154 | { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, 155 | ] 156 | 157 | [[package]] 158 | name = "platformdirs" 159 | version = "4.3.6" 160 | source = { registry = "https://pypi.org/simple" } 161 | sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } 162 | wheels = [ 163 | { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, 164 | ] 165 | 166 | [[package]] 167 | name = "pluggy" 168 | version = "1.5.0" 169 | source = { registry = "https://pypi.org/simple" } 170 | sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } 171 | wheels = [ 172 | { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, 173 | ] 174 | 175 | [[package]] 176 | name = "pyproject-api" 177 | version = "1.9.0" 178 | source = { registry = "https://pypi.org/simple" } 179 | dependencies = [ 180 | { name = "packaging" }, 181 | { name = "tomli", marker = "python_full_version < '3.11'" }, 182 | ] 183 | sdist = { url = "https://files.pythonhosted.org/packages/7e/66/fdc17e94486836eda4ba7113c0db9ac7e2f4eea1b968ee09de2fe75e391b/pyproject_api-1.9.0.tar.gz", hash = "sha256:7e8a9854b2dfb49454fae421cb86af43efbb2b2454e5646ffb7623540321ae6e", size = 22714 } 184 | wheels = [ 185 | { url = "https://files.pythonhosted.org/packages/b0/1d/92b7c765df46f454889d9610292b0ccab15362be3119b9a624458455e8d5/pyproject_api-1.9.0-py3-none-any.whl", hash = "sha256:326df9d68dea22d9d98b5243c46e3ca3161b07a1b9b18e213d1e24fd0e605766", size = 13131 }, 186 | ] 187 | 188 | [[package]] 189 | name = "pytest" 190 | version = "8.3.5" 191 | source = { registry = "https://pypi.org/simple" } 192 | dependencies = [ 193 | { name = "colorama", marker = "sys_platform == 'win32'" }, 194 | { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, 195 | { name = "iniconfig" }, 196 | { name = "packaging" }, 197 | { name = "pluggy" }, 198 | { name = "tomli", marker = "python_full_version < '3.11'" }, 199 | ] 200 | sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } 201 | wheels = [ 202 | { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, 203 | ] 204 | 205 | [[package]] 206 | name = "pytest-django" 207 | version = "4.10.0" 208 | source = { registry = "https://pypi.org/simple" } 209 | dependencies = [ 210 | { name = "pytest" }, 211 | ] 212 | sdist = { url = "https://files.pythonhosted.org/packages/a5/10/a096573b4b896f18a8390d9dafaffc054c1f613c60bf838300732e538890/pytest_django-4.10.0.tar.gz", hash = "sha256:1091b20ea1491fd04a310fc9aaff4c01b4e8450e3b157687625e16a6b5f3a366", size = 84710 } 213 | wheels = [ 214 | { url = "https://files.pythonhosted.org/packages/58/4c/a4fe18205926216e1aebe1f125cba5bce444f91b6e4de4f49fa87e322775/pytest_django-4.10.0-py3-none-any.whl", hash = "sha256:57c74ef3aa9d89cae5a5d73fbb69a720a62673ade7ff13b9491872409a3f5918", size = 23975 }, 215 | ] 216 | 217 | [[package]] 218 | name = "ruff" 219 | version = "0.11.0" 220 | source = { registry = "https://pypi.org/simple" } 221 | sdist = { url = "https://files.pythonhosted.org/packages/77/2b/7ca27e854d92df5e681e6527dc0f9254c9dc06c8408317893cf96c851cdd/ruff-0.11.0.tar.gz", hash = "sha256:e55c620690a4a7ee6f1cccb256ec2157dc597d109400ae75bbf944fc9d6462e2", size = 3799407 } 222 | wheels = [ 223 | { url = "https://files.pythonhosted.org/packages/48/40/3d0340a9e5edc77d37852c0cd98c5985a5a8081fc3befaeb2ae90aaafd2b/ruff-0.11.0-py3-none-linux_armv6l.whl", hash = "sha256:dc67e32bc3b29557513eb7eeabb23efdb25753684b913bebb8a0c62495095acb", size = 10098158 }, 224 | { url = "https://files.pythonhosted.org/packages/ec/a9/d8f5abb3b87b973b007649ac7bf63665a05b2ae2b2af39217b09f52abbbf/ruff-0.11.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:38c23fd9bdec4eb437b4c1e3595905a0a8edfccd63a790f818b28c78fe345639", size = 10879071 }, 225 | { url = "https://files.pythonhosted.org/packages/ab/62/aaa198614c6211677913ec480415c5e6509586d7b796356cec73a2f8a3e6/ruff-0.11.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7c8661b0be91a38bd56db593e9331beaf9064a79028adee2d5f392674bbc5e88", size = 10247944 }, 226 | { url = "https://files.pythonhosted.org/packages/9f/52/59e0a9f2cf1ce5e6cbe336b6dd0144725c8ea3b97cac60688f4e7880bf13/ruff-0.11.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b6c0e8d3d2db7e9f6efd884f44b8dc542d5b6b590fc4bb334fdbc624d93a29a2", size = 10421725 }, 227 | { url = "https://files.pythonhosted.org/packages/a6/c3/dcd71acc6dff72ce66d13f4be5bca1dbed4db678dff2f0f6f307b04e5c02/ruff-0.11.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c3156d3f4b42e57247275a0a7e15a851c165a4fc89c5e8fa30ea6da4f7407b8", size = 9954435 }, 228 | { url = "https://files.pythonhosted.org/packages/a6/9a/342d336c7c52dbd136dee97d4c7797e66c3f92df804f8f3b30da59b92e9c/ruff-0.11.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:490b1e147c1260545f6d041c4092483e3f6d8eba81dc2875eaebcf9140b53905", size = 11492664 }, 229 | { url = "https://files.pythonhosted.org/packages/84/35/6e7defd2d7ca95cc385ac1bd9f7f2e4a61b9cc35d60a263aebc8e590c462/ruff-0.11.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1bc09a7419e09662983b1312f6fa5dab829d6ab5d11f18c3760be7ca521c9329", size = 12207856 }, 230 | { url = "https://files.pythonhosted.org/packages/22/78/da669c8731bacf40001c880ada6d31bcfb81f89cc996230c3b80d319993e/ruff-0.11.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcfa478daf61ac8002214eb2ca5f3e9365048506a9d52b11bea3ecea822bb844", size = 11645156 }, 231 | { url = "https://files.pythonhosted.org/packages/ee/47/e27d17d83530a208f4a9ab2e94f758574a04c51e492aa58f91a3ed7cbbcb/ruff-0.11.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6fbb2aed66fe742a6a3a0075ed467a459b7cedc5ae01008340075909d819df1e", size = 13884167 }, 232 | { url = "https://files.pythonhosted.org/packages/9f/5e/42ffbb0a5d4b07bbc642b7d58357b4e19a0f4774275ca6ca7d1f7b5452cd/ruff-0.11.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:92c0c1ff014351c0b0cdfdb1e35fa83b780f1e065667167bb9502d47ca41e6db", size = 11348311 }, 233 | { url = "https://files.pythonhosted.org/packages/c8/51/dc3ce0c5ce1a586727a3444a32f98b83ba99599bb1ebca29d9302886e87f/ruff-0.11.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e4fd5ff5de5f83e0458a138e8a869c7c5e907541aec32b707f57cf9a5e124445", size = 10305039 }, 234 | { url = "https://files.pythonhosted.org/packages/60/e0/475f0c2f26280f46f2d6d1df1ba96b3399e0234cf368cc4c88e6ad10dcd9/ruff-0.11.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:96bc89a5c5fd21a04939773f9e0e276308be0935de06845110f43fd5c2e4ead7", size = 9937939 }, 235 | { url = "https://files.pythonhosted.org/packages/e2/d3/3e61b7fd3e9cdd1e5b8c7ac188bec12975c824e51c5cd3d64caf81b0331e/ruff-0.11.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a9352b9d767889ec5df1483f94870564e8102d4d7e99da52ebf564b882cdc2c7", size = 10923259 }, 236 | { url = "https://files.pythonhosted.org/packages/30/32/cd74149ebb40b62ddd14bd2d1842149aeb7f74191fb0f49bd45c76909ff2/ruff-0.11.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:049a191969a10897fe052ef9cc7491b3ef6de79acd7790af7d7897b7a9bfbcb6", size = 11406212 }, 237 | { url = "https://files.pythonhosted.org/packages/00/ef/033022a6b104be32e899b00de704d7c6d1723a54d4c9e09d147368f14b62/ruff-0.11.0-py3-none-win32.whl", hash = "sha256:3191e9116b6b5bbe187447656f0c8526f0d36b6fd89ad78ccaad6bdc2fad7df2", size = 10310905 }, 238 | { url = "https://files.pythonhosted.org/packages/ed/8a/163f2e78c37757d035bd56cd60c8d96312904ca4a6deeab8442d7b3cbf89/ruff-0.11.0-py3-none-win_amd64.whl", hash = "sha256:c58bfa00e740ca0a6c43d41fb004cd22d165302f360aaa56f7126d544db31a21", size = 11411730 }, 239 | { url = "https://files.pythonhosted.org/packages/4e/f7/096f6efabe69b49d7ca61052fc70289c05d8d35735c137ef5ba5ef423662/ruff-0.11.0-py3-none-win_arm64.whl", hash = "sha256:868364fc23f5aa122b00c6f794211e85f7e78f5dffdf7c590ab90b8c4e69b657", size = 10538956 }, 240 | ] 241 | 242 | [[package]] 243 | name = "sqlparse" 244 | version = "0.5.3" 245 | source = { registry = "https://pypi.org/simple" } 246 | sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999 } 247 | wheels = [ 248 | { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415 }, 249 | ] 250 | 251 | [[package]] 252 | name = "tabulate" 253 | version = "0.9.0" 254 | source = { registry = "https://pypi.org/simple" } 255 | sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090 } 256 | wheels = [ 257 | { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252 }, 258 | ] 259 | 260 | [[package]] 261 | name = "tomli" 262 | version = "2.2.1" 263 | source = { registry = "https://pypi.org/simple" } 264 | sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } 265 | wheels = [ 266 | { 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 }, 267 | { 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 }, 268 | { 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 }, 269 | { 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 }, 270 | { 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 }, 271 | { 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 }, 272 | { 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 }, 273 | { 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 }, 274 | { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, 275 | { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, 276 | { 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 }, 277 | { 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 }, 278 | { 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 }, 279 | { 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 }, 280 | { 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 }, 281 | { 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 }, 282 | { 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 }, 283 | { 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 }, 284 | { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, 285 | { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, 286 | { 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 }, 287 | { 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 }, 288 | { 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 }, 289 | { 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 }, 290 | { 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 }, 291 | { 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 }, 292 | { 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 }, 293 | { 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 }, 294 | { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, 295 | { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, 296 | { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, 297 | ] 298 | 299 | [[package]] 300 | name = "tox" 301 | version = "4.24.2" 302 | source = { registry = "https://pypi.org/simple" } 303 | dependencies = [ 304 | { name = "cachetools" }, 305 | { name = "chardet" }, 306 | { name = "colorama" }, 307 | { name = "filelock" }, 308 | { name = "packaging" }, 309 | { name = "platformdirs" }, 310 | { name = "pluggy" }, 311 | { name = "pyproject-api" }, 312 | { name = "tomli", marker = "python_full_version < '3.11'" }, 313 | { name = "typing-extensions", marker = "python_full_version < '3.11'" }, 314 | { name = "virtualenv" }, 315 | ] 316 | sdist = { url = "https://files.pythonhosted.org/packages/51/93/30e4d662748d8451acde46feca03886b85bd74a453691d56abc44ef4bd37/tox-4.24.2.tar.gz", hash = "sha256:d5948b350f76fae436d6545a5e87c2b676ab7a0d7d88c1308651245eadbe8aea", size = 195354 } 317 | wheels = [ 318 | { url = "https://files.pythonhosted.org/packages/7b/eb/f7e6e77a664a96163cc1e7f9829f2e01b5b99aeb1edf0cdf1cd95859f310/tox-4.24.2-py3-none-any.whl", hash = "sha256:92e8290e76ad4e15748860a205865696409a2d014eedeb796a34a0f3b5e7336e", size = 172155 }, 319 | ] 320 | 321 | [[package]] 322 | name = "typing-extensions" 323 | version = "4.12.2" 324 | source = { registry = "https://pypi.org/simple" } 325 | sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } 326 | wheels = [ 327 | { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, 328 | ] 329 | 330 | [[package]] 331 | name = "tzdata" 332 | version = "2025.1" 333 | source = { registry = "https://pypi.org/simple" } 334 | sdist = { url = "https://files.pythonhosted.org/packages/43/0f/fa4723f22942480be4ca9527bbde8d43f6c3f2fe8412f00e7f5f6746bc8b/tzdata-2025.1.tar.gz", hash = "sha256:24894909e88cdb28bd1636c6887801df64cb485bd593f2fd83ef29075a81d694", size = 194950 } 335 | wheels = [ 336 | { url = "https://files.pythonhosted.org/packages/0f/dd/84f10e23edd882c6f968c21c2434fe67bd4a528967067515feca9e611e5e/tzdata-2025.1-py2.py3-none-any.whl", hash = "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639", size = 346762 }, 337 | ] 338 | 339 | [[package]] 340 | name = "virtualenv" 341 | version = "20.29.3" 342 | source = { registry = "https://pypi.org/simple" } 343 | dependencies = [ 344 | { name = "distlib" }, 345 | { name = "filelock" }, 346 | { name = "platformdirs" }, 347 | ] 348 | sdist = { url = "https://files.pythonhosted.org/packages/c7/9c/57d19fa093bcf5ac61a48087dd44d00655f85421d1aa9722f8befbf3f40a/virtualenv-20.29.3.tar.gz", hash = "sha256:95e39403fcf3940ac45bc717597dba16110b74506131845d9b687d5e73d947ac", size = 4320280 } 349 | wheels = [ 350 | { url = "https://files.pythonhosted.org/packages/c2/eb/c6db6e3001d58c6a9e67c74bb7b4206767caa3ccc28c6b9eaf4c23fb4e34/virtualenv-20.29.3-py3-none-any.whl", hash = "sha256:3e3d00f5807e83b234dfb6122bf37cfadf4be216c53a49ac059d02414f819170", size = 4301458 }, 351 | ] 352 | --------------------------------------------------------------------------------