Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,17 @@ jobs:
- pytest-benchmark-4
- pytest-benchmark-5
- valgrind
python-version:
- "3.9"
- "3.10"
- "3.11"
- "3.12"
- "3.13"
- "3.14"
python:
- version: "3.9"
- version: "3.10"
- version: "3.11"
- version: "3.12"
- version: "3.13"
- version: "3.14"
- version: "3.14t"
- version: "3.15.0-beta.1"
- version: "3.15.0-beta.1"
architecture: "x64-freethreaded"
pytest-version:
- ">=8.1.1"

Expand All @@ -50,10 +54,11 @@ jobs:
with:
submodules: true
- uses: astral-sh/setup-uv@v7
- name: "Set up Python ${{ matrix.python-version }}"
- name: "Set up Python ${{ matrix.python.version }}${{ matrix.python.architecture && format(' ({0})', matrix.python.architecture) || '' }}"
uses: actions/setup-python@v6
with:
python-version: "${{ matrix.python-version }}"
python-version: "${{ matrix.python.version }}"
architecture: "${{ matrix.python.architecture }}"
- if: matrix.config == 'valgrind' || matrix.config == 'pytest-benchmark'
name: Install valgrind
run: |
Expand Down
10 changes: 7 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"cffi >= 1.17.1",
"pytest>=3.8",
"rich>=13.8.1",
"importlib-metadata>=8.5.0; python_version < '3.10'",
Expand All @@ -47,7 +46,7 @@ compat = [
[tool.uv]
# Python builds change with uv versions, and we are quite susceptible to that.
# We pin uv to to make sure reproducibility is maintained for any contributor.
required-version = "0.9.5"
required-version = "0.11.14"

[tool.uv.sources]
pytest-codspeed = { workspace = true }
Expand All @@ -66,12 +65,17 @@ dev = [
pytest11 = { codspeed = "pytest_codspeed.plugin" }

[build-system]
requires = ["setuptools >= 61", "cffi >= 1.17.1"]
requires = ["setuptools >= 61"]
build-backend = "setuptools.build_meta"

[tool.setuptools]
license-files = [] # Workaround of https://github.com/astral-sh/uv/issues/9513

[tool.setuptools.package-data]
pytest_codspeed = [
"instruments/hooks/instrument-hooks/includes/*.h",
]

[tool.setuptools.dynamic]
version = { attr = "pytest_codspeed.__version__" }

Expand Down
34 changes: 14 additions & 20 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,7 @@
import importlib.util
import os
import platform
from pathlib import Path

from setuptools import setup

build_path = Path(__file__).parent / "src/pytest_codspeed/instruments/hooks/build.py"

spec = importlib.util.spec_from_file_location("build", build_path)
assert spec is not None, "The spec should be initialized"
build = importlib.util.module_from_spec(spec)
assert spec.loader is not None, "The loader should be initialized"
spec.loader.exec_module(build)
from setuptools import Extension, setup

system = platform.system()
current_arch = platform.machine()
Expand All @@ -38,22 +28,26 @@
"The extension is required but the current platform is not supported"
)

ffi_extension = build.ffibuilder.distutils_extension()
ffi_extension.optional = not IS_EXTENSION_REQUIRED
# Build native C extension
native_extension = Extension(
"pytest_codspeed.instruments.hooks.dist_instrument_hooks",
sources=[
"src/pytest_codspeed/instruments/hooks/instrument_hooks_module.c",
"src/pytest_codspeed/instruments/hooks/instrument-hooks/dist/core.c",
],
include_dirs=["src/pytest_codspeed/instruments/hooks/instrument-hooks/includes"],
optional=not IS_EXTENSION_REQUIRED,
)

print(
"CodSpeed native extension is "
+ ("required" if IS_EXTENSION_REQUIRED else "optional")
)

setup(
package_data={
"pytest_codspeed": [
"instruments/hooks/instrument-hooks/includes/*.h",
"instruments/hooks/instrument-hooks/dist/*.c",
]
},
ext_modules=(
[ffi_extension] if IS_EXTENSION_BUILDABLE and not SKIP_EXTENSION_BUILD else []
[native_extension]
if IS_EXTENSION_BUILDABLE and not SKIP_EXTENSION_BUILD
else []
),
)
8 changes: 4 additions & 4 deletions src/pytest_codspeed/instruments/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,12 @@ def __codspeed_root_frame__() -> T:

# Manually call the library function to avoid an extra stack frame. Also
# call the callgrind markers directly to avoid extra overhead.
self.instrument_hooks.lib.callgrind_start_instrumentation()
self.instrument_hooks.callgrind_start_instrumentation()
try:
return __codspeed_root_frame__()
finally:
# Ensure instrumentation is stopped even if the test failed
self.instrument_hooks.lib.callgrind_stop_instrumentation()
self.instrument_hooks.callgrind_stop_instrumentation()
self.instrument_hooks.stop_benchmark()
self.instrument_hooks.set_executed_benchmark(uri)

Expand Down Expand Up @@ -133,11 +133,11 @@ def __codspeed_root_frame__(*args, **kwargs) -> T:

# Manually call the library function to avoid an extra stack frame. Also
# call the callgrind markers directly to avoid extra overhead.
self.instrument_hooks.lib.callgrind_start_instrumentation()
self.instrument_hooks.callgrind_start_instrumentation()
try:
out = __codspeed_root_frame__(*args, **kwargs)
finally:
self.instrument_hooks.lib.callgrind_stop_instrumentation()
self.instrument_hooks.callgrind_stop_instrumentation()
self.instrument_hooks.stop_benchmark()
self.instrument_hooks.set_executed_benchmark(uri)
if pedantic_options.teardown is not None:
Expand Down
65 changes: 34 additions & 31 deletions src/pytest_codspeed/instruments/hooks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,21 @@
from pytest_codspeed.utils import SUPPORTS_PERF_TRAMPOLINE

if TYPE_CHECKING:
from cffi import FFI

from .dist_instrument_hooks import InstrumentHooksPointer, LibType
from typing import Any, Callable

# Feature flags for instrument hooks
FEATURE_DISABLE_CALLGRIND_MARKERS = 0


class InstrumentHooks:
"""Zig library wrapper class providing benchmark measurement functionality."""
"""Native library wrapper class providing benchmark measurement functionality."""

lib: LibType
ffi: FFI
instance: InstrumentHooksPointer
_module: Any
_instance: Any
# Bound directly to the C functions in __init__ to avoid an extra Python
# stack frame when starting/stopping callgrind instrumentation.
callgrind_start_instrumentation: Callable[[], None]
callgrind_stop_instrumentation: Callable[[], None]

def __init__(self) -> None:
if os.environ.get("CODSPEED_ENV") is None:
Expand All @@ -34,32 +35,37 @@ def __init__(self) -> None:
)

