fix(tests): qualify traced function names on Python 3.10

This commit is contained in:
mateo-berri 2026-09-02 21:13:15 -07:00
parent 32f71950ec
commit f9f32b49a6
2 changed files with 80 additions and 8 deletions

View file

@ -2,11 +2,12 @@ from __future__ import annotations
import sys
import threading
from collections.abc import Generator, Sequence
from collections.abc import Generator, Iterator, Mapping, Sequence
from contextlib import contextmanager
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from types import CodeType, FrameType, FunctionType
from types import CodeType, FrameType, FunctionType, MappingProxyType
from typing import Final
@ -27,11 +28,11 @@ class PythonProfiler:
def __call__(self, frame: FrameType, event: str, _arg: object) -> None:
if event != "call" or frame in self._seen_frames:
return
function_name: Final = self.function_name(frame.f_code)
function_name: Final = self.function_name(frame)
if function_name is None:
return
ancestors: Final = tuple(
name for ancestor in _frame_ancestors(frame) if (name := self.function_name(ancestor.f_code)) is not None
name for ancestor in _frame_ancestors(frame) if (name := self.function_name(ancestor)) is not None
)
self._seen_frames.add(frame)
self.events.append(
@ -42,13 +43,57 @@ class PythonProfiler:
)
)
def function_name(self, code: CodeType) -> str | None:
def function_name(self, frame: FrameType) -> str | None:
code: Final = frame.f_code
if self._source_root is None:
return self._names_by_code.get(code)
if not code.co_filename.startswith(self._source_root):
return None
relative: Final = code.co_filename.removeprefix(self._source_root)
return f"{relative}:{code.co_firstlineno} {getattr(code, 'co_qualname', code.co_name)}"
return f"{relative}:{code.co_firstlineno} {_qualified_name(frame)}"
def _qualified_name(frame: FrameType) -> str:
code: Final = frame.f_code
native: Final = getattr(code, "co_qualname", None)
if isinstance(native, str):
return native
module_name: Final = frame.f_globals.get("__name__")
if not isinstance(module_name, str):
return code.co_name
return _module_qualnames(module_name).get(code, code.co_name)
@lru_cache(maxsize=None)
def _module_qualnames(module_name: str) -> Mapping[CodeType, str]:
module: Final = sys.modules.get(module_name)
if module is None:
return MappingProxyType({})
return MappingProxyType(dict(_declared_functions(vars(module), frozenset())))
def _declared_functions(namespace: Mapping[str, object], visited: frozenset[int]) -> Iterator[tuple[CodeType, str]]:
for attribute in tuple(namespace.values()):
for value in _accessors(attribute):
if isinstance(value, FunctionType):
yield from ((wrapped.__code__, wrapped.__qualname__) for wrapped in _unwrapped(value))
elif isinstance(value, type) and id(value) not in visited:
yield from _declared_functions(dict(vars(value)), visited | {id(value)})
def _unwrapped(function: FunctionType) -> Iterator[FunctionType]:
yield function
inner: Final = getattr(function, "__wrapped__", None)
if isinstance(inner, FunctionType):
yield from _unwrapped(inner)
def _accessors(value: object) -> tuple[object, ...]:
if isinstance(value, (staticmethod, classmethod)):
return (value.__func__,)
if isinstance(value, property):
return tuple(accessor for accessor in (value.fget, value.fset, value.fdel) if accessor is not None)
return (value,)
def _frame_ancestors(frame: FrameType) -> Generator[FrameType]:

View file

@ -2,9 +2,11 @@ from __future__ import annotations
import asyncio
import sys
from collections.abc import Callable
from functools import wraps
from pathlib import Path
from types import FunctionType
from typing import Final, cast
from typing import Final, ParamSpec, TypeVar, cast
import pytest
@ -14,7 +16,18 @@ from tests.sdk_function_trace import (
TraceStep,
assert_function_trace_parity,
)
from tests.sdk_function_trace.profiler import profile_python
from tests.sdk_function_trace.profiler import _module_qualnames, profile_python
_P = ParamSpec("_P")
_T = TypeVar("_T")
def _passthrough(function: Callable[_P, _T]) -> Callable[_P, _T]:
@wraps(function)
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T:
return function(*args, **kwargs)
return wrapper
class First:
@ -29,6 +42,20 @@ class Second:
return None
class Decorated:
@_passthrough
def call(self) -> None:
return None
def test_source_profiler_qualifies_decorated_methods_by_class() -> None:
with profile_python(source_root=Path(__file__).parent) as profiler:
Decorated().call()
assert any(event.function.endswith(" Decorated.call") for event in profiler.events)
assert _module_qualnames(__name__)[cast(FunctionType, Decorated.call.__wrapped__).__code__] == "Decorated.call"
def test_profiler_matches_code_objects_and_keeps_repeated_calls() -> None:
with profile_python((First.run,)) as profiler:
Second.run()