django-devbar 0.4.0__tar.gz → 0.5.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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-devbar
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Lightweight performance devbar for Django
5
5
  Keywords: django,debug,performance,monitoring,development
6
6
  Author: amureki
@@ -31,16 +31,24 @@ Description-Content-Type: text/markdown
31
31
 
32
32
  # django-devbar
33
33
 
34
+ > [!WARNING]
35
+ > This package is experimental and may introduce breaking changes in minor versions.
36
+
34
37
  Lightweight performance devbar for Django. Shows DB query count, query duration, application time, and detects duplicate queries with visual severity indicators.
35
38
 
36
- Normal operation – showing query count, duration, and application time: ![devbar example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example.svg)
39
+ ## Showcase
37
40
 
38
- Duplicate queries detected – severity indicator and details shown: ![devbar warning example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example-warning.svg)
41
+ Normal operation – showing query count, duration, and application time:
39
42
 
40
- [![Chrome DevTools extension](https://github.com/user-attachments/assets/bc06af44-a0b3-4561-a13f-8007da53cb9e)](#chrome-extension)
43
+ ![devbar example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example.svg)
41
44
 
42
- > [!WARNING]
43
- > This package is experimental and may introduce breaking changes in minor versions.
45
+ Duplicate queries detected – severity indicator and details shown:
46
+
47
+ ![devbar warning example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example-warning.svg)
48
+
49
+ [optional] Chrome extension - showing detailed metrics in DevTools panel:
50
+
51
+ [![Chrome DevTools extension](https://github.com/user-attachments/assets/9bbc41b7-3d93-46e7-aef4-9330a862b8f0)](#chrome-extension)
44
52
 
45
53
  ## Motivation
46
54
 
@@ -1,15 +1,23 @@
1
1
  # django-devbar
2
2
 
3
+ > [!WARNING]
4
+ > This package is experimental and may introduce breaking changes in minor versions.
5
+
3
6
  Lightweight performance devbar for Django. Shows DB query count, query duration, application time, and detects duplicate queries with visual severity indicators.
4
7
 
5
- Normal operation – showing query count, duration, and application time: ![devbar example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example.svg)
8
+ ## Showcase
6
9
 
7
- Duplicate queries detected – severity indicator and details shown: ![devbar warning example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example-warning.svg)
10
+ Normal operation – showing query count, duration, and application time:
8
11
 
9
- [![Chrome DevTools extension](https://github.com/user-attachments/assets/bc06af44-a0b3-4561-a13f-8007da53cb9e)](#chrome-extension)
12
+ ![devbar example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example.svg)
10
13
 
11
- > [!WARNING]
12
- > This package is experimental and may introduce breaking changes in minor versions.
14
+ Duplicate queries detected – severity indicator and details shown:
15
+
16
+ ![devbar warning example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example-warning.svg)
17
+
18
+ [optional] Chrome extension - showing detailed metrics in DevTools panel:
19
+
20
+ [![Chrome DevTools extension](https://github.com/user-attachments/assets/9bbc41b7-3d93-46e7-aef4-9330a862b8f0)](#chrome-extension)
13
21
 
14
22
  ## Motivation
15
23
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "django-devbar"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "Lightweight performance devbar for Django"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -13,6 +13,7 @@ from .conf import (
13
13
  get_position,
14
14
  get_show_bar,
15
15
  )
16
+ from .tracker import format_sql, truncate_sql
16
17
 
17
18
  BODY_CLOSE_RE = re.compile(rb"</body\s*>", re.IGNORECASE)
18
19
 
@@ -41,10 +42,10 @@ class DevBarMiddleware:
41
42
  stats = tracker.get_stats()
42
43
 
43
44
  db_time = stats["duration"]
44
- python_time = max(0, total_time - db_time)
45
+ python_time = round(max(0, total_time - db_time), 2)
45
46
 
46
47
  stats["python_time"] = python_time
47
- stats["total_time"] = total_time
48
+ stats["total_time"] = round(total_time, 2)
48
49
 
49
50
  if get_enable_devtools_data():
50
51
  self._add_devtools_data_header(response, stats)
@@ -57,14 +58,31 @@ class DevBarMiddleware:
57
58
  return response
58
59
 
59
60
  def _add_devtools_data_header(self, response, stats):
61
+ # Abbreviated keys used to minimize DevBar-Data header size
60
62
  extension_data = {
61
- "count": stats["count"],
62
- "db_time": stats["duration"],
63
- "app_time": stats["python_time"],
64
- "total_time": stats["total_time"],
63
+ "c": stats["count"],
64
+ "db": round(stats["duration"], 2),
65
+ "app": stats["python_time"],
66
+ "full": stats["total_time"],
65
67
  }
66
- if stats.get("duplicate_queries"):
67
- extension_data["duplicates"] = stats["duplicate_queries"]
68
+
69
+ all_queries = stats.get("queries", [])
70
+ duplicates = stats.get("duplicate_queries", [])
71
+
72
+ if all_queries:
73
+ processed_queries = [
74
+ {
75
+ "s": truncate_sql(q["sql"]),
76
+ "dur": q["duration"],
77
+ "dup": 1 if q["is_duplicate"] else 0,
78
+ }
79
+ for q in all_queries
80
+ ]
81
+ extension_data["q"] = processed_queries
82
+
83
+ if duplicates:
84
+ marked_duplicates = [{**d, "dup": 1} for d in duplicates]
85
+ extension_data["dup"] = marked_duplicates
68
86
 
69
87
  response["DevBar-Data"] = json.dumps(extension_data)
70
88
 
@@ -118,5 +136,8 @@ class DevBarMiddleware:
118
136
  def _build_duplicates_html(self, duplicates):
119
137
  if not duplicates:
120
138
  return ""
139
+ formatted_duplicates = [
140
+ {**dup, "sql": format_sql(dup["sql"])} for dup in duplicates
141
+ ]
121
142
  template = _template_engine.get_template("django_devbar/duplicates.html")
122
- return template.render(Context({"duplicates": duplicates}))
143
+ return template.render(Context({"duplicates": formatted_duplicates}))
@@ -0,0 +1,287 @@
1
+ import re
2
+ from contextvars import ContextVar
3
+ from time import perf_counter
4
+
5
+ SQL_KEYWORDS_RE = re.compile(
6
+ r"\b(SELECT|FROM|WHERE|JOIN|LEFT JOIN|RIGHT JOIN|INNER JOIN|OUTER JOIN|ON|AND|OR|ORDER BY|GROUP BY|HAVING|LIMIT|OFFSET|INSERT INTO|VALUES|UPDATE|SET|DELETE|CREATE TABLE|ALTER TABLE|DROP TABLE|DISTINCT|COUNT|SUM|AVG|MAX|MIN|UNION|EXISTS|IN|IS NULL|IS NOT NULL|LIKE|BETWEEN|CASE|WHEN|THEN|ELSE|END|ASC|DESC|AS|WITH|RECURSIVE)\b",
7
+ re.IGNORECASE,
8
+ )
9
+
10
+ SQL_CLAUSES_RE = re.compile(
11
+ r"\b(FROM|WHERE|JOIN|LEFT JOIN|RIGHT JOIN|INNER JOIN|OUTER JOIN|GROUP BY|ORDER BY|HAVING|LIMIT|OFFSET|ON|AND|OR|UNION|WITH|CASE|WHEN|THEN|ELSE)\b",
12
+ re.IGNORECASE,
13
+ )
14
+
15
+ _query_count: ContextVar[int] = ContextVar("query_count", default=0)
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
+ _query_log: ContextVar[list] = ContextVar("query_log", default=[])
20
+
21
+
22
+ def reset():
23
+ _query_count.set(0)
24
+ _query_duration.set(0.0)
25
+ _seen_queries.set({})
26
+ _duplicate_log.set([])
27
+ _query_log.set([])
28
+
29
+
30
+ def get_stats():
31
+ duplicate_queries = _duplicate_log.get()
32
+ duplicate_sqls = {d["sql"] for d in duplicate_queries}
33
+
34
+ queries = _query_log.get()
35
+ for q in queries:
36
+ q["is_duplicate"] = q["sql"] in duplicate_sqls
37
+
38
+ return {
39
+ "count": _query_count.get(),
40
+ "duration": _query_duration.get(),
41
+ "duplicate_queries": duplicate_queries,
42
+ "queries": queries,
43
+ }
44
+
45
+
46
+ def _hash_params(params):
47
+ try:
48
+ return hash(tuple(params)) if params else 0
49
+ except TypeError:
50
+ return hash(str(params))
51
+
52
+
53
+ def _record(sql, params, duration):
54
+ _query_count.set(_query_count.get() + 1)
55
+ _query_duration.set(_query_duration.get() + duration)
56
+
57
+ seen = _seen_queries.get()
58
+ params_hash = _hash_params(params)
59
+
60
+ query_log = _query_log.get()
61
+ query_log.append(
62
+ {
63
+ "sql": sql,
64
+ "duration": round(duration, 2),
65
+ }
66
+ )
67
+ _query_log.set(query_log)
68
+
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
+
79
+ def format_sql(sql):
80
+ """Highlight SQL keywords with HTML spans for visual formatting.
81
+
82
+ Escapes HTML first for security, then wraps SQL keywords in span tags.
83
+ Used for rendering duplicates in the injected devbar HTML.
84
+
85
+ Args:
86
+ sql: SQL query string
87
+
88
+ Returns:
89
+ Safe HTML string with keywords wrapped in spans
90
+ """
91
+ from django.utils.html import escape
92
+ from django.utils.safestring import mark_safe
93
+
94
+ escaped_sql = escape(sql)
95
+
96
+ def replace_keyword(match):
97
+ return f'<span class="sql-keyword">{match.group(0)}</span>'
98
+
99
+ highlighted = SQL_KEYWORDS_RE.sub(replace_keyword, escaped_sql)
100
+ return mark_safe(highlighted)
101
+
102
+
103
+ class SQLTruncator:
104
+ """Handles intelligent SQL query truncation while preserving the structure."""
105
+
106
+ def __init__(self, max_length=150):
107
+ self.max_length = max_length
108
+
109
+ def truncate(self, sql):
110
+ """Intelligently truncate long SQL queries while preserving the structure.
111
+
112
+ Args:
113
+ sql: SQL query string
114
+
115
+ Returns:
116
+ Truncated SQL string with "..." at the end if shortened
117
+ """
118
+ if len(sql) <= self.max_length:
119
+ return sql
120
+
121
+ sql_upper = sql.upper()
122
+
123
+ if sql_upper.startswith("SELECT"):
124
+ result = self._truncate_select(sql, sql_upper)
125
+ if result:
126
+ return result
127
+
128
+ return self._fallback_truncate(sql)
129
+
130
+ def _truncate_select(self, sql, sql_upper):
131
+ """Truncate SELECT queries while preserving columns and joins."""
132
+ distinct_match = re.search(r"\bDISTINCT\b", sql_upper[:20])
133
+ has_distinct = distinct_match is not None
134
+ from_match = re.search(r"\bFROM\b", sql_upper)
135
+
136
+ if not from_match:
137
+ return None
138
+
139
+ from_pos = from_match.start()
140
+
141
+ if has_distinct:
142
+ distinct_end = distinct_match.end()
143
+ select_part = sql[distinct_end:from_pos].strip()
144
+ else:
145
+ select_part = sql[6:from_pos].strip()
146
+
147
+ first_column = self._extract_first_column(select_part)
148
+ select_clause = "SELECT DISTINCT" if has_distinct else "SELECT"
149
+
150
+ from_part = sql[from_pos:]
151
+ table_match = re.search(r'FROM\s+(["\[]?)(\w+)', from_part, re.IGNORECASE)
152
+
153
+ if not table_match:
154
+ return None
155
+
156
+ table_name = table_match.group(2)
157
+ result = f"{select_clause} {first_column} FROM {table_name}"
158
+
159
+ # Add joins if they fit
160
+ join_result = self._add_joins_if_fit(result, from_part)
161
+ if join_result:
162
+ return join_result
163
+
164
+ return result if len(result) <= self.max_length else None
165
+
166
+ def _extract_first_column(self, select_part):
167
+ """Extract the first column from SELECT clause, handling quotes and parentheses."""
168
+ if not select_part.strip():
169
+ return "..."
170
+ columns = self._parse_columns(select_part)
171
+ return columns[0] + ", ..." if columns else "..."
172
+
173
+ def _parse_columns(self, select_part):
174
+ """Parse columns from SELECT clause, handling complex expressions."""
175
+ columns = []
176
+ in_quotes = False
177
+ quote_char = None
178
+ in_parens = False
179
+ paren_depth = 0
180
+ current_col = ""
181
+
182
+ for i, char in enumerate(select_part):
183
+ if char in ('"', "'") and (i == 0 or select_part[i - 1] != "\\"):
184
+ if not in_quotes:
185
+ in_quotes = True
186
+ quote_char = char
187
+ elif char == quote_char:
188
+ in_quotes = False
189
+ quote_char = None
190
+ current_col += char
191
+ elif in_quotes:
192
+ current_col += char
193
+ elif char == "(":
194
+ in_parens = True
195
+ paren_depth += 1
196
+ current_col += char
197
+ elif char == ")":
198
+ paren_depth -= 1
199
+ if paren_depth == 0:
200
+ in_parens = False
201
+ current_col += char
202
+ elif char == "," and not in_parens and not in_quotes:
203
+ if current_col.strip():
204
+ columns.append(current_col.strip())
205
+ break
206
+ current_col = ""
207
+ elif char in " \t\n" and not in_parens and not in_quotes:
208
+ if current_col.strip():
209
+ columns.append(current_col.strip())
210
+ current_col = ""
211
+ else:
212
+ current_col += char
213
+
214
+ if not columns and current_col.strip():
215
+ columns.append(current_col.strip())
216
+
217
+ return columns
218
+
219
+ def _add_joins_if_fit(self, base_result, from_part):
220
+ """Add JOIN clauses to result if they fit within max_length."""
221
+ join_matches = re.finditer(
222
+ r'\b(LEFT\s+|RIGHT\s+|INNER\s+|OUTER\s+)?JOIN\s+(["\[]?)(\w+)',
223
+ from_part,
224
+ re.IGNORECASE,
225
+ )
226
+
227
+ result = base_result
228
+
229
+ for join_match in join_matches:
230
+ join_type = (join_match.group(1) or "").strip()
231
+ join_table = join_match.group(3)
232
+ new_result = (
233
+ f"{result} {join_type} JOIN {join_table}"
234
+ if join_type
235
+ else f"{result} JOIN {join_table}"
236
+ )
237
+ if len(new_result) <= self.max_length:
238
+ result = new_result
239
+ else:
240
+ break
241
+
242
+ return result if len(result) <= self.max_length else None
243
+
244
+ def _fallback_truncate(self, sql):
245
+ """Fallback truncation at clause boundaries or simple length limit."""
246
+ # First try simple length truncation
247
+ if len(sql) <= self.max_length:
248
+ return sql
249
+
250
+ # Try clause boundary truncation
251
+ clause_positions = [
252
+ (match.start(), match.group())
253
+ for match in SQL_CLAUSES_RE.finditer(sql[: self.max_length + 50])
254
+ ]
255
+
256
+ if clause_positions:
257
+ best_pos = self._find_best_truncate_position(clause_positions)
258
+ if best_pos > 0:
259
+ return sql[:best_pos].rstrip() + "..."
260
+
261
+ # Final fallback: simple truncation
262
+ return sql[: self.max_length].rstrip() + "..."
263
+
264
+ def _find_best_truncate_position(self, clause_positions):
265
+ """Find the best position to truncate based on clause boundaries."""
266
+ best_pos = 0
267
+ for pos, _ in clause_positions:
268
+ if self.max_length > pos > best_pos:
269
+ best_pos = pos
270
+
271
+ if best_pos > self.max_length - 30:
272
+ best_pos = 0
273
+
274
+ return best_pos
275
+
276
+
277
+ def truncate_sql(sql, max_length=150):
278
+ truncator = SQLTruncator(max_length)
279
+ return truncator.truncate(sql)
280
+
281
+
282
+ def tracking_wrapper(execute, sql, params, many, context):
283
+ start = perf_counter()
284
+ try:
285
+ return execute(sql, params, many, context)
286
+ finally:
287
+ _record(sql, params, (perf_counter() - start) * 1000)
@@ -1,60 +0,0 @@
1
- from contextvars import ContextVar
2
- from time import perf_counter
3
-
4
- _query_count: ContextVar[int] = ContextVar("query_count", default=0)
5
- _query_duration: ContextVar[float] = ContextVar("query_duration", default=0.0)
6
- _seen_queries: ContextVar[dict] = ContextVar("seen_queries", default={})
7
- _duplicate_log: ContextVar[list] = ContextVar("duplicate_log", default=[])
8
-
9
-
10
- def reset():
11
- _query_count.set(0)
12
- _query_duration.set(0.0)
13
- _seen_queries.set({})
14
- _duplicate_log.set([])
15
-
16
-
17
- def get_stats():
18
- return {
19
- "count": _query_count.get(),
20
- "duration": _query_duration.get(),
21
- "duplicate_queries": _duplicate_log.get(),
22
- }
23
-
24
-
25
- def _hash_params(params):
26
- try:
27
- return hash(tuple(params)) if params else 0
28
- except TypeError:
29
- return hash(str(params))
30
-
31
-
32
- def _record(sql, params, duration):
33
- _query_count.set(_query_count.get() + 1)
34
- _query_duration.set(_query_duration.get() + duration)
35
-
36
- seen = _seen_queries.get()
37
- params_hash = _hash_params(params)
38
-
39
- if sql in seen:
40
- if params_hash in seen[sql]:
41
- param_str = str(params)
42
- if len(param_str) > 200:
43
- param_str = param_str[:200] + "..."
44
-
45
- duplicates = _duplicate_log.get()
46
- duplicates.append(
47
- {"sql": sql, "params": param_str, "duration": round(duration, 2)}
48
- )
49
- else:
50
- seen[sql].add(params_hash)
51
- else:
52
- seen[sql] = {params_hash}
53
-
54
-
55
- def tracking_wrapper(execute, sql, params, many, context):
56
- start = perf_counter()
57
- try:
58
- return execute(sql, params, many, context)
59
- finally:
60
- _record(sql, params, (perf_counter() - start) * 1000)