try:
from .dist_instrument_hooks import ffi, lib # type: ignore
from . import dist_instrument_hooks # type: ignore
except ImportError as e:
raise RuntimeError(f"Failed to load instrument hooks library: {e}") from e
self.lib = lib
self.ffi = ffi
self._module = dist_instrument_hooks
self.callgrind_start_instrumentation = (
dist_instrument_hooks.callgrind_start_instrumentation
)
self.callgrind_stop_instrumentation = (
dist_instrument_hooks.callgrind_stop_instrumentation
)

self.instance = self.lib.instrument_hooks_init()
if self.instance == 0:
self._instance = self._module.instrument_hooks_init()
if self._instance is None:
raise RuntimeError("Failed to initialize CodSpeed instrumentation library.")

if SUPPORTS_PERF_TRAMPOLINE and not sys.is_stack_trampoline_active():
sys.activate_stack_trampoline("perf") # type: ignore

def __del__(self):
if hasattr(self, "lib") and hasattr(self, "instance"):
self.lib.instrument_hooks_deinit(self.instance)
# Don't manually deinit - let the capsule destructor handle it
pass
Comment on lines +57 to +58
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The __del__ method with only a pass statement and comment should be removed entirely. An empty __del__ method can prevent proper garbage collection and the capsule destructor will be called automatically without defining __del__.

