django-devbar 0.5.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-devbar
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Lightweight performance devbar for Django
5
5
  Keywords: django,debug,performance,monitoring,development
6
6
  Author: amureki
7
- Author-email: amureki <oss@amureki.com>
7
+ Author-email: amureki <oss@amureki.me>
8
8
  License-Expression: MIT
9
9
  Classifier: Programming Language :: Python
10
10
  Classifier: Programming Language :: Python :: 3
@@ -34,7 +34,8 @@ Description-Content-Type: text/markdown
34
34
  > [!WARNING]
35
35
  > This package is experimental and may introduce breaking changes in minor versions.
36
36
 
37
- Lightweight performance devbar for Django. Shows DB query count, query duration, application time, and detects duplicate queries with visual severity indicators.
37
+ Lightweight performance devbar for Django.
38
+ Shows DB query count, query duration, application time, and detects similar and duplicate queries with visual severity indicators.
38
39
 
39
40
  ## Showcase
40
41
 
@@ -42,13 +43,13 @@ Normal operation – showing query count, duration, and application time:
42
43
 
43
44
  ![devbar example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example.svg)
44
45
 
45
- Duplicate queries detected – severity indicator and details shown:
46
+ Similar and duplicate queries detected – severity indicators and details shown:
46
47
 
47
48
  ![devbar warning example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example-warning.svg)
48
49
 
49
50
  [optional] Chrome extension - showing detailed metrics in DevTools panel:
50
51
 
