Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ba28fdeef9 | |||
| 9085bcb09a | |||
| 3ea551bc1a | |||
| 3bcf50f55f | |||
| 7f099b14f6 | |||
| 0e1087a614 |
@@ -26,6 +26,7 @@ tools.db
|
||||
**/*.prof
|
||||
**/*.db
|
||||
screenshot*
|
||||
Capture*
|
||||
|
||||
# Created by .ignore support plugin (hsz.mobi)
|
||||
### Python template
|
||||
|
||||
@@ -17,9 +17,13 @@
|
||||
*/
|
||||
|
||||
window.HTMX_DEBUG = false;
|
||||
window.HTMX_DOM_DEBUG = false;
|
||||
window.HTMX_SETTLE_DEBUG = false;
|
||||
(function () {
|
||||
console.log('Debug HTMX: htmx.logAll();');
|
||||
console.log('Perf HTMX: window.HTMX_DEBUG=true;');
|
||||
console.log('DOM mutations: window.HTMX_DOM_DEBUG=true;');
|
||||
console.log('Settle/transition debug: window.HTMX_SETTLE_DEBUG=true;');
|
||||
})();
|
||||
|
||||
(function () {
|
||||
@@ -38,12 +42,108 @@ window.HTMX_DEBUG = false;
|
||||
let counter = 0;
|
||||
const requests = new WeakMap();
|
||||
|
||||
function _startDomObserver(requestId, startTime) {
|
||||
const observer = new MutationObserver(mutations => {
|
||||
mutations.forEach(m => {
|
||||
const el = m.target;
|
||||
const label = el.id ? `#${el.id}` : `<${el.tagName.toLowerCase()} class="${el.className}">`;
|
||||
const t = (performance.now() - startTime).toFixed(2);
|
||||
console.debug(`[HTMX DOM] #${requestId} +${t}ms class changed on ${label} → "${el.className}"`);
|
||||
});
|
||||
});
|
||||
observer.observe(document.body, {attributes: true, subtree: true, attributeFilter: ['class']});
|
||||
return observer;
|
||||
}
|
||||
|
||||
function getInfo(detail) {
|
||||
const key = detail?.requestConfig ?? detail?.xhr ?? null;
|
||||
if (!key || !requests.has(key)) return null;
|
||||
return requests.get(key);
|
||||
}
|
||||
|
||||
// HTMX_SETTLE_DEBUG: trace transitions/animations + computed style on tsm_ after swap
|
||||
(function () {
|
||||
['transitionstart', 'transitionend', 'transitioncancel',
|
||||
'animationstart', 'animationend'].forEach(evtName => {
|
||||
document.addEventListener(evtName, e => {
|
||||
if (!window.HTMX_SETTLE_DEBUG) return;
|
||||
const t = window._perfT0 ? (performance.now() - window._perfT0).toFixed(1) : '?';
|
||||
const label = e.target.id ? `#${e.target.id}` : `<${e.target.tagName.toLowerCase()}>`;
|
||||
console.debug(`[SETTLE +${t}ms] ${evtName} on ${label} | property: ${e.propertyName ?? ''} | duration: ${e.elapsedTime ?? ''}s`);
|
||||
}, true);
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:afterSwap', e => {
|
||||
if (!window.HTMX_SETTLE_DEBUG) return;
|
||||
const target = e.detail?.target;
|
||||
if (!target?.id?.startsWith('tsm_')) return;
|
||||
const t = window._perfT0 ? (performance.now() - window._perfT0).toFixed(1) : '?';
|
||||
const cs = getComputedStyle(target);
|
||||
console.debug(`[SETTLE +${t}ms] tsm_ computed transitionDuration="${cs.transitionDuration}" transition="${cs.transition}"`);
|
||||
const child = target.firstElementChild;
|
||||
if (child) {
|
||||
const cs2 = getComputedStyle(child);
|
||||
console.debug(`[SETTLE +${t}ms] tsm_>child computed transitionDuration="${cs2.transitionDuration}" transition="${cs2.transition}"`);
|
||||
}
|
||||
});
|
||||
|
||||
// Patch setTimeout to detect delayed settle timers (main thread blocked by rendering)
|
||||
const _origST = window.setTimeout;
|
||||
window.setTimeout = function (fn, delay, ...args) {
|
||||
const createdAt = window._perfT0 ? (performance.now() - window._perfT0) : null;
|
||||
return _origST.call(window, function () {
|
||||
if (window.HTMX_SETTLE_DEBUG && createdAt !== null && delay >= 1) {
|
||||
const firedAt = performance.now() - window._perfT0;
|
||||
const late = (firedAt - createdAt - delay).toFixed(1);
|
||||
console.debug(`[TIMER] delay=${delay}ms | scheduled +${createdAt.toFixed(1)}ms | fired +${firedAt.toFixed(1)}ms | late=${late}ms`);
|
||||
}
|
||||
return fn.apply(this, args);
|
||||
}, delay, ...args);
|
||||
};
|
||||
|
||||
// PerformanceObserver: log all long tasks (>50ms JS blocks) with timing relative to _perfT0
|
||||
try {
|
||||
new PerformanceObserver(list => {
|
||||
if (!window.HTMX_SETTLE_DEBUG || !window._perfT0) return;
|
||||
list.getEntries().forEach(entry => {
|
||||
const start = (entry.startTime - window._perfT0).toFixed(1);
|
||||
const dur = entry.duration.toFixed(1);
|
||||
console.debug(`[LONG TASK] start +${start}ms | duration ${dur}ms`);
|
||||
});
|
||||
}).observe({entryTypes: ['longtask']});
|
||||
} catch (e) { /* longtask not supported */ }
|
||||
|
||||
// After swap: measure time until next rendering frame to detect rendering blockage
|
||||
document.addEventListener('htmx:afterSwap', e => {
|
||||
if (!window.HTMX_SETTLE_DEBUG || !window._perfT0) return;
|
||||
if (!e.detail?.target?.id?.startsWith('tsm_')) return;
|
||||
const swapAt = performance.now() - window._perfT0;
|
||||
requestAnimationFrame(() => {
|
||||
const rafAt = (performance.now() - window._perfT0).toFixed(1);
|
||||
console.debug(`[RENDER] swap at +${swapAt.toFixed(1)}ms | next rAF at +${rafAt}ms | rendering blocked ${(rafAt - swapAt).toFixed(1)}ms`);
|
||||
});
|
||||
});
|
||||
|
||||
// Patch requestAnimationFrame to identify which callback causes the long task
|
||||
const _origRAF = window.requestAnimationFrame;
|
||||
window.requestAnimationFrame = function (fn) {
|
||||
const stack = (new Error().stack || '').split('\n')[2]?.trim() || '?';
|
||||
const scheduledAt = window._perfT0 ? (performance.now() - window._perfT0) : null;
|
||||
return _origRAF.call(window, function (timestamp) {
|
||||
const t0 = performance.now();
|
||||
const result = fn(timestamp);
|
||||
if (window.HTMX_SETTLE_DEBUG && scheduledAt !== null) {
|
||||
const cost = (performance.now() - t0).toFixed(1);
|
||||
const firedAt = (t0 - window._perfT0).toFixed(1);
|
||||
if (parseFloat(cost) > 5) {
|
||||
console.debug(`[RAF SLOW] scheduled +${scheduledAt.toFixed(1)}ms | fired +${firedAt}ms | cost ${cost}ms | ${stack}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
};
|
||||
})();
|
||||
|
||||
EVENTS.forEach(eventName => {
|
||||
document.addEventListener(eventName, (e) => {
|
||||
if (!window.HTMX_DEBUG) return;
|
||||
@@ -59,7 +159,8 @@ window.HTMX_DEBUG = false;
|
||||
if (key) {
|
||||
const id = ++counter;
|
||||
const now = performance.now();
|
||||
requests.set(key, {id, start: now, last: now});
|
||||
const observer = window.HTMX_DOM_DEBUG ? _startDomObserver(id, now) : null;
|
||||
requests.set(key, {id, start: now, last: now, observer});
|
||||
prefix = `#${String(id).padStart(3)} + 0.0ms (Δ 0.0ms)`;
|
||||
} else {
|
||||
prefix = `# ? + 0.0ms (Δ 0.0ms)`;
|
||||
@@ -72,6 +173,10 @@ window.HTMX_DEBUG = false;
|
||||
const step = (now - info.last).toFixed(1);
|
||||
info.last = now;
|
||||
prefix = `#${String(info.id).padStart(3)} +${String(total).padStart(7)}ms (Δ${String(step).padStart(7)}ms)`;
|
||||
|
||||
if (eventName === 'htmx:afterSettle' || eventName === 'htmx:sendError') {
|
||||
info.observer?.disconnect();
|
||||
}
|
||||
} else {
|
||||
prefix = `# ? + ?.?ms (Δ ?.?ms)`;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
// Set window.KEYBOARD_DEBUG = true in the browser console to enable traces
|
||||
window.KEYBOARD_DEBUG = false;
|
||||
(function () {
|
||||
console.log('Perf Keyboard: window.KEYBOARD_DEBUG=true;');
|
||||
})();
|
||||
|
||||
(function () {
|
||||
function kbLog(...args) {
|
||||
@@ -169,6 +172,8 @@ window.KEYBOARD_DEBUG = false;
|
||||
* @param {KeyboardEvent} event - The keyboard event
|
||||
*/
|
||||
function handleKeyboardEvent(event) {
|
||||
window._perfT0 = performance.now();
|
||||
kbLog(`[PERF] keyboard_start: 0ms`);
|
||||
const key = normalizeKey(event.key);
|
||||
|
||||
// Add key to current pressed keys
|
||||
@@ -260,7 +265,9 @@ window.KEYBOARD_DEBUG = false;
|
||||
// We have matches and NO element has longer sequences possible
|
||||
// Trigger ALL matches immediately
|
||||
for (const match of currentMatches) {
|
||||
kbLog(`[PERF] before_trigger: ${(performance.now() - window._perfT0).toFixed(2)}ms`);
|
||||
triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, event);
|
||||
kbLog(`[PERF] after_trigger: ${(performance.now() - window._perfT0).toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
// Clear history after triggering
|
||||
@@ -299,6 +306,8 @@ window.KEYBOARD_DEBUG = false;
|
||||
KeyboardRegistry.snapshotHistory = [];
|
||||
}
|
||||
|
||||
kbLog(`[PERF] keyboard_end: ${(performance.now() - window._perfT0).toFixed(2)}ms`);
|
||||
|
||||
// If we found no match at all, clear the history
|
||||
// This handles invalid sequences like "A C" when only "A B" exists
|
||||
if (!foundAnyMatch) {
|
||||
|
||||
@@ -376,8 +376,13 @@ function triggerHtmxAction(elementId, config, combinationStr, isInside, event) {
|
||||
}
|
||||
}
|
||||
|
||||
// Use the HTMX target element as source so htmx-request is added there
|
||||
// instead of <body>, avoiding a full-document style recalculation.
|
||||
const targetSelector = config['hx-target'];
|
||||
const sourceElement = targetSelector ? document.querySelector(targetSelector) : element;
|
||||
htmxOptions.source = sourceElement ?? element;
|
||||
|
||||
// Make AJAX call with htmx
|
||||
//console.debug(`Triggering HTMX action for element ${elementId}: ${method} ${url}`, htmxOptions);
|
||||
htmx.ajax(method, url, htmxOptions);
|
||||
}
|
||||
|
||||
|
||||
@@ -162,6 +162,204 @@
|
||||
color: color-mix(in oklab, var(--color-base-content) 45%, transparent);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Detail panel — right side
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 10px;
|
||||
background: var(--color-base-200);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-title {
|
||||
flex: 1;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-cmd {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.mf-profiler-detail-duration {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.mf-profiler-view-toggle {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-view-btn-active {
|
||||
color: var(--color-primary) !important;
|
||||
background: color-mix(in oklab, var(--color-primary) 12%, transparent) !important;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Span tree — inside a Properties group card
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler-span-tree-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* details wrapper: no extra spacing */
|
||||
.mf-profiler-span-tree-content details {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.mf-profiler-span-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--color-border) 50%, transparent);
|
||||
}
|
||||
|
||||
/* summary reuses the same row style — override browser defaults */
|
||||
summary.mf-profiler-span-row {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
summary.mf-profiler-span-row::marker,
|
||||
summary.mf-profiler-span-row::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
summary.mf-profiler-span-row::before {
|
||||
content: '▶';
|
||||
font-size: 8px;
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
display: inline-block;
|
||||
transition: transform 0.15s ease;
|
||||
color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
|
||||
}
|
||||
|
||||
details[open] > summary.mf-profiler-span-row::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.mf-profiler-span-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mf-profiler-span-row:hover {
|
||||
background: var(--color-base-200);
|
||||
}
|
||||
|
||||
.mf-profiler-span-indent {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
align-self: stretch;
|
||||
border-left: 1px solid color-mix(in oklab, var(--color-border) 60%, transparent);
|
||||
}
|
||||
|
||||
.mf-profiler-span-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-left: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-span-name {
|
||||
min-width: 120px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-span-name-root {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar-bg {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
background: color-mix(in oklab, var(--color-border) 80%, transparent);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar.mf-profiler-fast {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
|
||||
.mf-profiler-span-bar.mf-profiler-medium {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar.mf-profiler-slow {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.mf-profiler-span-ms {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: color-mix(in oklab, var(--color-base-content) 55%, transparent);
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-span-ms.mf-profiler-medium {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.mf-profiler-span-ms.mf-profiler-slow {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.mf-profiler-cumulative-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in oklab, var(--color-primary) 10%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--color-primary) 30%, transparent);
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Empty state
|
||||
------------------------------------------------------------------ */
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
-ms-overflow-style: none; /* IE/Edge: hide scrollbar */
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
contain: layout style paint; /* Isolate from sibling DOM changes (perf: prevents style recalc cascade) */
|
||||
}
|
||||
|
||||
/* Chrome/Safari: hide scrollbar */
|
||||
|
||||
@@ -687,9 +687,10 @@ function updateDatagridSelection(datagridId) {
|
||||
element.style.userSelect = '';
|
||||
});
|
||||
|
||||
// Loop through the children of the selection manager
|
||||
// Loop through the children of the inner selection manager (#tsmi_)
|
||||
const tsmi = document.getElementById(`tsmi_${datagridId}`) ?? selectionManager;
|
||||
let hasFocusedCell = false;
|
||||
Array.from(selectionManager.children).forEach((selection) => {
|
||||
Array.from(tsmi.children).forEach((selection) => {
|
||||
const selectionType = selection.getAttribute('selection-type');
|
||||
const elementId = selection.getAttribute('element-id');
|
||||
|
||||
|
||||
@@ -26,10 +26,10 @@ class Boundaries(SingleInstance):
|
||||
Keep the boundaries updated
|
||||
"""
|
||||
|
||||
def __init__(self, owner, container_id: str = None, on_resize=None, _id=None):
|
||||
super().__init__(owner, _id=_id)
|
||||
self._owner = owner
|
||||
self._container_id = container_id or owner.get_id()
|
||||
def __init__(self, parent, container_id: str = None, on_resize=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self._owner = parent
|
||||
self._container_id = container_id or parent.get_id()
|
||||
self._on_resize = on_resize
|
||||
self._commands = Commands(self)
|
||||
self._state = BoundariesState()
|
||||
|
||||
@@ -34,6 +34,7 @@ from myfasthtml.core.formatting.dsl.parser import DSLParser
|
||||
from myfasthtml.core.formatting.engine import FormattingEngine
|
||||
from myfasthtml.core.instances import MultipleInstance, InstancesManager
|
||||
from myfasthtml.core.optimized_ft import OptimizedDiv
|
||||
from myfasthtml.core.profiler import profiler
|
||||
from myfasthtml.core.utils import merge_classes, is_null
|
||||
from myfasthtml.icons.carbon import row, column, grid
|
||||
from myfasthtml.icons.fluent import checkbox_unchecked16_regular
|
||||
@@ -156,21 +157,21 @@ class Commands(BaseCommands):
|
||||
"Click on the table",
|
||||
self._owner,
|
||||
self._owner.handle_on_click
|
||||
).htmx(target=f"#tsm_{self._id}")
|
||||
|
||||
).htmx(target=f"#tsmi_{self._id}")
|
||||
|
||||
def on_key_pressed(self):
|
||||
return Command("OnKeyPressed",
|
||||
"Key pressed on the table",
|
||||
self._owner,
|
||||
self._owner.on_key_pressed
|
||||
).htmx(target=f"#tsm_{self._id}")
|
||||
|
||||
).htmx(target=f"#tsmi_{self._id}")
|
||||
|
||||
def on_mouse_selection(self):
|
||||
return Command("OnMouseSelection",
|
||||
"Range selection with mouse",
|
||||
self._owner,
|
||||
self._owner.on_mouse_selection
|
||||
).htmx(target=f"#tsm_{self._id}")
|
||||
).htmx(target=f"#tsmi_{self._id}")
|
||||
|
||||
def toggle_columns_manager(self):
|
||||
return Command("ToggleColumnsManager",
|
||||
@@ -689,8 +690,8 @@ class DataGrid(MultipleInstance):
|
||||
else:
|
||||
logger.debug(f" is_inside=False")
|
||||
|
||||
return self.render_partial()
|
||||
|
||||
return self.render_partial(inner_mode=True)
|
||||
|
||||
def on_mouse_selection(self, combination, is_inside, cell_id_mousedown, cell_id_mouseup):
|
||||
logger.debug(f"on_mouse_selection {combination=} {is_inside=} {cell_id_mousedown=} {cell_id_mouseup=}")
|
||||
if (is_inside and
|
||||
@@ -706,21 +707,22 @@ class DataGrid(MultipleInstance):
|
||||
|
||||
self._state.selection.extra_selected.clear()
|
||||
self._state.selection.extra_selected.append(("range", (min_col, min_row, max_col, max_row)))
|
||||
|
||||
return self.render_partial()
|
||||
|
||||
|
||||
return self.render_partial(inner_mode=True)
|
||||
|
||||
@profiler.trace_calls()
|
||||
def on_key_pressed(self, combination, has_focus, is_inside):
|
||||
logger.debug(f"on_key_pressed table={self.get_table_name()} {combination=} {has_focus=} {is_inside=}")
|
||||
if combination == "esc":
|
||||
self._update_current_position(None, reset_selection=True)
|
||||
return self.render_partial("cell", pos=self._state.selection.last_selected)
|
||||
|
||||
return self.render_partial("cell", inner_mode=True, pos=self._state.selection.last_selected)
|
||||
|
||||
elif (combination == "enter" and
|
||||
self._settings.enable_edition and
|
||||
self._state.selection.selected and
|
||||
self._state.edition.under_edition is None):
|
||||
return self._enter_edition(self._state.selection.selected)
|
||||
|
||||
|
||||
elif combination in self._ARROW_KEY_DIRECTIONS:
|
||||
current_pos = (self._state.selection.selected
|
||||
or self._state.selection.last_selected
|
||||
@@ -728,8 +730,8 @@ class DataGrid(MultipleInstance):
|
||||
direction = self._ARROW_KEY_DIRECTIONS[combination]
|
||||
new_pos = self._navigate(current_pos, direction)
|
||||
self._update_current_position(new_pos)
|
||||
|
||||
return self.render_partial()
|
||||
|
||||
return self.render_partial(inner_mode=True)
|
||||
|
||||
def on_column_changed(self):
|
||||
logger.debug("on_column_changed")
|
||||
@@ -1178,10 +1180,27 @@ class DataGrid(MultipleInstance):
|
||||
selected.append(extra_sel)
|
||||
|
||||
return Div(
|
||||
*[Div(selection_type=s_type, element_id=f"{elt_id}") for s_type, elt_id in selected],
|
||||
Div(
|
||||
*[Div(selection_type=s_type, element_id=f"{elt_id}") for s_type, elt_id in selected],
|
||||
id=f"tsmi_{self._id}",
|
||||
),
|
||||
id=f"tsm_{self._id}",
|
||||
selection_mode=f"{self._state.selection.selection_mode}",
|
||||
)
|
||||
|
||||
def mk_selection_manager_inner(self):
|
||||
selected = []
|
||||
|
||||
if self._state.selection.selected:
|
||||
selected.append(("focus", self._get_element_id_from_pos("cell", self._state.selection.selected)))
|
||||
|
||||
for extra_sel in self._state.selection.extra_selected:
|
||||
selected.append(extra_sel)
|
||||
|
||||
return Div(
|
||||
*[Div(selection_type=s_type, element_id=f"{elt_id}") for s_type, elt_id in selected],
|
||||
id=f"tsmi_{self._id}",
|
||||
)
|
||||
|
||||
def mk_aggregation_cell(self, col_def, row_index: int, footer_conf, oob=False):
|
||||
"""
|
||||
@@ -1268,17 +1287,21 @@ class DataGrid(MultipleInstance):
|
||||
cls="grid",
|
||||
style="height: 100%; grid-template-rows: auto 1fr;",
|
||||
tabindex="-1",
|
||||
**{"hx-on:htmx:after-swap": f"if(event.detail.target.id==='tsm_{self._id}') updateDatagridSelection('{self._id}');"}
|
||||
**{"hx-on:htmx:after-swap": f"if(event.detail.target.id==='tsm_{self._id}'||event.detail.target.id==='tsmi_{self._id}') updateDatagridSelection('{self._id}');"}
|
||||
)
|
||||
|
||||
def render_partial(self, fragment="cell", **kwargs):
|
||||
def render_partial(self, fragment="cell", inner_mode=False, **kwargs):
|
||||
"""
|
||||
|
||||
:param fragment: cell | body | table | header
|
||||
:param inner_mode: When True, returns mk_selection_manager_inner() (outerHTML swap on #tsmi_)
|
||||
instead of mk_selection_manager() (outerHTML swap on #tsm_).
|
||||
Use inner_mode=True for navigation commands (arrow keys, click, mouse selection)
|
||||
to avoid style recalculation on #tsm_ siblings (e.g. the full table).
|
||||
:param kwargs: Additional parameters for specific fragments (col_id, optimal_width for header)
|
||||
:return:
|
||||
"""
|
||||
res = [self.mk_selection_manager()]
|
||||
res = [self.mk_selection_manager_inner() if inner_mode else self.mk_selection_manager()]
|
||||
|
||||
extra_attr = {
|
||||
"hx-on::after-settle": f"initDataGrid('{self._id}');",
|
||||
|
||||
@@ -4,7 +4,7 @@ from fasthtml.components import Div
|
||||
|
||||
from myfasthtml.controls.HierarchicalCanvasGraph import HierarchicalCanvasGraph, HierarchicalCanvasGraphConf
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
from myfasthtml.controls.Properties import Properties
|
||||
from myfasthtml.controls.Properties import Properties, PropertiesConf
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import SingleInstance, UniqueInstance, MultipleInstance, InstancesManager
|
||||
|
||||
@@ -73,10 +73,11 @@ class InstancesDebugger(SingleInstance):
|
||||
"Commands": {"*": "commands"},
|
||||
}
|
||||
|
||||
return self._panel.set_right(Properties(self,
|
||||
InstancesManager.get(session, instance_id),
|
||||
properties_def,
|
||||
_id="-properties"))
|
||||
return self._panel.set_right(Properties(
|
||||
self,
|
||||
conf=PropertiesConf(obj=InstancesManager.get(session, instance_id), groups=properties_def),
|
||||
_id="-properties",
|
||||
))
|
||||
|
||||
def _get_instance_kind(self, instance) -> str:
|
||||
"""Determine the instance kind for visualization.
|
||||
|
||||
@@ -232,7 +232,7 @@ class Panel(MultipleInstance):
|
||||
hide_icon,
|
||||
Div(content, id=self._ids.content(side)),
|
||||
cls=panel_cls,
|
||||
style=f"width: {self._state.left_width}px;",
|
||||
style=f"width: {self._state.right_width}px;",
|
||||
id=self._ids.panel(side)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,22 +1,121 @@
|
||||
import logging
|
||||
|
||||
from fasthtml.components import Div, Span
|
||||
from fasthtml.components import Details, Div, Span, Summary
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.IconsHelper import IconsHelper
|
||||
from myfasthtml.controls.Panel import Panel, PanelConf
|
||||
from myfasthtml.controls.Properties import Properties, PropertiesConf
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.constants import PROFILER_MAX_TRACES, MediaActions
|
||||
from myfasthtml.core.instances import SingleInstance
|
||||
from myfasthtml.core.profiler import profiler
|
||||
from myfasthtml.icons.fluent import arrow_clockwise20_regular
|
||||
from myfasthtml.core.profiler import CumulativeSpan, ProfilingSpan, ProfilingTrace, profiler
|
||||
from myfasthtml.icons.fluent import (
|
||||
arrow_clockwise20_regular,
|
||||
data_pie24_regular,
|
||||
text_bullet_list_tree20_filled,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("Profiler")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Span tree renderer — module-level, passed via PropertiesConf.types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _mk_span_rows(span, depth: int, total_ms: float):
|
||||
"""Recursively build the span tree.
|
||||
|
||||
Spans with children are rendered as a collapsible ``<details>`` element
|
||||
(expanded by default). Leaf spans and cumulative spans are rendered as
|
||||
plain ``<div>`` rows. The ``mf-profiler-span-row`` class is applied to
|
||||
both ``<summary>`` and ``<div>`` so CSS rules are shared.
|
||||
|
||||
Args:
|
||||
span: A ProfilingSpan or CumulativeSpan to render.
|
||||
depth: Current nesting depth (controls indentation).
|
||||
total_ms: Reference duration used to compute bar widths.
|
||||
|
||||
Returns:
|
||||
A single FT element (Details or Div).
|
||||
"""
|
||||
indent = [Div(cls="mf-profiler-span-indent") for _ in range(depth)]
|
||||
|
||||
if isinstance(span, CumulativeSpan):
|
||||
pct = (span.total_ms / total_ms * 100) if total_ms > 0 else 0
|
||||
duration_cls = _span_duration_cls(span.total_ms)
|
||||
badge = Span(
|
||||
f"×{span.count} · min {span.min_ms:.2f} · avg {span.avg_ms:.2f} · max {span.max_ms:.2f} ms",
|
||||
cls="mf-profiler-cumulative-badge",
|
||||
)
|
||||
return Div(
|
||||
*indent,
|
||||
Div(
|
||||
Span(span.name, cls="mf-profiler-span-name"),
|
||||
Div(Div(style=f"width:{pct:.1f}%"), cls="mf-profiler-span-bar-bg"),
|
||||
Span(f"{span.total_ms:.1f} ms", cls=f"mf-profiler-span-ms {duration_cls}"),
|
||||
badge,
|
||||
cls="mf-profiler-span-body",
|
||||
),
|
||||
cls="mf-profiler-span-row",
|
||||
)
|
||||
|
||||
pct = (span.duration_ms / total_ms * 100) if total_ms > 0 else 0
|
||||
duration_cls = _span_duration_cls(span.duration_ms)
|
||||
name_cls = "mf-profiler-span-name mf-profiler-span-name-root" if depth == 0 else "mf-profiler-span-name"
|
||||
row_content = (
|
||||
*indent,
|
||||
Div(
|
||||
Span(span.name, cls=name_cls),
|
||||
Div(Div(cls=f"mf-profiler-span-bar {duration_cls}", style=f"width:{pct:.1f}%"), cls="mf-profiler-span-bar-bg"),
|
||||
Span(f"{span.duration_ms:.1f} ms", cls=f"mf-profiler-span-ms {duration_cls}"),
|
||||
cls="mf-profiler-span-body",
|
||||
),
|
||||
)
|
||||
|
||||
if not span.children:
|
||||
return Div(*row_content, cls="mf-profiler-span-row")
|
||||
|
||||
return Details(
|
||||
Summary(*row_content, cls="mf-profiler-span-row"),
|
||||
*[_mk_span_rows(child, depth + 1, total_ms) for child in span.children],
|
||||
open=True,
|
||||
)
|
||||
|
||||
|
||||
def _span_duration_cls(duration_ms: float) -> str:
|
||||
"""Return the CSS modifier class for a span duration."""
|
||||
if duration_ms < 20:
|
||||
return "mf-profiler-fast"
|
||||
if duration_ms < 100:
|
||||
return "mf-profiler-medium"
|
||||
return "mf-profiler-slow"
|
||||
|
||||
|
||||
def _span_tree_renderer(span: ProfilingSpan, trace: ProfilingTrace):
|
||||
"""Renderer for ProfilingSpan values in a PropertiesConf.types mapping.
|
||||
|
||||
Args:
|
||||
span: The root span to render as a tree.
|
||||
trace: The parent trace, used to compute proportional bar widths.
|
||||
|
||||
Returns:
|
||||
A FT element containing the full span tree.
|
||||
"""
|
||||
return Div(_mk_span_rows(span, 0, trace.total_duration_ms), cls="mf-profiler-span-tree-content")
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
|
||||
def toggle_detail_view(self):
|
||||
return Command(
|
||||
"ProfilerToggleDetailView",
|
||||
"Switch between tree and pie view",
|
||||
self._owner,
|
||||
self._owner.handle_toggle_detail_view,
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
def toggle_enable(self):
|
||||
return Command(
|
||||
"ProfilerToggleEnable",
|
||||
@@ -50,7 +149,7 @@ class Commands(BaseCommands):
|
||||
self._owner,
|
||||
self._owner.handle_select_trace,
|
||||
kwargs={"trace_id": trace_id},
|
||||
).htmx(target=f"#{self._id}")
|
||||
).htmx(target=f"#tr_{trace_id}")
|
||||
|
||||
|
||||
class Profiler(SingleInstance):
|
||||
@@ -67,7 +166,9 @@ class Profiler(SingleInstance):
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self._panel = Panel(self, conf=PanelConf(show_right_title=False, show_display_right=False))
|
||||
self._panel.set_side_visible("right", True)
|
||||
self._selected_id: str | None = None
|
||||
self._detail_view: str = "tree"
|
||||
self.commands = Commands(self)
|
||||
logger.debug(f"Profiler created with id={self._id}")
|
||||
|
||||
@@ -89,11 +190,26 @@ class Profiler(SingleInstance):
|
||||
|
||||
def handle_select_trace(self, trace_id: str):
|
||||
"""Select a trace row and re-render to show it highlighted."""
|
||||
if self._selected_id is not None:
|
||||
old_trace = next((t for t in profiler.traces if t.trace_id == self._selected_id), None)
|
||||
else:
|
||||
old_trace = None
|
||||
|
||||
self._selected_id = trace_id
|
||||
trace = next((t for t in profiler.traces if t.trace_id == trace_id), None)
|
||||
|
||||
return (self._mk_trace_item(trace),
|
||||
self._mk_trace_item(old_trace),
|
||||
self._panel.set_right(self._mk_right_panel(trace)))
|
||||
|
||||
def handle_toggle_detail_view(self):
|
||||
"""Toggle detail panel between tree and pie view."""
|
||||
self._detail_view = "pie" if self._detail_view == "tree" else "tree"
|
||||
logger.debug(f"Profiler detail view set to {self._detail_view}")
|
||||
return self
|
||||
|
||||
def handle_refresh(self):
|
||||
"""Select a trace row and re-render to show it highlighted."""
|
||||
"""Refresh the trace list without changing selection."""
|
||||
return self
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -142,32 +258,36 @@ class Profiler(SingleInstance):
|
||||
id=f"tb_{self._id}",
|
||||
)
|
||||
|
||||
def _mk_trace_item(self, trace: ProfilingTrace):
|
||||
if trace is None:
|
||||
return None
|
||||
|
||||
ts = trace.timestamp.strftime("%H:%M:%S.") + f"{trace.timestamp.microsecond // 1000:03d}"
|
||||
duration_cls = self._duration_cls(trace.total_duration_ms)
|
||||
row_cls = "mf-profiler-row mf-profiler-row-selected" if trace.trace_id == self._selected_id else "mf-profiler-row"
|
||||
|
||||
return mk.mk(
|
||||
Div(
|
||||
Div(
|
||||
Span(trace.command_name, cls="mf-profiler-cmd"),
|
||||
Span(trace.command_description, cls="mf-profiler-cmd-description"),
|
||||
cls="mf-profiler-cmd-cell",
|
||||
),
|
||||
Span(f"{trace.total_duration_ms:.1f} ms", cls=f"mf-profiler-duration {duration_cls}"),
|
||||
Span(ts, cls="mf-profiler-ts"),
|
||||
cls=row_cls,
|
||||
id=f"tr_{trace.trace_id}",
|
||||
),
|
||||
command=self.commands.select_trace(trace.trace_id),
|
||||
)
|
||||
|
||||
def _mk_trace_list(self):
|
||||
"""Build the trace list with one clickable row per recorded trace."""
|
||||
traces = profiler.traces
|
||||
if not traces:
|
||||
return Div("No traces recorded.", cls="mf-profiler-empty")
|
||||
|
||||
rows = []
|
||||
for trace in traces:
|
||||
ts = trace.timestamp.strftime("%H:%M:%S.") + f"{trace.timestamp.microsecond // 1000:03d}"
|
||||
duration_cls = self._duration_cls(trace.total_duration_ms)
|
||||
row_cls = "mf-profiler-row mf-profiler-row-selected" if trace.trace_id == self._selected_id else "mf-profiler-row"
|
||||
|
||||
row = mk.mk(
|
||||
Div(
|
||||
Div(
|
||||
Span(trace.command_name, cls="mf-profiler-cmd"),
|
||||
Span(trace.command_description, cls="mf-profiler-cmd-description"),
|
||||
cls="mf-profiler-cmd-cell",
|
||||
),
|
||||
Span(f"{trace.total_duration_ms:.1f} ms", cls=f"mf-profiler-duration {duration_cls}"),
|
||||
Span(ts, cls="mf-profiler-ts"),
|
||||
cls=row_cls,
|
||||
),
|
||||
command=self.commands.select_trace(trace.trace_id),
|
||||
)
|
||||
rows.append(row)
|
||||
rows = [self._mk_trace_item(trace) for trace in reversed(traces)]
|
||||
|
||||
return Div(
|
||||
Div(
|
||||
@@ -184,13 +304,118 @@ class Profiler(SingleInstance):
|
||||
"""Placeholder shown in the right panel before a trace is selected."""
|
||||
return Div("Select a trace to view details.", cls="mf-profiler-empty")
|
||||
|
||||
def _mk_detail_header(self, trace: "ProfilingTrace"):
|
||||
"""Build the detail panel header with title and tree/pie toggle.
|
||||
|
||||
Args:
|
||||
trace: The selected trace.
|
||||
|
||||
Returns:
|
||||
A FT element for the detail header.
|
||||
"""
|
||||
duration_cls = self._duration_cls(trace.total_duration_ms)
|
||||
title = Div(
|
||||
Span(trace.command_name, cls="mf-profiler-detail-cmd"),
|
||||
Span(f" — {trace.total_duration_ms:.1f} ms", cls=f"mf-profiler-detail-duration {duration_cls}"),
|
||||
cls="mf-profiler-detail-title",
|
||||
)
|
||||
tree_cls = "mf-profiler-view-btn mf-profiler-view-btn-active" if self._detail_view == "tree" else "mf-profiler-view-btn"
|
||||
pie_cls = "mf-profiler-view-btn mf-profiler-view-btn-active" if self._detail_view == "pie" else "mf-profiler-view-btn"
|
||||
toggle = Div(
|
||||
mk.icon(text_bullet_list_tree20_filled, command=self.commands.toggle_detail_view(), tooltip="Span tree",
|
||||
cls=tree_cls),
|
||||
mk.icon(data_pie24_regular, command=self.commands.toggle_detail_view(), tooltip="Pie chart (coming soon)",
|
||||
cls=pie_cls),
|
||||
cls="mf-profiler-view-toggle",
|
||||
)
|
||||
return Div(title, toggle, cls="mf-profiler-detail-header")
|
||||
|
||||
def _mk_detail_body(self, trace: "ProfilingTrace"):
|
||||
"""Build the scrollable detail body: metadata, kwargs and span breakdown.
|
||||
|
||||
Args:
|
||||
trace: The selected trace.
|
||||
|
||||
Returns:
|
||||
A FT element for the detail body.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
meta_props = Properties(
|
||||
self,
|
||||
conf=PropertiesConf(
|
||||
obj=trace,
|
||||
groups={"Metadata": {
|
||||
"command": "command_name",
|
||||
"description": "command_description",
|
||||
"duration_ms": "total_duration_ms",
|
||||
"timestamp": "timestamp",
|
||||
}},
|
||||
),
|
||||
_id="-detail-meta",
|
||||
)
|
||||
|
||||
kwargs_obj = SimpleNamespace(**trace.kwargs) if trace.kwargs else SimpleNamespace()
|
||||
kwargs_props = Properties(
|
||||
self,
|
||||
conf=PropertiesConf(obj=kwargs_obj, groups={"kwargs": {"*": ""}}),
|
||||
_id="-detail-kwargs",
|
||||
)
|
||||
|
||||
span_props = None
|
||||
if trace.root_span is not None:
|
||||
span_props = Properties(
|
||||
self,
|
||||
conf=PropertiesConf(
|
||||
obj=trace,
|
||||
groups={"Span breakdown": {"root_span": "root_span"}},
|
||||
types={ProfilingSpan: _span_tree_renderer},
|
||||
),
|
||||
_id="-detail-spans",
|
||||
)
|
||||
|
||||
if self._detail_view == "pie":
|
||||
pie_placeholder = Div("Pie chart — coming soon.", cls="mf-profiler-empty")
|
||||
return Div(meta_props, kwargs_props, pie_placeholder, cls="mf-profiler-detail-body")
|
||||
|
||||
return Div(meta_props, kwargs_props, span_props, cls="mf-profiler-detail-body")
|
||||
|
||||
def _mk_detail_panel(self, trace: "ProfilingTrace"):
|
||||
"""Build the full detail panel for a selected trace.
|
||||
|
||||
Args:
|
||||
trace: The selected trace.
|
||||
|
||||
Returns:
|
||||
A FT element for the detail panel.
|
||||
"""
|
||||
return Div(
|
||||
self._mk_detail_header(trace),
|
||||
self._mk_detail_body(trace),
|
||||
cls="mf-profiler-detail",
|
||||
)
|
||||
|
||||
def _mk_right_panel(self, trace: "ProfilingTrace"):
|
||||
"""Build the right panel with a trace detail view."""
|
||||
return (
|
||||
self._mk_detail_panel(trace)
|
||||
if trace is not None
|
||||
else self._mk_detail_placeholder()
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Render
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def render(self):
|
||||
selected_trace = None
|
||||
if self._selected_id is not None:
|
||||
selected_trace = next(
|
||||
(t for t in profiler.traces if t.trace_id == self._selected_id), None
|
||||
)
|
||||
|
||||
self._panel.set_main(self._mk_trace_list())
|
||||
self._panel.set_right(self._mk_detail_placeholder())
|
||||
self._panel.set_right(self._mk_right_panel(selected_trace))
|
||||
return Div(
|
||||
self._mk_toolbar(),
|
||||
self._panel,
|
||||
|
||||
@@ -1,21 +1,42 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
from fasthtml.components import Div
|
||||
from myutils.ProxyObject import ProxyObject
|
||||
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
@dataclass
|
||||
class PropertiesConf:
|
||||
"""Declarative configuration for the Properties control.
|
||||
|
||||
Attributes:
|
||||
obj: The Python object whose attributes are displayed.
|
||||
groups: Mapping of group name to ProxyObject spec.
|
||||
types: Mapping of Python type to renderer callable.
|
||||
Each renderer has the signature ``(value, obj) -> FT``.
|
||||
"""
|
||||
|
||||
obj: Any = None
|
||||
groups: Optional[dict] = None
|
||||
types: Optional[dict] = field(default=None)
|
||||
|
||||
|
||||
class Properties(MultipleInstance):
|
||||
def __init__(self, parent, obj=None, groups: dict = None, _id=None):
|
||||
def __init__(self, parent, conf: PropertiesConf = None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.obj = obj
|
||||
self.groups = groups
|
||||
self.properties_by_group = self._create_properties_by_group()
|
||||
|
||||
def set_obj(self, obj, groups: dict = None):
|
||||
self.obj = obj
|
||||
self.groups = groups
|
||||
self.properties_by_group = self._create_properties_by_group()
|
||||
|
||||
self.conf = conf or PropertiesConf()
|
||||
self._refresh()
|
||||
|
||||
def set_conf(self, conf: PropertiesConf):
|
||||
self.conf = conf
|
||||
self._refresh()
|
||||
|
||||
def _refresh(self):
|
||||
self._types = self.conf.types or {}
|
||||
self._properties_by_group = self._create_properties_by_group()
|
||||
|
||||
def _mk_group_content(self, properties: dict):
|
||||
return Div(
|
||||
*[
|
||||
@@ -28,40 +49,68 @@ class Properties(MultipleInstance):
|
||||
],
|
||||
cls="mf-properties-group-content"
|
||||
)
|
||||
|
||||
|
||||
def _mk_property_value(self, value):
|
||||
for t, renderer in self._types.items():
|
||||
if isinstance(value, t):
|
||||
return renderer(value, self.conf.obj)
|
||||
|
||||
if isinstance(value, dict):
|
||||
return self._mk_group_content(value)
|
||||
|
||||
|
||||
if isinstance(value, (list, tuple)):
|
||||
return self._mk_group_content({i: item for i, item in enumerate(value)})
|
||||
|
||||
|
||||
return Div(str(value),
|
||||
cls="mf-properties-value",
|
||||
title=str(value))
|
||||
|
||||
|
||||
def _render_group_content(self, proxy):
|
||||
"""Render a group's content.
|
||||
|
||||
When the group contains exactly one property whose type is registered in
|
||||
``conf.types``, the type renderer replaces the entire group content (not
|
||||
just the value cell). This lets custom renderers (e.g. span trees) fill
|
||||
the full card width without a key/value row wrapper.
|
||||
|
||||
Otherwise, the standard key/value row layout is used.
|
||||
|
||||
Args:
|
||||
proxy: ProxyObject for this group.
|
||||
|
||||
Returns:
|
||||
A FT element containing the group content.
|
||||
"""
|
||||
properties = proxy.as_dict()
|
||||
if len(properties) == 1:
|
||||
k, v = next(iter(properties.items()))
|
||||
for t, renderer in self._types.items():
|
||||
if isinstance(v, t):
|
||||
return renderer(v, self.conf.obj)
|
||||
return self._mk_group_content(properties)
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
*[
|
||||
Div(
|
||||
Div(
|
||||
Div(group_name if group_name is not None else "", cls="mf-properties-group-header"),
|
||||
self._mk_group_content(proxy.as_dict()),
|
||||
self._render_group_content(proxy),
|
||||
cls="mf-properties-group-container"
|
||||
),
|
||||
cls="mf-properties-group-card"
|
||||
)
|
||||
for group_name, proxy in self.properties_by_group.items()
|
||||
for group_name, proxy in self._properties_by_group.items()
|
||||
],
|
||||
id=self._id,
|
||||
cls="mf-properties"
|
||||
)
|
||||
|
||||
|
||||
def _create_properties_by_group(self):
|
||||
if self.groups is None:
|
||||
return {None: ProxyObject(self.obj, {"*": ""})}
|
||||
|
||||
return {k: ProxyObject(self.obj, v) for k, v in self.groups.items()}
|
||||
|
||||
if self.conf.groups is None:
|
||||
return {None: ProxyObject(self.conf.obj, {"*": ""})}
|
||||
|
||||
return {k: ProxyObject(self.conf.obj, v) for k, v in self.conf.groups.items()}
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import inspect
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional, Literal
|
||||
@@ -32,9 +33,15 @@ class BaseInstance:
|
||||
if VERBOSE_VERBOSE:
|
||||
logger.debug(f"Creating new instance of type {cls.__name__}")
|
||||
|
||||
parent = args[0] if len(args) > 0 and isinstance(args[0], BaseInstance) else kwargs.get("parent", None)
|
||||
session = args[1] if len(args) > 1 and isinstance(args[1], dict) else kwargs.get("session", None)
|
||||
_id = args[2] if len(args) > 2 and isinstance(args[2], str) else kwargs.get("_id", None)
|
||||
sig = inspect.signature(cls.__init__)
|
||||
bound = sig.bind_partial(None, *args, **kwargs) # None pour 'self'
|
||||
bound.apply_defaults()
|
||||
arguments = bound.arguments
|
||||
|
||||
parent = arguments.get("parent", None)
|
||||
session = arguments.get("session", None)
|
||||
_id = arguments.get("_id", None)
|
||||
|
||||
if VERBOSE_VERBOSE:
|
||||
logger.debug(f" parent={parent}, session={debug_session(session)}, _id={_id}")
|
||||
|
||||
@@ -247,7 +254,7 @@ class InstancesManager:
|
||||
"""
|
||||
key = (InstancesManager.get_session_id(session), instance.get_id())
|
||||
|
||||
if isinstance(instance, SingleInstance) and key in InstancesManager.instances:
|
||||
if key in InstancesManager.instances and not isinstance(instance, UniqueInstance):
|
||||
raise DuplicateInstanceError(instance)
|
||||
|
||||
InstancesManager.instances[key] = instance
|
||||
|
||||
@@ -469,62 +469,98 @@ class ProfilingManager:
|
||||
return decorator(cls)
|
||||
return decorator
|
||||
|
||||
def trace_calls(self, fn):
|
||||
"""Function decorator — traces all sub-calls via sys.setprofile().
|
||||
def trace_calls(self, *, include: list[str] = None, max_depth: int = 10):
|
||||
"""Function decorator — traces sub-calls via sys.setprofile().
|
||||
|
||||
Use for exploration when the bottleneck location is unknown.
|
||||
sys.setprofile() is scoped to this function's execution only;
|
||||
the global profiler is restored on exit.
|
||||
Only calls whose top-level module is in ``include`` are recorded.
|
||||
By default, the top-level package of the decorated function is included.
|
||||
``max_depth`` caps the traced span tree depth as a safety net.
|
||||
|
||||
The root span for ``fn`` itself is created before setprofile is
|
||||
sys.setprofile() is scoped to the decorated function's execution only;
|
||||
the previous profiler is restored on exit.
|
||||
|
||||
The root span for the decorated function is created before setprofile is
|
||||
activated so that profiler internals are not captured as children.
|
||||
|
||||
Args:
|
||||
fn: The function to instrument.
|
||||
include: Top-level module names to trace (e.g. ``['myapp', 'myfasthtml']``).
|
||||
Defaults to the top-level package of the decorated function.
|
||||
max_depth: Maximum depth of the traced span tree. Calls beyond this
|
||||
depth are ignored. Defaults to 10.
|
||||
|
||||
Returns:
|
||||
A decorator that wraps the function with call tracing.
|
||||
|
||||
Example::
|
||||
|
||||
@profiler.trace_calls()
|
||||
def my_handler(self): ...
|
||||
|
||||
@profiler.trace_calls(include=['myapp', 'myfasthtml'], max_depth=5)
|
||||
def my_handler(self): ...
|
||||
"""
|
||||
manager = self
|
||||
|
||||
@functools.wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not manager.enabled:
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
call_stack: list[tuple[ProfilingSpan, object]] = []
|
||||
# Skip the first call event (fn itself — already represented by root_span)
|
||||
skip_first = [True]
|
||||
|
||||
def _profile(frame, event, arg):
|
||||
if event == 'call':
|
||||
if skip_first[0]:
|
||||
skip_first[0] = False
|
||||
return
|
||||
span = ProfilingSpan(name=frame.f_code.co_name)
|
||||
token = manager.push_span(span)
|
||||
call_stack.append((span, token))
|
||||
elif event in ('return', 'exception'):
|
||||
if call_stack:
|
||||
span, token = call_stack.pop()
|
||||
manager.pop_span(span, token)
|
||||
|
||||
# Build root span BEFORE activating setprofile so that profiler
|
||||
# internals (capture_args, ProfilingSpan.__init__, etc.) are not
|
||||
# captured as children.
|
||||
captured = manager.capture_args(fn, args, kwargs)
|
||||
root_span = ProfilingSpan(name=fn.__name__)
|
||||
root_span.data.update(captured)
|
||||
root_token = manager.push_span(root_span)
|
||||
|
||||
old_profile = sys.getprofile()
|
||||
sys.setprofile(_profile)
|
||||
try:
|
||||
result = fn(*args, **kwargs)
|
||||
finally:
|
||||
sys.setprofile(old_profile)
|
||||
manager.pop_span(root_span, root_token)
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
def decorator(fn):
|
||||
_include = list(include) if include else [fn.__module__.split('.')[0]]
|
||||
|
||||
@functools.wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not manager.enabled:
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
call_stack: list[tuple[ProfilingSpan, object]] = []
|
||||
# True if a span was pushed for this call, False if it was skipped.
|
||||
# Maintained in parallel with the raw call stack so that each
|
||||
# 'return' event is correctly paired with its 'call', regardless of
|
||||
# whether the call was traced or filtered out.
|
||||
open_stack: list[bool] = []
|
||||
# Skip the first call event (fn itself — already represented by root_span)
|
||||
skip_first = [True]
|
||||
|
||||
def _profile(frame, event, arg):
|
||||
if event == 'call':
|
||||
if skip_first[0]:
|
||||
skip_first[0] = False
|
||||
return
|
||||
module = frame.f_globals.get('__name__', '').split('.')[0]
|
||||
should_trace = module in _include and len(call_stack) < max_depth
|
||||
if should_trace:
|
||||
span = ProfilingSpan(name=frame.f_code.co_name)
|
||||
token = manager.push_span(span)
|
||||
call_stack.append((span, token))
|
||||
open_stack.append(True)
|
||||
else:
|
||||
open_stack.append(False)
|
||||
elif event in ('return', 'exception'):
|
||||
if not open_stack:
|
||||
return
|
||||
was_open = open_stack.pop()
|
||||
if was_open and call_stack:
|
||||
span, token = call_stack.pop()
|
||||
manager.pop_span(span, token)
|
||||
|
||||
# Build root span BEFORE activating setprofile so that profiler
|
||||
# internals (capture_args, ProfilingSpan.__init__, etc.) are not
|
||||
# captured as children.
|
||||
captured = manager.capture_args(fn, args, kwargs)
|
||||
root_span = ProfilingSpan(name=fn.__name__)
|
||||
root_span.data.update(captured)
|
||||
root_token = manager.push_span(root_span)
|
||||
|
||||
old_profile = sys.getprofile()
|
||||
sys.setprofile(_profile)
|
||||
try:
|
||||
result = fn(*args, **kwargs)
|
||||
finally:
|
||||
sys.setprofile(old_profile)
|
||||
manager.pop_span(root_span, root_token)
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
# --- Overhead measurement ---
|
||||
|
||||
|
||||
@@ -81,9 +81,10 @@ class TestProfilerBehaviour:
|
||||
|
||||
def test_i_can_select_trace_by_id(self, profiler_control):
|
||||
"""Test that handle_select_trace stores the given trace_id."""
|
||||
trace_id = str(uuid4())
|
||||
profiler_control.handle_select_trace(trace_id)
|
||||
assert profiler_control._selected_id == trace_id
|
||||
trace = make_trace()
|
||||
profiler._traces.appendleft(trace)
|
||||
profiler_control.handle_select_trace(trace.trace_id)
|
||||
assert profiler_control._selected_id == trace.trace_id
|
||||
|
||||
def test_i_can_select_trace_stable_when_new_trace_added(self, profiler_control):
|
||||
"""Test that selection by trace_id remains correct when a new trace is prepended.
|
||||
|
||||
@@ -41,4 +41,4 @@ def db_manager(parent):
|
||||
|
||||
@pytest.fixture
|
||||
def dsm(parent, db_manager):
|
||||
return DataServicesManager(parent, parent._session)
|
||||
return DataServicesManager(parent)
|
||||
|
||||
@@ -536,7 +536,7 @@ class TestTraceCalls:
|
||||
def helper_b():
|
||||
return 2
|
||||
|
||||
@p.trace_calls
|
||||
@p.trace_calls()
|
||||
def main_func():
|
||||
helper_a()
|
||||
helper_b()
|
||||
@@ -561,7 +561,7 @@ class TestTraceCalls:
|
||||
"""Test that trace_calls creates no spans when the profiler is disabled at call time."""
|
||||
p = fresh_profiler
|
||||
|
||||
@p.trace_calls
|
||||
@p.trace_calls()
|
||||
def main_func():
|
||||
return 99
|
||||
|
||||
@@ -576,7 +576,7 @@ class TestTraceCalls:
|
||||
"""Test that trace_calls captures the decorated function's arguments in the root span data."""
|
||||
p = fresh_profiler
|
||||
|
||||
@p.trace_calls
|
||||
@p.trace_calls()
|
||||
def compute(x, y):
|
||||
return x + y
|
||||
|
||||
@@ -588,6 +588,100 @@ class TestTraceCalls:
|
||||
assert main_span.data.get("x") == "3"
|
||||
assert main_span.data.get("y") == "7"
|
||||
|
||||
def test_i_can_use_trace_calls_with_include_filter(self, fresh_profiler):
|
||||
"""Test that only calls from included modules are traced."""
|
||||
import types
|
||||
p = fresh_profiler
|
||||
|
||||
# Simulate a function in a foreign module by overriding __globals__
|
||||
foreign_mod = types.ModuleType("foreignlib")
|
||||
foreign_mod.__name__ = "foreignlib"
|
||||
|
||||
def _foreign_impl():
|
||||
return 99
|
||||
|
||||
foreign_func = types.FunctionType(
|
||||
_foreign_impl.__code__,
|
||||
vars(foreign_mod),
|
||||
"foreign_func",
|
||||
)
|
||||
|
||||
@p.trace_calls()
|
||||
def main_func():
|
||||
foreign_func()
|
||||
return 42
|
||||
|
||||
with p.span("root") as root:
|
||||
main_func()
|
||||
|
||||
main_span = root.children[0]
|
||||
assert main_span.name == "main_func"
|
||||
child_names = [c.name for c in main_span.children]
|
||||
assert "foreign_func" not in child_names, "foreign module must be excluded by default"
|
||||
|
||||
def test_i_can_use_trace_calls_with_custom_include(self, fresh_profiler):
|
||||
"""Test that explicitly listed modules are included even when not the default."""
|
||||
import types
|
||||
p = fresh_profiler
|
||||
|
||||
extra_mod = types.ModuleType("extralib")
|
||||
extra_mod.__name__ = "extralib"
|
||||
|
||||
def _extra_impl():
|
||||
return 0
|
||||
|
||||
extra_func = types.FunctionType(
|
||||
_extra_impl.__code__,
|
||||
vars(extra_mod),
|
||||
"extra_func",
|
||||
)
|
||||
|
||||
current_top = __name__.split('.')[0]
|
||||
|
||||
@p.trace_calls(include=[current_top, "extralib"])
|
||||
def main_func():
|
||||
extra_func()
|
||||
return 42
|
||||
|
||||
with p.span("root") as root:
|
||||
main_func()
|
||||
|
||||
main_span = root.children[0]
|
||||
assert len(main_span.children) == 1, "explicitly included module must be traced"
|
||||
|
||||
def test_i_can_use_trace_calls_with_max_depth(self, fresh_profiler):
|
||||
"""Test that spans beyond max_depth are not recorded."""
|
||||
p = fresh_profiler
|
||||
|
||||
def level3():
|
||||
return 0
|
||||
|
||||
def level2():
|
||||
level3()
|
||||
return 1
|
||||
|
||||
def level1():
|
||||
level2()
|
||||
return 2
|
||||
|
||||
@p.trace_calls(max_depth=2)
|
||||
def main_func():
|
||||
level1()
|
||||
return 42
|
||||
|
||||
with p.span("root") as root:
|
||||
main_func()
|
||||
|
||||
main_span = root.children[0]
|
||||
assert main_span.name == "main_func"
|
||||
assert len(main_span.children) == 1
|
||||
level1_span = main_span.children[0]
|
||||
assert level1_span.name == "level1"
|
||||
assert len(level1_span.children) == 1
|
||||
level2_span = level1_span.children[0]
|
||||
assert level2_span.name == "level2"
|
||||
assert len(level2_span.children) == 0, "level3 must be excluded by max_depth=2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestProfilingManager — enable/disable, clear, overhead
|
||||
|
||||
Reference in New Issue
Block a user