Copilot uses AI. Check for mistakes.

def start_benchmark(self) -> None:
"""Start a new benchmark measurement."""
ret = self.lib.instrument_hooks_start_benchmark(self.instance)
ret = self._module.instrument_hooks_start_benchmark(self._instance)
if ret != 0:
warnings.warn("Failed to start benchmark measurement", RuntimeWarning)

def stop_benchmark(self) -> None:
"""Stop the current benchmark measurement."""
ret = self.lib.instrument_hooks_stop_benchmark(self.instance)
ret = self._module.instrument_hooks_stop_benchmark(self._instance)
if ret != 0:
warnings.warn("Failed to stop benchmark measurement", RuntimeWarning)

Expand All @@ -73,23 +79,23 @@ def set_executed_benchmark(self, uri: str, pid: int | None = None) -> None:
if pid is None:
pid = os.getpid()

ret = self.lib.instrument_hooks_set_executed_benchmark(
self.instance, pid, uri.encode("ascii")
ret = self._module.instrument_hooks_set_executed_benchmark(
self._instance, pid, uri.encode("ascii")
)
if ret != 0:
warnings.warn("Failed to set executed benchmark", RuntimeWarning)

def set_integration(self, name: str, version: str) -> None:
"""Set the integration name and version."""
ret = self.lib.instrument_hooks_set_integration(
self.instance, name.encode("ascii"), version.encode("ascii")
ret = self._module.instrument_hooks_set_integration(
self._instance, name.encode("ascii"), version.encode("ascii")
)
if ret != 0:
warnings.warn("Failed to set integration name and version", RuntimeWarning)

def is_instrumented(self) -> bool:
"""Check if simulation is active."""
return self.lib.instrument_hooks_is_instrumented(self.instance)
return self._module.instrument_hooks_is_instrumented(self._instance)

def set_feature(self, feature: int, enabled: bool) -> None:
"""Set a feature flag in the instrument hooks library.
Expand All @@ -98,7 +104,7 @@ def set_feature(self, feature: int, enabled: bool) -> None:
feature: The feature flag to set
enabled: Whether to enable or disable the feature
"""
self.lib.instrument_hooks_set_feature(feature, enabled)
self._module.instrument_hooks_set_feature(feature, enabled)

def set_environment(self, section_name: str, key: str, value: str) -> None:
"""Register a key-value pair under a named section for environment collection.
Expand All @@ -108,8 +114,8 @@ def set_environment(self, section_name: str, key: str, value: str) -> None:
key: The key (e.g. "version")
value: The value (e.g. "3.13.12")
"""
ret = self.lib.instrument_hooks_set_environment(
self.instance,
ret = self._module.instrument_hooks_set_environment(
self._instance,
section_name.encode("utf-8"),
key.encode("utf-8"),
value.encode("utf-8"),
Expand All @@ -127,14 +133,11 @@ def set_environment_list(
key: The key (e.g. "build_args")
values: The list of string values
"""
encoded = [self.ffi.new("char[]", v.encode("utf-8")) for v in values]
c_values = self.ffi.new("char*[]", encoded)
ret = self.lib.instrument_hooks_set_environment_list(
self.instance,
ret = self._module.instrument_hooks_set_environment_list(
self._instance,
section_name.encode("utf-8"),
key.encode("utf-8"),
c_values,
len(encoded),
[v.encode("utf-8") for v in values],
)
if ret != 0:
warnings.warn("Failed to set environment list data", RuntimeWarning)
Expand All @@ -149,7 +152,7 @@ def write_environment(self, pid: int | None = None) -> None:
"""
if pid is None:
pid = os.getpid()
ret = self.lib.instrument_hooks_write_environment(self.instance, pid)
ret = self._module.instrument_hooks_write_environment(self._instance, pid)
if ret != 0:
warnings.warn("Failed to write environment data", RuntimeWarning)

Expand Down
64 changes: 0 additions & 64 deletions src/pytest_codspeed/instruments/hooks/build.py

This file was deleted.

Loading
Loading