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/Makefile b/Makefile index 68ff7a83..d98c91f6 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,14 @@ MDLINT ?= $(shell which markdownlint) NIXIE ?= $(shell which nixie) MDFORMAT_ALL ?= $(shell which mdformat-all) UV ?= $(shell command -v uv 2>/dev/null || printf '%s/.local/bin/uv' "$$HOME") -TOOLS = $(MDFORMAT_ALL) ruff ty $(MDLINT) $(NIXIE) $(UV) +# Pin Ruff so `make` invokes the same version as the `ruff==` dev dependency +# in pyproject.toml and the `uv tool install ruff==` step in +# .github/workflows/ci.yml. Bump all three sites together: a version mismatch +# causes version-skew lint failures because rule sets differ between Ruff +# releases. +RUFF_VERSION ?= 0.15.12 +RUFF ?= $(UV) tool run --from ruff==$(RUFF_VERSION) ruff +TOOLS = $(MDFORMAT_ALL) ty $(MDLINT) $(NIXIE) $(UV) PY_SOURCES := $(sort $(shell find lading scripts -type f -name '*.py' -print)) VENV_TOOLS = interrogate pytest PYLINT_PYTHON ?= pypy @@ -59,17 +66,17 @@ $(VENV_TOOLS): build ## Verify required CLI tools in venv $(call ensure_tool_venv,$@) endif -fmt: ruff $(MDFORMAT_ALL) ## Format sources - ruff format - ruff check --select I --fix +fmt: $(UV) $(MDFORMAT_ALL) ## Format sources + $(RUFF) format + $(RUFF) check --select I --fix $(MDFORMAT_ALL) -check-fmt: ruff ## Verify formatting - ruff format --check +check-fmt: $(UV) ## Verify formatting + $(RUFF) format --check # mdformat-all doesn't currently do checking -lint: ruff build $(UV) interrogate ## Run linters - ruff check +lint: build $(UV) interrogate ## Run linters + $(RUFF) check $(UV) run interrogate --fail-under 100 lading $(PYLINT) $(PYLINT_TARGETS) diff --git a/docs/adr/004-in-process-metrics-backend.md b/docs/adr/004-in-process-metrics-backend.md new file mode 100644 index 00000000..2ed67cd4 --- /dev/null +++ b/docs/adr/004-in-process-metrics-backend.md @@ -0,0 +1,67 @@ +# ADR-004: In-process metrics accumulator flushed at exit + +## Status + +Accepted. + +## Date + +2026-06-14 + +## Context and problem statement + +`lading` needs to count operational events so maintainers can see how often a +release relied on a particular code path — for example, how often a crates.io +index-lookup failure was downgraded to a warning under +`--allow-unpublished-workspace-deps`, or how lockfile discovery, refresh, and +validation behaved during a `bump`. + +A `lading` invocation is a short-lived command-line process, not a long-running +service. There is no scrape endpoint to expose and no daemon lifetime over +which a time series would accumulate. Adding an exporter such as +`prometheus_client` or `statsd` would introduce a runtime dependency and a +network target with no consumer, and would still need a flush at process exit +to be useful for a one-shot command. The logs a `lading` run already emits are +the established operational boundary. + +## Decision + +Record metrics in an in-process accumulator, `lading.utils.metrics`, and flush +them as a single structured JSON log line at interpreter exit. + +- Labelled counters are recorded with `increment_counter(name, **labels)` and + duration aggregates with `observe_duration(name, seconds, **labels)`. +- `emit_summary` renders the accumulated counters and duration aggregates as + one `INFO` log line (`lading metrics summary: [...]`). Runs that record + nothing emit nothing. +- The flush is registered with `atexit` from explicit application bootstrap + (`lading.cli.main` calls `register_summary_atexit`) rather than as an + import-time side effect, so the exit-time behaviour is a visible lifecycle + decision. Registration is idempotent and sets its guard flag only after + `atexit.register` succeeds. +- The registry doubles as a deterministic test seam through `counter_value`, + `duration_stats`, `snapshot`, and `reset`. + +The metric contracts are documented in the developer guide; the +`publish.index_lookup_downgrade` counter carries `subcommand` (`package` or +`publish`) and `missing_crate` labels. + +Label values, including `missing_crate`, are recorded verbatim rather than +bucketed, hashed, or capped. The accumulator is process-local and flushed once, +so its cardinality is bounded by the work a single run performs (at most the +number of distinct crates in that publish run), not by an unbounded external +key space. The crate name is the actionable detail an operator needs, so +collapsing it to a bucket such as `other` would remove the metric's value +without removing a real cost. + +## Consequences + +- Metrics are dependency-free and add no network target; they are visible only + in the run's logs, which is where short-lived CLI output is already + aggregated. +- The verbatim `missing_crate` label is acceptable for the one-shot, + log-flushed design. If a future change exports these metrics to a long-lived + time-series backend, the cardinality decision must be revisited and label + values bucketed or aggregated at the export boundary at that time. +- New metrics should be added to the accumulator and documented in the + developer guide alongside the existing counters. diff --git a/docs/contents.md b/docs/contents.md index 5b8344b0..082a095f 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -24,6 +24,8 @@ looking for project guidance, then follow the link that matches the task. - [ADR-003: Use three-tier Python linting][adr-003] - accepted linting policy for Ruff, Interrogate, and Pylint. +- [ADR-004: In-process metrics accumulator flushed at exit][adr-004] - accepted + design for the `lading.utils.metrics` backend and metric contracts. ## Reference documents @@ -35,3 +37,4 @@ looking for project guidance, then follow the link that matches the task. spies, fixtures, and process-boundary assertions. [adr-003]: adr/003-three-tier-python-linting.md +[adr-004]: adr/004-in-process-metrics-backend.md diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 88645455..a7bf9c33 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -39,18 +39,25 @@ make lint ``` The target is deliberately three-tiered. Ruff runs first because it is fast, -handles broad style and correctness checks, and imports the stricter lint policy -used by `leynos/episodic`. If Ruff passes, the target runs `interrogate` with -`--fail-under 100` across `lading` to enforce **100% docstring coverage**. If -`interrogate` passes, the final tier runs Pylint through the pinned +handles broad style and correctness checks, and imports the stricter lint +policy used by `leynos/episodic`. If Ruff passes, the target runs `interrogate` +with `--fail-under 100` across `lading` to enforce **100% docstring coverage**. +If `interrogate` passes, the final tier runs Pylint through the pinned `pylint-pypy-shim` tool under PyPy. The final tier is focused on rule families -that complement Ruff, especially logging format safety, pattern matching checks, -selected simplification checks, deprecated standard-library usage, file hygiene, -and design-size limits. [ADR-003](adr/003-three-tier-python-linting.md) records -the policy decision. +that complement Ruff, especially logging format safety, pattern matching +checks, selected simplification checks, deprecated standard-library usage, file +hygiene, and design-size limits. +[ADR-003](adr/003-three-tier-python-linting.md) records the policy decision. The relevant Makefile variables are: +- `RUFF_VERSION` — pinned Ruff version; defaults to `0.15.12`. Keep it in sync + with the `ruff==` dev dependency in `pyproject.toml` and the + `uv tool install ruff==` step in `.github/workflows/ci.yml`, bumping all + three together to avoid version-skew lint failures. +- `RUFF` — the pinned Ruff command + (`uv tool run --from ruff==$(RUFF_VERSION) ruff`) that the `fmt`, + `check-fmt`, and `lint` targets invoke. - `PYLINT_PYTHON` — Python executable used by `uv tool run`; defaults to `pypy`. - `PYLINT_TARGETS` — directories passed to Pylint; defaults to `lading scripts tests`. @@ -503,8 +510,8 @@ The index-lookup handling is split across the adapter and decision helper: fatal. If the parsed name is not in the publish plan, the failure is fatal with guidance to publish or index that dependency first. The helper checks projected availability by comparing publish-order positions and raises for - out-of-plan, self, or late dependencies. If the parsed name is in the plan - and `allow_unpublished_workspace_deps` is set, the helper logs a warning and + out-of-plan, self, or late dependencies. If the parsed name is in the plan and + `allow_unpublished_workspace_deps` is set, the helper logs a warning and continues; otherwise it raises with guidance to enable the dry-run unpublished workspace dependency override or follow the staged-publish workaround. @@ -610,6 +617,35 @@ a formatted message that includes all four values. Using a single function for message construction keeps the error format consistent across the packaging and publish phases and makes snapshot testing straightforward. +### In-process metrics (`lading.utils.metrics`) + +`lading.utils.metrics` is a process-local metrics accumulator. Counters are +recorded with `increment_counter(name, **labels)` and flushed as a single +structured JSON log line at interpreter exit (`emit_summary`, registered via +`atexit`); runs that record no metrics emit nothing. The module deliberately +avoids exporter dependencies such as `prometheus_client` or `statsd`: a lading +invocation is a short-lived CLI process whose logs are already aggregated, so +the summary line is the operational boundary. Tests use `counter_value`, +`duration_stats`, `snapshot`, and `reset` as deterministic seams. +[ADR-004](adr/004-in-process-metrics-backend.md) records this backend decision, +including why label values such as `missing_crate` are kept verbatim rather +than bucketed. + +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`) `lading.runtime` owns the shared `CommandRunner` protocol and the production diff --git a/docs/users-guide.md b/docs/users-guide.md index 42183edf..04f2d26c 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -219,6 +219,8 @@ missing dependency is **not** in the publish plan, or when it appears **after** the current crate in `publish.order`, the failure is still treated as an error. Fix the explicit `publish.order` so foundational crates come before dependants, or remove `publish.order` and rely on dependency-derived topological sorting. +Each such downgrade is counted and surfaced in the metrics summary emitted at +exit; see [Observability](#observability). ## Configuration reference (`lading.toml`) @@ -291,6 +293,30 @@ or run the relevant Cargo command yourself before committing the bump. `"per-crate"`. Controls how `[patch.crates-io]` is edited in the staged workspace before packaging. +### Observability + +When `lading` runs, a structured JSON summary may appear in the log output at +`INFO` level just before the process exits. The flush is process-wide — any +command can emit it — and reports whichever metrics that run recorded. For +example, a `publish` run that downgraded an index-lookup failure emits: + +```plaintext +lading metrics summary: [{"metric": "publish.index_lookup_downgrade", "labels": {"missing_crate": "...", "subcommand": "..."}, "value": 1}] +``` + +Each entry records a counter name, the label values that identify it, and the +accumulated count for the current invocation. The summary line is omitted +entirely when no metrics were recorded (quiet runs stay quiet). + +#### `publish.index_lookup_downgrade` + +Incremented on each downgrade event when a crates.io index-lookup failure for a +sibling workspace dependency is downgraded to a warning because +`allow_unpublished_workspace_deps` is enabled. Labels: + +- `subcommand` — the Cargo subcommand that failed (`package` or `publish`). +- `missing_crate` — the name of the workspace dependency absent from the index. + ### `[preflight]` - `test_exclude`: array of strings, default `[]`. Crate names to exclude from diff --git a/lading/cli.py b/lading/cli.py index c7057d3e..a38231a9 100644 --- a/lading/cli.py +++ b/lading/cli.py @@ -32,7 +32,7 @@ from . import commands, config from .runtime import CommandRunner, subprocess_runner -from .utils import normalise_workspace_root +from .utils import metrics, normalise_workspace_root from .workspace import WorkspaceGraph, WorkspaceModelError, load_workspace from .workspace import metadata as metadata_module @@ -298,6 +298,10 @@ def main(argv: cabc.Sequence[str] | None = None) -> int: if argv is None: argv = sys.argv[1:] _configure_logging() + # Flush the accumulated metrics summary when this CLI process exits. + # Registered here in bootstrap so the lifecycle is explicit rather than + # an import-time side effect of lading.utils.metrics. + metrics.register_summary_atexit() workspace_override, remaining = _extract_workspace_override(list(argv)) workspace_root = normalise_workspace_root(workspace_override) if not remaining: diff --git a/lading/commands/lockfile.py b/lading/commands/lockfile.py index 07f1c8a7..734d0513 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,10 @@ def discover_tracked_lockfiles( if error_result is not None: return error_result lockfiles = _lockfiles_with_manifests(stdout, workspace_root, manifest_exists) + if lockfiles: + # Skip a zero-amount increment: it would create a 0-valued counter key + # and force an otherwise-silent exit summary (quiet runs stay quiet). + 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 +202,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 +238,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 +250,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/commands/publish_index_check.py b/lading/commands/publish_index_check.py index fab418ce..32e170eb 100644 --- a/lading/commands/publish_index_check.py +++ b/lading/commands/publish_index_check.py @@ -12,6 +12,7 @@ import logging import typing as typ +from lading.utils import metrics from lading.utils.process import with_detail if typ.TYPE_CHECKING: @@ -19,6 +20,10 @@ from lading.commands.publish import _PublishExecutionOptions from lading.commands.publish_plan import PublishPlan +# Counter incremented each time an index-lookup failure is downgraded to a +# warning because the missing dependency is in the publish plan (issue #68). +INDEX_LOOKUP_DOWNGRADE_METRIC = "publish.index_lookup_downgrade" + @dc.dataclass(frozen=True, slots=True) class _IndexMissingVersionFailure: @@ -224,16 +229,34 @@ def _canonical_crate_name(name: str) -> str: return name.replace("-", "_") +class _DependencyPlacement(typ.NamedTuple): + """Resolved publish-order placement for a downgraded index-lookup failure. + + Attributes + ---------- + current_index: + Publish-order index of the crate whose cargo invocation failed. + missing_index: + Publish-order index of the missing sibling dependency. + missing_canonical_name: + Canonicalised (underscore-only) name of the missing dependency. + """ + + current_index: int + missing_index: int + missing_canonical_name: str + + def _validate_dependency_placement( context: _IndexMissingVersionFailure, handling: _IndexMissingVersionHandling, missing_name: str, -) -> tuple[int, int, str]: +) -> _DependencyPlacement: """Resolve both crate indexes and raise on all fatal placement conditions. - Returns ``(current_index, missing_index, missing_canonical_name)`` when - the missing dependency is in the plan and is ordered before the current - crate. Raises the context exception class for every other case. + Returns a :class:`_DependencyPlacement` when the missing dependency is in + the plan and is ordered before the current crate. Raises the context + exception class for every other case. """ publishable_name_indexes = { _canonical_crate_name(entry.name): index @@ -270,7 +293,51 @@ def _validate_dependency_placement( _raise_self_dependency(context, missing_name=missing_name) if missing_index > current_index: _raise_out_of_order_dependency(context, missing_name=missing_name) - return current_index, missing_index, missing_canonical_name + return _DependencyPlacement(current_index, missing_index, missing_canonical_name) + + +def _emit_downgrade_success( + handling: _IndexMissingVersionHandling, + failure: CargoIndexLookupFailure, + *, + missing_name: str, + placement: _DependencyPlacement, +) -> None: + """Increment the downgrade metric and emit the associated log messages. + + Called only when the missing dependency is in the publish plan and the + caller has opted into the unpublished workspace dependency override. + ``placement`` is the :class:`_DependencyPlacement` resolved by + ``_validate_dependency_placement``. + """ + metrics.increment_counter( + INDEX_LOOKUP_DOWNGRADE_METRIC, + subcommand=failure.subcommand, + missing_crate=missing_name, + ) + handling.logger.warning( + "cargo %s for crate %s could not resolve sibling dependency %s " + "from crates.io; continuing because the unpublished workspace " + "dependency override is enabled", + failure.subcommand, + failure.crate_name, + missing_name, + ) + handling.logger.debug( + "canonicalised dependency name %r -> %r", + missing_name, + placement.missing_canonical_name, + ) + handling.logger.info( + "Downgraded cargo %s failure for crate %s (index %d) because " + "dependency %s (index %d) is part of the publish plan and the " + "unpublished workspace dependency override is enabled", + failure.subcommand, + failure.crate_name, + placement.current_index, + missing_name, + placement.missing_index, + ) def _handle_index_missing_version( @@ -302,9 +369,7 @@ def _handle_index_missing_version( if missing_name is None: _raise_name_extraction_failure(context) - current_index, missing_index, missing_canonical_name = ( - _validate_dependency_placement(context, handling, missing_name) - ) + placement = _validate_dependency_placement(context, handling, missing_name) handling.logger.debug( "index-missing-version handler: allow_unpublished_workspace_deps=%r " @@ -321,26 +386,9 @@ def _handle_index_missing_version( context, missing_name=missing_name ) - handling.logger.warning( - "cargo %s for crate %s could not resolve sibling dependency %s " - "from crates.io; continuing because the unpublished workspace " - "dependency override is enabled", - failure.subcommand, - failure.crate_name, - missing_name, - ) - handling.logger.debug( - "canonicalised dependency name %r -> %r", - missing_name, - missing_canonical_name, - ) - handling.logger.info( - "Downgraded cargo %s failure for crate %s (index %d) because " - "dependency %s (index %d) is part of the publish plan and the " - "unpublished workspace dependency override is enabled", - failure.subcommand, - failure.crate_name, - current_index, - missing_name, - missing_index, + _emit_downgrade_success( + handling, + failure, + missing_name=missing_name, + placement=placement, ) diff --git a/lading/utils/metrics.py b/lading/utils/metrics.py new file mode 100644 index 00000000..8309c404 --- /dev/null +++ b/lading/utils/metrics.py @@ -0,0 +1,167 @@ +"""In-process metrics accumulation for :mod:`lading`. + +Backend choice (issue #68): a process-local accumulator flushed to one +structured log line at interpreter exit. A lading invocation is a +short-lived CLI process, so exporters such as ``prometheus_client`` or +``statsd`` would add a runtime dependency and a network target without a +scraper to consume them. Log aggregation already ingests lading's output, +so the summary line is the operationally useful boundary, and the +in-process registry gives tests a deterministic seam. + +Counters are keyed by metric name plus a sorted tuple of label pairs. + +Examples +-------- +>>> from lading.utils import metrics +>>> metrics.reset() +>>> metrics.increment_counter("demo.events", kind="example") +>>> metrics.counter_value("demo.events", kind="example") +1 +""" + +from __future__ import annotations + +import atexit +import collections +import dataclasses as dc +import json +import logging +import threading + +_LOGGER = logging.getLogger(__name__) +_LOCK = threading.Lock() +type _CounterKey = tuple[str, tuple[tuple[str, str], ...]] +_COUNTERS: collections.Counter[_CounterKey] = collections.Counter() +_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()))) + + +def increment_counter(name: str, *, amount: int = 1, **labels: str) -> None: + """Increment the counter ``name`` for the supplied label values. + + A zero ``amount`` is a no-op and records nothing, so a counter surfaces in + the exit summary only when it actually changed (quiet runs stay quiet). + + Examples + -------- + >>> increment_counter("demo.total", subcommand="package") + """ + if amount == 0: + return + with _LOCK: + _COUNTERS[_counter_key(name, labels)] += amount + + +def counter_value(name: str, **labels: str) -> int: + """Return the current value of ``name`` for the supplied labels.""" + with _LOCK: + 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: + return dict(_COUNTERS) + + +def reset() -> None: + """Clear all recorded metrics; intended for test isolation.""" + with _LOCK: + _COUNTERS.clear() + _DURATIONS.clear() + + +def emit_summary() -> None: + """Log the accumulated counters as one structured summary line. + + Emits nothing when no metrics were recorded, so quiet runs stay quiet. + """ + with _LOCK: + if not _COUNTERS and not _DURATIONS: + return + 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)) + + +def register_summary_atexit() -> None: + """Register :func:`emit_summary` to run at interpreter exit. + + Call this once from application bootstrap (the CLI entry point) so the + exit-time flush is an explicit lifecycle decision rather than a hidden + side effect of importing this module. Repeated calls register the hook at + most once. + + The check, registration, and flag update happen together under ``_LOCK``, + and the flag is set only after ``atexit.register`` returns. A failed + registration therefore leaves the flag unset so a later call can retry, + rather than recording the hook as registered when it is not. + """ + with _LOCK: + if _summary_hook_registered.is_set(): + return + atexit.register(emit_summary) + _summary_hook_registered.set() + + +__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/publish/test_downgrade_metrics.py b/tests/unit/publish/test_downgrade_metrics.py new file mode 100644 index 00000000..4125cb82 --- /dev/null +++ b/tests/unit/publish/test_downgrade_metrics.py @@ -0,0 +1,190 @@ +"""Tests for the publish index-lookup downgrade counter (issue #68).""" + +from __future__ import annotations + +import collections.abc as cabc +import json +import logging +import typing as typ + +import pytest + +from lading.commands import publish, publish_index_check +from lading.commands.cargo_output_adapter import CargoIndexLookupFailure +from lading.utils import metrics + +from .conftest import ( + INDEX_MISSING_STDERR_BETA, + INDEX_MISSING_STDERR_EXTERNAL, + make_config, + make_dependency_chain, + make_workspace, +) + +if typ.TYPE_CHECKING: + from pathlib import Path + +_METRIC = publish_index_check.INDEX_LOOKUP_DOWNGRADE_METRIC + + +@pytest.fixture(autouse=True) +def _reset_metrics() -> cabc.Iterator[None]: + """Isolate the metric registry for each test.""" + metrics.reset() + yield + metrics.reset() + + +def _invoke_handler( + tmp_path: Path, + *, + stderr: str, + missing_crate: str, + allow_unpublished_workspace_deps: bool, +) -> None: + """Drive ``_handle_index_missing_version`` for crate beta.""" + workspace_root = tmp_path / "workspace" + crates = make_dependency_chain(workspace_root) + plan = publish.plan_publication( + make_workspace(workspace_root, *crates), make_config() + ) + failure = CargoIndexLookupFailure( + crate_name="beta", + subcommand="package", + exit_code=1, + stdout="", + stderr=stderr, + missing_dependency_name=missing_crate, + ) + publish._handle_index_missing_version( + failure, + plan=plan, + options=publish._PublishExecutionOptions( + live=False, + allow_dirty=True, + allow_unpublished_workspace_deps=allow_unpublished_workspace_deps, + ), + ) + + +def test_downgrade_path_increments_counter( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """The in-plan downgrade increments the labelled counter once.""" + caplog.set_level(logging.WARNING, logger="lading.commands.publish") + + _invoke_handler( + tmp_path, + stderr=INDEX_MISSING_STDERR_BETA, + missing_crate="alpha", + allow_unpublished_workspace_deps=True, + ) + + assert ( + metrics.counter_value(_METRIC, subcommand="package", missing_crate="alpha") == 1 + ) + + +@pytest.mark.parametrize( + ("stderr", "missing_crate", "allow_unpublished_workspace_deps", "match"), + [ + pytest.param( + INDEX_MISSING_STDERR_BETA, + "alpha", + False, + "scheduled in this publish run but is not yet on crates.io", + id="flag_disabled", + ), + pytest.param( + INDEX_MISSING_STDERR_EXTERNAL, + "external_crate", + True, + "is not part of the current publish plan", + id="out_of_plan", + ), + ], +) +def test_raise_paths_do_not_increment_counter( + tmp_path: Path, + *, + stderr: str, + missing_crate: str, + allow_unpublished_workspace_deps: bool, + match: str, +) -> None: + """Neither the flag-disabled nor the out-of-plan path counts a downgrade.""" + with pytest.raises(publish.PublishPreflightError, match=match): + _invoke_handler( + tmp_path, + stderr=stderr, + missing_crate=missing_crate, + allow_unpublished_workspace_deps=allow_unpublished_workspace_deps, + ) + + assert metrics.snapshot() == {} + + +def _make_beta_package_index_failure_runner() -> cabc.Callable[ + [cabc.Sequence[str]], tuple[int, str, str] +]: + """Return a runner whose ``cargo package`` for crate beta misses the index.""" + + def runner( + command: cabc.Sequence[str], + *, + cwd: Path | None = None, + env: cabc.Mapping[str, str] | None = None, + ) -> tuple[int, str, str]: + del env + is_package = tuple(command[:2]) == ("cargo", "package") + is_beta = cwd is not None and cwd.name == "beta" + if is_package and is_beta: + return (1, "", INDEX_MISSING_STDERR_BETA) + return (0, "", "") + + return runner + + +def test_full_publish_run_records_and_emits_downgrade_metric( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """An end-to-end downgrade is counted and surfaced in the exit summary. + + Drives ``publish.run`` through the real index-failure parsing and downgrade + path (rather than the handler in isolation), then confirms ``emit_summary`` + renders the counter operators see at process exit. + """ + caplog.set_level(logging.INFO, logger="lading.utils.metrics") + root = tmp_path / "workspace" + workspace = make_workspace(root, *make_dependency_chain(root)) + configuration = make_config() + + publish.run( + root, + configuration, + workspace, + options=publish.PublishOptions( + build_directory=tmp_path / "build", + command_runner=_make_beta_package_index_failure_runner(), + allow_unpublished_workspace_deps=True, + ), + ) + + assert ( + metrics.counter_value(_METRIC, subcommand="package", missing_crate="alpha") == 1 + ) + + metrics.emit_summary() + + summaries = [ + record.getMessage() + for record in caplog.records + if "metrics summary" in record.getMessage() + ] + assert len(summaries) == 1 + payload = json.loads(summaries[0].partition(": ")[2]) + assert { + "metric": _METRIC, + "labels": {"missing_crate": "alpha", "subcommand": "package"}, + "value": 1, + } in payload diff --git a/tests/unit/test_lockfile.py b/tests/unit/test_lockfile.py index c52530d1..49ae4199 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 @@ -37,8 +38,9 @@ _HYPOTHESIS_WORKSPACE = Path("/repo") +@pytest.mark.usefixtures("_metrics_registry") def test_discover_tracked_lockfiles_returns_empty_result(tmp_path: Path) -> None: - """Empty git output produces no lockfiles.""" + """Empty git output produces no lockfiles and records no discovery metric.""" (tmp_path / "Cargo.lock").write_text("", encoding="utf-8") def runner( @@ -57,6 +59,9 @@ def runner( "git repo with no tracked lockfiles should return an empty tuple; " f"got {result!r}" ) + # A zero-count discovery must not record a counter, so quiet runs stay quiet. + assert metrics.counter_value(lockfile.DISCOVERED_LOCKFILES_METRIC) == 0 + assert metrics.snapshot() == {} def test_discover_tracked_lockfiles_filters_missing_manifests(tmp_path: Path) -> None: @@ -332,3 +337,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/__snapshots__/test_metrics.ambr b/tests/unit/utils/__snapshots__/test_metrics.ambr new file mode 100644 index 00000000..bd03bf2c --- /dev/null +++ b/tests/unit/utils/__snapshots__/test_metrics.ambr @@ -0,0 +1,7 @@ +# serializer version: 1 +# name: test_emit_summary_logs_structured_payload + 'lading metrics summary: [{"metric": "demo.pair", "labels": {"a": "1", "b": "2"}, "value": 1}, {"metric": "demo.total", "labels": {"subcommand": "package"}, "value": 2}, {"metric": "demo.total", "labels": {"subcommand": "publish"}, "value": 1}]' +# --- +# name: test_emit_summary_snapshot_with_counters_and_durations + 'lading metrics summary: [{"metric": "demo.total", "labels": {"subcommand": "package"}, "value": 1}, {"metric": "demo.duration", "labels": {"operation": "refresh"}, "count": 2, "total_seconds": 2.0}]' +# --- diff --git a/tests/unit/utils/test_metrics.py b/tests/unit/utils/test_metrics.py new file mode 100644 index 00000000..b192f31e --- /dev/null +++ b/tests/unit/utils/test_metrics.py @@ -0,0 +1,243 @@ +"""Unit tests for the in-process metrics accumulator.""" + +from __future__ import annotations + +import collections.abc as cabc +import json +import logging +import threading +import typing as typ + +import pytest + +from lading.utils import metrics + +if typ.TYPE_CHECKING: + from syrupy.assertion import SnapshotAssertion + + LogCaptureFixture = pytest.LogCaptureFixture +else: # pragma: no cover - typing helpers + LogCaptureFixture = typ.Any + + +@pytest.fixture(autouse=True) +def _reset_metrics() -> cabc.Iterator[None]: + """Isolate the metric registry for each test.""" + metrics.reset() + yield + metrics.reset() + + +def test_increment_accumulates_per_label_set() -> None: + """Distinct label values accumulate independently.""" + metrics.increment_counter("demo.total", subcommand="package") + metrics.increment_counter("demo.total", subcommand="package") + metrics.increment_counter("demo.total", subcommand="publish") + + assert metrics.counter_value("demo.total", subcommand="package") == 2 + assert metrics.counter_value("demo.total", subcommand="publish") == 1 + assert metrics.counter_value("demo.total", subcommand="check") == 0 + + +def test_zero_amount_increment_is_a_noop() -> None: + """increment_counter with amount=0 must not create a registry entry.""" + metrics.increment_counter("noop.counter", amount=0, label="x") + assert metrics.counter_value("noop.counter", label="x") == 0 + assert metrics.snapshot() == {} + + +def test_zero_amount_increment_does_not_break_quiet_run( + caplog: LogCaptureFixture, +) -> None: + """emit_summary must stay silent when only zero-amount increments occurred.""" + metrics.increment_counter("noop.counter", amount=0) + with caplog.at_level(logging.INFO): + metrics.emit_summary() + summary_records = [ + record for record in caplog.records if "metrics summary" in record.getMessage() + ] + assert summary_records == [] + + +def test_label_order_does_not_matter() -> None: + """Label ordering is normalised in the registry key.""" + metrics.increment_counter("demo.pair", a="1", b="2") + + assert metrics.counter_value("demo.pair", b="2", a="1") == 1 + + +def test_snapshot_and_reset() -> None: + """Snapshots copy the registry and reset clears it.""" + metrics.increment_counter("demo.total") + + captured = metrics.snapshot() + assert captured == {("demo.total", ()): 1} + + metrics.reset() + assert metrics.snapshot() == {} + # The snapshot is a copy, unaffected by the reset. + assert captured == {("demo.total", ()): 1} + + +def test_emit_summary_logs_structured_payload( + caplog: LogCaptureFixture, + snapshot: SnapshotAssertion, +) -> None: + """The summary line carries a JSON payload of every counter. + + Snapshotting the rendered message locks the exact structured output format + operators see, including counter ordering and label-key normalisation. + """ + caplog.set_level(logging.INFO, logger="lading.utils.metrics") + metrics.increment_counter("demo.total", subcommand="package") + metrics.increment_counter("demo.total", subcommand="package") + metrics.increment_counter("demo.total", subcommand="publish") + metrics.increment_counter("demo.pair", b="2", a="1") + + metrics.emit_summary() + + summaries = [ + record.getMessage() + for record in caplog.records + if "metrics summary" in record.getMessage() + ] + assert len(summaries) == 1 + assert summaries[0] == snapshot + + +def test_emit_summary_is_silent_without_metrics( + caplog: LogCaptureFixture, +) -> None: + """Quiet runs do not emit an empty summary line.""" + caplog.set_level(logging.INFO, logger="lading.utils.metrics") + + metrics.emit_summary() + + 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_emit_summary_snapshot_with_counters_and_durations( + caplog: LogCaptureFixture, + snapshot: SnapshotAssertion, +) -> None: + """Counters and duration aggregates serialise together in a stable layout. + + Locks the combined output format so a regression in ordering or in the + counter-vs-duration field shapes is caught. + """ + caplog.set_level(logging.INFO, logger="lading.utils.metrics") + metrics.increment_counter("demo.total", subcommand="package") + metrics.observe_duration("demo.duration", 0.25, operation="refresh") + metrics.observe_duration("demo.duration", 1.75, operation="refresh") + + metrics.emit_summary() + + summaries = [ + record.getMessage() + for record in caplog.records + if "metrics summary" in record.getMessage() + ] + assert len(summaries) == 1 + assert summaries[0] == snapshot + + +def test_register_summary_atexit_registers_once( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Bootstrap registration installs ``emit_summary`` at most once. + + Registration moved out of module import into explicit bootstrap, so the + helper must be idempotent across repeated calls (e.g. successive in-process + CLI invocations during tests). + """ + registered: list[cabc.Callable[[], None]] = [] + monkeypatch.setattr(metrics, "_summary_hook_registered", threading.Event()) + monkeypatch.setattr(metrics.atexit, "register", registered.append) + + metrics.register_summary_atexit() + metrics.register_summary_atexit() + + assert registered == [metrics.emit_summary] + + +def test_register_summary_atexit_leaves_flag_unset_on_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A failed ``atexit.register`` leaves the hook unregistered and retryable.""" + monkeypatch.setattr(metrics, "_summary_hook_registered", threading.Event()) + + def boom(_func: cabc.Callable[[], None]) -> None: + message = "registration failed" + raise RuntimeError(message) + + monkeypatch.setattr(metrics.atexit, "register", boom) + with pytest.raises(RuntimeError, match="registration failed"): + metrics.register_summary_atexit() + assert not metrics._summary_hook_registered.is_set() + + # With registration working, a later call records the hook exactly once. + registered: list[cabc.Callable[[], None]] = [] + monkeypatch.setattr(metrics.atexit, "register", registered.append) + metrics.register_summary_atexit() + + assert registered == [metrics.emit_summary] + assert metrics._summary_hook_registered.is_set() + + +def test_register_summary_atexit_registers_once_under_concurrency( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Concurrent callers register the hook exactly once. + + The check-and-register is performed under ``_LOCK``, so no interleaving of + threads can pass the guard twice and register ``emit_summary`` more than + once. + """ + monkeypatch.setattr(metrics, "_summary_hook_registered", threading.Event()) + registered: list[cabc.Callable[[], None]] = [] + monkeypatch.setattr(metrics.atexit, "register", registered.append) + + worker_count = 8 + barrier = threading.Barrier(worker_count) + + def worker() -> None: + # Release all threads together to maximise contention on the guard. + barrier.wait() + metrics.register_summary_atexit() + + threads = [threading.Thread(target=worker) for _ in range(worker_count)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert registered == [metrics.emit_summary] diff --git a/tests/unit/utils/test_metrics_properties.py b/tests/unit/utils/test_metrics_properties.py new file mode 100644 index 00000000..877ebd81 --- /dev/null +++ b/tests/unit/utils/test_metrics_properties.py @@ -0,0 +1,101 @@ +"""Hypothesis property tests for the in-process metrics accumulator. + +The accumulator carries two non-obvious invariants that example-based tests +only spot-check: + +- **Label-order invariance** — counters are keyed by metric name plus a + *sorted* tuple of label pairs, so the order labels are supplied in must never + change which counter is addressed. +- **Accumulation** — repeated increments for the same label set must sum, + independent of interleaving with other label sets. + +A third property guards the test seam itself: :func:`snapshot` must return an +isolated copy that later mutation and :func:`reset` cannot disturb. + +Each property resets the process-global registry at the start of the body +rather than via a function-scoped fixture, which Hypothesis discourages when +combined with ``@given``. +""" + +from __future__ import annotations + +import collections +import operator + +from hypothesis import given, settings +from hypothesis import strategies as st + +from lading.utils import metrics + +# ``increment_counter(name, *, amount=1, **labels)`` reserves these keyword +# names, so they cannot be supplied as label keys through the kwargs API. +_RESERVED_LABEL_KEYS = frozenset({"name", "amount"}) + +# Label keys and values span the printable ASCII range; keys within a single +# label set are kept unique so a dict round-trip cannot collapse them. +_label_text = st.text( + alphabet=st.characters(min_codepoint=33, max_codepoint=126), + min_size=1, + max_size=8, +) +_label_key = _label_text.filter(lambda key: key not in _RESERVED_LABEL_KEYS) +_label_pairs = st.lists( + st.tuples(_label_key, _label_text), + min_size=2, + max_size=5, + unique_by=operator.itemgetter(0), +) + + +@given(pairs=_label_pairs, data=st.data()) +@settings(max_examples=100, deadline=None) +def test_label_order_does_not_affect_counter_identity( + pairs: list[tuple[str, str]], data: st.DataObject +) -> None: + """Any permutation of label kwargs addresses the same counter.""" + metrics.reset() + permuted = data.draw(st.permutations(pairs)) + + metrics.increment_counter("prop.identity", **dict(pairs)) + + assert metrics.counter_value("prop.identity", **dict(permuted)) == 1 + assert metrics.snapshot() == {("prop.identity", tuple(sorted(pairs))): 1} + + +@given( + increments=st.lists( + st.tuples(_label_text, st.integers(min_value=1, max_value=1000)), + min_size=1, + max_size=30, + ) +) +@settings(max_examples=100, deadline=None) +def test_increments_accumulate_per_label( + increments: list[tuple[str, int]], +) -> None: + """The final counter for each label equals the sum of its increments.""" + metrics.reset() + expected: collections.Counter[str] = collections.Counter() + + for subcommand, amount in increments: + metrics.increment_counter("prop.count", amount=amount, subcommand=subcommand) + expected[subcommand] += amount + + for subcommand, total in expected.items(): + assert metrics.counter_value("prop.count", subcommand=subcommand) == total + + +@given(pairs=_label_pairs) +@settings(max_examples=100, deadline=None) +def test_snapshot_is_an_isolated_copy(pairs: list[tuple[str, str]]) -> None: + """A snapshot is unaffected by later increments and registry resets.""" + metrics.reset() + metrics.increment_counter("prop.snapshot", **dict(pairs)) + + captured = metrics.snapshot() + expected = dict(captured) + + metrics.increment_counter("prop.snapshot", **dict(pairs)) + metrics.reset() + + assert captured == expected 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]]