51
- [![Chrome DevTools extension](https://github.com/user-attachments/assets/9bbc41b7-3d93-46e7-aef4-9330a862b8f0)](#chrome-extension)
52
+ [![Chrome DevTools extension](https://github.com/user-attachments/assets/f53a4bb8-fba1-446c-8224-82eac47bd027)](#chrome-extension)
52
53
 
53
54
  ## Motivation
54
55
 
@@ -100,7 +101,7 @@ Django DevBar adds HTTP response headers with performance metrics:
100
101
 
101
102
  An additional header is included by default in DEBUG mode:
102
103
 
103
- - **DevBar-Data** - JSON header with comprehensive metrics including duplicate query details
104
+ - **DevBar-Data** - JSON header with comprehensive metrics including similar and duplicate query details
104
105
 
105
106
  This is useful for:
106
107
 
@@ -3,7 +3,8 @@
3
3
  > [!WARNING]
4
4
  > This package is experimental and may introduce breaking changes in minor versions.
5
5
 
6
- Lightweight performance devbar for Django. Shows DB query count, query duration, application time, and detects duplicate queries with visual severity indicators.
6
+ Lightweight performance devbar for Django.
7
+ Shows DB query count, query duration, application time, and detects similar and duplicate queries with visual severity indicators.
7
8
 
8
9
  ## Showcase
9
10
 
@@ -11,13 +12,13 @@ Normal operation – showing query count, duration, and application time:
11
12
 
12
13
  ![devbar example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example.svg)
13
14
 
14
- Duplicate queries detected – severity indicator and details shown:
15
+ Similar and duplicate queries detected – severity indicators and details shown:
15
16
 
16
17
  ![devbar warning example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example-warning.svg)
17
18
 
18
19
  [optional] Chrome extension - showing detailed metrics in DevTools panel:
19
20
 
20
- [![Chrome DevTools extension](https://github.com/user-attachments/assets/9bbc41b7-3d93-46e7-aef4-9330a862b8f0)](#chrome-extension)
21
+ [![Chrome DevTools extension](https://github.com/user-attachments/assets/f53a4bb8-fba1-446c-8224-82eac47bd027)](#chrome-extension)
21
22
 
22
23
  ## Motivation
23
24
 
@@ -69,7 +70,7 @@ Django DevBar adds HTTP response headers with performance metrics:
69
70
 
70
71
  An additional header is included by default in DEBUG mode:
71
72
 
72
- - **DevBar-Data** - JSON header with comprehensive metrics including duplicate query details
73
+ - **DevBar-Data** - JSON header with comprehensive metrics including similar and duplicate query details
73
74
 
74
75
  This is useful for:
75
76
 
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "django-devbar"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  description = "Lightweight performance devbar for Django"
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  authors = [
8
- { name = "amureki", email = "oss@amureki.com" },
8
+ { name = "amureki", email = "oss@amureki.me" },
9
9
  ]
10
10
  requires-python = ">=3.10"
11
11
  dependencies = ["django>=4.2"]
@@ -75,6 +75,7 @@ class DevBarMiddleware:
75
75
  "s": truncate_sql(q["sql"]),
76
76
  "dur": q["duration"],
77
77
  "dup": 1 if q["is_duplicate"] else 0,
78
+ "sim": 1 if q.get("is_similar") else 0,
78
79
  }
79
80
  for q in all_queries
80
81
  ]
@@ -113,6 +114,7 @@ class DevBarMiddleware:
113
114
  duplicates_html = self._build_duplicates_html(
114
115
  stats.get("duplicate_queries", [])
115
116
  )
117
+ similar_html = self._build_similar_html(stats.get("similar_queries", []))
116
118
 
117
119
  template = _template_engine.get_template("django_devbar/devbar.html")
118
120
  html = template.render(
@@ -123,6 +125,7 @@ class DevBarMiddleware:
123
125
  "app_time": stats["python_time"],
124
126
  "query_count": stats["count"],
125
127
  "duplicates_html": duplicates_html,
128
+ "similar_html": similar_html,
126
129
  }
127
130
  )
128
131
  )
@@ -133,11 +136,29 @@ class DevBarMiddleware:
133
136
  response.content = content[:idx] + payload + content[idx:]
134
137
  response["Content-Length"] = str(len(response.content))
135
138
 
139
+ def _deduplicate_queries(self, queries):
140
+ seen_sqls = set()
141
+ unique = []
142
+ for q in queries:
143
+ if q["sql"] not in seen_sqls:
144
+ seen_sqls.add(q["sql"])
145
+ unique.append({**q, "sql": format_sql(q["sql"])})
146
+ return unique
147
+
136
148
  def _build_duplicates_html(self, duplicates):
137
149
  if not duplicates:
138
150
  return ""
139
- formatted_duplicates = [
140
- {**dup, "sql": format_sql(dup["sql"])} for dup in duplicates
141
- ]
151
+ unique = self._deduplicate_queries(duplicates)
142
152
  template = _template_engine.get_template("django_devbar/duplicates.html")
143
- return template.render(Context({"duplicates": formatted_duplicates}))
153
+ return template.render(
154
+ Context({"duplicates": unique, "total_count": len(duplicates)})
155
+ )
156
+
157
+ def _build_similar_html(self, similar):
158
+ if not similar:
159
+ return ""
160
+ unique = self._deduplicate_queries(similar)
161
+ template = _template_engine.get_template("django_devbar/similar.html")
162
+ return template.render(
163
+ Context({"similar": unique, "total_count": len(similar)})
164
+ )
@@ -13,44 +13,40 @@
13
13
  background: rgba(20, 20, 20, 0.92);
14
14
  color: #f5f5f5;
15
15
  }
16
- #django-devbar.level-warn { border-left: 3px solid #f59e0b; }
17
- #django-devbar.has-duplicates { border-left: 3px solid #f59e0b; }
16
+
18
17
  #django-devbar span { opacity: 0.7; }
19
18
  #django-devbar strong { opacity: 1; font-weight: 600; }
20
19
  #django-devbar details { display: inline; position: relative; }
21
20
  #django-devbar summary {
22
21
  all: revert;
23
- color: #f59e0b; font-weight: 600; cursor: pointer; list-style: none;
22
+ font-weight: 600; cursor: pointer; list-style: none;
24
23
  }
24
+ #django-devbar .dup-details > summary { color: #f87171; }
25
+ #django-devbar .sim-details > summary { color: #fb923c; }
25
26
  #django-devbar summary::-webkit-details-marker { display: none; }
26
- #django-devbar details[open] > .dup-list {
27
+ #django-devbar details[open] > .query-list {
27
28
  position: absolute; bottom: 100%; right: -8px; margin-bottom: 8px;
28
29
  background: rgba(20, 20, 20, 0.92); border-radius: 4px; padding: 6px;
29
30
  width: min(500px, 90vw); max-height: 200px; overflow-y: auto; overflow-x: hidden;
30
31
  box-sizing: border-box;
31
32
  box-shadow: 0 2px 8px rgba(0,0,0,0.15), 0 1px 2px rgba(0,0,0,0.2);
32
33
  }
33
- #django-devbar .dup-list details {
34
+ #django-devbar .query-list details {
34
35
  display: block;
35
36
  margin: 0 0 6px 0;
36
37
  font-size: 11px;
37
38
  color: #f5f5f5;
38
39
  }
