django-devbar 0.1.2__tar.gz → 0.2.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.1.2
3
+ Version: 0.2.0
4
4
  Summary: Lightweight performance devbar for Django
5
5
  Keywords: django,debug,performance,monitoring,development
6
6
  Author: amureki
@@ -33,11 +33,19 @@ Description-Content-Type: text/markdown
33
33
 
34
34
  Lightweight performance devbar for Django. Shows DB query count, query duration, application time, and detects duplicate queries with visual severity indicators.
35
35
 
36
- ![devbar example](https://raw.githubusercontent.com/amureki/django-devbar/main/docs/devbar-example.svg)
36
+ ![devbar example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example.svg)
37
+
38
+ ![devbar warning example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example-warning.svg)
39
+
40
+ > **Note:** This package is experimental and may introduce breaking changes in minor versions.
37
41
 
38
42
  ## Installation
39
43
 
40
44
  ```bash
45
+ # Using uv (recommended)
46
+ uv add --dev django-devbar
47
+
48
+ # Or using pip
41
49
  pip install django-devbar
42
50
  ```
43
51
 
@@ -53,34 +61,22 @@ MIDDLEWARE = [
53
61
 
54
62
  ## Configuration
55
63
 
56
- ```python
57
- # Position: bottom-right, bottom-left, top-right, top-left (default: bottom-right)
58
- DEVBAR_POSITION = "top-left"
59
-
60
- # Show HTML overlay (default: DEBUG)
61
- DEVBAR_SHOW_BAR = True
62
-
63
- # Add DevBar-* response headers (default: False)
64
- DEVBAR_SHOW_HEADERS = True
64
+ All settings are optional. Configure via a `DEVBAR` dict in your Django settings:
65
65
 
66
- # Enable console logging for duplicate queries (default: True)
67
- DEVBAR_ENABLE_CONSOLE = True
68
-
69
- # Performance thresholds for warning/critical levels (defaults shown)
70
- DEVBAR_THRESHOLDS = {
71
- "time_warning": 500, # ms
72
- "time_critical": 1500, # ms
73
- "count_warning": 20, # queries
74
- "count_critical": 50, # queries
66
+ ```python
67
+ 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 X-DevBar-* headers to responses
75
71
  }
76
72
  ```
77
73
 
78
74
  ## Response Headers
79
75
 
80
- When `DEVBAR_SHOW_HEADERS = True`, performance metrics are added as HTTP response headers. This is useful for:
76
+ When `DEVBAR = {'SHOW_HEADERS': True}`, performance metrics are added as HTTP response headers. This is useful for:
81
77
 
82
78
  - **API endpoints** where the HTML overlay can't be displayed
83
- - **Automated testing** to assert performance thresholds (e.g., fail CI if query count exceeds a limit)
79
+ - **Automated testing** to assert performance metrics (e.g., fail CI if query count exceeds a limit)
84
80
  - **Monitoring tools** that can capture and aggregate header values
85
81
 
86
82
  Headers included:
@@ -2,11 +2,19 @@
2
2
 
3
3
  Lightweight performance devbar for Django. Shows DB query count, query duration, application time, and detects duplicate queries with visual severity indicators.
4
4
 
5
- ![devbar example](https://raw.githubusercontent.com/amureki/django-devbar/main/docs/devbar-example.svg)
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.
6
10
 
7
11
  ## Installation
8
12
 
9
13
  ```bash
14
+ # Using uv (recommended)
15
+ uv add --dev django-devbar
16
+
17
+ # Or using pip
10
18
  pip install django-devbar
11
19
  ```
12
20
 
@@ -22,34 +30,22 @@ MIDDLEWARE = [
22
30
 
23
31
  ## Configuration
24
32
 
25
- ```python
26
- # Position: bottom-right, bottom-left, top-right, top-left (default: bottom-right)
27
- DEVBAR_POSITION = "top-left"
28
-
29
- # Show HTML overlay (default: DEBUG)
30
- DEVBAR_SHOW_BAR = True
31
-
32
- # Add DevBar-* response headers (default: False)
33
- DEVBAR_SHOW_HEADERS = True
33
+ All settings are optional. Configure via a `DEVBAR` dict in your Django settings:
34
34
 
35
- # Enable console logging for duplicate queries (default: True)
36
- DEVBAR_ENABLE_CONSOLE = True
37
-
38
- # Performance thresholds for warning/critical levels (defaults shown)
39
- DEVBAR_THRESHOLDS = {
40
- "time_warning": 500, # ms
41
- "time_critical": 1500, # ms
42
- "count_warning": 20, # queries
43
- "count_critical": 50, # queries
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 X-DevBar-* headers to responses
44
40
  }
45
41
  ```
46
42
 
47
43
  ## Response Headers
48
44
 
49
- When `DEVBAR_SHOW_HEADERS = True`, performance metrics are added as HTTP response headers. This is useful for:
45
+ When `DEVBAR = {'SHOW_HEADERS': True}`, performance metrics are added as HTTP response headers. This is useful for:
50
46
 
51
47
  - **API endpoints** where the HTML overlay can't be displayed
52
- - **Automated testing** to assert performance thresholds (e.g., fail CI if query count exceeds a limit)
48
+ - **Automated testing** to assert performance metrics (e.g., fail CI if query count exceeds a limit)
53
49
  - **Monitoring tools** that can capture and aggregate header values
54
50
 
55
51
  Headers included:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "django-devbar"
3
- version = "0.1.2"
3
+ version = "0.2.0"
4
4
  description = "Lightweight performance devbar for Django"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,4 +1,3 @@
1
1
  from .middleware import DevBarMiddleware
2
2
 
3
- __version__ = "0.1.2"
4
3
  __all__ = ["DevBarMiddleware"]
@@ -0,0 +1,33 @@
1
+ from django.conf import settings
2
+
3
+ POSITIONS = {
4
+ "bottom-right": "bottom:0;right:0",
5
+ "bottom-left": "bottom:0;left:0",
6
+ "top-right": "top:0;right:0",
7
+ "top-left": "top:0;left:0",
8
+ }
9
+
10
+
11
+ def get_config():
12
+ return {
13
+ "POSITION": "bottom-right",
14
+ "SHOW_BAR": None, # None = use settings.DEBUG
15
+ "SHOW_HEADERS": False,
16
+ **getattr(settings, "DEVBAR", {}),
17
+ }
18
+
19
+
20
+ def get_position():
21
+ config = get_config()
22
+ key = config["POSITION"]
23
+ return POSITIONS.get(key, POSITIONS["bottom-right"])
24
+
25
+
26
+ def get_show_bar():
27
+ config = get_config()
28
+ show_bar = config["SHOW_BAR"]
29
+ return settings.DEBUG if show_bar is None else show_bar
30
+
31
+
32
+ def get_show_headers():
33
+ return get_config()["SHOW_HEADERS"]
@@ -0,0 +1,109 @@
1
+ import re
2
+ from contextlib import ExitStack
3
+ from pathlib import Path
4
+ from time import perf_counter
5
+
6
+ from django.db import connections
7
+ from django.template import Context, Engine
8
+
9
+ from . import tracker
10
+ from .conf import (
11
+ get_position,
12
+ get_show_bar,
13
+ get_show_headers,
14
+ )
15
+
16
+ BODY_CLOSE_RE = re.compile(rb"</body\s*>", re.IGNORECASE)
17
+
18
+ _template_engine = Engine(
19
+ dirs=[Path(__file__).parent / "templates"],
20
+ autoescape=True,
21
+ )
22
+
23
+
24
+ class DevBarMiddleware:
25
+ def __init__(self, get_response):
26
+ self.get_response = get_response
27
+
28
+ def __call__(self, request):
29
+ tracker.reset()
30
+ request_start = perf_counter()
31
+
32
+ with ExitStack() as stack:
33
+ for alias in connections:
34
+ stack.enter_context(
35
+ connections[alias].execute_wrapper(tracker.tracking_wrapper)
36
+ )
37
+ response = self.get_response(request)
38
+
39
+ total_time = (perf_counter() - request_start) * 1000
40
+ stats = tracker.get_stats()
41
+
42
+ db_time = stats["duration"]
43
+ python_time = max(0, total_time - db_time)
44
+
45
+ stats["python_time"] = python_time
46
+ stats["total_time"] = total_time
47
+
48
+ level = "warn" if stats["has_duplicates"] else "ok"
49
+
50
+ if get_show_headers():
51
+ self._add_headers(response, stats)
52
+
53
+ if get_show_bar() and self._can_inject(response):
54
+ self._inject_devbar(response, stats, level)
55
+
56
+ return response
57
+
58
+ def _add_headers(self, response, stats):
59
+ response["DevBar-Query-Count"] = str(stats["count"])
60
+ response["DevBar-DB-Time"] = f"{stats['duration']:.0f}ms"
61
+ response["DevBar-App-Time"] = f"{stats['python_time']:.0f}ms"
62
+ if stats["has_duplicates"]:
63
+ response["DevBar-Duplicates"] = str(len(stats["duplicate_queries"]))
64
+
65
+ def _can_inject(self, response):
66
+ if getattr(response, "streaming", False):
67
+ return False
68
+ content_type = response.get("Content-Type", "").lower()
69
+ if "html" not in content_type:
70
+ return False
71
+ if response.get("Content-Encoding"):
72
+ return False
73
+ return hasattr(response, "content")
74
+
75
+ def _inject_devbar(self, response, stats, level):
76
+ content = response.content
77
+ matches = list(BODY_CLOSE_RE.finditer(content))
78
+ if not matches:
79
+ return
80
+
81
+ duplicates_html = self._build_duplicates_html(
82
+ stats.get("duplicate_queries", [])
83
+ )
84
+
85
+ template = _template_engine.get_template("django_devbar/devbar.html")
86
+ html = template.render(
87
+ Context(
88
+ {
89
+ "position": get_position(),
90
+ "level": level,
91
+ "db_time": stats["duration"],
92
+ "app_time": stats["python_time"],
93
+ "query_count": stats["count"],
94
+ "duplicates_html": duplicates_html,
95
+ }
96
+ )
97
+ )
98
+
99
+ payload = html.encode(response.charset or "utf-8")
100
+
101
+ idx = matches[-1].start()
102
+ response.content = content[:idx] + payload + content[idx:]
103
+ response["Content-Length"] = str(len(response.content))
104
+
105
+ def _build_duplicates_html(self, duplicates):
106
+ if not duplicates:
107
+ return ""
108
+ template = _template_engine.get_template("django_devbar/duplicates.html")
109
+ return template.render(Context({"duplicates": duplicates}))
@@ -0,0 +1,92 @@
1
+ <style>
2
+ #django-devbar {
3
+ position: fixed; {{ position }}; z-index: 999999999;
4
+ display: flex; align-items: center; gap: 5px;
5
+ font-family: -apple-system, system-ui, sans-serif;
6
+ font-size: 11px; font-weight: 500;
7
+ padding: 4px 8px; margin: 8px; border-radius: 4px;
8
+ backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
9
+ box-shadow: 0 2px 8px rgba(0,0,0,0.15), 0 1px 2px rgba(0,0,0,0.2);
10
+ transition: all 0.2s ease;
11
+ cursor: default;
12
+ line-height: 1.3;
13
+ background: rgba(20, 20, 20, 0.92);
14
+ color: #f5f5f5;
15
+ }
16
+ #django-devbar.level-warn { border-left: 3px solid #f59e0b; }
17
+ #django-devbar span { opacity: 0.7; }
18
+ #django-devbar strong { opacity: 1; font-weight: 600; }
19
+ #django-devbar details { display: inline; position: relative; }
20
+ #django-devbar summary {
21
+ all: revert;
22
+ color: #f59e0b; font-weight: 600; cursor: pointer; list-style: none;
23
+ }
24
+ #django-devbar summary::-webkit-details-marker { display: none; }
25
+ #django-devbar details[open] > .dup-list {
26
+ position: absolute; bottom: 100%; right: -8px; margin-bottom: 8px;
27
+ background: rgba(20, 20, 20, 0.92); border-radius: 4px; padding: 6px;
28
+ width: min(500px, 90vw); max-height: 200px; overflow-y: auto; overflow-x: hidden;
29
+ box-sizing: border-box;
30
+ box-shadow: 0 2px 8px rgba(0,0,0,0.15), 0 1px 2px rgba(0,0,0,0.2);
31
+ }
32
+ #django-devbar .dup-list details {
33
+ display: block;
34
+ margin: 0 0 6px 0;
35
+ font-size: 11px;
36
+ color: #f5f5f5;
37
+ }
38
+ #django-devbar .dup-list details:last-child { margin-bottom: 0; }
39
+ #django-devbar .dup-list summary {
40
+ all: revert;
41
+ cursor: pointer;
42
+ list-style: none;
43
+ padding: 6px 8px;
44
+ background: rgba(251, 191, 36, 0.1);
45
+ border-radius: 3px;
46
+ border-left: 2px solid #fbbf24;
47
+ transition: background 0.15s ease;
48
+ }
49
+ #django-devbar .dup-list summary:hover {
50
+ background: rgba(251, 191, 36, 0.2);
51
+ }
52
+ #django-devbar .dup-list summary code {
53
+ white-space: nowrap;
54
+ overflow: hidden;
55
+ text-overflow: ellipsis;
56
+ display: inline-block;
57
+ max-width: 420px;
58
+ vertical-align: bottom;
59
+ }
60
+ #django-devbar .dup-list summary::before {
61
+ content: '▶ ';
62
+ display: inline-block;
63
+ transition: transform 0.15s ease;
64
+ color: #fbbf24;
65
+ font-size: 8px;
66
+ margin-right: 6px;
67
+ }
68
+ #django-devbar .dup-list details[open] > summary::before {
69
+ transform: rotate(90deg);
70
+ }
71
+ #django-devbar .dup-list code {
72
+ all: revert;
73
+ color: #fbbf24; font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
74
+ overflow-wrap: break-word;
75
+ }
76
+ #django-devbar .dup-list details[open] > code {
77
+ display: block;
78
+ padding: 8px;
79
+ background: rgba(0, 0, 0, 0.3);
80
+ border-radius: 3px;
81
+ margin-top: 6px;
82
+ white-space: pre-wrap;
83
+ line-height: 1.5;
84
+ font-size: 10px;
85
+ }
86
+ @media (max-width: 640px) { #django-devbar { display: none; } }
87
+ </style>
88
+ <div id="django-devbar" class="level-{{ level }}">
89
+ <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
+ </div>
@@ -0,0 +1 @@
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 %}
@@ -1,39 +0,0 @@
1
- from django.conf import settings
2
-
3
- POSITIONS = {
4
- "bottom-right": "bottom:0;right:0",
5
- "bottom-left": "bottom:0;left:0",
6
- "top-right": "top:0;right:0",
7
- "top-left": "top:0;left:0",
8
- }
9
-
10
- DEFAULT_THRESHOLDS = {
11
- "time_warning": 500,
12
- "time_critical": 1500,
13
- "count_warning": 20,
14
- "count_critical": 50,
15
- }
16
-
17
-
18
- def get_position():
19
- key = getattr(settings, "DEVBAR_POSITION", "bottom-right")
20
- return POSITIONS.get(key, POSITIONS["bottom-right"])
21
-
22
-
23
- def get_show_bar():
24
- return getattr(settings, "DEVBAR_SHOW_BAR", settings.DEBUG)
25
-
26
-
27
- def get_show_headers():
28
- return getattr(settings, "DEVBAR_SHOW_HEADERS", False)
29
-
30
-
31
- def get_enable_console():
32
- return getattr(settings, "DEVBAR_ENABLE_CONSOLE", True)
33
-
34
-
35
- def get_thresholds():
36
- user_thresholds = getattr(settings, "DEVBAR_THRESHOLDS", {})
37
- final = DEFAULT_THRESHOLDS.copy()
38
- final.update(user_thresholds)
39
- return final
@@ -1,153 +0,0 @@
1
- import json
2
- import re
3
- from contextlib import ExitStack
4
- from time import perf_counter
5
-
6
- from django.db import connections
7
-
8
- from . import tracker
9
- from .conf import (
10
- get_position,
11
- get_show_bar,
12
- get_show_headers,
13
- get_thresholds,
14
- get_enable_console,
15
- )
16
-
17
- STYLE_BLOCK = """<style>
18
- #django-devbar {
19
- position: fixed; %s; z-index: 99999;
20
- font-family: -apple-system, system-ui, sans-serif;
21
- font-size: 11px; font-weight: 500;
22
- padding: 4px 8px; border-radius: 4px;
23
- backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
24
- box-shadow: 0 2px 5px rgba(0,0,0,0.1);
25
- transition: all 0.2s ease;
26
- cursor: default;
27
- line-height: 1.3;
28
- background: rgba(20, 20, 20, 0.85);
29
- color: #e5e5e5;
30
- }
31
- #django-devbar.level-warn { border-left: 3px solid #f59e0b; }
32
- #django-devbar.level-crit { border-left: 3px solid #dc2626; }
33
- #django-devbar span { opacity: 0.7; margin-right: 2px; }
34
- #django-devbar strong { opacity: 1; font-weight: 600; margin-right: 6px; }
35
- </style>"""
36
-
37
- BAR_TEMPLATE = """<div id="django-devbar" class="level-%s">
38
- <span>db</span> <strong>%.0fms</strong>
39
- <span>py</span> <strong>%.0fms</strong>
40
- <span>count</span> <strong>%d</strong>%s
41
- </div>"""
42
-
43
- SCRIPT_TEMPLATE = """<script>
44
- (function() {
45
- const stats = %s;
46
- if (stats.duplicates && stats.duplicates.length > 0) {
47
- console.groupCollapsed(`⚠️ Django DevBar: Duplicate Queries Detected (${stats.duplicates.length})`);
48
- console.table(stats.duplicates.map(d => ({SQL: d.sql, Parameters: d.params, Duration_ms: d.duration})));
49
- console.groupEnd();
50
- }
51
- })();
52
- </script>"""
53
-
54
- BODY_CLOSE_RE = re.compile(rb"</body\s*>", re.IGNORECASE)
55
-
56
-
57
- class DevBarMiddleware:
58
- def __init__(self, get_response):
59
- self.get_response = get_response
60
-
61
- def __call__(self, request):
62
- tracker.reset()
63
- request_start = perf_counter()
64
-
65
- with ExitStack() as stack:
66
- for alias in connections:
67
- stack.enter_context(
68
- connections[alias].execute_wrapper(tracker.tracking_wrapper)
69
- )
70
- response = self.get_response(request)
71
-
72
- total_time = (perf_counter() - request_start) * 1000
73
- stats = tracker.get_stats()
74
-
75
- db_time = stats["duration"]
76
- python_time = max(0, total_time - db_time)
77
-
78
- stats["python_time"] = python_time
79
- stats["total_time"] = total_time
80
-
81
- thresholds = get_thresholds()
82
- level = self._determine_level(stats, total_time, thresholds)
83
-
84
- if get_show_headers():
85
- self._add_headers(response, stats)
86
-
87
- if get_show_bar() and self._can_inject(response):
88
- self._inject_devbar(response, stats, level)
89
-
90
- return response
91
-
92
- def _determine_level(self, stats, total_time, thresholds):
93
- if (
94
- total_time > thresholds["time_critical"]
95
- or stats["count"] > thresholds["count_critical"]
96
- ):
97
- return "crit"
98
- if (
99
- stats["has_duplicates"]
100
- or total_time > thresholds["time_warning"]
101
- or stats["count"] > thresholds["count_warning"]
102
- ):
103
- return "warn"
104
- return "ok"
105
-
106
- def _add_headers(self, response, stats):
107
- response["DevBar-Query-Count"] = str(stats["count"])
108
- response["DevBar-DB-Time"] = f"{stats['duration']:.0f}ms"
109
- response["DevBar-App-Time"] = f"{stats['python_time']:.0f}ms"
110
- if stats["has_duplicates"]:
111
- response["DevBar-Duplicates"] = str(len(stats["duplicate_queries"]))
112
-
113
- def _can_inject(self, response):
114
- if getattr(response, "streaming", False):
115
- return False
116
- content_type = response.get("Content-Type", "").lower()
117
- if "html" not in content_type:
118
- return False
119
- if response.get("Content-Encoding"):
120
- return False
121
- return hasattr(response, "content")
122
-
123
- def _inject_devbar(self, response, stats, level):
124
- content = response.content
125
- matches = list(BODY_CLOSE_RE.finditer(content))
126
- if not matches:
127
- return
128
-
129
- dup_marker = (
130
- ' <strong style="color:#f59e0b">(D)</strong>'
131
- if stats["has_duplicates"]
132
- else ""
133
- )
134
-
135
- css = STYLE_BLOCK % get_position()
136
- html = BAR_TEMPLATE % (
137
- level,
138
- stats["duration"],
139
- stats["python_time"],
140
- stats["count"],
141
- dup_marker,
142
- )
143
-
144
- script = ""
145
- if get_enable_console() and stats.get("duplicate_queries"):
146
- console_data = {"duplicates": stats["duplicate_queries"]}
147
- script = SCRIPT_TEMPLATE % json.dumps(console_data)
148
-
149
- payload = (css + html + script).encode(response.charset or "utf-8")
150
-
151
- idx = matches[-1].start()
152
- response.content = content[:idx] + payload + content[idx:]
153
- response["Content-Length"] = str(len(response.content))