diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa584b99..c2669b5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,11 @@ jobs: - name: Install CLI tools run: | - for tool in mbake ty ruff; do uv tool install ${tool}; done + for tool in mbake ty; do uv tool install ${tool}; done + # Pin ruff to the same version as the dev dependency group in + # pyproject.toml so the linter behaves identically across the uv tool, + # the synced virtualenv, and developer PATH. Bump both sites together. + uv tool install ruff==0.15.12 npm install -g markdownlint-cli2 - name: Validate Makefile diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 6ff596ed..1ae89949 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -626,6 +626,15 @@ Defined metrics: | Metric | Labels | Incremented when | | -------------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `publish.index_lookup_downgrade` | `subcommand`, `missing_crate` | `_handle_index_missing_version` downgrades a crates.io index-lookup failure to a warning (in-plan, override enabled). | +| `lockfile.discovered` | (none) | Incremented by the number of tracked lockfiles each `discover_tracked_lockfiles` call returns. | +| `lockfile.refresh` | `outcome` | One increment per `refresh_lockfile` invocation; `outcome` is `success` or `failure`. | +| `lockfile.refresh.duration` | (none) | Duration observation around each `cargo generate-lockfile` invocation. | +| `lockfile.validate` | `outcome` | One increment per `validate_lockfile_freshness` call; `outcome` is `fresh`, `stale`, or `failed`. | +| `lockfile.validate.duration` | (none) | Duration observation around each `cargo metadata --locked` probe. | + +Duration metrics aggregate a count and total seconds per label set via +`observe_duration` / `duration_stats` and appear in the exit summary with +`count` and `total_seconds` fields. ### Command runners (`lading.runtime`) diff --git a/lading/commands/lockfile.py b/lading/commands/lockfile.py index 07f1c8a7..9e049681 100644 --- a/lading/commands/lockfile.py +++ b/lading/commands/lockfile.py @@ -31,10 +31,12 @@ import collections.abc as cabc import dataclasses as dc import logging +import time import typing as typ from pathlib import Path from lading.exceptions import LadingError +from lading.utils import metrics from lading.utils.process import append_detail, command_detail, with_detail if typ.TYPE_CHECKING: @@ -43,6 +45,13 @@ LOGGER = logging.getLogger(__name__) _ManifestExists = cabc.Callable[[Path], bool] +# Metric names (issue #91); documented in docs/developers-guide.md. +DISCOVERED_LOCKFILES_METRIC = "lockfile.discovered" +REFRESH_METRIC = "lockfile.refresh" +REFRESH_DURATION_METRIC = "lockfile.refresh.duration" +VALIDATE_METRIC = "lockfile.validate" +VALIDATE_DURATION_METRIC = "lockfile.validate.duration" + class LockfileRefreshError(LadingError): """Raised when Cargo cannot regenerate a lockfile.""" @@ -153,6 +162,7 @@ def discover_tracked_lockfiles( if error_result is not None: return error_result lockfiles = _lockfiles_with_manifests(stdout, workspace_root, manifest_exists) + metrics.increment_counter(DISCOVERED_LOCKFILES_METRIC, amount=len(lockfiles)) LOGGER.info( "Discovered %d tracked lockfile(s) with adjacent manifests in %s", len(lockfiles), @@ -189,13 +199,17 @@ def refresh_lockfile( """ lockfile_path = manifest_path.parent / "Cargo.lock" LOGGER.info("Refreshing %s", lockfile_path) + started_at = time.perf_counter() exit_code, stdout, stderr = runner( ("cargo", "generate-lockfile", "--manifest-path", str(manifest_path)), cwd=manifest_path.parent, ) + metrics.observe_duration(REFRESH_DURATION_METRIC, time.perf_counter() - started_at) if exit_code != 0: + metrics.increment_counter(REFRESH_METRIC, outcome="failure") message = with_detail(f"Failed to refresh {lockfile_path}", stdout, stderr) raise LockfileRefreshError(message) + metrics.increment_counter(REFRESH_METRIC, outcome="success") LOGGER.info("Refreshed %s", lockfile_path) return lockfile_path @@ -221,6 +235,7 @@ def validate_lockfile_freshness( because Cargo says it needs updating under ``--locked``, or failed for another reason. """ + started_at = time.perf_counter() exit_code, stdout, stderr = runner( ( "cargo", @@ -232,12 +247,14 @@ def validate_lockfile_freshness( ), cwd=manifest_path.parent, ) + metrics.observe_duration(VALIDATE_DURATION_METRIC, time.perf_counter() - started_at) detail = command_detail(stdout, stderr) is_fresh = exit_code == 0 is_stale = _is_lockfile_stale_detail(detail) state = "fresh" if not is_fresh: state = "stale" if is_stale else "failed" + metrics.increment_counter(VALIDATE_METRIC, outcome=state) LOGGER.info( "Validated lockfile freshness for %s: %s", manifest_path, diff --git a/lading/utils/metrics.py b/lading/utils/metrics.py index 59c88af0..d4292454 100644 --- a/lading/utils/metrics.py +++ b/lading/utils/metrics.py @@ -23,6 +23,7 @@ import atexit import collections +import dataclasses as dc import json import logging import threading @@ -34,6 +35,17 @@ _summary_hook_registered = threading.Event() +@dc.dataclass(slots=True) +class DurationStats: + """Aggregated duration observations for one metric/label set.""" + + count: int = 0 + total_seconds: float = 0.0 + + +_DURATIONS: dict[_CounterKey, DurationStats] = {} + + def _counter_key(name: str, labels: dict[str, str]) -> _CounterKey: """Return the registry key for ``name`` with sorted ``labels``.""" return (name, tuple(sorted(labels.items()))) @@ -56,6 +68,30 @@ def counter_value(name: str, **labels: str) -> int: return _COUNTERS[_counter_key(name, labels)] +def observe_duration(name: str, seconds: float, **labels: str) -> None: + """Record one duration observation for ``name``. + + Examples + -------- + >>> observe_duration("demo.duration", 0.25, operation="refresh") + """ + with _LOCK: + stats = _DURATIONS.setdefault(_counter_key(name, labels), DurationStats()) + stats.count += 1 + stats.total_seconds += seconds + + +def duration_stats(name: str, **labels: str) -> DurationStats: + """Return the aggregated durations recorded for ``name``.""" + with _LOCK: + stats = _DURATIONS.get(_counter_key(name, labels)) + return ( + DurationStats() + if stats is None + else DurationStats(stats.count, stats.total_seconds) + ) + + def snapshot() -> dict[_CounterKey, int]: """Return a copy of the counter registry for assertions.""" with _LOCK: @@ -66,6 +102,7 @@ def reset() -> None: """Clear all recorded metrics; intended for test isolation.""" with _LOCK: _COUNTERS.clear() + _DURATIONS.clear() def emit_summary() -> None: @@ -74,12 +111,21 @@ def emit_summary() -> None: Emits nothing when no metrics were recorded, so quiet runs stay quiet. """ with _LOCK: - if not _COUNTERS: + if not _COUNTERS and not _DURATIONS: return - rendered = [ + rendered: list[dict[str, object]] = [ {"metric": name, "labels": dict(labels), "value": value} for (name, labels), value in sorted(_COUNTERS.items()) ] + rendered.extend( + { + "metric": name, + "labels": dict(labels), + "count": stats.count, + "total_seconds": round(stats.total_seconds, 6), + } + for (name, labels), stats in sorted(_DURATIONS.items()) + ) _LOGGER.info("lading metrics summary: %s", json.dumps(rendered)) @@ -99,9 +145,12 @@ def register_summary_atexit() -> None: __all__ = [ + "DurationStats", "counter_value", + "duration_stats", "emit_summary", "increment_counter", + "observe_duration", "register_summary_atexit", "reset", "snapshot", diff --git a/pyproject.toml b/pyproject.toml index eb161a60..08b253b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,12 @@ dev = [ "pytest", "pytest-bdd", "pytest-cov", - "ruff", + # Pin ruff so the lockfile-installed linter matches the version CI installs + # as a uv tool (.github/workflows/ci.yml) and the one developers run from + # PATH. Keeping them identical avoids version-skew lint failures such as + # preview-only rules firing in one environment but not another. Bump both + # sites together. + "ruff==0.15.12", "pyright", "pytest-timeout", "cmd-mox@git+https://github.com/leynos/cmd-mox@f583c279a15760aba5cfd9bddf1fbbe9b1f8c429", diff --git a/tests/unit/test_lockfile.py b/tests/unit/test_lockfile.py index c52530d1..5442f858 100644 --- a/tests/unit/test_lockfile.py +++ b/tests/unit/test_lockfile.py @@ -11,6 +11,7 @@ from hypothesis import given from lading.commands import lockfile +from lading.utils import metrics # --------------------------------------------------------------------------- # Hypothesis strategies for _lockfiles_with_manifests property tests @@ -332,3 +333,91 @@ def test_returned_paths_are_subset_of_git_stdout(stdout: str) -> None: f"Returned path {path} (relative: {relative!r}) " f"does not appear in git stdout lines: {tracked_lines!r}" ) + + +# --------------------------------------------------------------------------- +# Metrics instrumentation (issue #91) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def _metrics_registry() -> cabc.Iterator[None]: + """Isolate the metric registry for instrumentation tests.""" + metrics.reset() + yield + metrics.reset() + + +def _static_runner( + exit_code: int, stdout: str, stderr: str +) -> cabc.Callable[..., tuple[int, str, str]]: + """Return a runner producing a fixed result.""" + + def runner( + command: cabc.Sequence[str], + *, + cwd: Path | None = None, + env: cabc.Mapping[str, str] | None = None, + ) -> tuple[int, str, str]: + del command, cwd, env + return exit_code, stdout, stderr + + return runner + + +@pytest.mark.usefixtures("_metrics_registry") +def test_discovery_records_lockfile_count(tmp_path: Path) -> None: + """Discovery increments the discovered-lockfiles counter by the count.""" + (tmp_path / "Cargo.toml").write_text("", encoding="utf-8") + (tmp_path / "Cargo.lock").write_text("", encoding="utf-8") + + lockfile.discover_tracked_lockfiles(tmp_path, _static_runner(0, "Cargo.lock\n", "")) + + assert metrics.counter_value(lockfile.DISCOVERED_LOCKFILES_METRIC) == 1 + + +@pytest.mark.usefixtures("_metrics_registry") +def test_refresh_records_success_outcome_and_duration(tmp_path: Path) -> None: + """A successful refresh counts a success and observes a duration.""" + lockfile.refresh_lockfile(tmp_path / "Cargo.toml", _static_runner(0, "", "")) + + assert metrics.counter_value(lockfile.REFRESH_METRIC, outcome="success") == 1 + assert metrics.counter_value(lockfile.REFRESH_METRIC, outcome="failure") == 0 + assert metrics.duration_stats(lockfile.REFRESH_DURATION_METRIC).count == 1 + + +@pytest.mark.usefixtures("_metrics_registry") +def test_refresh_records_failure_outcome(tmp_path: Path) -> None: + """A failed refresh counts a failure and still observes a duration.""" + with pytest.raises(lockfile.LockfileRefreshError): + lockfile.refresh_lockfile( + tmp_path / "Cargo.toml", _static_runner(101, "", "boom") + ) + + assert metrics.counter_value(lockfile.REFRESH_METRIC, outcome="failure") == 1 + assert metrics.duration_stats(lockfile.REFRESH_DURATION_METRIC).count == 1 + + +@pytest.mark.usefixtures("_metrics_registry") +@pytest.mark.parametrize( + ("exit_code", "stderr", "expected_state"), + [ + (0, "", "fresh"), + ( + 101, + "the lock file needs to be updated but --locked was passed", + "stale", + ), + (101, "unrelated explosion", "failed"), + ], +) +def test_validation_records_outcome_and_duration( + tmp_path: Path, exit_code: int, stderr: str, expected_state: str +) -> None: + """Validation counts each outcome state and observes a duration.""" + lockfile.validate_lockfile_freshness( + tmp_path / "Cargo.toml", _static_runner(exit_code, "", stderr) + ) + + assert metrics.counter_value(lockfile.VALIDATE_METRIC, outcome=expected_state) == 1 + assert metrics.duration_stats(lockfile.VALIDATE_DURATION_METRIC).count == 1 diff --git a/tests/unit/utils/test_metrics.py b/tests/unit/utils/test_metrics.py index 76b5319a..05b32161 100644 --- a/tests/unit/utils/test_metrics.py +++ b/tests/unit/utils/test_metrics.py @@ -3,6 +3,7 @@ from __future__ import annotations import collections.abc as cabc +import json import logging import operator import threading @@ -161,6 +162,35 @@ def test_emit_summary_is_silent_without_metrics( assert not caplog.records +def test_observe_duration_aggregates() -> None: + """Duration observations aggregate count and total seconds.""" + metrics.observe_duration("demo.duration", 0.25, operation="refresh") + metrics.observe_duration("demo.duration", 0.5, operation="refresh") + + stats = metrics.duration_stats("demo.duration", operation="refresh") + assert stats.count == 2 + assert stats.total_seconds == pytest.approx(0.75) + assert metrics.duration_stats("demo.duration", operation="other").count == 0 + + +def test_emit_summary_includes_durations(caplog: LogCaptureFixture) -> None: + """The summary payload renders duration aggregates alongside counters.""" + caplog.set_level(logging.INFO, logger="lading.utils.metrics") + metrics.observe_duration("demo.duration", 0.25) + + metrics.emit_summary() + + payload = json.loads(caplog.records[-1].getMessage().partition(": ")[2]) + assert payload == [ + { + "metric": "demo.duration", + "labels": {}, + "count": 1, + "total_seconds": 0.25, + } + ] + + def test_register_summary_atexit_registers_once( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/uv.lock b/uv.lock index 3a4f582f..e1867717 100644 --- a/uv.lock +++ b/uv.lock @@ -249,7 +249,7 @@ dev = [ { name = "pytest-bdd" }, { name = "pytest-cov" }, { name = "pytest-timeout" }, - { name = "ruff" }, + { name = "ruff", specifier = "==0.15.12" }, { name = "syrupy", specifier = ">=5.1.0" }, ] @@ -555,28 +555,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.13.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/8e/f9f9ca747fea8e3ac954e3690d4698c9737c23b51731d02df999c150b1c9/ruff-0.13.3.tar.gz", hash = "sha256:5b0ba0db740eefdfbcce4299f49e9eaefc643d4d007749d77d047c2bab19908e", size = 5438533, upload-time = "2025-10-02T19:29:31.582Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/33/8f7163553481466a92656d35dea9331095122bb84cf98210bef597dd2ecd/ruff-0.13.3-py3-none-linux_armv6l.whl", hash = "sha256:311860a4c5e19189c89d035638f500c1e191d283d0cc2f1600c8c80d6dcd430c", size = 12484040, upload-time = "2025-10-02T19:28:49.199Z" }, - { url = "https://files.pythonhosted.org/packages/b0/b5/4a21a4922e5dd6845e91896b0d9ef493574cbe061ef7d00a73c61db531af/ruff-0.13.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2bdad6512fb666b40fcadb65e33add2b040fc18a24997d2e47fee7d66f7fcae2", size = 13122975, upload-time = "2025-10-02T19:28:52.446Z" }, - { url = "https://files.pythonhosted.org/packages/40/90/15649af836d88c9f154e5be87e64ae7d2b1baa5a3ef317cb0c8fafcd882d/ruff-0.13.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fc6fa4637284708d6ed4e5e970d52fc3b76a557d7b4e85a53013d9d201d93286", size = 12346621, upload-time = "2025-10-02T19:28:54.712Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/bcbccb8141305f9a6d3f72549dd82d1134299177cc7eaf832599700f95a7/ruff-0.13.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c9e6469864f94a98f412f20ea143d547e4c652f45e44f369d7b74ee78185838", size = 12574408, upload-time = "2025-10-02T19:28:56.679Z" }, - { url = "https://files.pythonhosted.org/packages/ce/19/0f3681c941cdcfa2d110ce4515624c07a964dc315d3100d889fcad3bfc9e/ruff-0.13.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5bf62b705f319476c78891e0e97e965b21db468b3c999086de8ffb0d40fd2822", size = 12285330, upload-time = "2025-10-02T19:28:58.79Z" }, - { url = "https://files.pythonhosted.org/packages/10/f8/387976bf00d126b907bbd7725219257feea58650e6b055b29b224d8cb731/ruff-0.13.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78cc1abed87ce40cb07ee0667ce99dbc766c9f519eabfd948ed87295d8737c60", size = 13980815, upload-time = "2025-10-02T19:29:01.577Z" }, - { url = "https://files.pythonhosted.org/packages/0c/a6/7c8ec09d62d5a406e2b17d159e4817b63c945a8b9188a771193b7e1cc0b5/ruff-0.13.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4fb75e7c402d504f7a9a259e0442b96403fa4a7310ffe3588d11d7e170d2b1e3", size = 14987733, upload-time = "2025-10-02T19:29:04.036Z" }, - { url = "https://files.pythonhosted.org/packages/97/e5/f403a60a12258e0fd0c2195341cfa170726f254c788673495d86ab5a9a9d/ruff-0.13.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:17b951f9d9afb39330b2bdd2dd144ce1c1335881c277837ac1b50bfd99985ed3", size = 14439848, upload-time = "2025-10-02T19:29:06.684Z" }, - { url = "https://files.pythonhosted.org/packages/39/49/3de381343e89364c2334c9f3268b0349dc734fc18b2d99a302d0935c8345/ruff-0.13.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6052f8088728898e0a449f0dde8fafc7ed47e4d878168b211977e3e7e854f662", size = 13421890, upload-time = "2025-10-02T19:29:08.767Z" }, - { url = "https://files.pythonhosted.org/packages/ab/b5/c0feca27d45ae74185a6bacc399f5d8920ab82df2d732a17213fb86a2c4c/ruff-0.13.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc742c50f4ba72ce2a3be362bd359aef7d0d302bf7637a6f942eaa763bd292af", size = 13444870, upload-time = "2025-10-02T19:29:11.234Z" }, - { url = "https://files.pythonhosted.org/packages/50/a1/b655298a1f3fda4fdc7340c3f671a4b260b009068fbeb3e4e151e9e3e1bf/ruff-0.13.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8e5640349493b378431637019366bbd73c927e515c9c1babfea3e932f5e68e1d", size = 13691599, upload-time = "2025-10-02T19:29:13.353Z" }, - { url = "https://files.pythonhosted.org/packages/32/b0/a8705065b2dafae007bcae21354e6e2e832e03eb077bb6c8e523c2becb92/ruff-0.13.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6b139f638a80eae7073c691a5dd8d581e0ba319540be97c343d60fb12949c8d0", size = 12421893, upload-time = "2025-10-02T19:29:15.668Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1e/cbe7082588d025cddbb2f23e6dfef08b1a2ef6d6f8328584ad3015b5cebd/ruff-0.13.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6b547def0a40054825de7cfa341039ebdfa51f3d4bfa6a0772940ed351d2746c", size = 12267220, upload-time = "2025-10-02T19:29:17.583Z" }, - { url = "https://files.pythonhosted.org/packages/a5/99/4086f9c43f85e0755996d09bdcb334b6fee9b1eabdf34e7d8b877fadf964/ruff-0.13.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9cc48a3564423915c93573f1981d57d101e617839bef38504f85f3677b3a0a3e", size = 13177818, upload-time = "2025-10-02T19:29:19.943Z" }, - { url = "https://files.pythonhosted.org/packages/9b/de/7b5db7e39947d9dc1c5f9f17b838ad6e680527d45288eeb568e860467010/ruff-0.13.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1a993b17ec03719c502881cb2d5f91771e8742f2ca6de740034433a97c561989", size = 13618715, upload-time = "2025-10-02T19:29:22.527Z" }, - { url = "https://files.pythonhosted.org/packages/28/d3/bb25ee567ce2f61ac52430cf99f446b0e6d49bdfa4188699ad005fdd16aa/ruff-0.13.3-py3-none-win32.whl", hash = "sha256:f14e0d1fe6460f07814d03c6e32e815bff411505178a1f539a38f6097d3e8ee3", size = 12334488, upload-time = "2025-10-02T19:29:24.782Z" }, - { url = "https://files.pythonhosted.org/packages/cf/49/12f5955818a1139eed288753479ba9d996f6ea0b101784bb1fe6977ec128/ruff-0.13.3-py3-none-win_amd64.whl", hash = "sha256:621e2e5812b691d4f244638d693e640f188bacbb9bc793ddd46837cea0503dd2", size = 13455262, upload-time = "2025-10-02T19:29:26.882Z" }, - { url = "https://files.pythonhosted.org/packages/fe/72/7b83242b26627a00e3af70d0394d68f8f02750d642567af12983031777fc/ruff-0.13.3-py3-none-win_arm64.whl", hash = "sha256:9e9e9d699841eaf4c2c798fa783df2fabc680b72059a02ca0ed81c460bc58330", size = 12538484, upload-time = "2025-10-02T19:29:28.951Z" }, +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] [[package]]