39
- #django-devbar .dup-list details:last-child { margin-bottom: 0; }
40
- #django-devbar .dup-list summary {
40
+ #django-devbar .query-list details:last-child { margin-bottom: 0; }
41
+ #django-devbar .query-list summary {
41
42
  all: revert;
42
43
  cursor: pointer;
43
44
  list-style: none;
44
45
  padding: 6px 8px;
45
- background: rgba(251, 191, 36, 0.1);
46
46
  border-radius: 3px;
47
- border-left: 2px solid #fbbf24;
48
47
  transition: background 0.15s ease;
49
48
  }
50
- #django-devbar .dup-list summary:hover {
51
- background: rgba(251, 191, 36, 0.2);
52
- }
53
- #django-devbar .dup-list summary code {
49
+ #django-devbar .query-list summary code {
54
50
  white-space: nowrap;
55
51
  overflow: hidden;
56
52
  text-overflow: ellipsis;
@@ -58,23 +54,22 @@
58
54
  max-width: 420px;
59
55
  vertical-align: bottom;
60
56
  }
61
- #django-devbar .dup-list summary::before {
57
+ #django-devbar .query-list summary::before {
62
58
  content: '▶ ';
63
59
  display: inline-block;
64
60
  transition: transform 0.15s ease;
65
- color: #fbbf24;
66
61
  font-size: 8px;
67
62
  margin-right: 6px;
68
63
  }
69
- #django-devbar .dup-list details[open] > summary::before {
64
+ #django-devbar .query-list details[open] > summary::before {
70
65
  transform: rotate(90deg);
71
66
  }
72
- #django-devbar .dup-list code {
67
+ #django-devbar .query-list code {
73
68
  all: revert;
74
- color: #fbbf24; font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
69
+ font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
75
70
  overflow-wrap: break-word;
76
71
  }
77
- #django-devbar .dup-list details[open] > code {
72
+ #django-devbar .query-list details[open] > code {
78
73
  display: block;
79
74
  padding: 8px;
80
75
  background: rgba(0, 0, 0, 0.3);
@@ -84,10 +79,16 @@
84
79
  line-height: 1.5;
85
80
  font-size: 10px;
86
81
  }
