Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-asv-runner for 
openSUSE:Factory checked in at 2026-08-05 17:49:35
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-asv-runner (Old)
 and      /work/SRC/openSUSE:Factory/.python-asv-runner.new.16738 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-asv-runner"

Wed Aug  5 17:49:35 2026 rev:2 rq:1369597 version:0.3.0

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-asv-runner/python-asv-runner.changes      
2024-10-17 18:41:28.519621944 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-asv-runner.new.16738/python-asv-runner.changes
   2026-08-05 17:50:30.366282482 +0200
@@ -1,0 +2,32 @@
+Tue Aug  4 21:22:22 UTC 2026 - Dirk Müller <[email protected]>
+
+- update to 0.3.0:
+  * `asv_runner.__version__` is now defined for source-tree and
+    editable imports, resolving the installed distribution version
+    with a `0.0.0.dev0` fallback; built artifacts keep the static
+    build-time string.
+  * Releases publish the sdist alongside wheels again: the wheel and
+    sdist jobs uploaded artifacts under the same default name and the
+    publish step picked up only the wheel, which is why 0.2.5 reached
+    PyPI wheel-only and downstream rebuilds from GitHub tarballs
+    shipped wrong version metadata.
+  * Timing benchmarks with `setup` hooks now observe freshly set-up
+    state on every timed call
+    re-runs `setup` between calls, auto-calibrated `number` resolves
+    to 1, and an explicitly set `number > 1` batches with `setup`
+    interleaved between individually timed calls (only the calls are
+    timed, at the cost of two clock reads per call).
+    Benchmarks without `setup` hooks are unchanged, `setup_cache`
+    remains the batching path for input-building setups, and
+    `timeraw_*` benchmarks (one fresh subprocess per sample) are
+    exempt. Timings for fast benchmarks that define `setup` will
+    shift relative to earlier releases because samples are no longer
+    amortized over auto-calibrated batches.
+  * Benchmark `version` remains SHA-256 of source text for full
+    backwards compatibility with asv and historical results.
+    Token-stable `code_fingerprint()` is exposed via `version_alts`
+    (and still used where helpful) so tooling can backfill/accept
+    both identities across releases.  Timeraw `env` identity also
+    uses SHA-256 of code+env payload on the wire.
+
+-------------------------------------------------------------------

Old:
----
  asv_runner-0.2.1.tar.gz

New:
----
  asv_runner-0.3.0.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-asv-runner.spec ++++++
--- /var/tmp/diff_new_pack.nLLx2e/_old  2026-08-05 17:50:32.706364470 +0200
+++ /var/tmp/diff_new_pack.nLLx2e/_new  2026-08-05 17:50:32.718364890 +0200
@@ -1,7 +1,7 @@
 #
-# spec file for package python-asv_runner
+# spec file for package python-asv-runner
 #
-# Copyright (c) 2024 SUSE LLC
+# Copyright (c) 2026 SUSE LLC and contributors
 #
 # All modifications and additions to the file contributed by third parties
 # remain the property of their copyright owners, unless otherwise agreed
@@ -15,18 +15,19 @@
 # Please submit bugfixes or comments via https://bugs.opensuse.org/
 #
 
+
 %{?sle15_python_module_pythons}
 Name:           python-asv-runner
-Version:        0.2.1
+Version:        0.3.0
 Release:        0
 Summary:        Core Python benchmark code for ASV
 License:        BSD-3-Clause
-URL:            https://asv.readthedocs.io/projects/asv-runner/en/latest/
+URL:            https://github.com/airspeed-velocity/asv_runner
 Source:         
https://files.pythonhosted.org/packages/source/a/asv_runner/asv_runner-%{version}.tar.gz
-BuildRequires:  python-rpm-macros
 BuildRequires:  %{python_module pdm-backend}
 BuildRequires:  %{python_module pip}
 BuildRequires:  %{python_module wheel}
+BuildRequires:  python-rpm-macros
 # SECTION test requirements
 BuildRequires:  %{python_module importlib-metadata}
 # /SECTION

++++++ asv_runner-0.2.1.tar.gz -> asv_runner-0.3.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asv_runner-0.2.1/PKG-INFO 
new/asv_runner-0.3.0/PKG-INFO
--- old/asv_runner-0.2.1/PKG-INFO       1970-01-01 01:00:00.000000000 +0100
+++ new/asv_runner-0.3.0/PKG-INFO       1970-01-01 01:00:00.000000000 +0100
@@ -1,8 +1,7 @@
 Metadata-Version: 2.1
 Name: asv_runner
-Version: 0.2.1
+Version: 0.3.0
 Summary: Core Python benchmark code for ASV
-Home-page: https://asv.readthedocs.io/projects/asv-runner/en/latest/
 Author-Email: Rohit Goswami <[email protected]>, Michael Droettboom <[email protected]>
 Maintainer-Email: Rohit Goswami <[email protected]>
 License: BSD-3-Clause
@@ -18,11 +17,12 @@
 Classifier: Programming Language :: Python :: 3.11
 Classifier: Programming Language :: Python :: 3.12
 Classifier: Topic :: System :: Benchmark
-Project-URL: Homepage, 
https://asv.readthedocs.io/projects/asv-runner/en/latest/
-Project-URL: Repository, https://github.com/airspeed-velocity/asv_runner
-Project-URL: Documentation, 
https://asv.readthedocs.io/projects/asv-runner/en/latest/
+Project-URL: homepage, 
https://asv.readthedocs.io/projects/asv-runner/en/latest/
+Project-URL: repository, https://github.com/airspeed-velocity/asv_runner
+Project-URL: documentation, 
https://asv.readthedocs.io/projects/asv-runner/en/latest/
 Requires-Python: >=3.7
-Requires-Dist: importlib-metadata
+Requires-Dist: importlib-metadata; python_version < "3.8"
+Provides-Extra: docs
 Requires-Dist: furo; extra == "docs"
 Requires-Dist: sphinx; extra == "docs"
 Requires-Dist: sphinx-contributors; extra == "docs"
@@ -32,7 +32,6 @@
 Requires-Dist: sphinxcontrib-spelling; extra == "docs"
 Requires-Dist: myst-parser>=2; extra == "docs"
 Requires-Dist: sphinx-autodoc2>=0.4.2; extra == "docs"
-Provides-Extra: docs
 Description-Content-Type: text/markdown
 
 # About 
[![Documentation](https://img.shields.io/badge/Documentation-latest-brightgreen?style=for-the-badge)](https://asv.readthedocs.io/projects/asv-runner/en/latest/)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asv_runner-0.2.1/asv_runner/__init__.py 
new/asv_runner-0.3.0/asv_runner/__init__.py
--- old/asv_runner-0.2.1/asv_runner/__init__.py 2024-02-17 15:11:32.140207300 
+0100
+++ new/asv_runner-0.3.0/asv_runner/__init__.py 2026-08-04 13:53:21.864131500 
+0200
@@ -1 +1 @@
-__version__ = '0.2.1'
\ No newline at end of file
+__version__ = '0.3.0'
\ No newline at end of file
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asv_runner-0.2.1/asv_runner/_aux.py 
new/asv_runner-0.3.0/asv_runner/_aux.py
--- old/asv_runner-0.2.1/asv_runner/_aux.py     2024-02-11 21:37:44.847475300 
+0100
+++ new/asv_runner-0.3.0/asv_runner/_aux.py     2026-08-04 13:53:18.022216300 
+0200
@@ -178,7 +178,7 @@
         data += s
         if not s:
             raise RuntimeError(
-                "did not receive data from socket " f"(size {size}, got only 
{data!r})"
+                f"did not receive data from socket (size {size}, got only 
{data!r})"
             )
     return data
 
