django-devbar 0.3.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.3.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,13 +31,34 @@ 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
 
39
+ ## Showcase
40
+
41
+ Normal operation – showing query count, duration, and application time:
42
+
36
43
  ![devbar example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example.svg)
37
44
 
45
+ Duplicate queries detected – severity indicator and details shown:
46
+
38
47
  ![devbar warning example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example-warning.svg)
39
48
 
40
- > **Note:** This package is experimental and may introduce breaking changes in minor versions.
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)
52
+
53
+ ## Motivation
54
+
55
+ While [Django Debug Toolbar](https://django-debug-toolbar.readthedocs.io/) is a proven tool trusted by countless projects, django-devbar explores a different approach to development debugging:
56
+
57
+ - **Minimal setup** — just add middleware, nothing else required
58
+ - **Browser DevTools integration** — Chrome extension panel works with any response type, including JSON
59
+ - **Zero extra dependencies** — only requires Django
60
+ - **Lower overhead** — no extra requests to debug endpoints
61
+ - **Non-intrusive** — HTML bar is optional; works via headers and DevTools panel alone
41
62
 
42
63
  ## Installation
43
64
 
@@ -65,34 +86,36 @@ All settings are optional. Configure via a `DEVBAR` dict in your Django settings
65
86
 
66
87
  ```python
67
88
  DEVBAR = {
68
- 'POSITION': 'bottom-right', # bottom-right, bottom-left, top-right, top-left
69
- 'SHOW_BAR': None, # None (default) = follows DEBUG, or True/False to override
70
- 'SHOW_HEADERS': False, # Add DevBar-* headers to responses
89
+ "POSITION": "bottom-right", # bottom-right (default), bottom-left, top-right, top-left
90
+ "SHOW_BAR": None, # follows DEBUG; set True/False to override
91
+ "ENABLE_DEVTOOLS_DATA": None, # follows DEBUG; set True/False to override
71
92
  }
72
93
  ```
73
94
 
74
95
  ## Response Headers
75
96
 
76
- When `DEVBAR = {'SHOW_HEADERS': True}`, performance metrics are added as HTTP response headers. This is useful for:
97
+ Django DevBar adds HTTP response headers with performance metrics:
98
+
99
+ - **Server-Timing** (always present) – Standard HTTP header with database, application, and total time metrics. Visible in Chrome DevTools Network tab under Timing.
100
+
101
+ An additional header is included by default in DEBUG mode:
102
+
103
+ - **DevBar-Data** - JSON header with comprehensive metrics including duplicate query details
104
+
105
+ This is useful for:
77
106
 
78
107
  - **API endpoints** where the HTML overlay can't be displayed
79
108
  - **Automated testing** to assert performance metrics (e.g., fail CI if query count exceeds a limit)
80
- - **Monitoring tools** that can capture and aggregate header values
109
+ - **Browser extensions** that need detailed duplicate query information
81
110
 
82
- Headers included:
111
+ ### Server-Timing format
83
112
 
84
- | Header | Example | Description |
85
- |--------|---------|-------------|
86
- | `DevBar-Query-Count` | `12` | Number of database queries executed |
87
- | `DevBar-DB-Time` | `87ms` | Total time spent in database queries |
88
- | `DevBar-App-Time` | `41ms` | Application time (total time minus DB time) |
89
- | `DevBar-Duplicates` | `3` | Number of duplicate queries detected (only present if duplicates found) |
113
+ ```
114
+ Server-Timing: db;dur=87.50, app;dur=41.30, total;dur=128.80
115
+ ```
90
116
 
91
117
  ## Chrome Extension
92
118
 
93
- ![Chrome DevTools extension](https://github.com/user-attachments/assets/4288f507-0bd5-4b8c-a3dc-0c4d5b93305f)
94
-
95
119
  View Django DevBar metrics directly in Chrome DevTools with the [official extension](https://chromewebstore.google.com/detail/django-devbar/fehcaaopchkbknbdhjadnmehiifdmeid).
96
- Requires `DEVBAR = {'SHOW_HEADERS': True}`.
97
120
 
98
121
  See [chrome-extension/README.md](chrome-extension/README.md) for more details.
@@ -0,0 +1,90 @@
1
+ # django-devbar
2
+
3
+ > [!WARNING]
4
+ > This package is experimental and may introduce breaking changes in minor versions.
5
+
6
+ Lightweight performance devbar for Django. Shows DB query count, query duration, application time, and detects duplicate queries with visual severity indicators.
7
+
8
+ ## Showcase
9
+
10
+ Normal operation – showing query count, duration, and application time:
11
+
12
+ ![devbar example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example.svg)
13
+
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)
21
+
22
+ ## Motivation
23
+
24
+ While [Django Debug Toolbar](https://django-debug-toolbar.readthedocs.io/) is a proven tool trusted by countless projects, django-devbar explores a different approach to development debugging:
25
+
26
+ - **Minimal setup** — just add middleware, nothing else required
27
+ - **Browser DevTools integration** — Chrome extension panel works with any response type, including JSON
28
+ - **Zero extra dependencies** — only requires Django
29
+ - **Lower overhead** — no extra requests to debug endpoints
30
+ - **Non-intrusive** — HTML bar is optional; works via headers and DevTools panel alone
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ # Using uv (recommended)
36
+ uv add --dev django-devbar
37
+
38
+ # Or using pip
39
+ pip install django-devbar
40
+ ```
41
+
42
+ Add to your middleware as early as possible, but after any middleware that encodes the response (e.g., `GZipMiddleware`):
43
+
44
+ ```python
45
+ MIDDLEWARE = [
46
+ "django.middleware.security.SecurityMiddleware",
47
+ "django_devbar.DevBarMiddleware",
48
+ # ...
49
+ ]
50
+ ```
51
+
52
+ ## Configuration
53
+
54
+ All settings are optional. Configure via a `DEVBAR` dict in your Django settings:
55
+
56
+ ```python
57
+ DEVBAR = {
58
+ "POSITION": "bottom-right", # bottom-right (default), bottom-left, top-right, top-left
59
+ "SHOW_BAR": None, # follows DEBUG; set True/False to override
60
+ "ENABLE_DEVTOOLS_DATA": None, # follows DEBUG; set True/False to override
61
+ }
62
+ ```
63
+
64
+ ## Response Headers
65
+
66
+ Django DevBar adds HTTP response headers with performance metrics:
67
+
68
+ - **Server-Timing** (always present) – Standard HTTP header with database, application, and total time metrics. Visible in Chrome DevTools Network tab under Timing.
69
+
70
+ An additional header is included by default in DEBUG mode:
71
+
72
+ - **DevBar-Data** - JSON header with comprehensive metrics including duplicate query details
73
+
74
+ This is useful for:
75
+
76
+ - **API endpoints** where the HTML overlay can't be displayed
77
+ - **Automated testing** to assert performance metrics (e.g., fail CI if query count exceeds a limit)
78
+ - **Browser extensions** that need detailed duplicate query information
79
+
80
+ ### Server-Timing format
81
+
82
+ ```
83
+ Server-Timing: db;dur=87.50, app;dur=41.30, total;dur=128.80
84
+ ```
85
+
86
+ ## Chrome Extension
87
+
88
+ View Django DevBar metrics directly in Chrome DevTools with the [official extension](https://chromewebstore.google.com/detail/django-devbar/fehcaaopchkbknbdhjadnmehiifdmeid).
89
+
90
+ See [chrome-extension/README.md](chrome-extension/README.md) for more details.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "django-devbar"
3
- version = "0.3.0"
3
+ version = "0.5.0"
4
4
  description = "Lightweight performance devbar for Django"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -12,7 +12,7 @@ def get_config():
12
12
  return {
13
13
  "POSITION": "bottom-right",
14
14
  "SHOW_BAR": None, # None = use settings.DEBUG
15
- "SHOW_HEADERS": False,
15
+ "ENABLE_DEVTOOLS_DATA": None, # None = use settings.DEBUG
16
16
  **getattr(settings, "DEVBAR", {}),
17
17
  }
18
18
 
@@ -29,5 +29,7 @@ def get_show_bar():
29
29
  return settings.DEBUG if show_bar is None else show_bar
30
30
 
31
31
 
32
- def get_show_headers():
33
- return get_config()["SHOW_HEADERS"]
32
+ def get_enable_devtools_data():
33
+ config = get_config()
34
+ enable = config["ENABLE_DEVTOOLS_DATA"]
35
+ return settings.DEBUG if enable is None else enable
@@ -9,10 +9,11 @@ from django.template import Context, Engine
9
9
 
10
10
  from . import tracker
11
11
  from .conf import (
12
+ get_enable_devtools_data,
12
13
  get_position,
13
14
  get_show_bar,
14
- get_show_headers,
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,41 +42,58 @@ 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
- level = "warn" if stats["has_duplicates"] else "ok"
50
+ if get_enable_devtools_data():
51
+ self._add_devtools_data_header(response, stats)
50
52
 
51
- if get_show_headers():
52
- self._add_headers(response, stats)
53
+ self._add_server_timing_header(response, stats)
53
54
 
54
55
  if get_show_bar() and self._can_inject(response):
55
- self._inject_devbar(response, stats, level)
56
+ self._inject_devbar(response, stats)
56
57
 
57
58
  return response
58
59
 
59
- def _add_headers(self, response, stats):
60
- response["DevBar-Query-Count"] = str(stats["count"])
61
- response["DevBar-DB-Time"] = f"{stats['duration']:.0f}ms"
62
- response["DevBar-App-Time"] = f"{stats['python_time']:.0f}ms"
63
- if stats["has_duplicates"]:
64
- response["DevBar-Duplicates"] = str(len(stats["duplicate_queries"]))
65
-
66
- # Add comprehensive JSON data for browser DevTools extension
60
+ def _add_devtools_data_header(self, response, stats):
61
+ # Abbreviated keys used to minimize DevBar-Data header size
67
62
  extension_data = {
68
- "count": stats["count"],
69
- "db_time": stats["duration"],
70
- "app_time": stats["python_time"],
71
- "total_time": stats["total_time"],
72
- "has_duplicates": stats["has_duplicates"],
63
+ "c": stats["count"],
64
+ "db": round(stats["duration"], 2),
65
+ "app": stats["python_time"],
66
+ "full": stats["total_time"],
73
67
  }
74
- if stats.get("duplicate_queries"):
75
- 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
76
86
 
77
87
  response["DevBar-Data"] = json.dumps(extension_data)
78
88
 
89
+ def _add_server_timing_header(self, response, stats):
90
+ parts = [
91
+ f"db;dur={stats['duration']:.2f}",
92
+ f"app;dur={stats['python_time']:.2f}",
93
+ f"total;dur={stats['total_time']:.2f}",
94
+ ]
95
+ response["Server-Timing"] = ", ".join(parts)
96
+
79
97
  def _can_inject(self, response):
80
98
  if getattr(response, "streaming", False):
81
99
  return False
@@ -86,7 +104,7 @@ class DevBarMiddleware:
86
104
  return False
87
105
  return hasattr(response, "content")
88
106
 
89
- def _inject_devbar(self, response, stats, level):
107
+ def _inject_devbar(self, response, stats):
90
108
  content = response.content
91
109
  matches = list(BODY_CLOSE_RE.finditer(content))
92
110
  if not matches:
@@ -101,7 +119,6 @@ class DevBarMiddleware:
101
119
  Context(
102
120
  {
103
121
  "position": get_position(),
104
- "level": level,
105
122
  "db_time": stats["duration"],
106
123
  "app_time": stats["python_time"],
107
124
  "query_count": stats["count"],
@@ -119,5 +136,8 @@ class DevBarMiddleware:
119
136
  def _build_duplicates_html(self, duplicates):
120
137
  if not duplicates:
121
138
  return ""
139
+ formatted_duplicates = [
140
+ {**dup, "sql": format_sql(dup["sql"])} for dup in duplicates
141
+ ]
122
142
  template = _template_engine.get_template("django_devbar/duplicates.html")
123
- return template.render(Context({"duplicates": duplicates}))
143
+ return template.render(Context({"duplicates": formatted_duplicates}))
@@ -14,6 +14,7 @@
14
14
  color: #f5f5f5;
15
15
  }
16
16
  #django-devbar.level-warn { border-left: 3px solid #f59e0b; }
17
+ #django-devbar.has-duplicates { border-left: 3px solid #f59e0b; }
17
18
  #django-devbar span { opacity: 0.7; }
18
19
  #django-devbar strong { opacity: 1; font-weight: 600; }
19
20
  #django-devbar details { display: inline; position: relative; }
@@ -85,8 +86,8 @@
85
86
  }
86
87
  @media (max-width: 640px) { #django-devbar { display: none; } }
87
88
  </style>
88
- <div id="django-devbar" class="level-{{ level }}">
89
+ <div id="django-devbar"{% if duplicates_html %} class="has-duplicates"{% endif %}>
90
+ <span>queries</span> <strong>{{ query_count }}</strong> <span>·</span>
89
91
  <span>db</span> <strong>{{ db_time|floatformat:0 }}ms</strong> <span>·</span>
90
- <span>app</span> <strong>{{ app_time|floatformat:0 }}ms</strong> <span>·</span>
91
- <span>queries</span> <strong>{{ query_count }}</strong>{{ duplicates_html|safe }}
92
+ <span>app</span> <strong>{{ app_time|floatformat:0 }}ms</strong>{{ duplicates_html|safe }}
92
93
  </div>
@@ -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,67 +0,0 @@
1
- # django-devbar
2
-
3
- Lightweight performance devbar for Django. Shows DB query count, query duration, application time, and detects duplicate queries with visual severity indicators.
4
-
5
- ![devbar example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example.svg)
6
-
7
- ![devbar warning example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example-warning.svg)
8
-
9
- > **Note:** This package is experimental and may introduce breaking changes in minor versions.
10
-
11
- ## Installation
12
-
13
- ```bash
14
- # Using uv (recommended)
15
- uv add --dev django-devbar
16
-
17
- # Or using pip
18
- pip install django-devbar
19
- ```
20
-
21
- Add to your middleware as early as possible, but after any middleware that encodes the response (e.g., `GZipMiddleware`):
22
-
23
- ```python
24
- MIDDLEWARE = [
25
- "django.middleware.security.SecurityMiddleware",
26
- "django_devbar.DevBarMiddleware",
27
- # ...
28
- ]
29
- ```
30
-
31
- ## Configuration
32
-
33
- All settings are optional. Configure via a `DEVBAR` dict in your Django settings:
34
-
35
- ```python
36
- DEVBAR = {
37
- 'POSITION': 'bottom-right', # bottom-right, bottom-left, top-right, top-left
38
- 'SHOW_BAR': None, # None (default) = follows DEBUG, or True/False to override
39
- 'SHOW_HEADERS': False, # Add DevBar-* headers to responses
40
- }
41
- ```
42
-
43
- ## Response Headers
44
-
45
- When `DEVBAR = {'SHOW_HEADERS': True}`, performance metrics are added as HTTP response headers. This is useful for:
46
-
47
- - **API endpoints** where the HTML overlay can't be displayed
48
- - **Automated testing** to assert performance metrics (e.g., fail CI if query count exceeds a limit)
49
- - **Monitoring tools** that can capture and aggregate header values
50
-
51
- Headers included:
52
-
53
- | Header | Example | Description |
54
- |--------|---------|-------------|
55
- | `DevBar-Query-Count` | `12` | Number of database queries executed |
56
- | `DevBar-DB-Time` | `87ms` | Total time spent in database queries |
57
- | `DevBar-App-Time` | `41ms` | Application time (total time minus DB time) |
58
- | `DevBar-Duplicates` | `3` | Number of duplicate queries detected (only present if duplicates found) |
59
-
60
- ## Chrome Extension
61
-
62
- ![Chrome DevTools extension](https://github.com/user-attachments/assets/4288f507-0bd5-4b8c-a3dc-0c4d5b93305f)
63
-
64
- View Django DevBar metrics directly in Chrome DevTools with the [official extension](https://chromewebstore.google.com/detail/django-devbar/fehcaaopchkbknbdhjadnmehiifdmeid).
65
- Requires `DEVBAR = {'SHOW_HEADERS': True}`.
66
-
67
- See [chrome-extension/README.md](chrome-extension/README.md) for more details.
@@ -1,61 +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
- "has_duplicates": bool(_duplicate_log.get()),
22
- "duplicate_queries": _duplicate_log.get(),
23
- }
24
-
25
-
26
- def _hash_params(params):
27
- try:
28
- return hash(tuple(params)) if params else 0
29
- except TypeError:
30
- return hash(str(params))
31
-
32
-
33
- def _record(sql, params, duration):
34
- _query_count.set(_query_count.get() + 1)
35
- _query_duration.set(_query_duration.get() + duration)
36
-
37
- seen = _seen_queries.get()
38
- params_hash = _hash_params(params)
39
-
40
- if sql in seen:
41
- if params_hash in seen[sql]:
42
- param_str = str(params)
43
- if len(param_str) > 200:
44
- param_str = param_str[:200] + "..."
45
-
46
- duplicates = _duplicate_log.get()
47
- duplicates.append(
48
- {"sql": sql, "params": param_str, "duration": round(duration, 2)}
49
- )
50
- else:
51
- seen[sql].add(params_hash)
52
- else:
53
- seen[sql] = {params_hash}
54
-
55
-
56
- def tracking_wrapper(execute, sql, params, many, context):
57
- start = perf_counter()
58
- try:
59
- return execute(sql, params, many, context)
60
- finally:
61
- _record(sql, params, (perf_counter() - start) * 1000)