pagebar 0.1.0__py3-none-any.whl
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.
- pagebar/__init__.py +10 -0
- pagebar/_core.py +392 -0
- pagebar-0.1.0.dist-info/METADATA +156 -0
- pagebar-0.1.0.dist-info/RECORD +6 -0
- pagebar-0.1.0.dist-info/WHEEL +4 -0
- pagebar-0.1.0.dist-info/licenses/LICENSE +21 -0
pagebar/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""pagebar — a tiny prod-safe perf footer for ASGI apps.
|
|
2
|
+
|
|
3
|
+
Public surface lives in :mod:`pagebar._core`; this module just re-exports it.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pagebar._core import PagebarMiddleware, __version__, pagebar_html
|
|
9
|
+
|
|
10
|
+
__all__ = ["PagebarMiddleware", "__version__", "pagebar_html"]
|
pagebar/_core.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""pagebar implementation. Public surface is re-exported from ``pagebar``.
|
|
2
|
+
|
|
3
|
+
Drop ``PagebarMiddleware`` into the ASGI stack, drop ``pagebar_html()`` into
|
|
4
|
+
the host's base template. Per-request state lives in ``ContextVar``\\ s so
|
|
5
|
+
the helper can read what the middleware wrote without threading anything
|
|
6
|
+
through.
|
|
7
|
+
|
|
8
|
+
The full set of surfaced fields is fixed by the README — adding to it is a
|
|
9
|
+
security review, not a feature request.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import gc
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from contextvars import ContextVar
|
|
22
|
+
from html import escape
|
|
23
|
+
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
24
|
+
from typing import TYPE_CHECKING, Any
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("pagebar")
|
|
32
|
+
|
|
33
|
+
# Time units, named so PLR2004 (magic-value-comparison) is honest.
|
|
34
|
+
_MS_PER_SEC = 1000
|
|
35
|
+
_SEC_PER_MIN = 60
|
|
36
|
+
_SEC_PER_HOUR = 3600
|
|
37
|
+
_SEC_PER_DAY = 86400
|
|
38
|
+
_BYTES_PER_MIB = 1024 * 1024
|
|
39
|
+
_SQL_TEXT_MAX = 500
|
|
40
|
+
_SQL_PARAMS_MAX = 200
|
|
41
|
+
|
|
42
|
+
# Process uptime baseline. Captured at import — fine for the worker-lifetime
|
|
43
|
+
# view; on multi-worker setups each worker has its own.
|
|
44
|
+
_BOOT_TIME = time.monotonic()
|
|
45
|
+
|
|
46
|
+
# Per-request state. ContextVars are the simplest way to hand data from
|
|
47
|
+
# middleware to the template helper without coupling them.
|
|
48
|
+
_start: ContextVar[float] = ContextVar("pagebar_start", default=0.0)
|
|
49
|
+
_queries: ContextVar[int] = ContextVar("pagebar_queries", default=0)
|
|
50
|
+
_method: ContextVar[str] = ContextVar("pagebar_method", default="")
|
|
51
|
+
_path: ContextVar[str] = ContextVar("pagebar_path", default="")
|
|
52
|
+
_version: ContextVar[str] = ContextVar("pagebar_version", default="dev")
|
|
53
|
+
|
|
54
|
+
# Unsafe mode — opt-in surfacing of SQL text + params. Default off.
|
|
55
|
+
_unsafe: ContextVar[bool] = ContextVar("pagebar_unsafe", default=False)
|
|
56
|
+
_sql_log: ContextVar[list] = ContextVar("pagebar_sql_log") # set per request
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Optional SQLAlchemy listener. Registered globally — same pattern as every
|
|
60
|
+
# other tracing/perf tool. +1 to an int per cursor execute. If SQLAlchemy
|
|
61
|
+
# isn't installed the counter just stays at zero.
|
|
62
|
+
def _on_query(
|
|
63
|
+
_c: Any, _cur: Any, statement: str, parameters: Any, _ctx: Any, _many: Any
|
|
64
|
+
) -> None:
|
|
65
|
+
_queries.set(_queries.get() + 1)
|
|
66
|
+
if _unsafe.get():
|
|
67
|
+
_sql_log.get().append({
|
|
68
|
+
"sql": statement,
|
|
69
|
+
"params": parameters,
|
|
70
|
+
"_t0": time.perf_counter(),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _on_query_done(*_args: Any, **_kwargs: Any) -> None:
|
|
75
|
+
if _unsafe.get():
|
|
76
|
+
log = _sql_log.get()
|
|
77
|
+
if log and "_t0" in log[-1]:
|
|
78
|
+
log[-1]["ms"] = (time.perf_counter() - log[-1].pop("_t0")) * 1000
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _register_sqlalchemy_listener() -> None:
|
|
82
|
+
"""Hook the SQL counter into SQLAlchemy if it's importable.
|
|
83
|
+
|
|
84
|
+
Keeps the imports local so ty doesn't see a module-level name typed both
|
|
85
|
+
as ``type[Engine]`` and ``None``.
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
from sqlalchemy import event # noqa: PLC0415
|
|
89
|
+
from sqlalchemy.engine import Engine # noqa: PLC0415
|
|
90
|
+
except ImportError: # pragma: no cover
|
|
91
|
+
return
|
|
92
|
+
event.listens_for(Engine, "before_cursor_execute")(_on_query)
|
|
93
|
+
event.listens_for(Engine, "after_cursor_execute")(_on_query_done)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_register_sqlalchemy_listener()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_BOT_RE = re.compile(r"bot|crawler|spider|googlebot|bingbot", re.IGNORECASE)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _is_bot(scope: dict) -> bool:
|
|
103
|
+
for name, val in scope.get("headers", ()):
|
|
104
|
+
if name == b"user-agent":
|
|
105
|
+
return bool(_BOT_RE.search(val.decode("latin-1", "replace")))
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _resolve_version(package: str, explicit: str) -> str:
|
|
110
|
+
if explicit:
|
|
111
|
+
return explicit
|
|
112
|
+
if not package:
|
|
113
|
+
return "dev"
|
|
114
|
+
try:
|
|
115
|
+
return _pkg_version(package)
|
|
116
|
+
except PackageNotFoundError:
|
|
117
|
+
logger.warning(
|
|
118
|
+
"pagebar: distribution %r not installed; falling back to 'dev'", package
|
|
119
|
+
)
|
|
120
|
+
return "dev"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class PagebarMiddleware:
|
|
124
|
+
"""ASGI middleware: timer + bot/disable gate + SQL log under unsafe."""
|
|
125
|
+
|
|
126
|
+
def __init__( # noqa: PLR0913 — config-heavy by design
|
|
127
|
+
self,
|
|
128
|
+
app: Any,
|
|
129
|
+
*,
|
|
130
|
+
package: str = "",
|
|
131
|
+
version: str = "",
|
|
132
|
+
enabled: bool | Callable[[dict], bool] = True,
|
|
133
|
+
unsafe: bool | Callable[[dict], bool] = False,
|
|
134
|
+
query_budget: int = 0,
|
|
135
|
+
) -> None:
|
|
136
|
+
self.app = app
|
|
137
|
+
self.package = package
|
|
138
|
+
self.version = _resolve_version(package, version)
|
|
139
|
+
self.enabled = enabled
|
|
140
|
+
self.unsafe = unsafe
|
|
141
|
+
self.query_budget = query_budget
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def bound(cls, **kwargs: Any) -> type[PagebarMiddleware]:
|
|
145
|
+
"""Return a subclass with config kwargs baked in.
|
|
146
|
+
|
|
147
|
+
Litestar (and similar frameworks) instantiate middleware as
|
|
148
|
+
``mw_cls(app)`` with no further kwargs. Use this when you can't
|
|
149
|
+
thread keyword args through:
|
|
150
|
+
|
|
151
|
+
middleware=[
|
|
152
|
+
PagebarMiddleware.bound(package="my-app", unsafe=app.debug),
|
|
153
|
+
...,
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
For Starlette/FastAPI, prefer the plain
|
|
157
|
+
``Middleware(PagebarMiddleware, package=..., unsafe=...)`` form —
|
|
158
|
+
no wrapper needed.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
# ty rejects ``type[Self]`` as a class base; cast to ``Any``.
|
|
162
|
+
base: Any = cls
|
|
163
|
+
|
|
164
|
+
class _Bound(base):
|
|
165
|
+
def __init__(self, app: Any) -> None:
|
|
166
|
+
super().__init__(app, **kwargs)
|
|
167
|
+
|
|
168
|
+
_Bound.__name__ = f"{cls.__name__}Bound"
|
|
169
|
+
_Bound.__qualname__ = _Bound.__name__
|
|
170
|
+
return _Bound
|
|
171
|
+
|
|
172
|
+
async def __call__(self, scope: dict, receive: Callable, send: Callable) -> None:
|
|
173
|
+
if scope["type"] != "http":
|
|
174
|
+
await self.app(scope, receive, send)
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
on = self.enabled(scope) if callable(self.enabled) else self.enabled
|
|
178
|
+
if not on or _is_bot(scope):
|
|
179
|
+
await self.app(scope, receive, send)
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
_start.set(time.perf_counter())
|
|
183
|
+
_queries.set(0)
|
|
184
|
+
_method.set(scope.get("method", ""))
|
|
185
|
+
_path.set(scope.get("path", ""))
|
|
186
|
+
_version.set(self.version)
|
|
187
|
+
_unsafe.set(self.unsafe(scope) if callable(self.unsafe) else self.unsafe)
|
|
188
|
+
_sql_log.set([])
|
|
189
|
+
|
|
190
|
+
await self.app(scope, receive, send)
|
|
191
|
+
|
|
192
|
+
if self.query_budget > 0 and _queries.get() > self.query_budget:
|
|
193
|
+
logger.warning(
|
|
194
|
+
"pagebar: SQL budget exceeded: %s %s — %d queries "
|
|
195
|
+
"(budget=%d, elapsed=%.3fs)",
|
|
196
|
+
_method.get(),
|
|
197
|
+
_path.get(),
|
|
198
|
+
_queries.get(),
|
|
199
|
+
self.query_budget,
|
|
200
|
+
time.perf_counter() - _start.get(),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# Bottom-right pill. Single dark color; opens upward into a small panel.
|
|
205
|
+
_STYLE = """\
|
|
206
|
+
.pagebar {
|
|
207
|
+
position: fixed;
|
|
208
|
+
bottom: 8px;
|
|
209
|
+
right: 8px;
|
|
210
|
+
z-index: 2147483647;
|
|
211
|
+
font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
212
|
+
background: #1f2937;
|
|
213
|
+
color: #e5e7eb;
|
|
214
|
+
border-radius: 6px;
|
|
215
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, .2);
|
|
216
|
+
padding: 4px 10px;
|
|
217
|
+
cursor: pointer;
|
|
218
|
+
user-select: none;
|
|
219
|
+
max-width: 90vw;
|
|
220
|
+
}
|
|
221
|
+
.pagebar-panel {
|
|
222
|
+
margin-top: 6px;
|
|
223
|
+
padding: 8px 10px;
|
|
224
|
+
background: #111827;
|
|
225
|
+
border-radius: 4px;
|
|
226
|
+
display: none;
|
|
227
|
+
}
|
|
228
|
+
.pagebar.--open .pagebar-panel {
|
|
229
|
+
display: block;
|
|
230
|
+
}
|
|
231
|
+
.pagebar-row {
|
|
232
|
+
display: flex;
|
|
233
|
+
justify-content: space-between;
|
|
234
|
+
gap: 1em;
|
|
235
|
+
margin: 2px 0;
|
|
236
|
+
white-space: nowrap;
|
|
237
|
+
}
|
|
238
|
+
.pagebar-row span:last-child {
|
|
239
|
+
color: #9ca3af;
|
|
240
|
+
}
|
|
241
|
+
.pagebar-sql {
|
|
242
|
+
margin-top: 8px;
|
|
243
|
+
padding-top: 8px;
|
|
244
|
+
border-top: 1px solid #374151;
|
|
245
|
+
max-height: 40vh;
|
|
246
|
+
overflow: auto;
|
|
247
|
+
}
|
|
248
|
+
.pagebar-sql-item {
|
|
249
|
+
margin: 4px 0;
|
|
250
|
+
padding: 4px;
|
|
251
|
+
background: #0b1220;
|
|
252
|
+
border-radius: 3px;
|
|
253
|
+
}
|
|
254
|
+
.pagebar-sql-meta {
|
|
255
|
+
color: #f59e0b;
|
|
256
|
+
font-size: 11px;
|
|
257
|
+
}
|
|
258
|
+
.pagebar-sql-text {
|
|
259
|
+
white-space: pre-wrap;
|
|
260
|
+
word-break: break-word;
|
|
261
|
+
margin-top: 2px;
|
|
262
|
+
}
|
|
263
|
+
.pagebar-sql-params {
|
|
264
|
+
color: #9ca3af;
|
|
265
|
+
font-size: 11px;
|
|
266
|
+
margin-top: 2px;
|
|
267
|
+
}
|
|
268
|
+
.pagebar-unsafe-tag {
|
|
269
|
+
color: #fca5a5;
|
|
270
|
+
font-size: 10px;
|
|
271
|
+
margin-left: 4px;
|
|
272
|
+
background: #7f1d1d;
|
|
273
|
+
padding: 1px 4px;
|
|
274
|
+
border-radius: 3px;
|
|
275
|
+
}
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
# Closes on click outside the panel; ESC closes too. State in localStorage.
|
|
279
|
+
_SCRIPT = """\
|
|
280
|
+
(() => {
|
|
281
|
+
const bar = document.currentScript.previousElementSibling;
|
|
282
|
+
const KEY = 'pagebar.open';
|
|
283
|
+
const apply = () => {
|
|
284
|
+
bar.classList.toggle('--open', localStorage.getItem(KEY) === '1');
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
bar.addEventListener('click', (e) => {
|
|
288
|
+
if (e.target.closest('.pagebar-panel')) return;
|
|
289
|
+
const next = bar.classList.contains('--open') ? '0' : '1';
|
|
290
|
+
localStorage.setItem(KEY, next);
|
|
291
|
+
apply();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
document.addEventListener('keydown', (e) => {
|
|
295
|
+
if (e.key === 'Escape') {
|
|
296
|
+
localStorage.setItem(KEY, '0');
|
|
297
|
+
apply();
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
apply();
|
|
302
|
+
})();
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _fmt_elapsed(seconds: float) -> str:
|
|
307
|
+
ms = seconds * _MS_PER_SEC
|
|
308
|
+
if ms < _MS_PER_SEC:
|
|
309
|
+
return f"{ms:.0f}ms"
|
|
310
|
+
return f"{ms / _MS_PER_SEC:.2f}s"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _fmt_uptime(seconds: float) -> str:
|
|
314
|
+
if seconds < _SEC_PER_MIN:
|
|
315
|
+
return f"{seconds:.0f}s"
|
|
316
|
+
if seconds < _SEC_PER_HOUR:
|
|
317
|
+
return f"{seconds / _SEC_PER_MIN:.0f}m"
|
|
318
|
+
if seconds < _SEC_PER_DAY:
|
|
319
|
+
return f"{seconds / _SEC_PER_HOUR:.1f}h"
|
|
320
|
+
return f"{seconds / _SEC_PER_DAY:.1f}d"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _rss_mib() -> str:
|
|
324
|
+
"""Process resident set size in MiB, or '—' if unavailable (Windows)."""
|
|
325
|
+
try:
|
|
326
|
+
import resource # noqa: PLC0415
|
|
327
|
+
|
|
328
|
+
rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
|
329
|
+
except ImportError: # Windows: no `resource` module
|
|
330
|
+
return "—"
|
|
331
|
+
# ru_maxrss is bytes on macOS, kibibytes on Linux/BSD.
|
|
332
|
+
bytes_ = rss if sys.platform == "darwin" else rss * 1024
|
|
333
|
+
return f"{bytes_ / _BYTES_PER_MIB:.1f} MiB"
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _render_sql_log() -> str:
|
|
337
|
+
log = _sql_log.get()
|
|
338
|
+
if not log:
|
|
339
|
+
return ""
|
|
340
|
+
items = []
|
|
341
|
+
for i, e in enumerate(log, 1):
|
|
342
|
+
sql = escape(str(e.get("sql", "")))[:_SQL_TEXT_MAX]
|
|
343
|
+
params = escape(repr(e.get("params", "")))[:_SQL_PARAMS_MAX]
|
|
344
|
+
ms = e.get("ms")
|
|
345
|
+
meta = f"#{i} · {ms:.1f}ms" if ms is not None else f"#{i}"
|
|
346
|
+
items.append(
|
|
347
|
+
f'<div class="pagebar-sql-item">'
|
|
348
|
+
f'<div class="pagebar-sql-meta">{meta}</div>'
|
|
349
|
+
f'<div class="pagebar-sql-text">{sql}</div>'
|
|
350
|
+
f'<div class="pagebar-sql-params">{params}</div>'
|
|
351
|
+
f"</div>"
|
|
352
|
+
)
|
|
353
|
+
return f'<div class="pagebar-sql">{"".join(items)}</div>'
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def pagebar_html(*, nonce: str = "") -> str:
|
|
357
|
+
"""Return the bar's HTML fragment. Safe to inject as-is."""
|
|
358
|
+
elapsed = time.perf_counter() - _start.get() if _start.get() else 0.0
|
|
359
|
+
q = _queries.get()
|
|
360
|
+
method = escape(_method.get())
|
|
361
|
+
path = escape(_path.get())
|
|
362
|
+
ver = escape(_version.get())
|
|
363
|
+
unsafe = _unsafe.get()
|
|
364
|
+
|
|
365
|
+
rows = (
|
|
366
|
+
("Version", ver),
|
|
367
|
+
("Time", _fmt_elapsed(elapsed)),
|
|
368
|
+
("SQL", f"{q} quer{'y' if q == 1 else 'ies'}"),
|
|
369
|
+
("Request", f"{method} {path}"),
|
|
370
|
+
("Memory", _rss_mib()),
|
|
371
|
+
("Uptime", _fmt_uptime(time.monotonic() - _BOOT_TIME)),
|
|
372
|
+
("Threads", str(threading.active_count())),
|
|
373
|
+
("GC", "/".join(str(n) for n in gc.get_count())),
|
|
374
|
+
("Python", f"{sys.version_info.major}.{sys.version_info.minor}"),
|
|
375
|
+
("PID", str(os.getpid())),
|
|
376
|
+
)
|
|
377
|
+
rows_html = "".join(
|
|
378
|
+
f'<div class="pagebar-row"><span>{k}</span><span>{v}</span></div>'
|
|
379
|
+
for k, v in rows
|
|
380
|
+
)
|
|
381
|
+
sql_html = _render_sql_log() if unsafe else ""
|
|
382
|
+
unsafe_tag = '<span class="pagebar-unsafe-tag">UNSAFE</span>' if unsafe else ""
|
|
383
|
+
n = f' nonce="{escape(nonce)}"' if nonce else ""
|
|
384
|
+
pill = f"{ver} · {_fmt_elapsed(elapsed)} · {q} SQL · {method} {path}"
|
|
385
|
+
return (
|
|
386
|
+
f"<style{n}>{_STYLE}</style>"
|
|
387
|
+
f'<div class="pagebar">'
|
|
388
|
+
f"<span>{pill}{unsafe_tag}</span>"
|
|
389
|
+
f'<div class="pagebar-panel">{rows_html}{sql_html}</div>'
|
|
390
|
+
f"</div>"
|
|
391
|
+
f"<script{n}>{_SCRIPT}</script>"
|
|
392
|
+
)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pagebar
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A tiny prod-safe perf footer & toolbar for ASGI apps.
|
|
5
|
+
Author-email: Stéfane Fermigier <sf@fermigier.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: asgi,footer,monitoring,performance,sql
|
|
9
|
+
Classifier: Framework :: AsyncIO
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Provides-Extra: sqlalchemy
|
|
20
|
+
Requires-Dist: sqlalchemy>=2.0; extra == 'sqlalchemy'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# pagebar
|
|
24
|
+
|
|
25
|
+
A tiny prod-safe perf footer for ASGI apps. One line at the bottom of every page, click to expand. No SQL text, no env vars, no stack traces — nothing that could leak.
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
v2026.6.19 · 22ms · 4 SQL · GET /editeurs · 200
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
pip install pagebar # core only
|
|
35
|
+
pip install pagebar[sqlalchemy] # with SQL query counter
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Use
|
|
39
|
+
|
|
40
|
+
### Starlette / FastAPI
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from pagebar import PagebarMiddleware, pagebar_html
|
|
44
|
+
from starlette.applications import Starlette
|
|
45
|
+
from starlette.middleware import Middleware
|
|
46
|
+
|
|
47
|
+
app = Starlette(
|
|
48
|
+
middleware=[Middleware(PagebarMiddleware, package="my-app")],
|
|
49
|
+
routes=[...],
|
|
50
|
+
)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Litestar (and other `mw_cls(app)`-style frameworks)
|
|
54
|
+
|
|
55
|
+
Litestar instantiates middleware as `cls(app)` with no further kwargs, so config has to be baked in. Use `PagebarMiddleware.bound(...)`:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from litestar import Litestar
|
|
59
|
+
from pagebar import PagebarMiddleware
|
|
60
|
+
|
|
61
|
+
app = Litestar(
|
|
62
|
+
middleware=[
|
|
63
|
+
PagebarMiddleware.bound(package="my-app", unsafe=False),
|
|
64
|
+
],
|
|
65
|
+
route_handlers=[...],
|
|
66
|
+
)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`.bound(**kwargs)` returns a thin subclass with the kwargs baked into `__init__`. Same fields as the regular constructor.
|
|
70
|
+
|
|
71
|
+
### Template
|
|
72
|
+
|
|
73
|
+
In your base template, anywhere inside `<body>`:
|
|
74
|
+
|
|
75
|
+
```html
|
|
76
|
+
{{ pagebar_html() | safe }}
|
|
77
|
+
</body>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
That's it. One name for the middleware, one for the helper.
|
|
81
|
+
|
|
82
|
+
## What's on screen
|
|
83
|
+
|
|
84
|
+
| Field | Source |
|
|
85
|
+
|---------|-------------------------------------------------------------------|
|
|
86
|
+
| Version | `importlib.metadata.version(package)` — or explicit `version=` |
|
|
87
|
+
| Time | `time.perf_counter()` delta |
|
|
88
|
+
| SQL | SQLAlchemy `before_cursor_execute` listener (if installed) |
|
|
89
|
+
| Request | method + path |
|
|
90
|
+
| Memory | `resource.getrusage().ru_maxrss` — RSS in MiB |
|
|
91
|
+
| Uptime | `time.monotonic()` since worker import |
|
|
92
|
+
| Threads | `threading.active_count()` |
|
|
93
|
+
| GC | `gc.get_count()` — gen0/gen1/gen2 pending |
|
|
94
|
+
| Python | `sys.version_info` |
|
|
95
|
+
| PID | `os.getpid()` |
|
|
96
|
+
|
|
97
|
+
No SQL text. No params. No env. No traces. That's the whole surface.
|
|
98
|
+
|
|
99
|
+
## Knobs
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
PagebarMiddleware(
|
|
103
|
+
app,
|
|
104
|
+
package="my-app", # distribution name (PyPI / pyproject.toml)
|
|
105
|
+
version="", # escape hatch: bypass importlib.metadata lookup
|
|
106
|
+
enabled=True, # bool or callable(scope) -> bool
|
|
107
|
+
unsafe=False, # bool or callable(scope) -> bool — see below
|
|
108
|
+
query_budget=20, # >0 → log a WARNING when SQL count exceeds this
|
|
109
|
+
)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Unsafe mode
|
|
113
|
+
|
|
114
|
+
In dev, you usually *want* to see the SQL text. Flip `unsafe=True` and the bar adds, per request: each statement (truncated), bound parameters, and per-statement timing. A red `UNSAFE` badge in the pill makes the mode obvious.
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
import os
|
|
118
|
+
PagebarMiddleware(app, package="my-app", unsafe=bool(os.getenv("DEBUG")))
|
|
119
|
+
# or framework-driven
|
|
120
|
+
PagebarMiddleware(app, package="my-app", unsafe=app.debug)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`unsafe` defaults to `False` and **only** takes its value from constructor wiring — no URL parameter, no header, no cookie. The host's deploy config is the authority.
|
|
124
|
+
|
|
125
|
+
`enabled` lets you hide the bar from JSON endpoints, healthchecks, or admin routes:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
def show(scope):
|
|
129
|
+
return not scope["path"].startswith(("/api/", "/health"))
|
|
130
|
+
|
|
131
|
+
Middleware(PagebarMiddleware, package="my-app", enabled=show)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Bots are skipped automatically (`User-Agent` matching `bot|crawler|spider|googlebot|bingbot`).
|
|
135
|
+
|
|
136
|
+
## CSP
|
|
137
|
+
|
|
138
|
+
`pagebar_html()` accepts a `nonce` keyword that's applied to the inline `<style>` and `<script>`:
|
|
139
|
+
|
|
140
|
+
```html
|
|
141
|
+
{{ pagebar_html(nonce=csp_nonce) | safe }}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Otherwise the host needs `'unsafe-inline'` for both directives. No external assets, no third-party requests.
|
|
145
|
+
|
|
146
|
+
## Why not the Django/Flask/Litestar debug toolbars?
|
|
147
|
+
|
|
148
|
+
They reveal SQL text, environment, settings, request bodies, stack traces — fine in dev, unacceptable in production. pagebar surfaces a fixed, public-safe set of fields. The shape is the security model.
|
|
149
|
+
|
|
150
|
+
## Non-goals
|
|
151
|
+
|
|
152
|
+
No panels system, no plugins, no per-framework adapters, no APM, no time series, no SQL EXPLAIN, no profiler. Pure ASGI middleware + one helper function in one file.
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
pagebar/__init__.py,sha256=Gb-s-v6ESCbq4IYeE3LmBKzxslLXyRGZvqSZNOQrZ0Y,314
|
|
2
|
+
pagebar/_core.py,sha256=ckTQcvF_H7NezL3rdTPAqomoH96yH9h-fUPNiAUy8c0,11955
|
|
3
|
+
pagebar-0.1.0.dist-info/METADATA,sha256=UlKixCnAOk5dcoihpZ2e9KJTUsLvAq6u9uPnxrDJD4E,5354
|
|
4
|
+
pagebar-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
5
|
+
pagebar-0.1.0.dist-info/licenses/LICENSE,sha256=vUkf6nsiiAu8kQuDkDVPwiP3XBYOrTmN7hiJFKiC0Lc,1089
|
|
6
|
+
pagebar-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stéfane Fermigier / Abilian SAS
|
|
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.
|