@@ -215,4 +215,4 @@
         try:
             set_cpu_affinity(affinity_list)
         except BaseException as exc:
-            print(f"asv: setting cpu affinity {affinity_list !r} failed: {exc 
!r}")
+            print(f"asv: setting cpu affinity {affinity_list!r} failed: 
{exc!r}")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asv_runner-0.2.1/asv_runner/benchmarks/__init__.py 
new/asv_runner-0.3.0/asv_runner/benchmarks/__init__.py
--- old/asv_runner-0.2.1/asv_runner/benchmarks/__init__.py      2024-02-11 
22:48:09.428048400 +0100
+++ new/asv_runner-0.3.0/asv_runner/benchmarks/__init__.py      2026-08-04 
13:53:18.022216300 +0200
@@ -32,13 +32,15 @@
 adding a new submodule with an "export_as_benchmark" attribute.
 """
 
-
 import importlib
 import pkgutil
 from pathlib import Path
 
 # py37 doesn't have importlib.metadata
-from importlib_metadata import distributions
+try:
+    from importlib.metadata import distributions
+except ImportError:
+    from importlib_metadata import distributions
 
 from ._exceptions import NotRequired
 
@@ -48,11 +50,11 @@
 submodule_names = [
     name for _, name, _ in pkgutil.iter_modules(pkgpath) if "_" not in name
 ]
-asv_modules = [
-    dist.metadata["Name"]
-    for dist in distributions()
-    if dist.metadata["Name"].startswith("asv_bench")
-]
+asv_modules = []
+for dist in distributions():
+    name = dist.metadata.get("Name", None)
+    if isinstance(name, str) and name.startswith("asv_bench"):
+        asv_modules.append(name)
 benchmark_types = []
 
 # Builtin modules
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asv_runner-0.2.1/asv_runner/benchmarks/_base.py 
new/asv_runner-0.3.0/asv_runner/benchmarks/_base.py
--- old/asv_runner-0.2.1/asv_runner/benchmarks/_base.py 2023-08-20 
14:25:18.051434300 +0200
+++ new/asv_runner-0.3.0/asv_runner/benchmarks/_base.py 2026-08-04 
13:53:18.022365000 +0200
@@ -1,14 +1,53 @@
 import cProfile as profile
 import inspect
+import io
 import itertools
 import math
 import os
 import re
 import textwrap
+import tokenize
 from collections import Counter
 from hashlib import sha256
 
 
+def _token_fingerprint(code):
+    """
+    SHA-256 of Python token stream, ignoring comments and non-semantic 
whitespace.
+
+    Falls back to hashing the raw UTF-8 bytes if tokenization fails (e.g. 
incomplete
+    snippets). Used for default benchmark ``version`` so cosmetic edits do not
+    invalidate results (asv_runner#43).
+    """
+    if not code:
+        return sha256(b"").hexdigest()
+    try:
+        tokens = []
+        readline = io.StringIO(code).readline
+        for tok in tokenize.generate_tokens(readline):
+            toknum, tokval = tok[0], tok[1]
+            if toknum in (
+                tokenize.COMMENT,
+                tokenize.NL,
+                tokenize.NEWLINE,
+                tokenize.INDENT,
+                tokenize.DEDENT,
+                tokenize.ENDMARKER,
+                tokenize.ENCODING,
+            ):
+                continue
+            tokens.append((toknum, tokval))
+        payload = repr(tokens).encode("utf-8")
+    except (tokenize.TokenError, IndentationError):
+        payload = code.encode("utf-8")
+    return sha256(payload).hexdigest()
+
+
+def code_fingerprint(code):
+    """Public alias for token-stable hashing of benchmark source 
(asv_runner#43)."""
+    return _token_fingerprint(code)
+
+
 def _get_attr(source, name, ignore_case=False):
     """
     Retrieves an attribute from a source by its name.
@@ -214,7 +253,7 @@
         fn = inspect.getsourcefile(obj)
         fn = os.path.relpath(fn, basedir)
         _, lineno = inspect.getsourcelines(obj)
-        return f" in {fn !s}:{lineno !s}"
+        return f" in {fn!s}:{lineno!s}"
     except Exception:
         return ""
 
@@ -260,8 +299,8 @@
         info = inspect.getfullargspec(func)
     except Exception as exc:
         print(
-            f"{benchmark_name !s}: failed to check "
-            f"({func !r}{_get_sourceline_info(func, root) !s}): {exc !s}"
+            f"{benchmark_name!s}: failed to check "
+            f"({func!r}{_get_sourceline_info(func, root)!s}): {exc!s}"
         )
         return True
 
@@ -282,9 +321,9 @@
         else:
             num_args_str = f"{min_num_args}-{max_num_args}"
         print(
-            f"{benchmark_name !s}: wrong number of arguments "
-            f"(for {func !r}{_get_sourceline_info(func, root) !s}):",
-            f"expected {num_args_str}, " f"has {args_str}",
+            f"{benchmark_name!s}: wrong number of arguments "
+            f"(for {func!r}{_get_sourceline_info(func, root)!s}):",
+            f"expected {num_args_str}, has {args_str}",
         )
 
     return ok
@@ -372,9 +411,7 @@
 
     if len(param_names) != len(params):
         param_names = param_names[: len(params)]
-        param_names += [
-            "param%d" % (k + 1,) for k in range(len(param_names), len(params))
-        ]
+        param_names += [f"param{k + 1}" for k in range(len(param_names), 
len(params))]
 
     return params, param_names
 
@@ -504,9 +541,20 @@
         self.setup_cache_timeout = _get_first_attr([self._setup_cache], 
"timeout", None)
         self.timeout = _get_first_attr(attr_sources, "timeout", None)
         self.code = get_source_code([self.func] + self._setups + 
[self._setup_cache])
+        # Primary version: SHA-256 of source text (backwards compatible with 
asv
+        # tests and historical result keys). Token-stable fingerprint is an
+        # alternate identity for tooling/backfill (cosmetic edits share token 
hash).
         code_text = self.code.encode("utf-8")
         code_hash = sha256(code_text).hexdigest()
-        self.version = str(_get_first_attr(attr_sources, "version", code_hash))
+        token_hash = code_fingerprint(self.code)
+        explicit_version = _get_first_attr(attr_sources, "version", None)
+        if explicit_version is not None:
+            self.version = str(explicit_version)
+            self.version_alts = ()
+        else:
+            self.version = code_hash
+            # Prefer unique alts only (token form when it differs from wire 
version)
+            self.version_alts = (token_hash,) if token_hash != code_hash else 
()
         self.type = "base"
         self.unit = "unit"
 
@@ -515,6 +563,7 @@
         self._params = _get_first_attr(attr_sources, "params", [])
         self.param_names = _get_first_attr(attr_sources, "param_names", [])
         self._current_params = ()
+        self._current_cache = None
 
         self._params, self.param_names = _validate_params(
             self._params, self.param_names, self.name
@@ -557,12 +606,29 @@
                 f"Invalid benchmark parameter permutation index: {param_idx!r}"
             )
 
+    def set_cache(self, cache):
+        """
+        Set the setup_cache result for the benchmark.
+
+        Kept separate from ``_current_params`` so ``skip_params`` / 
``@skip_for_params``
+        still match the user-declared parameter tuples (asv_runner#49).
+
+        #### Parameters
+        **cache** (`Any`)
+        : Value returned by ``setup_cache``, or ``None`` if unused.  A value of
+        ``None`` means "no cache argument" (same as the historical
+        ``insert_param`` path that only ran when the pickle was not ``None``).
+        Falsy but non-``None`` caches (``0``, ``[]``, ``{}``) are still passed
+        through to setup/run/teardown.
+        """
+        self._current_cache = cache
+
     def insert_param(self, param):
         """
         Inserts a parameter at the beginning of the current parameter list.
 
-        This method modifies the `_current_params` attribute, inserting the 
provided
-        parameter value at the front of the parameter tuple.
+        Deprecated for setup_cache; use :meth:`set_cache` instead so skip lists
+        are not polluted with the cache object.
 
         #### Parameters
         **param** (`Any`)
@@ -570,6 +636,27 @@
         """
         self._current_params = tuple([param] + list(self._current_params))
 
+    def _build_params(self):
+        """Benchmark call args: optional cache first, then declared params."""
+        # Use ``is not None`` so falsy caches (0, [], {}) still prepend.
+        if self._current_cache is not None:
+            return (self._current_cache,) + tuple(self._current_params)
+        return tuple(self._current_params)
+
+    @staticmethod
+    def _is_parameter_free_setup(setup):
+        """
+        True if ``setup`` can be called with no positional arguments.
+
+        Used so module-level ``setup(*args, **kwargs)`` runs before
+        ``setup_cache`` while class ``setup(self, n)`` waits for ``do_setup``.
+        """
+        try:
+            inspect.signature(setup).bind()
+        except TypeError:
+            return False
+        return True
+
     def check(self, root):
         """
         Checks call syntax (argument count) for benchmark's setup, call, and 
teardown.
@@ -629,10 +716,10 @@
             return True
         try:
             for setup in self._setups:
-                setup(*self._current_params)
+                setup(*self._build_params())
         except NotImplementedError as e:
             # allow skipping test
-            print(f"asv: skipped: {e !r} ")
+            print(f"asv: skipped: {e!r} ")
             return True
         return False
 
@@ -648,17 +735,23 @@
             # Skip
             return
         for teardown in self._teardowns:
-            teardown(*self._current_params)
+            teardown(*self._build_params())
 
     def do_setup_cache(self):
+        # Run parameter-free setup hooks before setup_cache (asv#1592).
+        # Module-level ``setup(*args, **kwargs)`` (e.g. pandas random seed) is
+        # included; class setups that require benchmark params run in do_setup.
         if self._setup_cache is not None:
+            for setup in self._setups:
+                if self._is_parameter_free_setup(setup):
+                    setup()
             return self._setup_cache()
 
     def do_run(self):
         if tuple(self._current_params) in self._skip_tuples:
             # Skip
             return
-        return self.run(*self._current_params)
+        return self.run(*self._build_params())
 
     def do_profile(self, filename=None):
         """
@@ -701,5 +794,5 @@
             self.redo_setup()
 
             profile.runctx(
-                code, {"run": self.func, "params": self._current_params}, {}, 
filename
+                code, {"run": self.func, "params": self._build_params()}, {}, 
filename
             )
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asv_runner-0.2.1/asv_runner/benchmarks/mark.py 
new/asv_runner-0.3.0/asv_runner/benchmarks/mark.py
--- old/asv_runner-0.2.1/asv_runner/benchmarks/mark.py  2023-08-13 
23:55:04.284114000 +0200
+++ new/asv_runner-0.3.0/asv_runner/benchmarks/mark.py  2026-08-04 
13:53:18.022365000 +0200
@@ -420,7 +420,96 @@
     return decorator
 
 
+# Attributes ASV / asv_runner already honor on benchmark callables (asv#1469).
+# Keep aligned with readers in benchmarks/_base.py and asv's attr discovery.
+_BENCHMARK_ATTRS = frozenset(
+    {
+        "pretty_name",
+        "timeout",
+        "version",
+        "warmup_time",
+        "sample_time",
+        "number",
+        "repeat",
+        "rounds",
+        "min_run_count",
+        "processes",
+        "timer",
+        "params",
+        "param_names",
+        "skip_params",
+        "skip_benchmark",
+        "setup_cache_timeout",
+        "goal_time",
+        "max_time",
+        # timeraw: injected into timed subprocess (asv#1471)
+        "env",
+    }
+)
+
+
+def benchmark(**attrs):
+    """
+    Decorator to set benchmark metadata attributes on a function or class.
+
+    Equivalent to assigning attributes such as ``pretty_name`` or ``timeout``
+    on the callable, with optional validation of known names.
+
+    #### Parameters
+    **attrs**
+    : Keyword arguments stored as attributes on the decorated object.
+      Common keys: ``pretty_name``, ``timeout``, ``version``, ``warmup_time``,
+      ``sample_time``, ``number``, ``repeat``, ``rounds``, ``min_run_count``,
+      ``processes``, ``timer``, ``params``, ``param_names``.
+
+    #### Returns
+    **decorator** (`function`)
+    : Decorator that applies the attributes and returns the original object
+      (classes) or a thin wrapper (functions) so attributes attach reliably.
+
+    #### Example
+    ```{code-block} python
+    from asv_runner.benchmarks.mark import benchmark
+
+    @benchmark(pretty_name="keys", timeout=60)
+    def time_keys(self):
+        for key in self.d.keys():
+            pass
+    ```
+    """
+    unknown = set(attrs) - _BENCHMARK_ATTRS
+    if unknown:
+        raise TypeError(
+            "Unknown benchmark attribute(s): "
+            + ", ".join(sorted(unknown))
+            + ". Known: "
+            + ", ".join(sorted(_BENCHMARK_ATTRS))
+        )
+
+    def decorator(obj):
+        if inspect.isclass(obj):
+            for key, value in attrs.items():
+                setattr(obj, key, value)
+            return obj
+
+        if not callable(obj):
+            raise TypeError(
+                "The benchmark decorator can only be used with functions or 
classes"
+            )
+
+        @functools.wraps(obj)
+        def wrapper(*args, **kwargs):
+            return obj(*args, **kwargs)
+
+        for key, value in attrs.items():
+            setattr(wrapper, key, value)
+        return wrapper
+
+    return decorator
+
+
 __all__ = [
+    "benchmark",
     "parameterize",
     "skip_benchmark",
     "skip_benchmark_if",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asv_runner-0.2.1/asv_runner/benchmarks/time.py 
new/asv_runner-0.3.0/asv_runner/benchmarks/time.py
--- old/asv_runner-0.2.1/asv_runner/benchmarks/time.py  2023-08-20 
15:38:25.433409000 +0200
+++ new/asv_runner-0.3.0/asv_runner/benchmarks/time.py  2026-08-04 
13:53:18.022497000 +0200
@@ -1,3 +1,4 @@
+import gc
 import re
 import sys
 import timeit
@@ -7,6 +8,39 @@
 wall_timer = timeit.default_timer
 
 
+class _SetupInterleavedTimer:
+    """
+    ``timeit.Timer``-compatible batching for explicit ``number > 1`` with
+    setup hooks (asv#966): setup re-runs before every call via
+    ``redo_setup`` and only the calls accumulate into the sample, at the
+    cost of two clock reads per call. Garbage collection is disabled for
+    the duration of a sample, matching ``timeit.Timer.timeit``.
+    """
+
+    def __init__(self, func, redo_setup, timer):
+        self._func = func
+        self._redo_setup = redo_setup
+        self._timer = timer
+
+    def timeit(self, number):
+        func = self._func
+        redo_setup = self._redo_setup
+        timer = self._timer
+        total = 0.0
+        gcold = gc.isenabled()
+        gc.disable()
+        try:
+            for _ in range(number):
+                redo_setup()
+                start = timer()
+                func()
+                total += timer() - start
+        finally:
+            if gcold:
+                gc.enable()
+        return total
+
+
 class TimeBenchmark(Benchmark):
     """
     Represents a single benchmark for timing.
@@ -42,6 +76,13 @@
 
     name_regex = re.compile("^(Time[A-Z_].+)|(time_.+)$")
 
+    # With setup hooks present, every timed call observes freshly
+    # set-up state (asv#966): auto-calibrated number resolves to 1, and
+    # an explicit number > 1 batches through _SetupInterleavedTimer.
+    # Subclasses whose samples do not share in-process state (timeraw:
+    # one subprocess per sample) opt out.
+    _setup_isolates_timed_calls = True
+
     def __init__(self, name, func, attr_sources):
         """
         Initialize a new instance of `TimeBenchmark`.
@@ -82,18 +123,26 @@
         self._load_vars()
         return result
 
-    def _get_timer(self, *param):
-        """Get a `timeit.Timer` for the current benchmark."""
+    def _param_bound_func(self, *param):
         if param:
 
             def func():
                 self.func(*param)
 
-        else:
-            func = self.func
+            return func
+        return self.func
+
+    def _get_timer(self, *param):
+        """Get a `timeit.Timer` for the current benchmark."""
+        func = self._param_bound_func(*param)
         timer = timeit.Timer(stmt=func, setup=self.redo_setup, 
timer=self.timer)
         return timer
 
+    def _get_interleaved_timer(self, *param):
+        """Get a per-call-timed batch timer that re-runs setup between 
calls."""
+        func = self._param_bound_func(*param)
+        return _SetupInterleavedTimer(func, self.redo_setup, self.timer)
+
     def run(self, *param):
         """
         Run the benchmark with the given parameters.
@@ -137,7 +186,6 @@
                 # Transient effects exist also on CPython, e.g. from
                 # OS scheduling
                 warmup_time = 0.1
-        timer = self._get_timer(*param)
 
         try:
             min_repeat, max_repeat, max_time = self.repeat
@@ -162,13 +210,30 @@
         max_repeat = int(max_repeat)
         max_time = float(max_time)
 
+        number = self.number
+        # timeit(number=N) runs setup once, then the stmt N times: setup
+        # (via redo_setup) is per sample, never between the inner calls.
+        # With setup hooks present every timed call must observe freshly
+        # set-up state (asv#966): auto number resolves to 1 under plain
+        # timeit, and an explicit number > 1 batches through
+        # _SetupInterleavedTimer (setup between calls, calls timed
+        # individually). Without setup hooks, plain timeit batching is
+        # unchanged.
+        isolate = self._setups and self._setup_isolates_timed_calls
+        if isolate and number == 0:
+            number = 1
+        if isolate and number > 1:
+            timer = self._get_interleaved_timer(*param)
+        else:
+            timer = self._get_timer(*param)
+
         samples, number = self.benchmark_timing(
             timer,
             min_repeat,
             max_repeat,
             max_time=max_time,
             warmup_time=warmup_time,
-            number=self.number,
+            number=number,
             min_run_count=self.min_run_count,
         )
 
@@ -205,7 +270,10 @@
         : The time spent warming up the benchmark.
 
         **number** (`int`)
-        : The number of executions of the setup statement.
+        : The number of executions of the timed statement per sample
+        (``timeit``'s ``number``). Without setup hooks, setup runs once
+        per sample; with setup hooks the timer re-runs setup before
+        every execution and only the executions are timed.
 
         **min_run_count** (`int`)
         : The minimum number of runs required for the benchmark.
@@ -275,7 +343,12 @@
         elif warmup_time > 0:
             # Warmup
             while True:
-                self._redo_setup_next = False
+                if not self._setups:
+                    # Skipping redo_setup keeps warmup cheap; with setup
+                    # hooks present, warmup must observe the same
+                    # freshly-set-up state as the measurement loop below
+                    # (asv#966).
+                    self._redo_setup_next = False
                 timing = timer.timeit(number)
                 run_count += number
                 if wall_timer() >= start_time + warmup_time:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asv_runner-0.2.1/asv_runner/benchmarks/timeraw.py 
new/asv_runner-0.3.0/asv_runner/benchmarks/timeraw.py
--- old/asv_runner-0.2.1/asv_runner/benchmarks/timeraw.py       2023-08-06 
19:20:14.069301400 +0200
+++ new/asv_runner-0.3.0/asv_runner/benchmarks/timeraw.py       2026-08-04 
13:53:18.022497000 +0200
@@ -1,35 +1,68 @@
+import os
 import re
 import subprocess
 import sys
 import textwrap
+from hashlib import sha256
 
-from ._base import _get_first_attr
+from ._base import _get_first_attr, code_fingerprint
 from .time import TimeBenchmark
 
 
+def _normalize_timeraw_env(env):
+    """
+    Normalize a timeraw ``env`` mapping once (load time).
+
+    #### Parameters
+    **env** (`dict` or `None`)
+    : Extra environment variables for the timed subprocess, or ``None``.
+
+    #### Returns
+    **out** (`dict` or `None`)
+    : Mapping of ``str`` keys to ``str`` values, or ``None`` if unused/empty.
+
+    #### Raises
+    **TypeError**
+    : If ``env`` is not a ``dict``, or any value is ``None`` (cannot be a real
+    env entry; refusing to coerce to the string ``"None"``).
+    """
+    if env is None:
+        return None
+    if not isinstance(env, dict):
+        raise TypeError(
+            f"timeraw benchmark attribute 'env' must be a dict, got 
{type(env)!r}"
+        )
+    if not env:
+        return None
+    out = {}
+    for key, value in env.items():
+        if value is None:
+            raise TypeError(
+                f"timeraw env values must not be None (key {key!r}); "
+                "omit the key instead"
+            )
+        out[str(key)] = str(value)
+    return out
+
+
+def _env_fingerprint(env):
+    """
+    Stable, collision-resistant string for ``env`` in benchmark version 
identity.
+
+    Uses ``repr(sorted(items))`` so values containing newlines or ``=`` cannot
+    alias another mapping (e.g. ``{"A": "1\\nB=2"}`` vs ``{"A": "1", "B": 
"2"}``).
+    """
+    if not env:
+        return ""
+    # Sorted (key, value) pairs; repr is unambiguous for str pairs on 3.7+.
+    return repr(sorted(env.items()))
+
+
 class _SeparateProcessTimer:
     """
-    This class provides a timer that runs a given function in a separate Python
-    process.
+    Timer that runs a statement in a separate Python process via ``timeit``.
 
-    The function should return the statement to be timed. This statement is
-    executed using the Python timeit module in a new Python process. The
-    execution time is then returned.
-
-    #### Attributes
-    **subprocess_tmpl** (`str`)
-    : The template Python code to be run in the subprocess. It imports 
necessary
-    modules and prints the execution time of the statement.
-
-    **func** (`callable`)
-    : The function to be timed. This function should return a string of Python
-    code to be executed, or a tuple of two strings: the code to be executed and
-    the setup code to be run before timing.
-
-    #### Methods
-    **timeit(number)**
-    : Run the function's code `number` times in a separate Python process, and
-    return the execution time.
+    **env** must already be normalized (or ``None``); not re-validated here.
     """
 
     subprocess_tmpl = textwrap.dedent(
@@ -41,28 +74,18 @@
     '''
     ).strip()
 
-    def __init__(self, func):
+    def __init__(self, func, env=None):
         self.func = func
+        self.env = env
+
+    def _child_environ(self):
+        """Explicit child environ: parent mapping plus optional overrides."""
+        child = dict(os.environ)
+        if self.env:
+            child.update(self.env)
+        return child
 
     def timeit(self, number):
-        """
-        Run the function's code `number` times in a separate Python process, 
and
-        return the execution time.
-
-        #### Parameters
-        **number** (`int`)
-        : The number of times to execute the function's code.
-
-        #### Returns
-        **time** (`float`)
-        : The time it took to execute the function's code `number` times.
-
-        #### Notes
-        The function's code is executed in a separate Python process to avoid
-        interference from the parent process. The function can return either a
-        single string of code to be executed, or a tuple of two strings: the
-        code to be executed and the setup code to be run before timing.
-        """
         stmt = self.func()
         if isinstance(stmt, tuple):
             stmt, setup = stmt
@@ -75,85 +98,83 @@
 
         code = self.subprocess_tmpl.format(stmt=stmt, setup=setup, 
number=number)
 
-        res = subprocess.check_output([sys.executable, "-c", code])
-        return float(res.strip())
+        evaler = textwrap.dedent(
+            """
+            import sys
+            code = sys.stdin.read()
+            exec(code)
+            """
+        )
+
+        proc = subprocess.Popen(
+            [sys.executable, "-c", evaler],
+            stdin=subprocess.PIPE,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.PIPE,
+            env=self._child_environ(),
+        )
+        stdout, stderr = proc.communicate(input=code.encode("utf-8"))
+        # Forward timed-process stderr to this process so asv can record it
+        # (e.g. timeraw_count writes markers for number*repeat checks).
+        if stderr:
+            sys.stderr.write(stderr.decode("utf-8", errors="replace"))
+            sys.stderr.flush()
+        if proc.returncode != 0:
+            raise RuntimeError(f"Subprocess failed: {stderr.decode()}")
+
+        return float(stdout.decode("utf-8").strip())
 
 
 class TimerawBenchmark(TimeBenchmark):
     """
-    Represents a benchmark for tracking timing benchmarks run once in
-    a separate process.
-
-    This class inherits from `TimeBenchmark` and modifies it to run the
-    benchmark function in a separate process. This is useful for isolating the
-    benchmark from any potential side effects caused by other Python code
-    running in the same process.
-
-    #### Attributes
-    **name_regex** (`re.Pattern`)
-    : The regular expression used to match the names of functions that should 
be
-    considered as raw timing benchmarks.
-
-    **number** (`int`)
-    : The number of times to execute the function's code. By default, the
-    function's code is executed once.
-
-    #### Methods
-    **_load_vars()**
-    : Loads variables for the benchmark from the function's attributes or from
-    default values.
+    Timing benchmark executed once per sample in a separate process.
 
-    **_get_timer(*param)**
-    : Returns a timer that runs the benchmark function in a separate process.
-
-    **do_profile(filename=None)**
-    : Raises a ValueError. Raw timing benchmarks cannot be profiled.
+    Set ``env = {"KEY": "value"}`` on the function or class (or use
+    ``@benchmark(env={...})``) to inject variables into the **timed** child
+    (airspeed-velocity/asv#1471). Default ``version`` includes a fingerprint of
+    that mapping so env-only changes invalidate results.
     """
 
     name_regex = re.compile("^(Timeraw[A-Z_].+)|(timeraw_.+)$")
 
+    # Each sample is a fresh subprocess, so in-process setup hooks never
+    # leak state between samples; the number applied inside the child to
+    # the returned (stmt, setup) sources is out of scope for the
+    # setup-hook check in TimeBenchmark.run.
+    _setup_isolates_timed_calls = False
+
+    def __init__(self, name, func, attr_sources):
+        TimeBenchmark.__init__(self, name, func, attr_sources)
+        explicit_version = _get_first_attr(attr_sources, "version", None)
+        if explicit_version is None:
+            env_fp = _env_fingerprint(
+                _normalize_timeraw_env(_get_first_attr(attr_sources, "env", 
None))
+            )
+            if env_fp:
+                payload = self.code + "\n# timeraw_env\n" + env_fp
+                self.version = sha256(payload.encode("utf-8")).hexdigest()
+                token = code_fingerprint(payload)
+                self.version_alts = (token,) if token != self.version else ()
+
     def _load_vars(self):
-        """
-        Loads variables for the benchmark from the function's attributes or 
from
-        default values.
-        """
         TimeBenchmark._load_vars(self)
         self.number = int(_get_first_attr(self._attr_sources, "number", 1))
+        self._timeraw_env = _normalize_timeraw_env(
+            _get_first_attr(self._attr_sources, "env", None)
+        )
         del self.timer
 
     def _get_timer(self, *param):
-        """
-        Returns a timer that runs the benchmark function in a separate process.
-
-        #### Parameters
-        **param** (`tuple`)
-        : The parameters to pass to the benchmark function.
-
-        #### Returns
-        **timer** (`_SeparateProcessTimer`)
-        : A timer that runs the function in a separate process.
-        """
         if param:
 
             def func():
-                self.func(*param)
+                return self.func(*param)
 
         else:
             func = self.func
-        return _SeparateProcessTimer(func)
+        return _SeparateProcessTimer(func, env=self._timeraw_env)
 
     def do_profile(self, filename=None):
-        """
-        Raises a ValueError. Raw timing benchmarks cannot be profiled.
-
-        #### Parameters
-        **filename** (`str`, optional)
-        : The name of the file to which to save the profile. Default is None.
-
-        #### Raises
-        **ValueError**
-        : Always. Raw timing benchmarks cannot be profiled.
-        """
         raise ValueError("Raw timing benchmarks cannot be profiled")
 
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asv_runner-0.2.1/asv_runner/console.py 
new/asv_runner-0.3.0/asv_runner/console.py
--- old/asv_runner-0.2.1/asv_runner/console.py  2023-09-11 05:19:52.171772500 
+0200
+++ new/asv_runner-0.3.0/asv_runner/console.py  2026-08-04 13:53:18.022497000 
+0200
@@ -233,7 +233,7 @@
 
 
 def truncate_left(s, l):
-    return f"...{s[-(l - 3):]}" if len(s) > l else s
+    return f"...{s[-(l - 3) :]}" if len(s) > l else s
 
 
 class Log:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asv_runner-0.2.1/asv_runner/run.py 
new/asv_runner-0.3.0/asv_runner/run.py
--- old/asv_runner-0.2.1/asv_runner/run.py      2023-08-20 04:12:14.535963500 
+0200
+++ new/asv_runner-0.3.0/asv_runner/run.py      2026-08-04 13:53:18.022497000 
+0200
@@ -21,7 +21,7 @@
     CPU affinity based on them. It then creates a benchmark
     from the `benchmark_id`. If the benchmark has a setup
     cache key, it loads the cache from a file and inserts it
-    into the benchmark parameters.
+    via ``set_cache`` (kept separate from declared params for skip lists).
 
     Then, the function runs the setup for the benchmark. If
     the setup indicates that the benchmark should be skipped,
@@ -59,8 +59,9 @@
     if benchmark.setup_cache_key is not None:
         with open("cache.pickle", "rb") as fd:
             cache = pickle.load(fd)
-        if cache is not None:
-            benchmark.insert_param(cache)
+        benchmark.set_cache(cache)
+    else:
+        benchmark.set_cache(None)
 
     skip = benchmark.do_setup()
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asv_runner-0.2.1/asv_runner/server.py 
new/asv_runner-0.3.0/asv_runner/server.py
--- old/asv_runner-0.2.1/asv_runner/server.py   2023-08-06 19:20:14.069301400 
+0200
+++ new/asv_runner-0.3.0/asv_runner/server.py   2026-08-04 13:53:18.022497000 
+0200
@@ -47,7 +47,7 @@
         data += s
         if not s:
             raise RuntimeError(
-                "did not receive data from socket " f"(size {size}, got only 
{data !r})"
+                f"did not receive data from socket (size {size}, got only 
{data!r})"
             )
     return data
 
@@ -157,7 +157,7 @@
             cwd = command.pop("cwd")
 
             if command:
-                raise RuntimeError(f"Command contained unknown data: 
{command_text !r}")
+                raise RuntimeError(f"Command contained unknown data: 
{command_text!r}")
 
             # Spawn benchmark
             run_args = (
@@ -230,6 +230,14 @@
         except KeyboardInterrupt:
             break
         finally:
-            conn.close()
+            # Parent may deliver SIGINT during teardown (asv#1511); do not let
+            # cleanup raise KeyboardInterrupt and spam the benchmark log.
+            try:
+                conn.close()
+            except KeyboardInterrupt:
+                pass
             if stdout_file is not None:
-                os.unlink(stdout_file)
+                try:
+                    os.unlink(stdout_file)
+                except (KeyboardInterrupt, OSError):
+                    pass
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asv_runner-0.2.1/asv_runner/util.py 
new/asv_runner-0.3.0/asv_runner/util.py
--- old/asv_runner-0.2.1/asv_runner/util.py     2023-08-06 19:20:14.072634700 
+0200
+++ new/asv_runner-0.3.0/asv_runner/util.py     2026-08-04 13:53:18.022497000 
+0200
@@ -107,6 +107,8 @@
     ("h", 60 * 60),
     ("d", 60 * 60 * 24),
     ("w", 60 * 60 * 24 * 7),
+    ("M", int(60 * 60 * 24 * 7 * 52 / 12)),
+    ("q", int(60 * 60 * 24 * 7 * 52 / 4)),
     ("y", 60 * 60 * 24 * 7 * 52),
     ("C", 60 * 60 * 24 * 7 * 52 * 100),
 )
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asv_runner-0.2.1/pyproject.toml 
new/asv_runner-0.3.0/pyproject.toml
--- old/asv_runner-0.2.1/pyproject.toml 2024-02-17 15:11:32.146874000 +0100
+++ new/asv_runner-0.3.0/pyproject.toml 2026-08-04 13:53:21.868299200 +0200
@@ -23,12 +23,12 @@
     "Topic :: System :: Benchmark",
 ]
 dependencies = [
-    "importlib-metadata",
+    "importlib-metadata; python_version < '3.8'",
 ]
 requires-python = ">=3.7"
 readme = "README.md"
 dynamic = []
-version = "0.2.1"
+version = "0.3.0"
 
 [project.license]
 text = "BSD-3-Clause"
@@ -59,6 +59,8 @@
 
 [tool.ruff]
 line-length = 88
+
+[tool.ruff.lint]
 extend-ignore = [
     "E741",
 ]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asv_runner-0.2.1/tests/test_cache_skip_setup.py 
new/asv_runner-0.3.0/tests/test_cache_skip_setup.py
--- old/asv_runner-0.2.1/tests/test_cache_skip_setup.py 1970-01-01 
01:00:00.000000000 +0100
+++ new/asv_runner-0.3.0/tests/test_cache_skip_setup.py 2026-08-04 
13:53:18.024187600 +0200
@@ -0,0 +1,126 @@
+# Regression tests for asv_runner#49 and airspeed-velocity/asv#1592.
+# Drive shipped Benchmark / mark APIs only (no re-implementation).
+
+
+import os
+import sys
+import unittest
+
+# Allow running without install: repo root on path
+_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
+if _ROOT not in sys.path:
+    sys.path.insert(0, _ROOT)
+
+from asv_runner.benchmarks._base import Benchmark  # noqa: E402
+from asv_runner.benchmarks.mark import benchmark, skip_for_params  # noqa: E402
+from asv_runner.benchmarks.track import TrackBenchmark  # noqa: E402
+
+
+def _make_track_benchmark(func, name="mod.track_x"):
+    """Build a real TrackBenchmark from a plain function (shipped ctor)."""
+    # attr_sources: function first so decorators / attrs on func win.
+    return TrackBenchmark(name, func, [func])
+
+
+class TestCacheSkipAndSetup(unittest.TestCase):
+    def test_skip_params_ignores_cache_object(self):
+        # Declared skip is (2,); cache must not be part of the skip key.
+        @skip_for_params([(2,)])
+        def track_fn(cache, n):
+            return n
+
+        track_fn.params = [1, 2]
+        track_fn.param_names = ["n"]
+
+        b = _make_track_benchmark(track_fn, "mod.track_fn")
+        self.assertEqual(b._skip_tuples, [(2,)])
+
+        b.set_param_idx(1)  # n == 2
+        cache_obj = {"payload": 99}
+        b.set_cache(cache_obj)
+
+        # Skip key is declared params only (asv_runner#49).
+        self.assertTrue(b.do_setup())
+        self.assertIsNone(b.do_run())
+        self.assertEqual(b._build_params(), (cache_obj, 2))
+
+        b.set_param_idx(0)  # n == 1 — not skipped
+        b.set_cache(cache_obj)
+        self.assertFalse(b.do_setup())
+        self.assertEqual(b.do_run(), 1)
+
+    def test_falsy_cache_still_prepended(self):
+        def track_fn(cache, n):
+            return (cache, n)
+
+        track_fn.params = [3]
+        b = _make_track_benchmark(track_fn, "mod.track_falsy")
+        b.set_param_idx(0)
+        for falsy in (0, [], {}, ""):
+            b.set_cache(falsy)
+            self.assertEqual(b._build_params(), (falsy, 3))
+            self.assertEqual(b.do_run(), (falsy, 3))
+
+    def test_none_cache_does_not_prepend(self):
+        def track_fn(n):
+            return n
+
+        track_fn.params = [4]
+        b = _make_track_benchmark(track_fn, "mod.track_none_cache")
+        b.set_param_idx(0)
+        b.set_cache(None)
+        self.assertEqual(b._build_params(), (4,))
+        self.assertEqual(b.do_run(), 4)
+
+    def test_parameter_free_setup_runs_before_setup_cache(self):
+        order = []
+
+        def module_setup(*args, **kwargs):
+            order.append("module_setup")
+
+        def class_setup(n):
+            order.append("class_setup")
+            raise AssertionError("class setup must not run in do_setup_cache")
+
+        def setup_cache():
+            order.append("setup_cache")
+            return {"ok": True}
+
+        def track_fn(cache, n):
+            return n
+
+        track_fn.params = [1]
+        track_fn.setup = class_setup
+        track_fn.setup_cache = setup_cache
+
+        b = _make_track_benchmark(track_fn, "mod.track_seed")
+        # Inject module-level setup at front of MRO-style list (as discovery 
does).
+        b._setups = [module_setup, class_setup]
+        b._setup_cache = setup_cache
+
+        out = b.do_setup_cache()
+        self.assertEqual(out, {"ok": True})
+        self.assertEqual(order, ["module_setup", "setup_cache"])
+        self.assertTrue(Benchmark._is_parameter_free_setup(module_setup))
+        self.assertFalse(Benchmark._is_parameter_free_setup(class_setup))
+
+    def test_benchmark_decorator_attrs_and_unknown(self):
+        @benchmark(pretty_name="Pretty", timeout=12.5, max_time=30.0)
+        def track_fn():
+            return 0
+
+        self.assertEqual(track_fn.pretty_name, "Pretty")
+        self.assertEqual(track_fn.timeout, 12.5)
+        self.assertEqual(track_fn.max_time, 30.0)
+
+        with self.assertRaises(TypeError) as ctx:
+
+            @benchmark(not_a_real_attr=1)
+            def bad():
+                pass
+
+        self.assertIn("Unknown benchmark attribute", str(ctx.exception))
+
+
+if __name__ == "__main__":
+    unittest.main()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asv_runner-0.2.1/tests/test_hash_and_timing.py 
new/asv_runner-0.3.0/tests/test_hash_and_timing.py
--- old/asv_runner-0.2.1/tests/test_hash_and_timing.py  1970-01-01 
01:00:00.000000000 +0100
+++ new/asv_runner-0.3.0/tests/test_hash_and_timing.py  2026-08-04 
13:53:18.024634600 +0200
@@ -0,0 +1,80 @@
+# asv_runner#43 hashing + asv_runner#33 timing scale regression.
+
+import os
+import sys
+import time
+import unittest
+
+_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
+if _ROOT not in sys.path:
+    sys.path.insert(0, _ROOT)
+
+from asv_runner.benchmarks._base import code_fingerprint  # noqa: E402
+from asv_runner.benchmarks.time import TimeBenchmark  # noqa: E402
+
+
+class TestCodeFingerprint(unittest.TestCase):
+    def test_whitespace_and_comment_stable(self):
+        a = "def f():\n    return 1 + 2\n"
+        b = "def f():\n    return 1+2  # trailing comment\n"
+        self.assertEqual(code_fingerprint(a), code_fingerprint(b))
+
+    def test_semantic_change_differs(self):
+        a = "def f():\n    return 1\n"
+        b = "def f():\n    return 2\n"
+        self.assertNotEqual(code_fingerprint(a), code_fingerprint(b))
+
+
+class TestTimingScale(unittest.TestCase):
+    """asv_runner#33: samples are per-call seconds, not systematically 
~half."""
+
+    def test_sleep_sample_near_wall_duration(self):
+        delay = 0.05
+
+        def time_sleep():
+            time.sleep(delay)
+
+        time_sleep.number = 1
+        time_sleep.repeat = 3
+        time_sleep.warmup_time = 0
+        time_sleep.sample_time = 0.01
+        time_sleep.min_run_count = 1
+        time_sleep.rounds = 1
+        time_sleep.processes = 1
+
+        b = TimeBenchmark("m.time_sleep", time_sleep, [time_sleep])
+        b._load_vars()
+        result = b.run()
+        samples = result["samples"]
+        self.assertEqual(result["number"], 1)
+        # Each sample is total timeit time / number => ~delay seconds.
+        median = sorted(samples)[len(samples) // 2]
+        # Allow OS scheduling noise; fail if ~50% systematic (would be ~0.025).
+        self.assertGreater(median, delay * 0.7, msg=f"samples={samples!r}")
+        self.assertLess(median, delay * 2.5, msg=f"samples={samples!r}")
+
+
+if __name__ == "__main__":
+    unittest.main()
+
+
+class TestVersionBackfill(unittest.TestCase):
+    def test_primary_version_is_sha256_code_alts_token(self):
+        from hashlib import sha256
+
+        from asv_runner.benchmarks._base import code_fingerprint
+        from asv_runner.benchmarks.track import TrackBenchmark
+
+        def track_x():
+            """doc"""
+            return 1
+
+        b = TrackBenchmark("m.track_x", track_x, [track_x])
+        # getsourcelines works for real functions in this file
+        expected = sha256(b.code.encode("utf-8")).hexdigest()
+        self.assertEqual(b.version, expected)
+        token = code_fingerprint(b.code)
+        if token != expected:
+            self.assertIn(token, b.version_alts)
+        else:
+            self.assertEqual(b.version_alts, ())
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asv_runner-0.2.1/tests/test_setup_repeat.py 
new/asv_runner-0.3.0/tests/test_setup_repeat.py
--- old/asv_runner-0.2.1/tests/test_setup_repeat.py     1970-01-01 
01:00:00.000000000 +0100
+++ new/asv_runner-0.3.0/tests/test_setup_repeat.py     2026-08-04 
13:53:18.024634600 +0200
@@ -0,0 +1,128 @@
+# asv#966: with setup hooks present, every timed call must observe
+# freshly set-up state. Auto-calibrated number resolves to 1; warmup
+# re-runs setup between calls; an explicitly set number is honored and
+# batches with setup interleaved between individually timed calls.
+
+import os
+import sys
+import unittest
+
+_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
+if _ROOT not in sys.path:
+    sys.path.insert(0, _ROOT)
+
+from asv_runner.benchmarks.time import TimeBenchmark  # noqa: E402
+
+
+def _make_benchmark(suite, method_name):
+    method = getattr(suite, method_name)
+    b = TimeBenchmark(f"Suite.{method_name}", method, [method, suite])
+    b.do_setup()
+    return b
+
+
+class TestSetupBeforeEachTimedCall(unittest.TestCase):
+    def test_auto_number_resolves_to_one_with_mutating_setup(self):
+        """Reproducer from asv#966 under default (CPython) warmup."""
+
+        class Suite:
+            repeat = 5
+            min_run_count = 1
+            rounds = 1
+
+            def setup(self):
+                self.x = []
+
+            def time_mutate(self):
+                assert len(self.x) == 0
+                self.x.append(0)
+
+        result = _make_benchmark(Suite(), "time_mutate").run()
+        self.assertEqual(result["number"], 1)
+        self.assertGreaterEqual(len(result["samples"]), 1)
+
+    def test_explicit_number_one_survives_warmup(self):
+        """Warmup calls redo setup too: number=1 keeps its documented
+        semantics (asv#966, the explicit-number report)."""
+
+        class Suite:
+            number = 1
+            repeat = 3
+            min_run_count = 1
+            rounds = 1
+
+            def setup(self):
+                self.x = []
+
+            def time_mutate(self):
+                assert len(self.x) == 0
+                self.x.append(0)
+
+        result = _make_benchmark(Suite(), "time_mutate").run()
+        self.assertEqual(result["number"], 1)
+        self.assertGreaterEqual(len(result["samples"]), 1)
+
+    def test_explicit_number_wins_over_setup(self):
+        """A benign setup keeps timeit batching when number is explicit."""
+
+        class Suite:
+            number = 7
+            repeat = 2
+            warmup_time = 0
+            min_run_count = 1
+            rounds = 1
+
+            def setup(self):
+                self.data = list(range(8))
+
+            def time_read(self):
+                return sum(self.data)
+
+        result = _make_benchmark(Suite(), "time_read").run()
+        self.assertEqual(result["number"], 7)
+        self.assertGreaterEqual(len(result["samples"]), 1)
+
+    def test_explicit_number_batches_with_fresh_setup(self):
+        """Explicit number>1 with a mutating setup: setup re-runs before
+        every call and the batch still reports the requested number."""
+
+        class Suite:
+            number = 10
+            repeat = 3
+            warmup_time = 0.05
+            min_run_count = 1
+            rounds = 1
+
+            def setup(self):
+                self.x = []
+
+            def time_mutate(self):
+                assert len(self.x) == 0
+                self.x.append(0)
+
+        result = _make_benchmark(Suite(), "time_mutate").run()
+        self.assertEqual(result["number"], 10)
+        self.assertGreaterEqual(len(result["samples"]), 1)
+        self.assertTrue(all(s >= 0 for s in result["samples"]))
+
+    def test_auto_number_calibrates_without_setup(self):
+        def time_fast():
+            return 1 + 1
+
+        time_fast.number = 0  # auto
+        time_fast.repeat = 2
+        time_fast.warmup_time = 0
+        time_fast.sample_time = 0.001
+        time_fast.min_run_count = 1
+        time_fast.rounds = 1
+        time_fast.processes = 1
+
+        b = TimeBenchmark("m.time_fast", time_fast, [time_fast])
+        b.do_setup()
+        result = b.run()
+        self.assertGreater(result["number"], 1)
+        self.assertGreaterEqual(len(result["samples"]), 1)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asv_runner-0.2.1/tests/test_smoke_import.py 
new/asv_runner-0.3.0/tests/test_smoke_import.py
--- old/asv_runner-0.2.1/tests/test_smoke_import.py     1970-01-01 
01:00:00.000000000 +0100
+++ new/asv_runner-0.3.0/tests/test_smoke_import.py     2026-08-04 
13:53:18.024634600 +0200
@@ -0,0 +1,38 @@
+"""Smoke tests shipped with CI-only PRs (no behavior change to the package)."""
+
+import ast
+import pathlib
+import unittest
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+PKG = ROOT / "asv_runner"
+
+
+class TestSmokeImport(unittest.TestCase):
+    def test_import_package(self):
+        import asv_runner  # noqa: F401
+
+    def test_import_public_entrypoint_modules(self):
+        # Subprocess CLI surface used by asv.benchmark
+        import asv_runner.check  # noqa: F401
+        import asv_runner.discovery  # noqa: F401
+        import asv_runner.run  # noqa: F401
+        import asv_runner.server  # noqa: F401
+        import asv_runner.setup_cache  # noqa: F401
+        import asv_runner.timing  # noqa: F401
+
+    def test_package_is_py37_syntax(self):
+        # Static gate: no 3.8+ syntax nodes in runtime package.
+        for path in sorted(PKG.rglob("*.py")):
+            src = path.read_text(encoding="utf-8")
+            tree = ast.parse(src, filename=str(path))
+            for node in ast.walk(tree):
+                name = type(node).__name__
+                self.assertNotEqual(name, "NamedExpr", msg=str(path))
+                self.assertNotEqual(name, "Match", msg=str(path))
+            # feature_version is 3.8+ on the AST API; still validates parse on 
host
+            ast.parse(src, feature_version=(3, 7))
+
+
+if __name__ == "__main__":
+    unittest.main()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/asv_runner-0.2.1/tests/test_timeraw_env.py 
new/asv_runner-0.3.0/tests/test_timeraw_env.py
--- old/asv_runner-0.2.1/tests/test_timeraw_env.py      1970-01-01 
01:00:00.000000000 +0100
+++ new/asv_runner-0.3.0/tests/test_timeraw_env.py      2026-08-04 
13:53:18.024634600 +0200
@@ -0,0 +1,156 @@
+# Timeraw env design (asv#1471) + identity fingerprint.
+
+import os
+import sys
+import unittest
+
+_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
+if _ROOT not in sys.path:
+    sys.path.insert(0, _ROOT)
+
+from asv_runner.benchmarks.mark import benchmark  # noqa: E402
+from asv_runner.benchmarks.timeraw import (  # noqa: E402
+    TimerawBenchmark,
+    _normalize_timeraw_env,
+    _SeparateProcessTimer,
+)
+
+
+class TestTimerawEnvDesign(unittest.TestCase):
+    def test_normalize_rejects_non_dict_and_none_values(self):
+        self.assertIsNone(_normalize_timeraw_env(None))
+        self.assertIsNone(_normalize_timeraw_env({}))
+        with self.assertRaises(TypeError):
+            _normalize_timeraw_env([("A", "1")])
+        with self.assertRaises(TypeError):
+            _normalize_timeraw_env({"A": None})
+
+    def test_attribute_and_decorator_set_env(self):
+        def timeraw_a():
+            return "pass"
+
+        timeraw_a.env = {"IS_PERF": "1"}
+        b = TimerawBenchmark("m.timeraw_a", timeraw_a, [timeraw_a])
+        b._load_vars()
+        self.assertEqual(b._timeraw_env["IS_PERF"], "1")
+
+        @benchmark(env={"IS_PERF": "2"})
+        def timeraw_b():
+            return "pass"
+
+        b2 = TimerawBenchmark("m.timeraw_b", timeraw_b, [timeraw_b])
+        b2._load_vars()
+        self.assertEqual(b2._timeraw_env["IS_PERF"], "2")
+
+    def test_child_environ_merge_and_override(self):
+        timer = _SeparateProcessTimer(lambda: "pass", env={"PATH": 
"override-path"})
+        child = timer._child_environ()
+        self.assertEqual(child["PATH"], "override-path")
+        if "HOME" in os.environ:
+            self.assertEqual(child["HOME"], os.environ["HOME"])
+
+    def test_timeit_sees_env_in_subprocess(self):
+        def timeraw_probe():
+            return """
+            import os
+            assert os.environ.get("ASV_TIMERAW_PROBE") == "yes"
+            """
+
+        timeraw_probe.env = {"ASV_TIMERAW_PROBE": "yes"}
+        b = TimerawBenchmark("m.timeraw_probe", timeraw_probe, [timeraw_probe])
+        b._load_vars()
+        elapsed = b._get_timer().timeit(1)
+        self.assertIsInstance(elapsed, float)
+
+    def test_timer_does_not_renormalize(self):
+        timer = _SeparateProcessTimer(lambda: "pass", env={"K": "v"})
+        self.assertEqual(timer.env["K"], "v")
+
+    def test_env_changes_default_version(self):
+        def timeraw_x():
+            return "pass"
+
+        timeraw_x.env = {"A": "1"}
+        b1 = TimerawBenchmark("m.timeraw_x", timeraw_x, [timeraw_x])
+
+        def timeraw_y():
+            return "pass"
+
+        timeraw_y.env = {"A": "2"}
+        b2 = TimerawBenchmark("m.timeraw_y", timeraw_y, [timeraw_y])
+        self.assertNotEqual(b1.version, b2.version)
+
+        def timeraw_z():
+            return "pass"
+
+        timeraw_z.env = {"A": "1"}
+        timeraw_z.version = "fixed"
+        b3 = TimerawBenchmark("m.timeraw_z", timeraw_z, [timeraw_z])
+        self.assertEqual(b3.version, "fixed")
+
+    def test_env_fingerprint_no_newline_or_equals_collision(self):
+        from asv_runner.benchmarks.timeraw import _env_fingerprint
+
+        a = _env_fingerprint({"A": "1\nB=2"})
+        b = _env_fingerprint({"A": "1", "B": "2"})
+        self.assertNotEqual(a, b)
+
+        c = _env_fingerprint({"A": "B=1"})
+        d = _env_fingerprint({"A=B": "1"})
+        self.assertNotEqual(c, d)
+
+        def timeraw_nl():
+            return "pass"
+
+        timeraw_nl.env = {"A": "1\nB=2"}
+        b_nl = TimerawBenchmark("m.timeraw_nl", timeraw_nl, [timeraw_nl])
+
+        def timeraw_two():
+            return "pass"
+
+        timeraw_two.env = {"A": "1", "B": "2"}
+        b_two = TimerawBenchmark("m.timeraw_two", timeraw_two, [timeraw_two])
+        self.assertNotEqual(b_nl.version, b_two.version)
+
+        def timeraw_eq_val():
+            return "pass"
+
+        timeraw_eq_val.env = {"A": "B=1"}
+        b_eq_val = TimerawBenchmark(
+            "m.timeraw_eq_val", timeraw_eq_val, [timeraw_eq_val]
+        )
+
+        def timeraw_eq_key():
+            return "pass"
+
+        timeraw_eq_key.env = {"A=B": "1"}
+        b_eq_key = TimerawBenchmark(
+            "m.timeraw_eq_key", timeraw_eq_key, [timeraw_eq_key]
+        )
+        self.assertNotEqual(b_eq_val.version, b_eq_key.version)
+
+
+if __name__ == "__main__":
+    unittest.main()
+
+
+class TestTimerawStderrForward(unittest.TestCase):
+    def test_child_stderr_reaches_parent(self):
+        """asv test_timeraw_benchmark looks for markers on the runner process 
stderr."""
+        import io
+
+        from asv_runner.benchmarks.timeraw import _SeparateProcessTimer
+
+        def stmt():
+            return "import sys; sys.stderr.write('MARK')"
+
+        timer = _SeparateProcessTimer(stmt)
+        buf = io.StringIO()
+        old = sys.stderr
+        try:
+            sys.stderr = buf
+            timer.timeit(3)
+        finally:
+            sys.stderr = old
+        self.assertIn("MARK", buf.getvalue())
+        self.assertEqual(buf.getvalue().count("MARK"), 3)

Reply via email to