diff --git a/src/pytest_codspeed/instruments/analysis.py b/src/pytest_codspeed/instruments/analysis.py index 9c24f19..7fa3185 100644 --- a/src/pytest_codspeed/instruments/analysis.py +++ b/src/pytest_codspeed/instruments/analysis.py @@ -33,6 +33,7 @@ def __init__(self, config: CodSpeedConfig, mode: MeasurementMode) -> None: try: self.instrument_hooks = InstrumentHooks() self.instrument_hooks.set_integration("pytest-codspeed", __semver_version__) + self.instrument_hooks.collect_and_write_python_environment() except RuntimeError as e: if os.environ.get("CODSPEED_ENV") is not None: raise Exception( diff --git a/src/pytest_codspeed/instruments/hooks/__init__.py b/src/pytest_codspeed/instruments/hooks/__init__.py index 70e8206..225edc7 100644 --- a/src/pytest_codspeed/instruments/hooks/__init__.py +++ b/src/pytest_codspeed/instruments/hooks/__init__.py @@ -1,13 +1,18 @@ from __future__ import annotations import os +import platform +import shlex import sys +import sysconfig import warnings from typing import TYPE_CHECKING from pytest_codspeed.utils import SUPPORTS_PERF_TRAMPOLINE if TYPE_CHECKING: + from cffi import FFI + from .dist_instrument_hooks import InstrumentHooksPointer, LibType # Feature flags for instrument hooks @@ -18,6 +23,7 @@ class InstrumentHooks: """Zig library wrapper class providing benchmark measurement functionality.""" lib: LibType + ffi: FFI instance: InstrumentHooksPointer def __init__(self) -> None: @@ -28,10 +34,11 @@ def __init__(self) -> None: ) try: - from .dist_instrument_hooks import lib # type: ignore + from .dist_instrument_hooks import ffi, lib # 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.instance = self.lib.instrument_hooks_init() if self.instance == 0: @@ -92,3 +99,99 @@ def set_feature(self, feature: int, enabled: bool) -> None: enabled: Whether to enable or disable the feature """ self.lib.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. + + Args: + section_name: The section name (e.g. "Python") + key: The key (e.g. "version") + value: The value (e.g. "3.13.12") + """ + ret = self.lib.instrument_hooks_set_environment( + self.instance, + section_name.encode("utf-8"), + key.encode("utf-8"), + value.encode("utf-8"), + ) + if ret != 0: + warnings.warn("Failed to set environment data", RuntimeWarning) + + def set_environment_list( + self, section_name: str, key: str, values: list[str] + ) -> None: + """Register a list of values under a named section for environment collection. + + Args: + section_name: The section name (e.g. "python") + 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, + section_name.encode("utf-8"), + key.encode("utf-8"), + c_values, + len(encoded), + ) + if ret != 0: + warnings.warn("Failed to set environment list data", RuntimeWarning) + + def write_environment(self, pid: int | None = None) -> None: + """Flush all registered environment sections to disk. + + Writes to $CODSPEED_PROFILE_FOLDER/environment-.json. + + Args: + pid: Optional process ID (defaults to current process) + """ + if pid is None: + pid = os.getpid() + ret = self.lib.instrument_hooks_write_environment(self.instance, pid) + if ret != 0: + warnings.warn("Failed to write environment data", RuntimeWarning) + + def collect_and_write_python_environment(self) -> None: + """Collect Python toolchain information and write it to disk.""" + section = "python" + set_env = self.set_environment + + # Core identity + set_env(section, "version", sys.version) + set_env(section, "implementation", sys.implementation.name) + set_env(section, "compiler", platform.python_compiler()) + + config_vars = sysconfig.get_config_vars() + + # Build arguments as a list + config_args = config_vars.get("CONFIG_ARGS", "") + if config_args: + build_args = shlex.split(config_args) + self.set_environment_list(section, "build_args", build_args) + + # Performance-relevant build configuration as "KEY=value" list + _SYSCONFIG_KEYS = ( + "abiflags", + "PY_ENABLE_SHARED", + "Py_GIL_DISABLED", + "Py_DEBUG", + "WITH_PYMALLOC", + "WITH_MIMALLOC", + "WITH_FREELISTS", + "HAVE_COMPUTED_GOTOS", + "Py_STATS", + "Py_TRACE_REFS", + "WITH_VALGRIND", + "WITH_DTRACE", + ) + config_items = [] + for key in _SYSCONFIG_KEYS: + value = config_vars.get(key) + if value is not None: + config_items.append(f"{key}={value}") + config_items.append(f"perf_trampoline={SUPPORTS_PERF_TRAMPOLINE}") + self.set_environment_list(section, "config", config_items) + + self.write_environment() diff --git a/src/pytest_codspeed/instruments/hooks/build.py b/src/pytest_codspeed/instruments/hooks/build.py index a8b91cd..5577e4a 100644 --- a/src/pytest_codspeed/instruments/hooks/build.py +++ b/src/pytest_codspeed/instruments/hooks/build.py @@ -36,6 +36,15 @@ void callgrind_stop_instrumentation(); void instrument_hooks_set_feature(uint64_t feature, bool enabled); + +uint8_t instrument_hooks_set_environment(InstrumentHooks *, const char *section_name, + const char *key, const char *value); +uint8_t instrument_hooks_set_environment_list(InstrumentHooks *, + const char *section_name, + const char *key, + const char *const *values, + uint32_t count); +uint8_t instrument_hooks_write_environment(InstrumentHooks *, uint32_t pid); """) ffibuilder.set_source( @@ -47,6 +56,8 @@ "src/pytest_codspeed/instruments/hooks/instrument-hooks/dist/core.c", ], include_dirs=[str(includes_dir)], + # IMPORTANT: Keep in sync with instrument-hooks/ci.yml (COMMON_CFLAGS) + extra_compile_args=["-Wno-format-security"], ) if __name__ == "__main__": diff --git a/src/pytest_codspeed/instruments/hooks/dist_instrument_hooks.pyi b/src/pytest_codspeed/instruments/hooks/dist_instrument_hooks.pyi index 19e9d2e..bff301f 100644 --- a/src/pytest_codspeed/instruments/hooks/dist_instrument_hooks.pyi +++ b/src/pytest_codspeed/instruments/hooks/dist_instrument_hooks.pyi @@ -1,3 +1,5 @@ +from cffi import FFI + InstrumentHooksPointer = object class lib: @@ -31,5 +33,21 @@ class lib: def callgrind_stop_instrumentation() -> int: ... @staticmethod def instrument_hooks_set_feature(feature: int, enabled: bool) -> None: ... + @staticmethod + def instrument_hooks_set_environment( + hooks: InstrumentHooksPointer, section_name: bytes, key: bytes, value: bytes + ) -> int: ... + @staticmethod + def instrument_hooks_set_environment_list( + hooks: InstrumentHooksPointer, + section_name: bytes, + key: bytes, + values: FFI.CData, + count: int, + ) -> int: ... + @staticmethod + def instrument_hooks_write_environment( + hooks: InstrumentHooksPointer, pid: int + ) -> int: ... LibType = type[lib] diff --git a/src/pytest_codspeed/instruments/hooks/instrument-hooks b/src/pytest_codspeed/instruments/hooks/instrument-hooks index 89fb72a..9097bfd 160000 --- a/src/pytest_codspeed/instruments/hooks/instrument-hooks +++ b/src/pytest_codspeed/instruments/hooks/instrument-hooks @@ -1 +1 @@ -Subproject commit 89fb72a076ec71c9eca6eee9bca98bada4b4dfb4 +Subproject commit 9097bfd9e018777c566e3e11a148e464258d52e2 diff --git a/src/pytest_codspeed/instruments/walltime.py b/src/pytest_codspeed/instruments/walltime.py index 17673a3..2ec14ee 100644 --- a/src/pytest_codspeed/instruments/walltime.py +++ b/src/pytest_codspeed/instruments/walltime.py @@ -163,6 +163,7 @@ def __init__(self, config: CodSpeedConfig, _mode: MeasurementMode) -> None: try: self.instrument_hooks = InstrumentHooks() self.instrument_hooks.set_integration("pytest-codspeed", __semver_version__) + self.instrument_hooks.collect_and_write_python_environment() except RuntimeError as e: if os.environ.get("CODSPEED_ENV") is not None: warnings.warn(