From 829aeed2310df75104b02c0a0290a579cc02da6e Mon Sep 17 00:00:00 2001 From: DebanKsahu Date: Fri, 4 Sep 2026 09:19:14 +0000 Subject: [PATCH 01/19] impr: Added type annotation in `_utils` module (Iteration 1) --- pyperf/_utils.py | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/pyperf/_utils.py b/pyperf/_utils.py index 7cea4414..4433844e 100644 --- a/pyperf/_utils.py +++ b/pyperf/_utils.py @@ -1,3 +1,7 @@ +from typing import TypeVar +import subprocess +from io import TextIOWrapper +from collections.abc import Sequence import contextlib import math import os @@ -34,7 +38,7 @@ 2.042) -def tdist95conf_level(df): +def tdist95conf_level(df: int) -> float: """Approximate the 95% confidence interval for Student's T distribution. Given the degrees of freedom, returns an approximation to the 95% @@ -46,7 +50,6 @@ def tdist95conf_level(df): Returns: A float. """ - df = int(round(df)) highest_table_df = len(_T_DIST_95_CONF_LEVELS) if df >= 200: return 1.960 @@ -65,7 +68,7 @@ def tdist95conf_level(df): return _T_DIST_95_CONF_LEVELS[df] -def pooled_sample_variance(sample1, sample2): +def pooled_sample_variance(sample1: Sequence[float], sample2: Sequence[float]) -> float: """Find the pooled sample variance for two samples. Args: @@ -84,7 +87,7 @@ def pooled_sample_variance(sample1, sample2): return (math.fsum(squares1) + math.fsum(squares2)) / float(deg_freedom) -def tscore(sample1, sample2): +def tscore(sample1: Sequence[float], sample2: Sequence[float]) -> float: """Calculate a t-test score for the difference between two samples. Args: @@ -101,7 +104,7 @@ def tscore(sample1, sample2): return diff / math.sqrt(error * 2) -def is_significant(sample1, sample2): +def is_significant(sample1: Sequence[float], sample2: Sequence[float]) -> tuple[bool,float]: """Determine whether two samples differ significantly. This uses a Student's two-sample, two-tailed t-test with alpha=0.95. @@ -121,7 +124,7 @@ def is_significant(sample1, sample2): return (abs(t_score) >= critical_value, t_score) -def parse_run_list(run_list): +def parse_run_list(run_list: str) -> list[int]: run_list = run_list.strip() runs = [] @@ -148,12 +151,12 @@ def parse_run_list(run_list): return [run - 1 for run in runs] -def open_text(path, write=False): +def open_text(path: str | os.PathLike[str], write: bool=False) -> TextIOWrapper: mode = "w" if write else "r" return open(path, mode, encoding="utf-8") -def read_first_line(path, error=False): +def read_first_line(path: str | os.PathLike[str], error: bool=False) -> str: try: with open_text(path) as fp: line = fp.readline() @@ -165,36 +168,37 @@ def read_first_line(path, error=False): return '' -def proc_path(path): +def proc_path(path: str | os.PathLike[str]) -> str: return os.path.join("/proc", path) -def sysfs_path(path): +def sysfs_path(path: str | os.PathLike[str]) -> str: return os.path.join("/sys", path) -def python_implementation(): +def python_implementation() -> str: return sys.implementation.name.lower() -def python_has_jit(): +def python_has_jit() -> bool: implementation_name = python_implementation() if implementation_name == 'pypy': - return sys.pypy_translation_info["translation.jit"] + return bool(sys.pypy_translation_info["translation.jit"]) elif implementation_name in ['graalpython', 'graalpy']: return True elif implementation_name == 'cpython': jit_module = getattr(sys, '_jit', None) if jit_module is not None: - return jit_module.is_enabled() + return bool(jit_module.is_enabled()) return False elif hasattr(sys, "pyston_version_info") or "pyston_lite" in sys.modules: return True return False +_PROC_TYPE = TypeVar("_PROC_TYPE", str, bytes) @contextlib.contextmanager -def popen_killer(proc): +def popen_killer(proc: subprocess.Popen[_PROC_TYPE]): try: yield except: # noqa: E722 @@ -214,12 +218,12 @@ def popen_killer(proc): raise -def popen_communicate(proc): +def popen_communicate(proc: subprocess.Popen[_PROC_TYPE]) -> tuple[_PROC_TYPE,_PROC_TYPE]: with popen_killer(proc): return proc.communicate() -def get_python_names(python1, python2): +def get_python_names(python1: str, python2: str) -> tuple[str, str]: # FIXME: merge with format_filename_func() of __main__.py name1 = os.path.basename(python1) name2 = os.path.basename(python2) @@ -229,7 +233,7 @@ def get_python_names(python1, python2): return (python1, python2) -def abs_executable(python): +def abs_executable(python: str) -> str: orig_python = python # Replace "~" with the user home directory From fe055a7b5f729c6bd3a1a104a1471c78f4e31fb5 Mon Sep 17 00:00:00 2001 From: DebanKsahu Date: Sat, 5 Sep 2026 13:03:36 +0000 Subject: [PATCH 02/19] impr: Added type annotation in `_utils` module (Iteration 2) --- pyperf/_utils.py | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/pyperf/_utils.py b/pyperf/_utils.py index 4433844e..cb596593 100644 --- a/pyperf/_utils.py +++ b/pyperf/_utils.py @@ -1,3 +1,8 @@ +from __future__ import annotations +from cProfile import Profile +from typing import SupportsFloat +from collections.abc import Mapping +from collections.abc import Iterable from typing import TypeVar import subprocess from io import TextIOWrapper @@ -259,7 +264,7 @@ def abs_executable(python: str) -> str: return os.path.normpath(python) -def create_environ(inherit_environ, locale, copy_all): +def create_environ(inherit_environ: Iterable[str] | None, locale: bool, copy_all: bool) -> Mapping[str,str] : if copy_all: return os.environ env = {} @@ -287,7 +292,7 @@ def create_environ(inherit_environ, locale, copy_all): class _Pipe: _OPEN_MODE = "r" - def __init__(self, fd): + def __init__(self, fd: int): self._fd = fd self._file = None if MS_WINDOWS: @@ -316,19 +321,19 @@ def __exit__(self, *args): class ReadPipe(_Pipe): - def open_text(self): + def open_text(self) -> TextIOWrapper: file = open(self._fd, "r", encoding="utf8") self._file = file return file - def read_text(self, timeout=None): + def read_text(self, timeout: float | None=None) -> str: if timeout is not None: return self._read_text_timeout(timeout) else: with self.open_text() as rfile: return rfile.read() - def _read_text_timeout(self, timeout): + def _read_text_timeout(self, timeout: float) -> str: fd = self.fd os.set_blocking(fd, False) @@ -353,7 +358,7 @@ def _read_text_timeout(self, timeout): class WritePipe(_Pipe): - def to_subprocess(self): + def to_subprocess(self) -> str: if MS_WINDOWS: os.set_handle_inheritable(self._handle, True) arg = self._handle @@ -363,7 +368,7 @@ def to_subprocess(self): return str(arg) @classmethod - def from_subprocess(cls, arg): + def from_subprocess(cls, arg) -> WritePipe: arg = int(arg) if MS_WINDOWS: fd = msvcrt.open_osfhandle(arg, os.O_WRONLY) @@ -377,20 +382,20 @@ def open_text(self): return file -def create_pipe(): +def create_pipe() -> tuple[ReadPipe, WritePipe]: rfd, wfd = os.pipe() rpipe = ReadPipe(rfd) wpipe = WritePipe(wfd) return (rpipe, wpipe) -def median_abs_dev(values): +def median_abs_dev(values: Sequence[float | int]): # Median Absolute Deviation - median = float(statistics.median(values)) + median = statistics.median(values) return statistics.median([abs(median - sample) for sample in values]) -def percentile(values, p): +def percentile(values: Sequence[float | int], p: float) -> float | int: if not isinstance(p, float) or not (0.0 <= p <= 1.0): raise ValueError("p must be a float in the range [0.0; 1.0]") @@ -412,7 +417,7 @@ def percentile(values, p): if hasattr(statistics, 'geometric_mean'): _geometric_mean = statistics.geometric_mean else: - def _geometric_mean(data): + def _geometric_mean(data: Sequence[SupportsFloat]) -> float: # Compute exp(fmean(map(log, data))) using floats data = list(map(math.log, data)) @@ -422,14 +427,14 @@ def _geometric_mean(data): return math.exp(fmean) -def geometric_mean(data): +def geometric_mean(data: Sequence[SupportsFloat]) -> float: data = list(map(float, data)) if not data: raise ValueError("empty data") return _geometric_mean(data) -def merge_profile_stats(profiler, dst): +def merge_profile_stats(profiler: Profile, dst: str): """ Save pstats by merging into an existing file. """ From 047052e558bf308a8d3b13e4492b9e97692e0be9 Mon Sep 17 00:00:00 2001 From: DebanKsahu Date: Sun, 6 Sep 2026 17:38:13 +0000 Subject: [PATCH 03/19] impr: Added type annotation in `_formatter` module (Iteration 1) --- pyperf/_formatter.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/pyperf/_formatter.py b/pyperf/_formatter.py index 131721d7..c5a67aaf 100644 --- a/pyperf/_formatter.py +++ b/pyperf/_formatter.py @@ -1,7 +1,10 @@ +from typing import Literal +from collections.abc import Sequence +import datetime _TIMEDELTA_UNITS = ('sec', 'ms', 'us', 'ns') -def format_timedeltas(values): +def format_timedeltas(values: Sequence[float]) -> tuple[str, ...]: ref_value = abs(values[0]) for i in range(2, -9, -1): if ref_value >= 10.0 ** i: @@ -18,11 +21,11 @@ def format_timedeltas(values): return tuple(fmt % (value * factor,) for value in values) -def format_timedelta(value): +def format_timedelta(value: float) -> str: return format_timedeltas((value,))[0] -def format_filesize(size): +def format_filesize(size: float) -> str: if size < 10 * 1024: if size != 1: return '%.0f bytes' % size @@ -35,11 +38,11 @@ def format_filesize(size): return '%.1f KiB' % (size / 1024.0) -def format_filesizes(sizes): +def format_filesizes(sizes: Sequence[float]) -> tuple[str, ...]: return tuple(format_filesize(size) for size in sizes) -def format_seconds(seconds): +def format_seconds(seconds: float | None) -> str: # Coarse but human readable duration if not seconds: return '0 sec' @@ -64,7 +67,7 @@ def format_seconds(seconds): return ' '.join(parts) -def format_number(number, unit=None, units=None): +def format_number(number: int, unit: str | None = None, units: str | None = None) -> str: plural = (not number or abs(number) > 1) if number >= 10000: pow10 = 0 @@ -92,7 +95,7 @@ def format_number(number, unit=None, units=None): return '%s %s' % (number, unit) -def format_integers(numbers): +def format_integers(numbers: Sequence[int]) -> tuple[str, ...]: return tuple(format_number(number) for number in numbers) @@ -102,20 +105,20 @@ def format_integers(numbers): 'byte': format_filesizes, 'integer': format_integers, } +_UNIT_TYPE = Literal['second', 'byte', 'integer'] - -def format_values(unit, values): +def format_values(unit: _UNIT_TYPE, values: Sequence[float | int]) -> tuple[str, ...]: if not unit: unit = DEFAULT_UNIT formatter = UNIT_FORMATTERS[unit] return formatter(values) -def format_value(unit, value): +def format_value(unit: _UNIT_TYPE, value: float | int) -> str: return format_values(unit, (value,))[0] -def format_datetime(dt, microsecond=True): +def format_datetime(dt: datetime.datetime, microsecond: bool = True) -> str: if not microsecond: dt = dt.replace(microsecond=0) return dt.isoformat(' ') From 363a97ba74424e52abdefaa8e2d09c03eda4c651 Mon Sep 17 00:00:00 2001 From: DebanKsahu Date: Sat, 12 Sep 2026 06:02:36 +0000 Subject: [PATCH 04/19] impr: Resolved merge conflict in `_formatter` module --- pyperf/_formatter.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/pyperf/_formatter.py b/pyperf/_formatter.py index c5a67aaf..da34b4f7 100644 --- a/pyperf/_formatter.py +++ b/pyperf/_formatter.py @@ -1,4 +1,4 @@ -from typing import Literal +from typing import Literal, cast from collections.abc import Sequence import datetime _TIMEDELTA_UNITS = ('sec', 'ms', 'us', 'ns') @@ -69,6 +69,7 @@ def format_seconds(seconds: float | None) -> str: def format_number(number: int, unit: str | None = None, units: str | None = None) -> str: plural = (not number or abs(number) > 1) + str_number = str(number) if number >= 10000: pow10 = 0 x = number @@ -78,21 +79,21 @@ def format_number(number: int, unit: str | None = None, units: str | None = None if r: break if not r: - number = '10^%s' % pow10 + str_number = '10^%s' % pow10 - if isinstance(number, int) and number > 8192: + if str_number == str(number) and number > 8192: if number & (number-1) == 0: - number = '2^%s' % (number.bit_length() - 1) + str_number = '2^%s' % (number.bit_length() - 1) if not unit: - return str(number) + return str_number if plural: if not units: units = unit + 's' - return '%s %s' % (number, units) + return '%s %s' % (str_number, units) else: - return '%s %s' % (number, unit) + return '%s %s' % (str_number, unit) def format_integers(numbers: Sequence[int]) -> tuple[str, ...]: @@ -107,11 +108,15 @@ def format_integers(numbers: Sequence[int]) -> tuple[str, ...]: } _UNIT_TYPE = Literal['second', 'byte', 'integer'] -def format_values(unit: _UNIT_TYPE, values: Sequence[float | int]) -> tuple[str, ...]: +def format_values(unit: _UNIT_TYPE | None, values: Sequence[float | int]) -> tuple[str, ...]: if not unit: unit = DEFAULT_UNIT - formatter = UNIT_FORMATTERS[unit] - return formatter(values) + if unit == 'second': + return format_timedeltas(values) + elif unit == 'byte': + return format_filesizes(values) + elif unit == 'integer': + return format_integers(cast(Sequence[int],values)) def format_value(unit: _UNIT_TYPE, value: float | int) -> str: From 0a4afdd11446b2b0da8c2e2743565c3d2b62b1a5 Mon Sep 17 00:00:00 2001 From: DebanKsahu Date: Tue, 15 Sep 2026 08:18:28 +0000 Subject: [PATCH 05/19] feat: Added `pyrefly` and `mypy` settings to `pyproject.toml`. It will also support extension along with cli tool. --- pyproject.toml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index d007be21..11c6f2f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,8 @@ dependencies = ["psutil>=5.9.0"] [project.optional-dependencies] dev = [ 'tox', + "pyrefly==1.3.0", + "mypy==2.3.1", ] [project.scripts] @@ -83,3 +85,16 @@ extend-ignore = ["UP015", "UP031"] [tool.ruff.lint.mccabe] max-complexity = 31 + +[tool.pyrefly] +required-version = ">=1.3.0,<=1.3.0" +preset = "strict" +project-includes = ["pyperf"] +project-excludes = ["pyperf/tests"] +search-path = ["pyperf"] + +[tool.mypy] +strict = true +packages = ["pyperf"] +exclude = "^pyperf/tests/" +mypy_path = ["pyperf"] From ff10a3aad95394526067c5dd55ccb01dff66ac52 Mon Sep 17 00:00:00 2001 From: DebanKsahu Date: Tue, 15 Sep 2026 08:28:52 +0000 Subject: [PATCH 06/19] impr: Added type annotation in `_formatter` module (Iteration 2) --- pyperf/_formatter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyperf/_formatter.py b/pyperf/_formatter.py index da34b4f7..9d95e723 100644 --- a/pyperf/_formatter.py +++ b/pyperf/_formatter.py @@ -100,14 +100,14 @@ def format_integers(numbers: Sequence[int]) -> tuple[str, ...]: return tuple(format_number(number) for number in numbers) -DEFAULT_UNIT = 'second' + UNIT_FORMATTERS = { 'second': format_timedeltas, 'byte': format_filesizes, 'integer': format_integers, } _UNIT_TYPE = Literal['second', 'byte', 'integer'] - +DEFAULT_UNIT: _UNIT_TYPE = 'second' def format_values(unit: _UNIT_TYPE | None, values: Sequence[float | int]) -> tuple[str, ...]: if not unit: unit = DEFAULT_UNIT From 3c2c3faf42c4391f48b49cc0ae591c32a3f13bd5 Mon Sep 17 00:00:00 2001 From: DebanKsahu Date: Wed, 16 Sep 2026 13:00:25 +0000 Subject: [PATCH 07/19] chore: Updated `ruff` rules to automate some fixes for `mypy`'s missing return type issue for functions which return nothing --- pyproject.toml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 11c6f2f0..e74cf96c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,8 @@ dev = [ 'tox', "pyrefly==1.3.0", "mypy==2.3.1", + "ruff", + "pre-commit", ] [project.scripts] @@ -80,8 +82,8 @@ version = {attr = "pyperf.__version__"} fix = true [tool.ruff.lint] -extend-select = ["C90", "UP"] -extend-ignore = ["UP015", "UP031"] +extend-select = ["C90", "UP", "ANN201", "ANN202"] +extend-ignore = ["UP015", "UP031", "B009"] [tool.ruff.lint.mccabe] max-complexity = 31 From d593e5d049100efbf52a3f3fd7fde5e2dcf03eef Mon Sep 17 00:00:00 2001 From: DebanKsahu Date: Wed, 16 Sep 2026 13:59:02 +0000 Subject: [PATCH 08/19] impr: Added type annotation in `_hooks` module (Iteration 1) --- pyperf/_hooks.py | 86 +++++++++++++++++++++++++++--------------------- 1 file changed, 48 insertions(+), 38 deletions(-) diff --git a/pyperf/_hooks.py b/pyperf/_hooks.py index 6d4519ec..7126fe2b 100644 --- a/pyperf/_hooks.py +++ b/pyperf/_hooks.py @@ -1,6 +1,8 @@ # Hooks are installable context managers defined as entry points so that # arbitrary code can by run right before and after the actual internal # benchmarking code is run. +from __future__ import annotations +from collections.abc import Callable import abc @@ -13,20 +15,25 @@ import sys import tempfile import uuid +from abc import abstractmethod +from collections.abc import Generator, Sequence +from importlib.metadata import EntryPoint, EntryPoints +from types import TracebackType +from typing import Any -def get_hooks(): +def get_hooks() -> importlib.metadata.EntryPoints: hook_prefix = "pyperf.hook" entry_points = importlib.metadata.entry_points() group = entry_points.select(group=hook_prefix) return group -def get_hook_names(): +def get_hook_names() -> Generator[str]: return (x.name for x in get_hooks()) -def get_selected_hooks(hook_names, hooks=None): +def get_selected_hooks(hook_names: Sequence[str], hooks: EntryPoints | None = None) -> Generator[EntryPoint, None, None]: if hook_names is None: return @@ -35,7 +42,7 @@ def get_selected_hooks(hook_names, hooks=None): yield hook_mapping[hook_name] -def instantiate_selected_hooks(hook_names, hooks=None): +def instantiate_selected_hooks(hook_names: Sequence[str], hooks: EntryPoints | None = None) -> dict[str, HookBase]: hook_managers = {} for hook in get_selected_hooks(hook_names, hooks): try: @@ -53,65 +60,68 @@ class HookError(Exception): class HookBase(abc.ABC): - def __init__(self): + def __init__(self) -> None: """ Create a new instance of the hook. """ - pass - def teardown(self, _metadata): + @abstractmethod + def teardown(self, metadata: dict[str, Any]) -> None: """ Called when the hook is completed for a process. May add any information collected to the passed-in `metadata` dictionary. """ - pass - def __enter__(self): + @abstractmethod + def __enter__(self) -> None: """ Called immediately before running benchmark code. May be called multiple times per instance. """ - pass - def __exit__(self, _exc_type, _exc_value, _traceback): + @abstractmethod + def __exit__(self, _exc_type: type[BaseException] | None, _exc_value: BaseException | None, _traceback: TracebackType | None) -> None: """ Called immediately after running benchmark code. """ - pass class _test_hook(HookBase): - def __init__(self): + def __init__(self) -> None: self._count = 0 - def teardown(self, metadata): + def teardown(self, metadata: dict[str, Any]) -> None: metadata["_test_hook"] = self._count - def __enter__(self): + def __enter__(self) -> None: self._count += 1 - def __exit__(self, _exc_type, _exc_value, _traceback): + def __exit__(self, _exc_type: type[BaseException] | None, _exc_value: BaseException | None, _traceback: TracebackType | None) -> None: pass class pystats(HookBase): - def __init__(self): + def __init__(self) -> None: if not hasattr(sys, "_stats_on"): raise HookError( "Can not collect pystats because python was not built with --enable-pystats" ) - sys._stats_off() - sys._stats_clear() - def teardown(self, metadata): + self._stats_on: Callable[[], None] = getattr(sys, "_stats_on") + self._stats_off: Callable[[], None] = getattr(sys, "stats_off") + self._stats_clear: Callable[[], None] = getattr(sys, "stats_clear") + self._stats_off() + self._stats_clear() + + def teardown(self, metadata: dict[str,Any]) -> None: metadata["pystats"] = "enabled" - def __enter__(self): - sys._stats_on() + def __enter__(self) -> None: + self._stats_on() - def __exit__(self, _exc_type, _exc_value, _traceback): - sys._stats_off() + def __exit__(self, _exc_type: type[BaseException] | None, _exc_value: BaseException | None, _traceback: TracebackType | None) -> None: + self._stats_off() class perf_record(HookBase): @@ -127,7 +137,7 @@ class perf_record(HookBase): appended to the command line of perf-record, if provided. """ - def __init__(self): + def __init__(self) -> None: self.tempdir = tempfile.TemporaryDirectory() self.ctl_fifo = self.mkfifo(self.tempdir.name, "ctl_fifo") self.ack_fifo = self.mkfifo(self.tempdir.name, "ack_fifo") @@ -144,13 +154,13 @@ def __init__(self): self.ctl_fd = open(self.ctl_fifo, "w") self.ack_fd = open(self.ack_fifo, "r") - def __enter__(self): + def __enter__(self) -> None: self.exec_perf_cmd("enable") - def __exit__(self, _exc_type, _exc_value, _traceback): + def __exit__(self, _exc_type: type[BaseException] | None, _exc_value: BaseException | None, _traceback: TracebackType | None) -> None: self.exec_perf_cmd("disable") - def teardown(self, metadata): + def teardown(self, metadata: dict[str,Any]) -> None: try: self.exec_perf_cmd("stop") self.perf.wait(timeout=120) @@ -158,12 +168,12 @@ def teardown(self, metadata): self.ctl_fd.close() self.ack_fd.close() - def mkfifo(self, tmpdir, basename): + def mkfifo(self, tmpdir: str | os.PathLike[str], basename: str | os.PathLike[str]) -> str: path = os.path.join(tmpdir, basename) os.mkfifo(path) return path - def exec_perf_cmd(self, cmd): + def exec_perf_cmd(self, cmd: str) -> None: self.ctl_fd.write(f"{cmd}\n") self.ctl_fd.flush() self.ack_fd.readline() @@ -185,7 +195,7 @@ class tachyon(HookBase): `python -m profiling.sampling attach`. """ - def __init__(self): + def __init__(self) -> None: if sys.platform == "win32": raise HookError("tachyon hook is not supported on Windows") @@ -197,15 +207,15 @@ def __init__(self): ) try: - import profiling.sampling # noqa: F401 + import profiling.sampling # type: ignore[import-not-found] # noqa: F401 except ImportError: raise HookError("profiling.sampling module not available") - self.extra_opts = os.environ.get("PYPERF_TACHYON_OPTS", "") + self.extra_opts: str = os.environ.get("PYPERF_TACHYON_OPTS", "") - self._proc = None + self._proc: subprocess.Popen[bytes] | None = None - def __enter__(self): + def __enter__(self) -> None: if self._proc is not None: self._stop_profiler() @@ -223,10 +233,10 @@ def __enter__(self): stderr=subprocess.DEVNULL, ) - def __exit__(self, _exc_type, _exc_value, _traceback): + def __exit__(self, _exc_type: type[BaseException] | None, _exc_value: BaseException | None, _traceback: TracebackType | None) -> None: self._stop_profiler() - def _stop_profiler(self): + def _stop_profiler(self) -> None: if not self._proc: return @@ -244,7 +254,7 @@ def _stop_profiler(self): self._proc = None - def teardown(self, metadata): + def teardown(self, metadata: dict[str, Any]) -> None: self._stop_profiler() if self.extra_opts: metadata["tachyon_extra_opts"] = self.extra_opts From 7a538ccda7082abafe8f928a248dd6923f3f0450 Mon Sep 17 00:00:00 2001 From: DebanKsahu Date: Wed, 16 Sep 2026 14:06:51 +0000 Subject: [PATCH 09/19] chore: Revert `ANN201` and `ANN202` rule for now as a lot of files still do not have type annotations --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e74cf96c..4f8d7f4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ version = {attr = "pyperf.__version__"} fix = true [tool.ruff.lint] -extend-select = ["C90", "UP", "ANN201", "ANN202"] +extend-select = ["C90", "UP"] extend-ignore = ["UP015", "UP031", "B009"] [tool.ruff.lint.mccabe] From e9d9af79dc5550aaa245b5e8c5ff007684bd9a23 Mon Sep 17 00:00:00 2001 From: DebanKsahu Date: Fri, 25 Sep 2026 13:12:32 +0000 Subject: [PATCH 10/19] impr: Added type annotation in `_win_memory` module (Iteration 1) --- pyperf/_win_memory.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/pyperf/_win_memory.py b/pyperf/_win_memory.py index 7bb5c280..d4e1c564 100644 --- a/pyperf/_win_memory.py +++ b/pyperf/_win_memory.py @@ -1,4 +1,5 @@ -from _winapi import GetCurrentProcess +from typing import Literal +from _winapi import GetCurrentProcess # type: ignore[import-not-found, unused-ignore, attr-defined] try: import ctypes @@ -23,7 +24,19 @@ class PROCESS_MEMORY_COUNTERS_EX(ctypes.Structure): ('PrivateUsage', SIZE_T), ] - GetProcessMemoryInfo = ctypes.windll.psapi.GetProcessMemoryInfo + cb: int + PageFaultCount: int + PeakWorkingSetSize: int + WorkingSetSize: int + QuotaPeakPagedPoolUsage: int + QuotaPagedPoolUsage: int + QuotaPeakNonPagedPoolUsage: int + QuotaNonPagedPoolUsage: int + PagefileUsage: int + PeakPagefileUsage: int + PrivateUsage: int + + GetProcessMemoryInfo = ctypes.windll.psapi.GetProcessMemoryInfo # type: ignore[attr-defined, unused-ignore] GetProcessMemoryInfo.argtypes = [ wintypes.HANDLE, ctypes.POINTER(PROCESS_MEMORY_COUNTERS_EX), @@ -32,19 +45,19 @@ class PROCESS_MEMORY_COUNTERS_EX(ctypes.Structure): GetProcessMemoryInfo.restype = wintypes.BOOL -def get_peak_pagefile_usage(): +def get_peak_pagefile_usage() -> int: process = GetCurrentProcess() counters = PROCESS_MEMORY_COUNTERS_EX() - ret = GetProcessMemoryInfo(process, + ret = GetProcessMemoryInfo(process, # type: ignore[misc, unused-ignore] ctypes.byref(counters), ctypes.sizeof(counters)) if not ret: - raise ctypes.WinError() + raise ctypes.WinError() # type: ignore[attr-defined, unused-ignore] return counters.PeakPagefileUsage -def check_tracking_memory(): +def check_tracking_memory() -> Literal['memory usage is zero', 'missing ctypes module, unable to get GetProcessMemoryInfo()'] | None: if GetProcessMemoryInfo is None: return ("missing ctypes module, " "unable to get GetProcessMemoryInfo()") From 6db29f327ad9bd99e5e145e7e79bd3e7311b9395 Mon Sep 17 00:00:00 2001 From: DebanKsahu Date: Sat, 26 Sep 2026 06:57:43 +0000 Subject: [PATCH 11/19] impr: Added type annotation in `_metadata` module (Iteration 1) --- pyperf/_metadata.py | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/pyperf/_metadata.py b/pyperf/_metadata.py index 8138983e..3cb8e766 100644 --- a/pyperf/_metadata.py +++ b/pyperf/_metadata.py @@ -5,10 +5,12 @@ METADATA_VALUE_TYPES = (int, str, float) +MetadataValueType = int | str | float | list[str] +MetadataType = dict[str, MetadataValueType] NUMBER_TYPES = (int, float) -def _common_metadata(metadatas): +def _common_metadata(metadatas: list[MetadataType]) -> MetadataType: if not metadatas: return {} @@ -20,33 +22,33 @@ def _common_metadata(metadatas): return metadata -def format_generic(value): +def format_generic(value: MetadataValueType) -> str: if not isinstance(value, str): return str(value) return value -def format_system_load(load): +def format_system_load(load: float) -> str: # Format system load read from /proc/loadavg on Linux (ex: 0.12) return '%.2f' % load -def is_strictly_positive(value): +def is_strictly_positive(value: int) -> bool: return (value >= 1) -def is_positive(value): +def is_positive(value: int | float) -> bool: return (value >= 0) -def is_tags(value): +def is_tags(value: list[str]) -> bool: if not isinstance(value, list): return False return all(isinstance(x, str) and x not in ('all', '') for x in value) -def parse_load_avg(value): +def parse_load_avg(value: float | int | str) -> float | int: if isinstance(value, NUMBER_TYPES): return value else: @@ -54,7 +56,7 @@ def parse_load_avg(value): return float(value) -def format_noop(value): +def format_noop(value: str) -> str: return value @@ -95,11 +97,11 @@ def format_noop(value): DEFAULT_METADATA_INFO = _MetadataInfo(format_generic, METADATA_VALUE_TYPES, None, None) -def get_metadata_info(name): +def get_metadata_info(name: str) -> _MetadataInfo: return METADATA.get(name, DEFAULT_METADATA_INFO) -def check_metadata(name, value): +def check_metadata(name: str, value: MetadataValueType) -> None: info = get_metadata_info(name) if not isinstance(name, str): @@ -115,7 +117,7 @@ def check_metadata(name, value): % (name, value)) -def parse_metadata(metadata): +def parse_metadata(metadata: MetadataType) -> MetadataType: result = {} for name, value in metadata.items(): if isinstance(value, str): @@ -130,13 +132,13 @@ def parse_metadata(metadata): return result -def format_metadata(name, value): +def format_metadata(name: str, value: MetadataValueType): info = get_metadata_info(name) return info.formatter(value) class Metadata: - def __init__(self, name, value): + def __init__(self, name: str, value: MetadataValueType): self._name = name self._value = value @@ -152,7 +154,7 @@ def __str__(self): info = get_metadata_info(self._name) return info.formatter(self._value) - def __eq__(self, other): + def __eq__(self, other: object): if not isinstance(other, Metadata): return False return (self._name == other._name and self._value == other._value) @@ -162,7 +164,7 @@ def __repr__(self): % (self._name, self._value)) -def _exclude_common_metadata(metadata, common_metadata): +def _exclude_common_metadata(metadata: MetadataType, common_metadata: MetadataType) -> MetadataType: if common_metadata: metadata = {key: value for key, value in metadata.items() if key not in common_metadata} From 162af55561506da74a7d7630ce87decfe75f720f Mon Sep 17 00:00:00 2001 From: DebanKsahu Date: Mon, 28 Sep 2026 06:27:36 +0000 Subject: [PATCH 12/19] impr: Added type annotation in `_cpu_utils` module (Iteration 1) --- pyperf/_cpu_utils.py | 70 ++++++++++++++++++++++---------------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/pyperf/_cpu_utils.py b/pyperf/_cpu_utils.py index 3199e845..0e8f6134 100644 --- a/pyperf/_cpu_utils.py +++ b/pyperf/_cpu_utils.py @@ -1,3 +1,5 @@ +from typing import Literal +from collections.abc import Sequence import collections import os import re @@ -6,14 +8,13 @@ try: if not USE_PSUTIL: - psutil = None - else: - import psutil + raise ImportError + import psutil except ImportError: - psutil = None + psutil = None # type: ignore[assignment] -def get_logical_cpu_count(): +def get_logical_cpu_count() -> int | None: if psutil is not None: # Number of logical CPUs cpu_count = psutil.cpu_count() @@ -28,15 +29,15 @@ def get_logical_cpu_count(): return cpu_count -def format_cpu_list(cpus): +def format_cpu_list(cpus: Sequence[int]) -> str: + if len(cpus)==0: + return "None" cpus = sorted(cpus) parts = [] - first = None - last = None - for cpu in cpus: - if first is None: - first = cpu - elif cpu != last + 1: + first = cpus[0] + last = cpus[0] + for cpu in cpus[1:]: + if cpu != last + 1: if first != last: parts.append('%s-%s' % (first, last)) else: @@ -50,7 +51,7 @@ def format_cpu_list(cpus): return ','.join(parts) -def format_cpu_infos(infos): +def format_cpu_infos(infos: dict[int, str]) -> list[str]: groups = collections.defaultdict(list) for cpu, info in infos.items(): groups[info].append(cpu) @@ -59,19 +60,19 @@ def format_cpu_infos(infos): items.sort() text = [] for cpus, info in items: - cpus = format_cpu_list(cpus) - text.append('%s=%s' % (cpus, info)) + cpu_str = format_cpu_list(cpus) + text.append('%s=%s' % (cpu_str, info)) return text -def parse_cpu_list(cpu_list): +def parse_cpu_list(cpu_list: str) -> list[int] | None: cpu_list = cpu_list.strip(' \x00') # /sys/devices/system/cpu/nohz_full returns ' (null)\n' when NOHZ full # is not used if cpu_list == '(null)': - return + return None if not cpu_list: - return + return None cpus = [] for part in cpu_list.split(','): @@ -88,7 +89,7 @@ def parse_cpu_list(cpu_list): return cpus -def parse_cpu_mask(line): +def parse_cpu_mask(line: str) -> int: mask = 0 for part in line.split(','): mask <<= 32 @@ -96,7 +97,7 @@ def parse_cpu_mask(line): return mask -def format_cpu_mask(mask): +def format_cpu_mask(mask: int) -> str: parts = [] while 1: part = "%08x" % (mask & 0xffffffff) @@ -107,14 +108,14 @@ def format_cpu_mask(mask): return ','.join(reversed(parts)) -def format_cpus_as_mask(cpus): +def format_cpus_as_mask(cpus: Sequence[int]) -> str: mask = 0 for cpu in cpus: mask |= (1 << cpu) return format_cpu_mask(mask) -def get_isolated_cpus(): +def get_isolated_cpus() -> list[int] | None: """Get the list of isolated CPUs. Return a sorted list of CPU identifiers, or return None if no CPU is @@ -137,7 +138,7 @@ def get_isolated_cpus(): return None -def set_cpu_affinity(cpus): +def set_cpu_affinity(cpus: list[int]) -> Literal[True] | None: # Availability: some Unix platforms if hasattr(os, 'sched_setaffinity'): os.sched_setaffinity(0, cpus) @@ -145,40 +146,39 @@ def set_cpu_affinity(cpus): try: if not USE_PSUTIL: - return - else: - import psutil + raise ImportError + import psutil except ImportError: - return + return None # Availability: Linux, Windows, FreeBSD (psutil 2.2.0+) # https://psutil.rtfd.io/en/latest/index.html#psutil.Process.cpu_affinity proc = psutil.Process() if not hasattr(proc, 'cpu_affinity'): - return + return None proc.cpu_affinity(cpus) return True -def set_highest_priority(): +def set_highest_priority() -> Literal[True] | None: try: if not USE_PSUTIL: - return - else: - import psutil + raise ImportError + import psutil except ImportError: - return + return None proc = psutil.Process() if not hasattr(proc, 'nice'): - return + return None # Want to set realtime on Windows. # Fail hard for anything else right now, so it is obvious what to fix # when adding other OS support. try: - proc.nice(psutil.REALTIME_PRIORITY_CLASS) + proc.nice(psutil.REALTIME_PRIORITY_CLASS) # type: ignore[attr-defined,unused-ignore] return True except psutil.AccessDenied: pass + return None From 7624e878996dea56862424925986b37f95af11e1 Mon Sep 17 00:00:00 2001 From: DebanKsahu Date: Mon, 28 Sep 2026 15:31:29 +0000 Subject: [PATCH 13/19] impr: Added type annotation in `_linux_memory` module (Iteration 1) --- pyperf/_linux_memory.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pyperf/_linux_memory.py b/pyperf/_linux_memory.py index 7eca372d..9f7daa7b 100644 --- a/pyperf/_linux_memory.py +++ b/pyperf/_linux_memory.py @@ -1,3 +1,4 @@ +from typing import override import threading import time @@ -11,7 +12,7 @@ # for a quick introduction to smaps. # # Need Linux 2.6.16 or newer. -def read_smap_file(): +def read_smap_file() -> int: total = 0 fp = open(proc_path("self/smaps"), "rb") with fp: @@ -25,18 +26,19 @@ def read_smap_file(): class PeakMemoryUsageThread(threading.Thread): - def __init__(self): + def __init__(self) -> None: threading.Thread.__init__(self) self.peak_usage = 0 self._done = threading.Event() self.sleep = 0.010 # 10 ms self._quit = False - def get(self): + def get(self) -> None: usage = read_smap_file() self.peak_usage = max(self.peak_usage, usage) - def run(self): + @override + def run(self) -> None: try: while not self._quit: self.get() @@ -44,13 +46,13 @@ def run(self): finally: self._done.set() - def stop(self): + def stop(self) -> int: self._quit = True self._done.wait() return self.peak_usage -def check_tracking_memory(): +def check_tracking_memory() -> str | None: mem_thread = PeakMemoryUsageThread() try: mem_thread.get() From b336f4840be3ffffd045dabd501ccd60984474a6 Mon Sep 17 00:00:00 2001 From: DebanKsahu Date: Mon, 28 Sep 2026 15:34:32 +0000 Subject: [PATCH 14/19] impr: Added type annotation in `_psutil_memory` module (Iteration 1) --- pyperf/_psutil_memory.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/pyperf/_psutil_memory.py b/pyperf/_psutil_memory.py index 38298953..79738774 100644 --- a/pyperf/_psutil_memory.py +++ b/pyperf/_psutil_memory.py @@ -1,3 +1,5 @@ +from typing import Literal +from typing import override import os try: from pyperf._utils import USE_PSUTIL, BSD @@ -12,7 +14,7 @@ class PeakMemoryUsageThread(threading.Thread): - def __init__(self): + def __init__(self) -> None: threading.Thread.__init__(self) self.process = psutil.Process(os.getpid()) self.peak_usage = 0 @@ -20,7 +22,7 @@ def __init__(self): self.sleep = 0.010 # 10 ms self._quit = False - def get(self): + def get(self) -> None: if BSD: # USS (Unique Set Size) is not supported on BSD, # use RSS (Resident Set Size) instead. @@ -29,7 +31,8 @@ def get(self): usage = self.process.memory_full_info().uss self.peak_usage = max(self.peak_usage, usage) - def run(self): + @override + def run(self) -> None: try: while not self._quit: self.get() @@ -37,13 +40,13 @@ def run(self): finally: self._done.set() - def stop(self): + def stop(self) -> int: self._quit = True self._done.wait() return self.peak_usage -def check_tracking_memory(): +def check_tracking_memory() -> Literal['memory usage is zero'] | None: mem_thread = PeakMemoryUsageThread() mem_thread.get() From f4c51c5934ae132c43c2bb5a260bab1e0d16b3e3 Mon Sep 17 00:00:00 2001 From: DebanKsahu Date: Tue, 29 Sep 2026 06:09:36 +0000 Subject: [PATCH 15/19] impr: Added type annotation in `_process_time` module (Iteration 1) --- pyperf/_process_time.py | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/pyperf/_process_time.py b/pyperf/_process_time.py index f18dee20..5c86c73d 100644 --- a/pyperf/_process_time.py +++ b/pyperf/_process_time.py @@ -14,6 +14,11 @@ If resource.getrusage() is available: compute the maximum RSS memory in bytes per process and writes it into stdout as a second line. """ +from _typeshed import SupportsWrite +from pyperf._hooks import HookBase +from typing import Any +from typing import TypedDict +from io import TextIOWrapper import contextlib import json import os @@ -27,8 +32,12 @@ except ImportError: resource = None +class PopenKwargs(TypedDict, total=False, closed=True): + stdin: int | TextIOWrapper + stdout: int | TextIOWrapper + stderr: int -def get_max_rss(*, children): +def get_max_rss(*, children: bool) -> int: if resource is not None: if children: resource_type = resource.RUSAGE_CHILDREN @@ -42,7 +51,7 @@ def get_max_rss(*, children): return 0 -def merge_profile_stats_files(src, dst): +def merge_profile_stats_files(src: str, dst: str) -> None: """ Merging one existing pstats file into another. """ @@ -57,11 +66,11 @@ def merge_profile_stats_files(src, dst): os.rename(src, dst) -def bench_process(loops, args, kw, profile_filename=None): +def bench_process(loops: int, args: list[str], kw: PopenKwargs, profile_filename: str | None=None) -> tuple[float, int]: max_rss = 0 range_it = range(loops) start_time = time.perf_counter() - + temp_profile_filename = None if profile_filename: temp_profile_filename = tempfile.mktemp() args = [args[0], "-m", "cProfile", "-o", temp_profile_filename] + args[1:] @@ -77,14 +86,14 @@ def bench_process(loops, args, kw, profile_filename=None): if exitcode != 0: print("Command failed with exit code %s" % exitcode, file=sys.stderr) - if profile_filename: + if temp_profile_filename: os.unlink(temp_profile_filename) sys.exit(exitcode) rss = get_max_rss(children=True) - start_rss max_rss = max(max_rss, rss) - if profile_filename: + if temp_profile_filename and profile_filename: merge_profile_stats_files( temp_profile_filename, profile_filename ) @@ -93,7 +102,7 @@ def bench_process(loops, args, kw, profile_filename=None): return (dt, max_rss) -def load_hooks(metadata): +def load_hooks(metadata: dict[str, Any]) -> dict[str, HookBase]: hook_names = [] while "--hook" in sys.argv: hook_idx = sys.argv.index("--hook") @@ -102,19 +111,19 @@ def load_hooks(metadata): del sys.argv[hook_idx] del sys.argv[hook_idx] + hook_managers: dict[str, HookBase] = {} if len(hook_names): # Only import pyperf if we know we have hooks import pyperf._hooks hook_managers = pyperf._hooks.instantiate_selected_hooks(hook_names) metadata["hooks"] = ", ".join(hook_managers.keys()) - else: - hook_managers = {} + return hook_managers -def write_data(dt, max_rss, metadata, out=sys.stdout): +def write_data(dt: float, max_rss: int, metadata: dict[str,Any], out: SupportsWrite[str]=sys.stdout) -> None: # Write the data that is communicated back to the main orchestration process. # It is three lines containing: # - The runtime (in seconds) @@ -126,7 +135,7 @@ def write_data(dt, max_rss, metadata, out=sys.stdout): print(file=out) -def main(): +def main() -> None: # Make sure that the pyperf module wasn't imported if 'pyperf' in sys.modules: print("ERROR: don't run %s -m pyperf._process, run the .py script" @@ -146,13 +155,13 @@ def main(): else: profile_filename = None - metadata = {} + metadata: dict[str, Any] = {} hook_managers = load_hooks(metadata) loops = int(sys.argv[1]) args = sys.argv[2:] - kw = {} + kw: PopenKwargs = {} if hasattr(subprocess, 'DEVNULL'): devnull = None kw['stdin'] = subprocess.DEVNULL From 5af8e52e1390ae5419a50a7a67ad12c93c5ca449 Mon Sep 17 00:00:00 2001 From: DebanKsahu Date: Wed, 30 Sep 2026 08:52:50 +0000 Subject: [PATCH 16/19] impr: Added type annotation in `_metadata` module (Iteration 2) --- pyperf/_metadata.py | 82 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 64 insertions(+), 18 deletions(-) diff --git a/pyperf/_metadata.py b/pyperf/_metadata.py index 3cb8e766..b4384542 100644 --- a/pyperf/_metadata.py +++ b/pyperf/_metadata.py @@ -1,4 +1,10 @@ -import collections +from typing import cast +from typing import overload +from typing import Literal +from typing import Generic +from typing import TypeVar +from collections.abc import Callable +from typing import NamedTuple from pyperf._formatter import (format_number, format_seconds, format_filesize, UNIT_FORMATTERS) @@ -61,7 +67,12 @@ def format_noop(value: str) -> str: # types: accepted types -_MetadataInfo = collections.namedtuple('_MetadataInfo', 'formatter types check_value unit') +T = TypeVar("T") +class _MetadataInfo(NamedTuple, Generic[T]): + formatter: Callable[[T], str] + types: tuple[type[T], ...] + check_value: Callable[[T], bool] | None + unit: str | None BYTES = _MetadataInfo(format_filesize, (int,), is_positive, 'byte') DATETIME = _MetadataInfo(format_noop, (str,), None, None) @@ -69,9 +80,19 @@ def format_noop(value: str) -> str: WARMUPS = _MetadataInfo(format_number, (int,), is_positive, 'integer') SECONDS = _MetadataInfo(format_seconds, NUMBER_TYPES, is_positive, 'second') TAGS = _MetadataInfo(format_generic, (list,), is_tags, 'tag') +DEFAULT_METADATA_INFO: _MetadataInfo[MetadataValueType] = _MetadataInfo(format_generic, METADATA_VALUE_TYPES, None, None) + +MetadataInfoType = ( + _MetadataInfo[float] + | _MetadataInfo[int] + | _MetadataInfo[list[str]] + | _MetadataInfo[str] + | _MetadataInfo[int | float] + | _MetadataInfo[MetadataValueType] +) # Registry of metadata keys -METADATA = { +METADATA: dict[str, MetadataInfoType] = { 'loops': LOOPS, 'inner_loops': LOOPS, @@ -87,19 +108,40 @@ def format_noop(value: str) -> str: 'date': DATETIME, 'boot_time': DATETIME, - 'calibrate_loops': LOOPS, 'recalibrate_loops': LOOPS, 'calibrate_warmups': WARMUPS, 'recalibrate_warmups': WARMUPS, 'tags': TAGS, } -DEFAULT_METADATA_INFO = _MetadataInfo(format_generic, METADATA_VALUE_TYPES, None, None) +MetadataByteTypeName = Literal["mem_max_rss", "mem_peak_pagefile_usage", "command_max_rss"] +MetadataDatetimeTypeName = Literal["date", "boot_time"] +MetadataLoopTypeName = Literal["loops", "inner_loops", "recalibrate_loops"] +MetadataWarmupTypeName = Literal["calibrate_warmups", "recalibrate_warmups"] +MetadataNumberTypeName = Literal["duration", "uptime", "load_avg_1min"] +MetadataTagTypeName = Literal["tags"] +MetadataUnitTypeName = Literal["unit"] +MetadataIntTypeName = MetadataByteTypeName | MetadataLoopTypeName | MetadataWarmupTypeName +MetadataStringTypeName = MetadataDatetimeTypeName | MetadataUnitTypeName -def get_metadata_info(name: str) -> _MetadataInfo: - return METADATA.get(name, DEFAULT_METADATA_INFO) +@overload +def get_metadata_info(name: MetadataIntTypeName) -> _MetadataInfo[int]: ... + +@overload +def get_metadata_info(name: MetadataNumberTypeName) -> _MetadataInfo[int | float]: ... + +@overload +def get_metadata_info(name: MetadataStringTypeName) -> _MetadataInfo[str]: ... +@overload +def get_metadata_info(name: MetadataTagTypeName) -> _MetadataInfo[list[str]]: ... + +@overload +def get_metadata_info(name: str) -> MetadataInfoType: ... + +def get_metadata_info(name: str) -> MetadataInfoType: + return METADATA.get(name, DEFAULT_METADATA_INFO) def check_metadata(name: str, value: MetadataValueType) -> None: info = get_metadata_info(name) @@ -112,9 +154,11 @@ def check_metadata(name: str, value: MetadataValueType) -> None: raise ValueError("invalid metadata %r value type: got %r" % (name, type(value).__name__)) - if info.check_value is not None and not info.check_value(value): - raise ValueError("invalid metadata %r value: %r" - % (name, value)) + if info.check_value is not None: + checker = cast(Callable[[MetadataValueType], bool], info.check_value) + if not checker(value): + raise ValueError("invalid metadata %r value: %r" + % (name, value)) def parse_metadata(metadata: MetadataType) -> MetadataType: @@ -132,9 +176,10 @@ def parse_metadata(metadata: MetadataType) -> MetadataType: return result -def format_metadata(name: str, value: MetadataValueType): +def format_metadata(name: str, value: MetadataValueType) -> str: info = get_metadata_info(name) - return info.formatter(value) + formatter = cast(Callable[[MetadataValueType], str], info.formatter) + return formatter(value) class Metadata: @@ -143,23 +188,24 @@ def __init__(self, name: str, value: MetadataValueType): self._value = value @property - def name(self): + def name(self) -> str: return self._name @property - def value(self): + def value(self) -> MetadataValueType: return self._value - def __str__(self): + def __str__(self) -> str: info = get_metadata_info(self._name) - return info.formatter(self._value) + formatter = cast(Callable[[MetadataValueType], str], info.formatter) + return formatter(self._value) - def __eq__(self, other: object): + def __eq__(self, other: object) -> bool: if not isinstance(other, Metadata): return False return (self._name == other._name and self._value == other._value) - def __repr__(self): + def __repr__(self) -> str: return ('' % (self._name, self._value)) From 591320e2a8d04f9cc978fbc2adbb81b78fd0d557 Mon Sep 17 00:00:00 2001 From: DebanKsahu Date: Wed, 30 Sep 2026 09:01:26 +0000 Subject: [PATCH 17/19] fix: Fixed the `override` decorator import error as it is in `typing_extensions` module before `pypy3.12` --- pyperf/_linux_memory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyperf/_linux_memory.py b/pyperf/_linux_memory.py index 9f7daa7b..92d380f5 100644 --- a/pyperf/_linux_memory.py +++ b/pyperf/_linux_memory.py @@ -1,4 +1,4 @@ -from typing import override +from typing_extensions import override import threading import time From 5a9ef5149a5b69cb667e0a9729f072b06e3f4716 Mon Sep 17 00:00:00 2001 From: DebanKsahu Date: Wed, 30 Sep 2026 17:07:39 +0000 Subject: [PATCH 18/19] fix: Fixed the wrong import error of `TypedDict` and import error of type `SupportWrite` which caused the crash of `_process_time` module which cause the deadlock from `PeakMemoryUsageThread` --- pyperf/_process_time.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/pyperf/_process_time.py b/pyperf/_process_time.py index 5c86c73d..1351022b 100644 --- a/pyperf/_process_time.py +++ b/pyperf/_process_time.py @@ -14,10 +14,14 @@ If resource.getrusage() is available: compute the maximum RSS memory in bytes per process and writes it into stdout as a second line. """ -from _typeshed import SupportsWrite -from pyperf._hooks import HookBase +from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from _typeshed import SupportsWrite + from pyperf._hooks import HookBase from typing import Any -from typing import TypedDict +from typing_extensions import TypedDict from io import TextIOWrapper import contextlib import json @@ -30,7 +34,7 @@ try: import resource except ImportError: - resource = None + resource = None # type: ignore[assignment] class PopenKwargs(TypedDict, total=False, closed=True): stdin: int | TextIOWrapper From 912390b522104c049e0ae74c932c704f668de932 Mon Sep 17 00:00:00 2001 From: DebanKsahu Date: Wed, 30 Sep 2026 17:43:21 +0000 Subject: [PATCH 19/19] fix: Fixed error of unsuported multiple inheritance in `NameTuple` in python <=3.10 by making it a dataclass --- pyperf/_metadata.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pyperf/_metadata.py b/pyperf/_metadata.py index b4384542..f0042788 100644 --- a/pyperf/_metadata.py +++ b/pyperf/_metadata.py @@ -1,10 +1,10 @@ +from dataclasses import dataclass from typing import cast from typing import overload from typing import Literal from typing import Generic from typing import TypeVar from collections.abc import Callable -from typing import NamedTuple from pyperf._formatter import (format_number, format_seconds, format_filesize, UNIT_FORMATTERS) @@ -68,7 +68,9 @@ def format_noop(value: str) -> str: # types: accepted types T = TypeVar("T") -class _MetadataInfo(NamedTuple, Generic[T]): + +@dataclass(frozen=True) +class _MetadataInfo(Generic[T]): formatter: Callable[[T], str] types: tuple[type[T], ...] check_value: Callable[[T], bool] | None