82
+ #django-devbar .dup-list summary { background: rgba(220, 38, 38, 0.1); border-left: 2px solid #dc2626; }
83
+ #django-devbar .dup-list summary:hover { background: rgba(220, 38, 38, 0.2); }
84
+ #django-devbar .dup-list summary::before, #django-devbar .dup-list code { color: #f87171; }
85
+ #django-devbar .sim-list summary { background: rgba(234, 88, 12, 0.1); border-left: 2px solid #ea580c; }
86
+ #django-devbar .sim-list summary:hover { background: rgba(234, 88, 12, 0.2); }
87
+ #django-devbar .sim-list summary::before, #django-devbar .sim-list code { color: #fb923c; }
87
88
  @media (max-width: 640px) { #django-devbar { display: none; } }
88
89
  </style>
89
- <div id="django-devbar"{% if duplicates_html %} class="has-duplicates"{% endif %}>
90
+ <div id="django-devbar" class="{% if duplicates_html %}has-duplicates{% endif %}{% if similar_html %} has-similar{% endif %}">
90
91
  <span>queries</span> <strong>{{ query_count }}</strong> <span>·</span>
91
92
  <span>db</span> <strong>{{ db_time|floatformat:0 }}ms</strong> <span>·</span>
92
- <span>app</span> <strong>{{ app_time|floatformat:0 }}ms</strong>{{ duplicates_html|safe }}
93
+ <span>app</span> <strong>{{ app_time|floatformat:0 }}ms</strong>{{ duplicates_html|safe }}{{ similar_html|safe }}
93
94
  </div>
@@ -0,0 +1 @@
1
+ {% if duplicates %}<details class="dup-details"><summary>({{ total_count }}d)</summary><div class="query-list dup-list">{% for dup in duplicates %}<details><summary><code>{{ dup.sql }}</code></summary><code>{{ dup.sql }}</code></details>{% endfor %}</div></details>{% endif %}
@@ -0,0 +1 @@
1
+ {% if similar %}<details class="sim-details"><summary>({{ total_count }}s)</summary><div class="query-list sim-list">{% for q in similar %}<details><summary><code>{{ q.sql }}</code></summary><code>{{ q.sql }}</code></details>{% endfor %}</div></details>{% endif %}
@@ -14,31 +14,57 @@ SQL_CLAUSES_RE = re.compile(
14
14
 
15
15
  _query_count: ContextVar[int] = ContextVar("query_count", default=0)
16
16
  _query_duration: ContextVar[float] = ContextVar("query_duration", default=0.0)
17
- _seen_queries: ContextVar[dict] = ContextVar("seen_queries", default={})
18
- _duplicate_log: ContextVar[list] = ContextVar("duplicate_log", default=[])
19
17
  _query_log: ContextVar[list] = ContextVar("query_log", default=[])
20
18
 
21
19
 
22
20
  def reset():
23
21
  _query_count.set(0)
24
22
  _query_duration.set(0.0)
25
- _seen_queries.set({})
26
- _duplicate_log.set([])
27
23
  _query_log.set([])
28
24
 
29
25
 
30
26
  def get_stats():
31
- duplicate_queries = _duplicate_log.get()
32
- duplicate_sqls = {d["sql"] for d in duplicate_queries}
33
-
34
27
  queries = _query_log.get()
28
+
29
+ # Count occurrences of each SQL template and each (SQL, params_hash) pair
30
+ sql_counts = {}
31
+ duplicate_keys = set()
32
+ for q in queries:
33
+ sql = q["sql"]
34
+ params_hash = q["params_hash"]
35
+ sql_counts[sql] = sql_counts.get(sql, 0) + 1
36
+ dup_key = (sql, params_hash)
37
+ if dup_key in duplicate_keys:
38
+ q["is_duplicate"] = True
39
+ else:
40
+ duplicate_keys.add(dup_key)
41
+
42
+ # Similar = SQL template appears 2+ times (matches DDT semantics)
43
+ similar_sqls = {sql for sql, count in sql_counts.items() if count > 1}
44
+
45
+ # Second pass: mark all queries in groups with 2+ members
46
+ dup_key_counts = {}
35
47
  for q in queries:
36
- q["is_duplicate"] = q["sql"] in duplicate_sqls
48
+ dup_key = (q["sql"], q["params_hash"])
49
+ dup_key_counts[dup_key] = dup_key_counts.get(dup_key, 0) + 1
50
+
51
+ duplicate_keys_final = {k for k, count in dup_key_counts.items() if count > 1}
52
+
53
+ similar_queries = []
54
+ duplicate_queries = []
55
+ for q in queries:
56
+ q["is_similar"] = q["sql"] in similar_sqls
57
+ q["is_duplicate"] = (q["sql"], q["params_hash"]) in duplicate_keys_final
58
+ if q["is_similar"]:
59
+ similar_queries.append(q)
60
+ if q["is_duplicate"]:
61
+ duplicate_queries.append(q)
37
62
 
38
63
  return {
39
64
  "count": _query_count.get(),
40
65
  "duration": _query_duration.get(),
41
66
  "duplicate_queries": duplicate_queries,
67
+ "similar_queries": similar_queries,
42
68
  "queries": queries,
43
69
  }
44
70
 
@@ -54,7 +80,6 @@ def _record(sql, params, duration):
54
80
  _query_count.set(_query_count.get() + 1)
55
81
  _query_duration.set(_query_duration.get() + duration)
56
82
 
57
- seen = _seen_queries.get()
58
83
  params_hash = _hash_params(params)
59
84
 
60
85
  query_log = _query_log.get()
@@ -62,19 +87,11 @@ def _record(sql, params, duration):
62
87
  {
63
88
  "sql": sql,
64
89
  "duration": round(duration, 2),
90
+ "params_hash": params_hash,
65
91
  }
66
92
  )
67
93
  _query_log.set(query_log)
68
94
 
69
- if sql in seen:
70
- if params_hash in seen[sql]:
71
- duplicates = _duplicate_log.get()
72
- duplicates.append({"sql": sql, "duration": round(duration, 2)})
73
- else:
74
- seen[sql].add(params_hash)
75
- else:
76
- seen[sql] = {params_hash}
77
-
78
95
 
79
96
  def format_sql(sql):
80
97
  """Highlight SQL keywords with HTML spans for visual formatting.
@@ -1 +0,0 @@
1
- {% if duplicates %}<details><summary>({{ duplicates|length }}d)</summary><div class="dup-list">{% for dup in duplicates %}<details><summary><code>{{ dup.sql }}</code></summary><code>{{ dup.sql }}</code></details>{% endfor %}</div></details>{% endif %}