diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml new file mode 100644 index 0000000000..a9acd87c59 --- /dev/null +++ b/.github/workflows/codspeed.yml @@ -0,0 +1,132 @@ +name: codspeed + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +permissions: {} + +env: + UV_NO_SYNC: "1" + # Walltime benchmarks die without a traceback otherwise. + PYTHONFAULTHANDLER: "1" + +jobs: + compute-ros-pin: + # Same as ci.yml. + runs-on: ubuntu-latest + permissions: + contents: read # For checkout + outputs: + digest: ${{ steps.read.outputs.digest }} + steps: + - name: Checkout + uses: actions/checkout@v7 + - id: read + run: | + DIGEST=$(grep -oE 'sha256:[a-f0-9]{64}' docker/ros-dev-pin/Dockerfile) + if [ -z "$DIGEST" ]; then + echo "::error::No sha256 digest found in docker/ros-dev-pin/Dockerfile" + exit 1 + fi + echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" + + benchmarks: + # Same-repo PRs and pushes only: fork PRs must not run on the self-hosted + # runner (mirrors the self-hosted-tests gate in ci.yml). + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + needs: compute-ros-pin + timeout-minutes: 30 + # codspeed-macro doesn't support ARMv8.2+ needed for ML dependencies. + runs-on: + - self-hosted + - Linux + - base + container: + image: ghcr.io/dimensionalos/ros-dev@${{ needs.compute-ros-pin.outputs.digest }} + options: --memory=6g --memory-swap=6g --cap-add PERFMON + volumes: + - /var/cache/dimos-nix:/nix + - /var/cache/dimos-root-cache:/root/.cache + permissions: + contents: read + id-token: write # OIDC upload to CodSpeed (tokenless for public repos) + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Fetch the replay dataset from LFS if not cached + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + if git lfs pointer --check --file data/.lfs/go2_hongkong_office.db.tar.gz; then + git lfs pull --include="data/.lfs/go2_hongkong_office.db.tar.gz" --exclude="" + else + echo "archive already smudged" + fi + - name: Install system dependencies + run: | + apt-get update + apt-get install -y libturbojpeg + - name: Install uv + uses: astral-sh/setup-uv@v8.3.2 + with: + # /root/.cache is a persisted volume + enable-cache: false + # uv-managed rather than the container's python for sqlite (>=3.45). + # TODO: Can be dropped after upgrading to 26.04. + - name: Setup Python + run: uv python install 3.12 + - name: Install dependencies + run: uv sync --group tests --frozen + - name: Pre-extract the replay database + run: uv run python -c "from dimos.utils.data import get_data; print(get_data('go2_hongkong_office.db'))" + - name: Run benchmarks + uses: CodSpeedHQ/action@v5 + env: + # In this rootful container the runner would otherwise autodetect + # elevation and wrap the benchmark in systemd-run, which does not + # exist here. No cpuset hooks either, so run unisolated — the job + # already has the machine to itself. + CODSPEED_ISOLATION: "false" + with: + mode: walltime + run: uv run pytest --codspeed -m '' --no-cov dimos/robot/unitree/go2/test_replay_benchmark.py + + simulation: + # Single-process instruction-count benchmark (Valgrind) of the replay + # compute pipeline; runs on hosted runners — no self-hosted, no workers. + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + timeout-minutes: 45 + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # OIDC upload to CodSpeed + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Fetch the replay dataset + run: git lfs pull --include="data/.lfs/go2_short.db.tar.gz" --exclude="" + # pyaudio build needs portaudio; the recording's JPEG frames need turbojpeg. + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y portaudio19-dev libturbojpeg + - name: Install uv + uses: astral-sh/setup-uv@v8.3.2 + with: + enable-cache: true + - name: Setup Python + run: uv python install 3.12 + - name: Install dependencies + run: uv sync --group tests --frozen + - name: Pre-extract the replay database + run: uv run python -c "from dimos.utils.data import get_data; print(get_data('go2_short.db'))" + - name: Run benchmarks + uses: CodSpeedHQ/action@v5 + with: + mode: simulation + run: uv run pytest --codspeed -m '' --no-cov dimos/robot/unitree/go2/test_replay_pipeline_benchmark.py diff --git a/dimos/core/global_config.py b/dimos/core/global_config.py index f5cce34f11..741a115278 100644 --- a/dimos/core/global_config.py +++ b/dimos/core/global_config.py @@ -55,6 +55,13 @@ class GlobalConfig(BaseSettings): simulation: str = "" replay: bool = False replay_db: str = "go2_short" + # Playback rate multiplier for --replay (1.0 = realtime; large values drain + # as fast as the pipeline allows since late frames are never delayed). + replay_speed: float = 1.0 + # Seconds into the recording to start replay at (None = the beginning). + replay_seek: float | None = None + # Seconds of recording to play before the streams complete (None = all). + replay_duration: float | None = None new_memory: bool = False # Discover zenoh peers across the network. # Toggling off drops back to loopback-only discovery: diff --git a/dimos/robot/unitree/go2/connection.py b/dimos/robot/unitree/go2/connection.py index b285273f23..b7226b8432 100644 --- a/dimos/robot/unitree/go2/connection.py +++ b/dimos/robot/unitree/go2/connection.py @@ -143,8 +143,12 @@ def make_connection( connection_type = cfg.unitree_connection_type.lower() if ip in ("fake", "mock", "replay") or connection_type == "replay": - dataset = cfg.replay_db - return ReplayConnection(dataset=dataset) + return ReplayConnection( + dataset=cfg.replay_db, + speed=cfg.replay_speed, + seek=cfg.replay_seek, + duration=cfg.replay_duration, + ) elif ip == "mujoco" or connection_type in ("mujoco", "true"): from dimos.robot.unitree.mujoco_connection import MujocoConnection @@ -165,15 +169,20 @@ def make_connection( class ReplayConnection(UnitreeWebRTCConnection, CompositeResource): - def __init__( # type: ignore[no-untyped-def] + def __init__( self, dataset: str = "go2_china_office", - **kwargs, + *, + speed: float = 1.0, + seek: float | None = None, + duration: float | None = None, + loop: bool = False, ) -> None: self.dataset = dataset - self._loop = kwargs.get("loop", False) - self._seek = kwargs.get("seek") - self._duration = kwargs.get("duration") + self._speed = speed + self._seek = seek + self._duration = duration + self._loop = loop @cached_property def replay(self) -> Replay: @@ -183,7 +192,9 @@ def replay(self) -> Replay: SqliteStore(path=str(resolve_db_path(self.dataset)), must_exist=True) ) store.start() - return store.replay(loop=self._loop, seek=self._seek, duration=self._duration) + return store.replay( + speed=self._speed, seek=self._seek, duration=self._duration, loop=self._loop + ) def connect(self) -> None: pass diff --git a/dimos/robot/unitree/go2/test_connection.py b/dimos/robot/unitree/go2/test_connection.py index 79e99fd402..edf204c7be 100644 --- a/dimos/robot/unitree/go2/test_connection.py +++ b/dimos/robot/unitree/go2/test_connection.py @@ -50,6 +50,30 @@ def test_make_connection_webrtc_forwards_aes_128_key(stub_webrtc: MagicMock) -> ) +def test_make_connection_replay_forwards_speed_and_window() -> None: + """--replay-speed/--replay-seek/--replay-duration reach ReplayConnection. + + The `replay` property is lazy, so no database is opened here. + """ + cfg = GlobalConfig( + replay=True, + replay_db="some_db", + replay_speed=25.0, + replay_seek=3.5, + replay_duration=60.0, + ) + conn = go2_conn.make_connection(None, cfg) + assert isinstance(conn, go2_conn.ReplayConnection) + assert conn.dataset == "some_db" + assert (conn._speed, conn._seek, conn._duration) == (25.0, 3.5, 60.0) + + +def test_make_connection_replay_defaults_realtime() -> None: + conn = go2_conn.make_connection("replay", GlobalConfig(replay=True)) + assert isinstance(conn, go2_conn.ReplayConnection) + assert (conn._speed, conn._seek, conn._duration) == (1.0, None, None) + + def test_connection_config_aes_key_defaults_from_global_config() -> None: """ConnectionConfig.aes_128_key defaults from GlobalConfig.unitree_aes_128_key.""" g = GlobalConfig(robot_ip="127.0.0.1", unitree_aes_128_key="dd" * 16) diff --git a/dimos/robot/unitree/go2/test_replay_benchmark.py b/dimos/robot/unitree/go2/test_replay_benchmark.py new file mode 100644 index 0000000000..b33f5aa04b --- /dev/null +++ b/dimos/robot/unitree/go2/test_replay_benchmark.py @@ -0,0 +1,182 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""CodSpeed walltime benchmark of the unitree-go2 blueprint under replay. + +The equivalent of `dimos --replay --replay-db go2_hongkong_office run +unitree-go2`, bounded: build the coordinator, drain the first ~60s of the +recording as fast as the pipeline allows (large --replay-speed collapses the +wall-clock pacing), stop. The measured region is build+drain; replay starts +inside ModuleCoordinator.build() (GO2Connection.start() subscribes the replay +streams), so the two can't be separated without changing the blueprint. + +Drain completion is detected by quiescence — no arrival on any watched source +topic for QUIET_S — because exact delivery counting is unreliable by design +(zenoh gives Image/PointCloud2 topics latest-wins QoS; LCM UDP drops fragments +under flood). Windowed per-stream counts read from the database beforehand act +as validity floors so a silently dead stream fails the run instead of producing +a fast-but-meaningless sample. + +self_hosted (LFS data, ~10 worker processes): the self-hosted CI job runs it +as a plain test — the `benchmark` fixture just calls the function once — which +keeps the path in coverage; .github/workflows/codspeed.yml runs the same test +under `pytest --codspeed` for the measured walltime sample. Linux-only for +now (skipif_macos_bug): local macOS runs die on the coordinator->worker zenoh +RPC timeout, and the LCM fallback needs lo0 route + maxdgram tuning. Linux +smoke against the small bundled recording: + + DIMOS_BENCH_REPLAY_DB=go2_short DIMOS_BENCH_DURATION=10 \ + uv run pytest dimos/robot/unitree/go2/test_replay_benchmark.py \ + -m self_hosted --no-cov -v +""" + +import os +import threading +import time + +import pytest +from pytest_codspeed import BenchmarkFixture + +from dimos.core.global_config import global_config +from dimos.core.transport_factory import make_transport +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 + +REPLAY_DB = os.environ.get("DIMOS_BENCH_REPLAY_DB", "go2_hongkong_office") +DURATION = float(os.environ.get("DIMOS_BENCH_DURATION", 60)) +SPEED = 1000.0 +QUIET_S = 3.0 # all watched streams silent this long => drained +DRAIN_TIMEOUT = 420.0 # build+drain deadline, inside the test timeout +# GO2Connection source streams. camera_info is deliberately absent (published +# by a 1 Hz forever-loop thread — never quiesces). color_image is too: raw +# decoded Image frames (~MBs each) at max replay speed are a multi-GB/s LCM +# fragment flood of which essentially nothing survives to an observer, and the +# video path is already validity-checked synchronously — the first frame +# decodes inside GO2Connection.start(), so a broken codec fails build() +# loudly (see the missing-libturbojpeg incident). +WATCHED = (("odom", PoseStamped), ("lidar", PointCloud2)) +# Validity gates, not the timing edge: odom is small and near-lossless; lidar +# frames are compact enough to survive the flood with the 64MB rmem tuning. +FLOOR_FRACTION = {"odom": 0.9, "lidar": 0.5} + + +def _expected_counts(db_path: str, duration: float = DURATION) -> dict[str, int]: + """Windowed per-stream counts straight from the DB. + + Mirrors ReplayConnection's stream-name fallback (mid360-era recordings use + go2_lidar/go2_odom, older ones lidar/odom). + """ + from dimos.memory2.store.sqlite import SqliteStore + + store = SqliteStore(path=db_path, must_exist=True) + store.start() + try: + replay = store.replay(duration=duration) + available = replay.list_streams() + + def first_present(*names: str) -> str: + for name in names: + if name in available: + return name + raise KeyError(f"none of {names!r} in {db_path!r}; available: {available}") + + return { + "odom": replay.stream(first_present("go2_odom", "odom")).count(), + "lidar": replay.stream(first_present("go2_lidar", "lidar")).count(), + "color_image": replay.stream("color_image").count(), + } + finally: + store.stop() + + +@pytest.mark.self_hosted +# macOS: coordinator->worker zenoh RPC times out (set_transport), and the +# in-test LCM subscriptions would need lo0 route + maxdgram host tuning. +@pytest.mark.skipif_macos_bug +@pytest.mark.timeout(900) +def test_go2_replay_drain_walltime(benchmark: BenchmarkFixture) -> None: + """Build the unitree-go2 blueprint and drain a replay window at max speed.""" + from dimos.core.coordination.module_coordinator import ModuleCoordinator + from dimos.memory2.replay import resolve_db_path + from dimos.robot.get_all_blueprints import get_blueprint_by_name + + # Configure before resolving the blueprint: unitree_go2_basic composes its + # vis bundle from global_config.viewer at import time. build() also writes + # the blueprint's own overrides into global_config, so restore everything. + saved = global_config.model_dump() + global_config.update( + replay=True, + replay_db=REPLAY_DB, + replay_speed=SPEED, + replay_duration=DURATION, + viewer="none", + ) + try: + db_path = str(resolve_db_path(REPLAY_DB)) # LFS pull/extract on miss + expected = _expected_counts(db_path) + assert all(count > 0 for count in expected.values()), f"empty window: {expected}" + floors = {name: int(expected[name] * fraction) for name, fraction in FLOOR_FRACTION.items()} + + blueprint = get_blueprint_by_name("unitree-go2") + + counts = dict.fromkeys(FLOOR_FRACTION, 0) + last_arrival = time.monotonic() + lock = threading.Lock() + + def record(name: str) -> None: + nonlocal last_arrival + with lock: + counts[name] += 1 + last_arrival = time.monotonic() + + # Same topics and backend the blueprint materializes for these + # name-unique streams. Subscribe before build: replay data flows as + # soon as GO2Connection starts, mid-build. + transports = [make_transport(name, typ) for name, typ in WATCHED] + for (name, _), transport in zip(WATCHED, transports, strict=True): + transport.subscribe(lambda _msg, _name=name: record(_name)) + + state: dict[str, ModuleCoordinator] = {} + + def build_and_drain() -> None: + nonlocal last_arrival + state["coordinator"] = ModuleCoordinator.build(blueprint) + with lock: + last_arrival = time.monotonic() + deadline = time.monotonic() + DRAIN_TIMEOUT + while time.monotonic() < deadline: + with lock: + quiet = time.monotonic() - last_arrival + done = all(counts[name] >= floors[name] for name in counts) + if done and quiet >= QUIET_S: + return + time.sleep(0.2) + pytest.fail(f"window did not drain: counts={counts}, expected~{expected}") + + def teardown() -> None: + for transport in transports: + transport.stop() + coordinator = state.pop("coordinator", None) + if coordinator is not None: + # coordinator.stop() can hang on worker teardown; a hard exit + # (tool_replay_bench style) would lose the benchmark upload. + stopper = threading.Thread(target=coordinator.stop, daemon=True) + stopper.start() + stopper.join(timeout=90) + if stopper.is_alive(): + pytest.fail("coordinator.stop() hung") + + benchmark.pedantic(build_and_drain, teardown=teardown, rounds=1, warmup_rounds=0) + finally: + global_config.update(**saved) diff --git a/dimos/robot/unitree/go2/test_replay_pipeline_benchmark.py b/dimos/robot/unitree/go2/test_replay_pipeline_benchmark.py new file mode 100644 index 0000000000..6e15875b49 --- /dev/null +++ b/dimos/robot/unitree/go2/test_replay_pipeline_benchmark.py @@ -0,0 +1,145 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""CodSpeed simulation benchmark: the go2 replay compute pipeline, one process. + +The single-process complement to test_replay_benchmark.py. Simulation mode +counts instructions under Valgrind in one process, so this drives the +pipeline's compute directly and fully synchronously: decode the replay +window's frames (JPEG images, lidar pointclouds, odometry) and pull the lidar +through VoxelMapTransformer -> CostMapper._calculate_costmap — the same work +the e2e blueprint performs (its planner/patrol/movement modules idle without +goals), minus worker processes, transports, and schedulers. + +Deliberately no Module/rx machinery in the measured region: the timed replay's +shared wall-clock anchor skips late subscribers at high speed (fatal under +Valgrind's dilation), and module RPC/loop threads pollute the count. Pure +iterator pulls make completion exact by construction. Also runs as a plain +self_hosted test for coverage. + +Local smoke (small bundled recording): + + DIMOS_SIM_BENCH_DURATION=5 \ + uv run pytest dimos/robot/unitree/go2/test_replay_pipeline_benchmark.py \ + -m self_hosted --no-cov -v +""" + +from collections.abc import Iterator +import os +from typing import Any + +import pytest +from pytest_codspeed import BenchmarkFixture + +from dimos.core.global_config import global_config +from dimos.protocol.rpc.spec import RPCSpec +from dimos.robot.unitree.go2.test_replay_benchmark import _expected_counts + +REPLAY_DB = os.environ.get("DIMOS_SIM_BENCH_REPLAY_DB", "go2_short") +DURATION = float(os.environ.get("DIMOS_SIM_BENCH_DURATION", 10)) + + +class _NoRpc(RPCSpec): + """Disables the module RPC service (ModuleBase catches this ValueError). + + The benchmark only needs CostMapper for its configured compute; an RPC + service would add zenoh/LCM threads (and hangs zenoh setup on macOS). + """ + + def __init__(self, **_kwargs: Any) -> None: + raise ValueError("module RPC disabled for the benchmark") + + +@pytest.mark.self_hosted +@pytest.mark.timeout(600) +def test_go2_pipeline_simulation(benchmark: BenchmarkFixture) -> None: + """Decode a replay window and pull it through voxel map -> costmap.""" + from dimos.mapping.costmapper import CostMapper + from dimos.mapping.voxels.module import VoxelMapTransformer + from dimos.memory2.replay import resolve_db_path + from dimos.memory2.store.sqlite import SqliteStore + + saved = global_config.model_dump() + global_config.update(viewer="none") + cost = None + store = None + try: + db_path = str(resolve_db_path(REPLAY_DB)) # LFS pull/extract on miss + expected = _expected_counts(db_path, duration=DURATION) + assert all(count > 0 for count in expected.values()), f"empty window: {expected}" + + store = SqliteStore(path=db_path, must_exist=True) + store.start() + replay = store.replay(duration=DURATION) + available = replay.list_streams() + lidar_name = "go2_lidar" if "go2_lidar" in available else "lidar" + odom_name = "go2_odom" if "go2_odom" in available else "odom" + window_end = replay.first_ts() + DURATION # type: ignore[operator] + + def windowed(name: str) -> Any: + # The duration-only window of Replay._base_stream: everything + # before recording start + duration, in timestamp order. + return store.stream(name).before(window_end).order_by("ts") + + # Only the config'd compute is used (never started as a module). + cost = CostMapper(g=global_config, rpc_transport=_NoRpc) + # Suppressed rpc setup never assigns the attribute; _close_rpc guards + # on truthiness, so seed it for a clean _close_module in the finally. + cost.rpc = None + + counts = {"odom": 0, "lidar": 0, "color_image": 0} + produced = {"global_map": 0, "global_costmap": 0} + + def counted(observations: Iterator[Any], key: str) -> Iterator[Any]: + for obs in observations: + counts[key] += 1 + yield obs + + def drain() -> None: + # The blueprint's voxel pipeline: accumulate every lidar frame, + # emit the global map every 5th, costmap each emitted map. + transformer = VoxelMapTransformer( + emit_every=5, + voxel_size=0.05, + block_count=2_000_000, + device="CPU:0", + carve_columns=True, + frame_id="world", + ) + for map_obs in transformer(counted(iter(windowed(lidar_name)), "lidar")): + produced["global_map"] += 1 + cost._calculate_costmap(map_obs.data) + produced["global_costmap"] += 1 + # Decode odom and camera frames like GO2Connection does; their + # consumers idle in the e2e too. + for obs in counted(iter(windowed(odom_name)), "odom"): + _ = obs.data + for obs in counted(iter(windowed("color_image")), "color_image"): + _ = obs.data + if counts != expected or produced["global_costmap"] == 0: + pytest.fail(f"incomplete drain: counts={counts}, expected={expected}, {produced}") + + def teardown() -> None: + for key in counts: + counts[key] = 0 + for key in produced: + produced[key] = 0 + + benchmark.pedantic(drain, teardown=teardown, rounds=1, warmup_rounds=0) + finally: + if cost is not None: + cost._close_module() + if store is not None: + store.stop() + global_config.update(**saved) diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 9f57380290..ffba95e1e9 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -18,7 +18,10 @@ dimos [GLOBAL OPTIONS] COMMAND [ARGS] | `--robot-ips` | TEXT | `None` | Multiple robot IPs | | `--simulation` / `--no-simulation` | bool | `False` | Enable MuJoCo simulation | | `--replay` / `--no-replay` | bool | `False` | Use recorded replay data | -| `--replay-db` | TEXT | `go2_bigoffice` | Replay memory2 SQLite database name | +| `--replay-db` | TEXT | `go2_short` | Replay memory2 SQLite database name | +| `--replay-speed` | FLOAT | `1.0` | Replay playback rate multiplier (large values drain as fast as the pipeline allows) | +| `--replay-seek` | FLOAT | `None` | Seconds into the recording to start replay at | +| `--replay-duration` | FLOAT | `None` | Seconds of recording to play before the streams complete | | `--new-memory` / `--no-new-memory` | bool | `False` | Clear persistent memory on start | | `--viewer` | `rerun\|none` | `rerun` | Visualization backend | | `--rerun-open` | `native\|web\|both\|none` | `native` | How to open the Rerun viewer | diff --git a/pyproject.toml b/pyproject.toml index cff0c2b720..d2f6ad63a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -416,6 +416,7 @@ tests = [ "pytest-cov>=5.0", "pytest-error-for-skips>=2.0.2", "pytest-rerunfailures>=15.0", + "pytest-codspeed==5.0.3", "coverage>=7.0", "requests-mock==1.12.1", diff --git a/uv.lock b/uv.lock index 5d4fec44f7..17c3c9cfc8 100644 --- a/uv.lock +++ b/uv.lock @@ -2004,6 +2004,7 @@ tests = [ { name = "pygame" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-codspeed" }, { name = "pytest-cov" }, { name = "pytest-env" }, { name = "pytest-error-for-skips" }, @@ -2053,6 +2054,7 @@ tests-self-hosted = [ { name = "pygame" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-codspeed" }, { name = "pytest-cov" }, { name = "pytest-env" }, { name = "pytest-error-for-skips" }, @@ -2301,6 +2303,7 @@ tests = [ { name = "pygame", specifier = ">=2.6.1" }, { name = "pytest", specifier = "==8.3.5" }, { name = "pytest-asyncio", specifier = "==0.26.0" }, + { name = "pytest-codspeed", specifier = "==5.0.3" }, { name = "pytest-cov", specifier = ">=5.0" }, { name = "pytest-env", specifier = "==1.1.5" }, { name = "pytest-error-for-skips", specifier = ">=2.0.2" }, @@ -2352,6 +2355,7 @@ tests-self-hosted = [ { name = "pygame", specifier = ">=2.6.1" }, { name = "pytest", specifier = "==8.3.5" }, { name = "pytest-asyncio", specifier = "==0.26.0" }, + { name = "pytest-codspeed", specifier = "==5.0.3" }, { name = "pytest-cov", specifier = ">=5.0" }, { name = "pytest-env", specifier = "==1.1.5" }, { name = "pytest-error-for-skips", specifier = ">=2.0.2" }, @@ -7470,6 +7474,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694, upload-time = "2025-03-25T06:22:27.807Z" }, ] +[[package]] +name = "pytest-codspeed" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "pytest" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/b4/cf932fcd1960a2fd6d9b09eb403253a8709aeee975961afa6299239a830e/pytest_codspeed-5.0.3.tar.gz", hash = "sha256:91afef90e6a96b013495e4702ef5d6358614a449e71008cdc194ef668778b92f", size = 324571, upload-time = "2026-05-22T16:20:49.231Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/f5/a8f70147216e4b84046ca406d03ecc8e83e3ea56ba1bdca0bb79cca79fee/pytest_codspeed-5.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:005348ea52ace3ede2e2f595913912ad2564cca7b124211a88dc78a9cb1fca63", size = 366249, upload-time = "2026-05-22T16:20:39.985Z" }, + { url = "https://files.pythonhosted.org/packages/f6/bd/7a4dbcf457fcc3ed788c55d402f3af2671e0e342b6098090fd590aa8712e/pytest_codspeed-5.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbe6a4a00b449b6ba2771f644cbc38bdf55acf5c812e60e5659110e19dd9f510", size = 932229, upload-time = "2026-05-22T16:20:37.283Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/414ea4c66559f24ec06aeb6db62bfc7079582dac1452e648affe1eb5cfb4/pytest_codspeed-5.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac4344f34bbcdd17f6f8c30dbac3da2f80d223dd112e568fd7f7c2cd4cbc693", size = 934647, upload-time = "2026-05-22T16:20:31.997Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ef/32ce60d42a4aa43e728d988e13eb6568fbc7b10a514517b459bafd3f2b94/pytest_codspeed-5.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f56d0339cd98d26f6e561987be25bdd2761a5d53d8f73493b1ebe02d0d451093", size = 366253, upload-time = "2026-05-22T16:21:10.013Z" }, + { url = "https://files.pythonhosted.org/packages/2a/15/c66ef90a793c5d2c039e63a1726a5e55c678be2618b0f5f1660d0f79e25f/pytest_codspeed-5.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c682f6645d4eb472f3bd95dbda1805e3af4243610572cb7d6bf94a88e8a0b6c", size = 932465, upload-time = "2026-05-22T16:20:34.265Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7b/d231279301967f05b7909160489e85ee3a1b9da76094ea25343faba1abc2/pytest_codspeed-5.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f852bee785a7a124cb1720b1915670c6742af87747dc4d838f3ffdbd365ce9d9", size = 934925, upload-time = "2026-05-22T16:20:47.63Z" }, + { url = "https://files.pythonhosted.org/packages/c2/22/456c48160b761d5028c8afa119f085a9fc42855a783a13d73918078969f0/pytest_codspeed-5.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2eeb25fb1ac3f73c4de50e739e78fea396b89782bdb740bf2a7cd2df21f8d4ee", size = 366255, upload-time = "2026-05-22T16:20:56.214Z" }, + { url = "https://files.pythonhosted.org/packages/74/33/ac7441fa937c9d9f158083a8c46920a5a5c81ed3c5f96240fc8d650db5c2/pytest_codspeed-5.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73c5c9d98a3372a42611989ccfa437cce3842431ac6d6b9ab42c4f0e59c070f7", size = 932325, upload-time = "2026-05-22T16:21:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/77/bc/8b994adcb9e9016e7d9a808056a3dd9cca21441e432ef456eae2b697d7fe/pytest_codspeed-5.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2e0ab65df73e837666d12357280ca50ff6d6ac03ea5266703be518b68170edf", size = 934885, upload-time = "2026-05-22T16:21:01.444Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b2/1d2a993c532146dce9eca5b5942d51898021c3579ce18b2454f932a915f8/pytest_codspeed-5.0.3-py3-none-any.whl", hash = "sha256:fe2ea83c924c2250675b75686c3ee456b8cf0208d83d552e182a195fdf467378", size = 74033, upload-time = "2026-05-22T16:20:26.814Z" }, +] + [[package]] name = "pytest-cov" version = "7.1.0"