├── 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}')
10 | for child in parent.children.all():
11 | lines.append(f'- {child}
')
12 | for grandson in child.grandchildren.all():
13 | lines.append(f'- {grandson}
')
14 | lines.append('
')
15 | lines.append('
')
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 | [](https://github.com/conformist-mw/django-query-counter/actions/workflows/check.yml)
4 | [](https://badge.fury.io/py/django-query-counter)
5 | 
6 | 
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 | 
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 | 
146 | 
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 |
--------------------------------------------------------------------------------