`` rows. The ``mf-profiler-span-row`` class is applied to
+ both ``
`` and ```` so CSS rules are shared.
Args:
span: A ProfilingSpan or CumulativeSpan to render.
@@ -33,11 +38,10 @@ def _mk_span_rows(span, depth: int, total_ms: float) -> list:
total_ms: Reference duration used to compute bar widths.
Returns:
- List of FT elements, one per span row (depth-first order).
+ A single FT element (Details or Div).
"""
- rows = []
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)
@@ -45,7 +49,7 @@ def _mk_span_rows(span, depth: int, total_ms: float) -> list:
f"×{span.count} · min {span.min_ms:.2f} · avg {span.avg_ms:.2f} · max {span.max_ms:.2f} ms",
cls="mf-profiler-cumulative-badge",
)
- row = Div(
+ return Div(
*indent,
Div(
Span(span.name, cls="mf-profiler-span-name"),
@@ -56,27 +60,28 @@ def _mk_span_rows(span, depth: int, total_ms: float) -> list:
),
cls="mf-profiler-span-row",
)
- rows.append(row)
-
- else:
- 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 = Div(
- *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",
- ),
- cls="mf-profiler-span-row",
- )
- rows.append(row)
- for child in span.children:
- rows.extend(_mk_span_rows(child, depth + 1, total_ms))
-
- return rows
+
+ 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:
@@ -98,8 +103,7 @@ def _span_tree_renderer(span: ProfilingSpan, trace: ProfilingTrace):
Returns:
A FT element containing the full span tree.
"""
- rows = _mk_span_rows(span, 0, trace.total_duration_ms)
- return Div(*rows, cls="mf-profiler-span-tree-content")
+ return Div(_mk_span_rows(span, 0, trace.total_duration_ms), cls="mf-profiler-span-tree-content")
class Commands(BaseCommands):
@@ -187,12 +191,12 @@ 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(trace for trace in profiler.traces if trace.trace_id == self._selected_id)
+ 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(trace for trace in profiler.traces if trace.trace_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),
diff --git a/src/myfasthtml/controls/Properties.py b/src/myfasthtml/controls/Properties.py
index 942c70d..d5c7e4e 100644
--- a/src/myfasthtml/controls/Properties.py
+++ b/src/myfasthtml/controls/Properties.py
@@ -65,7 +65,7 @@ class Properties(MultipleInstance):
cls="mf-properties-value",
title=str(value))
- def _render_group_content(self, proxy) -> Div:
+ def _render_group_content(self, proxy):
"""Render a group's content.
When the group contains exactly one property whose type is registered in
diff --git a/src/myfasthtml/core/profiler.py b/src/myfasthtml/core/profiler.py
index b45f115..1fbde03 100644
--- a/src/myfasthtml/core/profiler.py
+++ b/src/myfasthtml/core/profiler.py
@@ -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 ---
diff --git a/tests/controls/test_profiler.py b/tests/controls/test_profiler.py
index 5db1c50..da1823d 100644
--- a/tests/controls/test_profiler.py
+++ b/tests/controls/test_profiler.py
@@ -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.
diff --git a/tests/core/test_profiler.py b/tests/core/test_profiler.py
index a6811bc..bd19697 100644
--- a/tests/core/test_profiler.py
+++ b/tests/core/test_profiler.py
@@ -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