From e4e5d735c149e7c38e9346e1d69829ce2c0fb73d Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 10 Jun 2026 01:51:28 +0200 Subject: [PATCH 01/17] Count publish index-lookup downgrade events The downgrade decision in _handle_index_missing_version (a crates.io index-lookup failure downgraded to a warning because the missing dependency is part of the publish plan) was visible only in logs, so operators could not track how often releases rely on the override. Add lading.utils.metrics: a process-local accumulator with labelled counters flushed as one structured JSON log line at interpreter exit. The deliberate backend choice over prometheus_client or statsd is documented in the module docstring and developers' guide: a lading run is a short-lived CLI process whose logs are already aggregated, so the summary line is the operational boundary and the registry gives tests a deterministic seam. The downgrade success path increments publish.index_lookup_downgrade with subcommand and missing_crate labels. Unit tests cover the accumulator itself and verify the counter increments exactly once on the downgrade path and never on the raise paths. Closes #68 --- docs/developers-guide.md | 46 ++++++++ lading/commands/publish_index_check.py | 10 ++ lading/utils/metrics.py | 93 ++++++++++++++++ tests/unit/publish/test_downgrade_metrics.py | 107 +++++++++++++++++++ tests/unit/utils/test_metrics.py | 86 +++++++++++++++ 5 files changed, 342 insertions(+) create mode 100644 lading/utils/metrics.py create mode 100644 tests/unit/publish/test_downgrade_metrics.py create mode 100644 tests/unit/utils/test_metrics.py diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 88645455..2d63b39a 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -184,6 +184,34 @@ exactly one place. ## Workspace discovery helpers + +### Workspace path normalization (`lading/utils/path.py`) + +`normalise_workspace_root(value)` is the shared helper that turns a +user-supplied workspace root into a canonical `Path`. Import it from +`lading.utils`: + +```python +from lading.utils import normalise_workspace_root + +normalise_workspace_root("~/workspace") # -> absolute, ~ expanded +normalise_workspace_root(None) # -> Path.cwd().resolve() +``` + +It accepts `Path`, `str`, or `None`. `None` selects the resolved current +working directory; any other value is expanded with `Path.expanduser` and +resolved with `Path.resolve(strict=False)`, so `~` is expanded, redundant +separators and `.`/`..` segments collapse, and the result is always absolute. +`strict=False` means a non-existent target is permitted rather than raising. + +The implementation is pure `pathlib` +(`Path(value).expanduser().resolve(strict=False)`); it no longer round-trips +through `plumbum`'s `local.path`. `plumbum` is therefore a dev-only dependency, +used solely by the end-to-end test helpers. Callers across the codebase rely on +this helper for consistent path handling, including `lading.cli`, +`lading.config`, `lading.workspace.metadata.load_cargo_metadata`, +`lading.commands.bump`, and `lading.commands.publish`. + ### Workspace path normalization (`lading/utils/path.py`) `normalise_workspace_root(value)` is the shared helper that turns a @@ -610,6 +638,24 @@ 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`, +`snapshot`, and `reset` as deterministic seams. + +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). | + ### Command runners (`lading.runtime`) `lading.runtime` owns the shared `CommandRunner` protocol and the production diff --git a/lading/commands/publish_index_check.py b/lading/commands/publish_index_check.py index fab418ce..5cd29400 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: @@ -321,6 +326,11 @@ def _handle_index_missing_version( context, missing_name=missing_name ) + 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 " diff --git a/lading/utils/metrics.py b/lading/utils/metrics.py new file mode 100644 index 00000000..4f58eb3d --- /dev/null +++ b/lading/utils/metrics.py @@ -0,0 +1,93 @@ +"""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 json +import logging +import threading + +_LOGGER = logging.getLogger(__name__) +_LOCK = threading.Lock() +_CounterKey = tuple[str, tuple[tuple[str, str], ...]] +_COUNTERS: collections.Counter[_CounterKey] = collections.Counter() + + +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. + + Examples + -------- + >>> increment_counter("demo.total", subcommand="package") + """ + 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 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() + + +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: + return + rendered = [ + {"metric": name, "labels": dict(labels), "value": value} + for (name, labels), value in sorted(_COUNTERS.items()) + ] + _LOGGER.info("lading metrics summary: %s", json.dumps(rendered)) + + +atexit.register(emit_summary) + +__all__ = [ + "counter_value", + "emit_summary", + "increment_counter", + "reset", + "snapshot", +] diff --git a/tests/unit/publish/test_downgrade_metrics.py b/tests/unit/publish/test_downgrade_metrics.py new file mode 100644 index 00000000..941f4daa --- /dev/null +++ b/tests/unit/publish/test_downgrade_metrics.py @@ -0,0 +1,107 @@ +"""Tests for the publish index-lookup downgrade counter (issue #68).""" + +from __future__ import annotations + +import collections.abc as cabc +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 + ) + assert metrics.snapshot() == { + (_METRIC, (("missing_crate", "alpha"), ("subcommand", "package"))): 1 + } + + +def test_raise_paths_do_not_increment_counter(tmp_path: Path) -> None: + """Neither the flag-disabled nor the out-of-plan path counts a downgrade.""" + with pytest.raises(publish.PublishPreflightError): + _invoke_handler( + tmp_path, + stderr=INDEX_MISSING_STDERR_BETA, + missing_crate="alpha", + allow_unpublished_workspace_deps=False, + ) + with pytest.raises(publish.PublishPreflightError): + _invoke_handler( + tmp_path, + stderr=INDEX_MISSING_STDERR_EXTERNAL, + missing_crate="external_crate", + allow_unpublished_workspace_deps=True, + ) + + assert metrics.snapshot() == {} diff --git a/tests/unit/utils/test_metrics.py b/tests/unit/utils/test_metrics.py new file mode 100644 index 00000000..94c15e03 --- /dev/null +++ b/tests/unit/utils/test_metrics.py @@ -0,0 +1,86 @@ +"""Unit tests for the in-process metrics accumulator.""" + +from __future__ import annotations + +import collections.abc as cabc +import json +import logging +import typing as typ + +import pytest + +from lading.utils import metrics + +if typ.TYPE_CHECKING: + 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_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, +) -> None: + """The summary line carries a JSON payload of every counter.""" + caplog.set_level(logging.INFO, logger="lading.utils.metrics") + metrics.increment_counter("demo.total", subcommand="package") + + metrics.emit_summary() + + summaries = [ + record for record in caplog.records if "metrics summary" in record.getMessage() + ] + assert len(summaries) == 1 + payload = json.loads(summaries[0].getMessage().partition(": ")[2]) + assert payload == [ + {"metric": "demo.total", "labels": {"subcommand": "package"}, "value": 1} + ] + + +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 From 7ff8dee27621c04585600b322bb92ff0dff59f45 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 10 Jun 2026 18:50:46 +0200 Subject: [PATCH 02/17] Extract _emit_downgrade_success to cut method length _handle_index_missing_version sat at exactly 70 lines, meeting (not beating) CodeScene's large-method threshold. The downgrade-success tail (the metric increment plus its three log statements) is a cohesive unit, so extract it into a new private _emit_downgrade_success helper invoked once the in-plan, override-enabled downgrade has been confirmed. The helper bundles the (current_index, missing_index, missing_canonical_name) triple returned by _validate_dependency_placement as a single placement argument, matching this module's parameter-object convention and keeping the call surface within the four-argument ruff and five-argument Pylint limits without suppression. Behaviour is unchanged: the metric labels and all three log payloads are byte-identical, and the existing publish_index_check tests pass without modification. Co-Authored-By: Claude Opus 4.8 (1M context) --- lading/commands/publish_index_check.py | 81 ++++++++++++++++---------- 1 file changed, 51 insertions(+), 30 deletions(-) diff --git a/lading/commands/publish_index_check.py b/lading/commands/publish_index_check.py index 5cd29400..ca7c2b46 100644 --- a/lading/commands/publish_index_check.py +++ b/lading/commands/publish_index_check.py @@ -278,6 +278,51 @@ def _validate_dependency_placement( return current_index, missing_index, missing_canonical_name +def _emit_downgrade_success( + handling: _IndexMissingVersionHandling, + failure: CargoIndexLookupFailure, + *, + missing_name: str, + placement: tuple[int, int, str], +) -> 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 ``_validate_dependency_placement`` result + ``(current_index, missing_index, missing_canonical_name)``. + """ + current_index, missing_index, missing_canonical_name = 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, + 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, + ) + + def _handle_index_missing_version( failure: CargoIndexLookupFailure, *, @@ -307,9 +352,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 " @@ -326,31 +369,9 @@ def _handle_index_missing_version( context, missing_name=missing_name ) - 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, - 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, ) From f994311d89f5ae556540ac80bd668c4395990fec Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 10 Jun 2026 21:08:04 +0200 Subject: [PATCH 03/17] Make metrics atexit flush explicit and broaden test coverage Address review feedback on the publish index-lookup downgrade metric. Unit Architecture (error): the metrics summary flush was registered with atexit as an import-time side effect of lading.utils.metrics, making the exit-time behaviour invisible and non-explicit. Move registration into an idempotent register_summary_atexit() helper and call it from the CLI bootstrap (main), so the lifecycle is an explicit decision. Convert the _CounterKey alias to a PEP 695 `type` statement. User-facing documentation: document the "lading metrics summary" JSON INFO line operators may see at exit, and the publish.index_lookup_downgrade counter and its labels, in docs/users-guide.md. Testing (compile-time/UI): snapshot the rendered emit_summary payload with syrupy instead of hand-asserting the parsed structure, locking the exact output format including counter ordering and label-key normalisation. Testing (property/proof): add Hypothesis property tests for the accumulator covering label-order invariance over arbitrary permutations, accumulation over random increment sequences, and snapshot copy isolation. Testing (unit/behavioural): add an end-to-end test driving publish.run() through the real index-failure parsing and downgrade path, then asserting emit_summary() renders the counter. Also unit-test the new idempotent atexit registration. The accumulator framework is retained deliberately: the documented backend decision (issue #68) treats the in-process registry as the operational boundary and the deterministic test seam these tests rely on. Moving the atexit hook into explicit bootstrap resolves the hidden-lifecycle concern without collapsing that seam. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/users-guide.md | 18 ++++ lading/cli.py | 6 +- lading/utils/metrics.py | 18 +++- tests/unit/publish/test_downgrade_metrics.py | 67 ++++++++++++ .../utils/__snapshots__/test_metrics.ambr | 4 + tests/unit/utils/test_metrics.py | 42 ++++++-- tests/unit/utils/test_metrics_properties.py | 101 ++++++++++++++++++ 7 files changed, 246 insertions(+), 10 deletions(-) create mode 100644 tests/unit/utils/__snapshots__/test_metrics.ambr create mode 100644 tests/unit/utils/test_metrics_properties.py diff --git a/docs/users-guide.md b/docs/users-guide.md index 42183edf..d6ef1e91 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -220,6 +220,24 @@ 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. +##### Metrics summary at exit + +When a run downgrades one or more index-lookup failures in this way, `lading` +emits a single structured summary line just before the process exits, at `INFO` +level, so you can confirm how often a release relied on the override: + +```text +INFO: lading metrics summary: [{"metric": "publish.index_lookup_downgrade", "labels": {"missing_crate": "alpha", "subcommand": "package"}, "value": 1}] +``` + +The payload is a JSON array of counters; each entry records the metric name, +its label values, and how many times it fired during the run. The +`publish.index_lookup_downgrade` counter is labelled with the cargo `subcommand` +(`package` or `publish`) and the `missing_crate` that was not yet on the +index. Runs that downgrade nothing emit no summary line, so quiet runs stay +quiet. The line is informational only — it does not change the command's exit +status. + ## Configuration reference (`lading.toml`) `lading` looks for `lading.toml` in the workspace root. The file must be a TOML 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/utils/metrics.py b/lading/utils/metrics.py index 4f58eb3d..66541412 100644 --- a/lading/utils/metrics.py +++ b/lading/utils/metrics.py @@ -29,8 +29,9 @@ _LOGGER = logging.getLogger(__name__) _LOCK = threading.Lock() -_CounterKey = tuple[str, tuple[tuple[str, str], ...]] +type _CounterKey = tuple[str, tuple[tuple[str, str], ...]] _COUNTERS: collections.Counter[_CounterKey] = collections.Counter() +_summary_hook_registered = threading.Event() def _counter_key(name: str, labels: dict[str, str]) -> _CounterKey: @@ -82,12 +83,25 @@ def emit_summary() -> None: _LOGGER.info("lading metrics summary: %s", json.dumps(rendered)) -atexit.register(emit_summary) +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. + """ + if _summary_hook_registered.is_set(): + return + _summary_hook_registered.set() + atexit.register(emit_summary) + __all__ = [ "counter_value", "emit_summary", "increment_counter", + "register_summary_atexit", "reset", "snapshot", ] diff --git a/tests/unit/publish/test_downgrade_metrics.py b/tests/unit/publish/test_downgrade_metrics.py index 941f4daa..02e100d0 100644 --- a/tests/unit/publish/test_downgrade_metrics.py +++ b/tests/unit/publish/test_downgrade_metrics.py @@ -3,6 +3,7 @@ from __future__ import annotations import collections.abc as cabc +import json import logging import typing as typ @@ -105,3 +106,69 @@ def test_raise_paths_do_not_increment_counter(tmp_path: Path) -> None: ) 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/utils/__snapshots__/test_metrics.ambr b/tests/unit/utils/__snapshots__/test_metrics.ambr new file mode 100644 index 00000000..f9b620f1 --- /dev/null +++ b/tests/unit/utils/__snapshots__/test_metrics.ambr @@ -0,0 +1,4 @@ +# 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}]' +# --- diff --git a/tests/unit/utils/test_metrics.py b/tests/unit/utils/test_metrics.py index 94c15e03..f23a977e 100644 --- a/tests/unit/utils/test_metrics.py +++ b/tests/unit/utils/test_metrics.py @@ -3,8 +3,8 @@ from __future__ import annotations import collections.abc as cabc -import json import logging +import threading import typing as typ import pytest @@ -12,6 +12,8 @@ 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 @@ -58,21 +60,28 @@ def test_snapshot_and_reset() -> None: def test_emit_summary_logs_structured_payload( caplog: LogCaptureFixture, + snapshot: SnapshotAssertion, ) -> None: - """The summary line carries a JSON payload of every counter.""" + """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 for record in caplog.records if "metrics summary" in record.getMessage() + record.getMessage() + for record in caplog.records + if "metrics summary" in record.getMessage() ] assert len(summaries) == 1 - payload = json.loads(summaries[0].getMessage().partition(": ")[2]) - assert payload == [ - {"metric": "demo.total", "labels": {"subcommand": "package"}, "value": 1} - ] + assert summaries[0] == snapshot def test_emit_summary_is_silent_without_metrics( @@ -84,3 +93,22 @@ def test_emit_summary_is_silent_without_metrics( metrics.emit_summary() assert not caplog.records + + +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] 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 From 0ef773697b16cb0a1023115252758953928d78a1 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 11 Jun 2026 00:24:58 +0200 Subject: [PATCH 04/17] Add Hypothesis property tests for the metrics accumulator Express the accumulator's algebraic invariants as Hypothesis properties in test_metrics.py: - test_label_order_invariance: counter_value is invariant under any permutation of the label kwargs. - test_accumulation_sums_increments: a counter equals the sum of its increment amounts. - test_snapshot_is_immutable_copy: mutating the registry after snapshot() does not disturb the captured copy. Adaptations from the requested draft, required to keep the lint and test gates green: - Draw permutations via Hypothesis (st.permutations) instead of random.shuffle, which trips ruff S311 and is non-reproducible. - Use operator.itemgetter(0) for unique_by (ruff FURB118). - Exclude the reserved label keys "name" and "amount", which collide with increment_counter's own parameters and would otherwise crash the generated examples. - Suppress the function_scoped_fixture health check, since the module's autouse reset fixture is function-scoped while each property resets the registry per example. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/unit/utils/test_metrics.py | 66 ++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/unit/utils/test_metrics.py b/tests/unit/utils/test_metrics.py index f23a977e..76b5319a 100644 --- a/tests/unit/utils/test_metrics.py +++ b/tests/unit/utils/test_metrics.py @@ -4,13 +4,23 @@ import collections.abc as cabc import logging +import operator import threading import typing as typ import pytest +from hypothesis import HealthCheck, 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"}) +_PROP_LABEL_KEY = st.text(min_size=1, max_size=20).filter( + lambda key: key not in _RESERVED_LABEL_KEYS +) + if typ.TYPE_CHECKING: from syrupy.assertion import SnapshotAssertion @@ -58,6 +68,62 @@ def test_snapshot_and_reset() -> None: assert captured == {("demo.total", ()): 1} +@given( + pairs=st.lists( + st.tuples(_PROP_LABEL_KEY, st.text(max_size=20)), + min_size=1, + max_size=8, + unique_by=operator.itemgetter(0), + ), + data=st.data(), +) +@settings( + max_examples=200, + suppress_health_check=[HealthCheck.function_scoped_fixture], +) +def test_label_order_invariance( + pairs: list[tuple[str, str]], data: st.DataObject +) -> None: + """counter_value is invariant under any permutation of label kwargs.""" + metrics.reset() + metrics.increment_counter("prop.order", **dict(pairs)) + + # Read back with the labels supplied in a different (drawn) order. + permuted = data.draw(st.permutations(pairs)) + assert metrics.counter_value("prop.order", **dict(permuted)) == 1 + + +@given( + increments=st.lists( + st.integers(min_value=1, max_value=100), min_size=1, max_size=50 + ) +) +@settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) +def test_accumulation_sums_increments(increments: list[int]) -> None: + """Counter value equals the sum of all increment amounts.""" + metrics.reset() + for amount in increments: + metrics.increment_counter("prop.sum", amount=amount, label="x") + assert metrics.counter_value("prop.sum", label="x") == sum(increments) + + +@given(st.integers(min_value=0, max_value=10)) +@settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) +def test_snapshot_is_immutable_copy(n: int) -> None: + """Mutations after snapshot() do not affect the captured copy.""" + metrics.reset() + for i in range(n): + metrics.increment_counter("prop.snap", i=str(i)) + captured = metrics.snapshot() + before = dict(captured) + + # Mutate the live registry. + metrics.increment_counter("prop.snap.extra") + metrics.reset() + + assert captured == before + + def test_emit_summary_logs_structured_payload( caplog: LogCaptureFixture, snapshot: SnapshotAssertion, From 2547eafc589b741edfe4bd5d2f6318d4d84a5aad Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 11 Jun 2026 00:30:53 +0200 Subject: [PATCH 05/17] Document metrics output under a users-guide Observability section Add a dedicated "Observability" reference subsection to the configuration reference in docs/users-guide.md, covering the structured JSON metrics summary emitted at INFO before process exit and documenting the publish.index_lookup_downgrade counter and its subcommand/missing_crate labels. Consolidate the earlier inline "Metrics summary at exit" note under the --allow-unpublished-workspace-deps section into a one-line cross-reference to the new Observability section, so the format and label reference live in a single canonical place rather than being duplicated. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/users-guide.md | 42 ++++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/docs/users-guide.md b/docs/users-guide.md index d6ef1e91..3b2a74ff 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -219,24 +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. - -##### Metrics summary at exit - -When a run downgrades one or more index-lookup failures in this way, `lading` -emits a single structured summary line just before the process exits, at `INFO` -level, so you can confirm how often a release relied on the override: - -```text -INFO: lading metrics summary: [{"metric": "publish.index_lookup_downgrade", "labels": {"missing_crate": "alpha", "subcommand": "package"}, "value": 1}] -``` - -The payload is a JSON array of counters; each entry records the metric name, -its label values, and how many times it fired during the run. The -`publish.index_lookup_downgrade` counter is labelled with the cargo `subcommand` -(`package` or `publish`) and the `missing_crate` that was not yet on the -index. Runs that downgrade nothing emit no summary line, so quiet runs stay -quiet. The line is informational only — it does not change the command's exit -status. +Each such downgrade is counted and surfaced in the metrics summary emitted at +exit; see [Observability](#observability). ## Configuration reference (`lading.toml`) @@ -309,6 +293,28 @@ 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 publish` or `lading package` runs, a structured JSON summary may +appear in the log output at `INFO` level just before the process exits: + +```text +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 once per crate each time 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 From cdcacf9af172fc35daf5f660342a6d90363dd9b3 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 11 Jun 2026 14:30:46 +0200 Subject: [PATCH 06/17] Fix developers-guide merge artifacts from rebase The rebase onto main left docs/developers-guide.md with a duplicated "Workspace path normalization" section and two double-blank-line runs, which tripped markdownlint MD024 (duplicate heading) and MD012 (multiple consecutive blank lines). Remove the duplicate section and collapse the stray blank lines. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/developers-guide.md | 49 ++++++++-------------------------------- 1 file changed, 10 insertions(+), 39 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 2d63b39a..6ff596ed 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -39,15 +39,15 @@ 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: @@ -184,34 +184,6 @@ exactly one place. ## Workspace discovery helpers - -### Workspace path normalization (`lading/utils/path.py`) - -`normalise_workspace_root(value)` is the shared helper that turns a -user-supplied workspace root into a canonical `Path`. Import it from -`lading.utils`: - -```python -from lading.utils import normalise_workspace_root - -normalise_workspace_root("~/workspace") # -> absolute, ~ expanded -normalise_workspace_root(None) # -> Path.cwd().resolve() -``` - -It accepts `Path`, `str`, or `None`. `None` selects the resolved current -working directory; any other value is expanded with `Path.expanduser` and -resolved with `Path.resolve(strict=False)`, so `~` is expanded, redundant -separators and `.`/`..` segments collapse, and the result is always absolute. -`strict=False` means a non-existent target is permitted rather than raising. - -The implementation is pure `pathlib` -(`Path(value).expanduser().resolve(strict=False)`); it no longer round-trips -through `plumbum`'s `local.path`. `plumbum` is therefore a dev-only dependency, -used solely by the end-to-end test helpers. Callers across the codebase rely on -this helper for consistent path handling, including `lading.cli`, -`lading.config`, `lading.workspace.metadata.load_cargo_metadata`, -`lading.commands.bump`, and `lading.commands.publish`. - ### Workspace path normalization (`lading/utils/path.py`) `normalise_workspace_root(value)` is the shared helper that turns a @@ -531,8 +503,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. @@ -638,7 +610,6 @@ 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 From 9ced3a82b266216bfa8a8166723b6ca76903b2cb Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 11 Jun 2026 14:37:06 +0200 Subject: [PATCH 07/17] Address review feedback on downgrade metric docs and tests Four review findings, all verified against current code: - docs/users-guide.md: the Observability section referenced a non-existent `lading package` command. The CLI exposes only `bump` and `publish` (cargo package/publish run under `lading publish`), so describe the summary as emitted by `lading publish`. - publish_index_check.py: replace the raw `tuple[int, int, str]` placement contract with a `_DependencyPlacement` NamedTuple (current_index/missing_index/missing_canonical_name). _emit_downgrade_success now reads named fields and _validate_dependency_placement returns the NamedTuple; _IndexMissingVersionHandling and CargoIndexLookupFailure are unchanged. A NamedTuple subclasses tuple, so the contract is explicit without breaking any unpacking. - test_downgrade_metrics.py: drop the brittle metrics.snapshot() internal-key assertion from test_downgrade_path_increments_counter; the public counter_value check covers the behaviour and label normalisation is exercised by the metrics property tests. - test_downgrade_metrics.py: parametrise test_raise_paths_do_not_increment_counter into flag_disabled and out_of_plan cases. The boolean parameter is keyword-only (FBT001-clean; pytest fills test parameters by keyword). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/users-guide.md | 4 +- lading/commands/publish_index_check.py | 41 ++++++++++++++------ tests/unit/publish/test_downgrade_metrics.py | 36 ++++++++++------- 3 files changed, 53 insertions(+), 28 deletions(-) diff --git a/docs/users-guide.md b/docs/users-guide.md index 3b2a74ff..10c29b9c 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -295,8 +295,8 @@ or run the relevant Cargo command yourself before committing the bump. ### Observability -When `lading publish` or `lading package` runs, a structured JSON summary may -appear in the log output at `INFO` level just before the process exits: +When `lading publish` runs, a structured JSON summary may appear in the log +output at `INFO` level just before the process exits: ```text lading metrics summary: [{"metric": "publish.index_lookup_downgrade", "labels": {"missing_crate": "...", "subcommand": "..."}, "value": 1}] diff --git a/lading/commands/publish_index_check.py b/lading/commands/publish_index_check.py index ca7c2b46..32e170eb 100644 --- a/lading/commands/publish_index_check.py +++ b/lading/commands/publish_index_check.py @@ -229,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 @@ -275,7 +293,7 @@ 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( @@ -283,16 +301,15 @@ def _emit_downgrade_success( failure: CargoIndexLookupFailure, *, missing_name: str, - placement: tuple[int, int, 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 ``_validate_dependency_placement`` result - ``(current_index, missing_index, missing_canonical_name)``. + ``placement`` is the :class:`_DependencyPlacement` resolved by + ``_validate_dependency_placement``. """ - current_index, missing_index, missing_canonical_name = placement metrics.increment_counter( INDEX_LOOKUP_DOWNGRADE_METRIC, subcommand=failure.subcommand, @@ -309,7 +326,7 @@ def _emit_downgrade_success( handling.logger.debug( "canonicalised dependency name %r -> %r", missing_name, - missing_canonical_name, + placement.missing_canonical_name, ) handling.logger.info( "Downgraded cargo %s failure for crate %s (index %d) because " @@ -317,9 +334,9 @@ def _emit_downgrade_success( "unpublished workspace dependency override is enabled", failure.subcommand, failure.crate_name, - current_index, + placement.current_index, missing_name, - missing_index, + placement.missing_index, ) diff --git a/tests/unit/publish/test_downgrade_metrics.py b/tests/unit/publish/test_downgrade_metrics.py index 02e100d0..b508d6dc 100644 --- a/tests/unit/publish/test_downgrade_metrics.py +++ b/tests/unit/publish/test_downgrade_metrics.py @@ -83,26 +83,34 @@ def test_downgrade_path_increments_counter( assert ( metrics.counter_value(_METRIC, subcommand="package", missing_crate="alpha") == 1 ) - assert metrics.snapshot() == { - (_METRIC, (("missing_crate", "alpha"), ("subcommand", "package"))): 1 - } -def test_raise_paths_do_not_increment_counter(tmp_path: Path) -> None: +@pytest.mark.parametrize( + ("stderr", "missing_crate", "allow_unpublished_workspace_deps"), + [ + pytest.param(INDEX_MISSING_STDERR_BETA, "alpha", False, id="flag_disabled"), + pytest.param( + INDEX_MISSING_STDERR_EXTERNAL, + "external_crate", + True, + 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, +) -> None: """Neither the flag-disabled nor the out-of-plan path counts a downgrade.""" with pytest.raises(publish.PublishPreflightError): _invoke_handler( tmp_path, - stderr=INDEX_MISSING_STDERR_BETA, - missing_crate="alpha", - allow_unpublished_workspace_deps=False, - ) - with pytest.raises(publish.PublishPreflightError): - _invoke_handler( - tmp_path, - stderr=INDEX_MISSING_STDERR_EXTERNAL, - missing_crate="external_crate", - allow_unpublished_workspace_deps=True, + stderr=stderr, + missing_crate=missing_crate, + allow_unpublished_workspace_deps=allow_unpublished_workspace_deps, ) assert metrics.snapshot() == {} From cfacc5cfe2dade5ce40a1dda911b0eeb35d87afa Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 11 Jun 2026 16:46:06 +0200 Subject: [PATCH 08/17] Close TOCTOU race in register_summary_atexit The double-registration guard read _summary_hook_registered.is_set() and called .set() as two separate steps, so two concurrent callers could both observe the event unset and each register emit_summary with atexit. Wrap the check-and-set together under _LOCK so the guard is atomic; atexit.register stays outside the lock (it does not re-enter _LOCK, so there is no deadlock). Behaviour is unchanged for the single-threaded bootstrap path: registration remains idempotent and emit_summary is registered at most once. Co-Authored-By: Claude Opus 4.8 (1M context) --- lading/utils/metrics.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lading/utils/metrics.py b/lading/utils/metrics.py index 66541412..6041faa0 100644 --- a/lading/utils/metrics.py +++ b/lading/utils/metrics.py @@ -91,9 +91,10 @@ def register_summary_atexit() -> None: side effect of importing this module. Repeated calls register the hook at most once. """ - if _summary_hook_registered.is_set(): - return - _summary_hook_registered.set() + with _LOCK: + if _summary_hook_registered.is_set(): + return + _summary_hook_registered.set() atexit.register(emit_summary) From 3e9ae95b54b724951b9aaf31011ef31f5ed3ac70 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 12 Jun 2026 14:44:59 +0200 Subject: [PATCH 09/17] Tighten atexit registration locking and metric docs Three review findings, all verified against current code: - metrics.py: move atexit.register(emit_summary) inside the _LOCK critical section in register_summary_atexit. With it outside, a second thread could observe the flag already set and return before the first thread finished registering, so registration was not atomic with the flag flip. atexit.register does not re-enter _LOCK (it only stores the callable; emit_summary acquires the lock later, at exit), so holding the lock here cannot deadlock. - users-guide.md: the metrics summary example used a ```text fence; the documentation style guide mandates `plaintext` for non-code text. - users-guide.md: the publish.index_lookup_downgrade counter increments on each downgrade event, not once per crate (a single crate can be downgraded in both the package and publish phases), so reword "once per crate" to "on each downgrade event". Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/users-guide.md | 4 ++-- lading/utils/metrics.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/users-guide.md b/docs/users-guide.md index 10c29b9c..00bbfceb 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -298,7 +298,7 @@ or run the relevant Cargo command yourself before committing the bump. When `lading publish` runs, a structured JSON summary may appear in the log output at `INFO` level just before the process exits: -```text +```plaintext lading metrics summary: [{"metric": "publish.index_lookup_downgrade", "labels": {"missing_crate": "...", "subcommand": "..."}, "value": 1}] ``` @@ -308,7 +308,7 @@ entirely when no metrics were recorded (quiet runs stay quiet). #### `publish.index_lookup_downgrade` -Incremented once per crate each time a crates.io index-lookup failure for a +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: diff --git a/lading/utils/metrics.py b/lading/utils/metrics.py index 6041faa0..59c88af0 100644 --- a/lading/utils/metrics.py +++ b/lading/utils/metrics.py @@ -95,7 +95,7 @@ def register_summary_atexit() -> None: if _summary_hook_registered.is_set(): return _summary_hook_registered.set() - atexit.register(emit_summary) + atexit.register(emit_summary) __all__ = [ From da1b2e47cd8b8cd3bcf1af431a6bc8d79cdb9983 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sun, 14 Jun 2026 21:18:04 +0100 Subject: [PATCH 10/17] Instrument lockfile discovery, refresh, and validation (#91) (#134) * Instrument lockfile discovery, refresh, and validation lockfile.py logged decisions but offered no structured metrics, so operators could not track lockfile throughput or error trends. Extend lading.utils.metrics with duration aggregation (observe_duration / duration_stats; count and total seconds per label set, rendered in the exit summary) and instrument the lockfile boundary: - lockfile.discovered counts tracked lockfiles found per discovery - lockfile.refresh counts each refresh_lockfile invocation with an outcome label (success/failure); lockfile.refresh.duration times the cargo generate-lockfile call - lockfile.validate counts each freshness probe with an outcome label (fresh/stale/failed); lockfile.validate.duration times the cargo metadata --locked call Metric names and labels are documented in the developers' guide. Builds on the metrics accumulator introduced for issue #68. Closes #91 * Pin ruff to 0.15.12 across CI, lockfile, and dev PATH The lint gate obtained ruff two ways: `uv tool install ruff` (latest) and the unpinned dev dependency, which uv.lock resolved to 0.13.3 and `uv sync` placed in the virtualenv. `make lint` runs bare `ruff`, which in CI resolved to the virtualenv's 0.13.3 while developers ran 0.15.12 from PATH. The preview-only rule RUF076 (autouse-fixture warning) exists in 0.13.3 but not 0.15.12, so CI failed on legitimate state-isolation fixtures that lint clean locally. Pin ruff to 0.15.12 in both the dev dependency group and the CI uv-tool install so every environment runs the identical linter, and regenerate uv.lock to match. The autouse fixtures are intentional per-test registry/env isolation and are left unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 6 ++- docs/developers-guide.md | 9 ++++ lading/commands/lockfile.py | 17 ++++++ lading/utils/metrics.py | 53 ++++++++++++++++++- pyproject.toml | 7 ++- tests/unit/test_lockfile.py | 89 ++++++++++++++++++++++++++++++++ tests/unit/utils/test_metrics.py | 30 +++++++++++ uv.lock | 45 ++++++++-------- 8 files changed, 229 insertions(+), 27 deletions(-) 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]] From 08ec163076ff7e7c1276f997114c6df36124266f Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 14 Jun 2026 22:21:50 +0200 Subject: [PATCH 11/17] Pin Ruff to 0.15.12 in the Makefile and CI The Makefile invoked a bare `ruff` from PATH and CI installed it unpinned (`uv tool install ruff`), so the Ruff version used for formatting and linting drifted with whatever happened to be installed. Pin it to 0.15.12 in both places: - Makefile: add a RUFF_VERSION variable and resolve Ruff through `uv tool run --from ruff==$(RUFF_VERSION) ruff`, mirroring how PYLINT is pinned via PYLINT_PYPY_SHIM_REF. The fmt, check-fmt, and lint recipes now run the pinned Ruff, and `ruff` is dropped from the PATH-tool existence checks (it is resolved through uv instead). - CI: install `ruff==0.15.12` rather than the latest release. mbake validates the Makefile; `make check-fmt` and `make lint` pass with the pinned 0.15.12 toolchain. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index 68ff7a83..706126a3 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,9 @@ 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) +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 +61,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) From dca9f406007b54f9a88b5fbab959236340f8cad5 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 14 Jun 2026 22:57:24 +0200 Subject: [PATCH 12/17] Record metrics backend in an ADR and harden atexit registration Address three review findings: - Developer documentation: add ADR-004 recording the system-design decision for the in-process lading.utils.metrics backend (atexit flush, explicit bootstrap registration, registry-as-test-seam) and the publish.index_lookup_downgrade metric contract. Index it in docs/contents.md and link it from the developers-guide metrics section. - Concurrency / atexit ordering: register_summary_atexit already performed the check-and-register under _LOCK (closing the interleaving the review described as a register-outside-the-lock window), but it set the guard flag before calling atexit.register. Reorder so the flag is set only after a successful atexit.register, leaving a failed registration unset and retryable. Add a failure-path test (register raises -> flag unset -> later call registers) and a concurrency test (8 threads released on a barrier register exactly once). - Observability cardinality: the missing_crate label is kept verbatim rather than bucketed/capped. ADR-004 records why: the accumulator is process-local and flushed once per run, so cardinality is bounded by the crates in that run (not an unbounded external key space), and the crate name is the actionable detail an operator needs. Bucketing would be revisited only if the metrics are ever exported to a long-lived time-series backend. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/adr/004-in-process-metrics-backend.md | 63 ++++++++++++++++++++++ docs/contents.md | 3 ++ docs/developers-guide.md | 3 ++ lading/utils/metrics.py | 7 ++- tests/unit/utils/test_metrics.py | 54 +++++++++++++++++++ 5 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 docs/adr/004-in-process-metrics-backend.md 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..41e3ab4a --- /dev/null +++ b/docs/adr/004-in-process-metrics-backend.md @@ -0,0 +1,63 @@ +# ADR-004: In-process metrics accumulator flushed at exit + +## Status + +Accepted. + +## Context + +`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 1ae89949..ded757b3 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -620,6 +620,9 @@ 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`, `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: diff --git a/lading/utils/metrics.py b/lading/utils/metrics.py index d4292454..84d03854 100644 --- a/lading/utils/metrics.py +++ b/lading/utils/metrics.py @@ -136,12 +136,17 @@ def register_summary_atexit() -> None: 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 - _summary_hook_registered.set() atexit.register(emit_summary) + _summary_hook_registered.set() __all__ = [ diff --git a/tests/unit/utils/test_metrics.py b/tests/unit/utils/test_metrics.py index 05b32161..df00c4b9 100644 --- a/tests/unit/utils/test_metrics.py +++ b/tests/unit/utils/test_metrics.py @@ -208,3 +208,57 @@ def test_register_summary_atexit_registers_once( 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] From 2095e2051bdd74722bd90bccc4985354db13a2a4 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 14 Jun 2026 23:37:18 +0200 Subject: [PATCH 13/17] Address metrics review: guard zero-count, harden tests, dedup properties - users-guide.md: the Observability lead-in scoped the metrics summary to `lading publish`, but register_summary_atexit is wired from CLI bootstrap and flushes process-wide for any command. Reword to "When `lading` runs" and describe a process-wide flush, keeping publish.index_lookup_downgrade as a concrete example. - lockfile.py: guard the discovered-lockfiles counter with `if lockfiles:`. A zero-amount increment created a 0-valued counter key, which made an otherwise silent run emit an exit summary; quiet runs now stay quiet. Add an assertion to test_discover_tracked_lockfiles_returns_empty_result confirming no metric is recorded on empty discovery. - test_downgrade_metrics.py: add `match=` patterns to the parametrised pytest.raises so the flag-disabled and out-of-plan paths assert their distinct error messages, not just the exception type. - test_metrics.py: remove the three @given properties duplicated from the canonical suite in test_metrics_properties.py (label-order invariance, accumulation, snapshot isolation); example-based coverage of these invariants remains in this file. Drop the now-unused hypothesis/operator imports. - test_metrics.py: add a syrupy snapshot test covering counters and duration aggregates together, locking the combined exit-summary serialisation. The "register under concurrency with ThreadPoolExecutor" suggestion is already covered by test_register_summary_atexit_registers_once_under_concurrency, which releases eight threads on a barrier and asserts a single registration; not duplicated. missing_crate cardinality is intentionally left verbatim per ADR-004. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/users-guide.md | 6 +- lading/commands/lockfile.py | 5 +- tests/unit/publish/test_downgrade_metrics.py | 14 ++- tests/unit/test_lockfile.py | 6 +- .../utils/__snapshots__/test_metrics.ambr | 3 + tests/unit/utils/test_metrics.py | 91 +++++-------------- 6 files changed, 52 insertions(+), 73 deletions(-) diff --git a/docs/users-guide.md b/docs/users-guide.md index 00bbfceb..04f2d26c 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -295,8 +295,10 @@ or run the relevant Cargo command yourself before committing the bump. ### Observability -When `lading publish` runs, a structured JSON summary may appear in the log -output at `INFO` level just before the process exits: +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}] diff --git a/lading/commands/lockfile.py b/lading/commands/lockfile.py index 9e049681..734d0513 100644 --- a/lading/commands/lockfile.py +++ b/lading/commands/lockfile.py @@ -162,7 +162,10 @@ 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)) + 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), diff --git a/tests/unit/publish/test_downgrade_metrics.py b/tests/unit/publish/test_downgrade_metrics.py index b508d6dc..4125cb82 100644 --- a/tests/unit/publish/test_downgrade_metrics.py +++ b/tests/unit/publish/test_downgrade_metrics.py @@ -86,13 +86,20 @@ def test_downgrade_path_increments_counter( @pytest.mark.parametrize( - ("stderr", "missing_crate", "allow_unpublished_workspace_deps"), + ("stderr", "missing_crate", "allow_unpublished_workspace_deps", "match"), [ - pytest.param(INDEX_MISSING_STDERR_BETA, "alpha", False, id="flag_disabled"), + 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", ), ], @@ -103,9 +110,10 @@ def test_raise_paths_do_not_increment_counter( 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): + with pytest.raises(publish.PublishPreflightError, match=match): _invoke_handler( tmp_path, stderr=stderr, diff --git a/tests/unit/test_lockfile.py b/tests/unit/test_lockfile.py index 5442f858..49ae4199 100644 --- a/tests/unit/test_lockfile.py +++ b/tests/unit/test_lockfile.py @@ -38,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( @@ -58,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: diff --git a/tests/unit/utils/__snapshots__/test_metrics.ambr b/tests/unit/utils/__snapshots__/test_metrics.ambr index f9b620f1..bd03bf2c 100644 --- a/tests/unit/utils/__snapshots__/test_metrics.ambr +++ b/tests/unit/utils/__snapshots__/test_metrics.ambr @@ -2,3 +2,6 @@ # 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 index df00c4b9..1167631d 100644 --- a/tests/unit/utils/test_metrics.py +++ b/tests/unit/utils/test_metrics.py @@ -5,23 +5,13 @@ import collections.abc as cabc import json import logging -import operator import threading import typing as typ import pytest -from hypothesis import HealthCheck, 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"}) -_PROP_LABEL_KEY = st.text(min_size=1, max_size=20).filter( - lambda key: key not in _RESERVED_LABEL_KEYS -) - if typ.TYPE_CHECKING: from syrupy.assertion import SnapshotAssertion @@ -69,62 +59,6 @@ def test_snapshot_and_reset() -> None: assert captured == {("demo.total", ()): 1} -@given( - pairs=st.lists( - st.tuples(_PROP_LABEL_KEY, st.text(max_size=20)), - min_size=1, - max_size=8, - unique_by=operator.itemgetter(0), - ), - data=st.data(), -) -@settings( - max_examples=200, - suppress_health_check=[HealthCheck.function_scoped_fixture], -) -def test_label_order_invariance( - pairs: list[tuple[str, str]], data: st.DataObject -) -> None: - """counter_value is invariant under any permutation of label kwargs.""" - metrics.reset() - metrics.increment_counter("prop.order", **dict(pairs)) - - # Read back with the labels supplied in a different (drawn) order. - permuted = data.draw(st.permutations(pairs)) - assert metrics.counter_value("prop.order", **dict(permuted)) == 1 - - -@given( - increments=st.lists( - st.integers(min_value=1, max_value=100), min_size=1, max_size=50 - ) -) -@settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) -def test_accumulation_sums_increments(increments: list[int]) -> None: - """Counter value equals the sum of all increment amounts.""" - metrics.reset() - for amount in increments: - metrics.increment_counter("prop.sum", amount=amount, label="x") - assert metrics.counter_value("prop.sum", label="x") == sum(increments) - - -@given(st.integers(min_value=0, max_value=10)) -@settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) -def test_snapshot_is_immutable_copy(n: int) -> None: - """Mutations after snapshot() do not affect the captured copy.""" - metrics.reset() - for i in range(n): - metrics.increment_counter("prop.snap", i=str(i)) - captured = metrics.snapshot() - before = dict(captured) - - # Mutate the live registry. - metrics.increment_counter("prop.snap.extra") - metrics.reset() - - assert captured == before - - def test_emit_summary_logs_structured_payload( caplog: LogCaptureFixture, snapshot: SnapshotAssertion, @@ -191,6 +125,31 @@ def test_emit_summary_includes_durations(caplog: LogCaptureFixture) -> None: ] +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: From 96985c4a0aeeaba52b38d646f594d5d888e5a883 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 14 Jun 2026 23:56:14 +0200 Subject: [PATCH 14/17] Align ADR-004 with the ADR template and document the Ruff pin Address documentation review findings (all verified against the current code): - docs/adr/004-in-process-metrics-backend.md: the ADR template in documentation-style-guide.md requires sections in the order Status, Date (YYYY-MM-DD), Context and problem statement. Add the missing `## Date` (2026-06-14, the ADR's creation date) and rename `## Context` to `## Context and problem statement`. - docs/developers-guide.md: add `duration_stats` to the lading.utils.metrics test-seam list (it is a public, query-only function in __all__ alongside counter_value, snapshot, and reset). - Makefile: add a comment above RUFF_VERSION explaining that the pin must stay in sync with the ruff== dev dependency in pyproject.toml and the uv tool install ruff== step in CI, and that all three must be bumped together to avoid version-skew lint failures. Skipped: the same Date/heading deviations exist in ADR-003 but are pre-existing and outside this review's scope, so ADR-003 is left unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 5 +++++ docs/adr/004-in-process-metrics-backend.md | 6 +++++- docs/developers-guide.md | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 706126a3..d98c91f6 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,11 @@ 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") +# 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) diff --git a/docs/adr/004-in-process-metrics-backend.md b/docs/adr/004-in-process-metrics-backend.md index 41e3ab4a..2ed67cd4 100644 --- a/docs/adr/004-in-process-metrics-backend.md +++ b/docs/adr/004-in-process-metrics-backend.md @@ -4,7 +4,11 @@ Accepted. -## Context +## 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 diff --git a/docs/developers-guide.md b/docs/developers-guide.md index ded757b3..b505f1e4 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -619,7 +619,7 @@ structured JSON log line at interpreter exit (`emit_summary`, registered via 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`, -`snapshot`, and `reset` as deterministic seams. +`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. From 84c3dcf9984a2cf2ef943985f85c5dfaf0846dcf Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 15 Jun 2026 01:17:41 +0200 Subject: [PATCH 15/17] Guard increment_counter against zero-amount writes Make "quiet runs stay quiet" robust at the source: increment_counter now returns early when amount == 0, so a zero increment never records a 0-valued key (a Counter with a 0-valued entry is truthy, which would otherwise make emit_summary surface a spurious metric). This generalises the call-site guard already present in discover_tracked_lockfiles to every caller. Add a regression test asserting a zero-amount increment records nothing. Other findings in the review were verified and skipped: - users-guide.md `#### publish.index_lookup_downgrade`: already correctly nested one level under `### Observability` (h3 -> h4); markdownlint MD001 passes. Demoting to `###` would make it a sibling of Observability and break the nesting, so it is left as-is. - docs/adr/004: the `## Date` and `## Context and problem statement` headings were already added in a previous commit, so no change is needed. Co-Authored-By: Claude Opus 4.8 (1M context) --- lading/utils/metrics.py | 5 +++++ tests/unit/utils/test_metrics.py | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/lading/utils/metrics.py b/lading/utils/metrics.py index 84d03854..8309c404 100644 --- a/lading/utils/metrics.py +++ b/lading/utils/metrics.py @@ -54,10 +54,15 @@ def _counter_key(name: str, labels: dict[str, str]) -> _CounterKey: 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 diff --git a/tests/unit/utils/test_metrics.py b/tests/unit/utils/test_metrics.py index 1167631d..93e5be60 100644 --- a/tests/unit/utils/test_metrics.py +++ b/tests/unit/utils/test_metrics.py @@ -46,6 +46,14 @@ def test_label_order_does_not_matter() -> None: assert metrics.counter_value("demo.pair", b="2", a="1") == 1 +def test_increment_counter_ignores_zero_amount() -> None: + """A zero-amount increment records nothing, so quiet runs stay quiet.""" + metrics.increment_counter("demo.zero", amount=0, subcommand="package") + + assert metrics.counter_value("demo.zero", subcommand="package") == 0 + assert metrics.snapshot() == {} + + def test_snapshot_and_reset() -> None: """Snapshots copy the registry and reset clears it.""" metrics.increment_counter("demo.total") From a634f9df1f664010e3f0205d9b62bae706663424 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 16 Jun 2026 20:41:26 +0200 Subject: [PATCH 16/17] Add regression tests for the zero-amount increment no-op Lock the "quiet runs stay quiet" invariant for increment_counter(amount=0): - test_zero_amount_increment_is_a_noop: a zero-amount increment creates no registry entry (counter_value is 0, snapshot is empty). - test_zero_amount_increment_does_not_break_quiet_run: emit_summary stays silent when only zero-amount increments occurred. These replace the earlier test_increment_counter_ignores_zero_amount, which asserted the same no-op invariant; consolidating avoids duplicate coverage and keeps the canonical test names and placement requested in review. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/unit/utils/test_metrics.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/tests/unit/utils/test_metrics.py b/tests/unit/utils/test_metrics.py index 93e5be60..b192f31e 100644 --- a/tests/unit/utils/test_metrics.py +++ b/tests/unit/utils/test_metrics.py @@ -39,6 +39,26 @@ def test_increment_accumulates_per_label_set() -> None: 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") @@ -46,14 +66,6 @@ def test_label_order_does_not_matter() -> None: assert metrics.counter_value("demo.pair", b="2", a="1") == 1 -def test_increment_counter_ignores_zero_amount() -> None: - """A zero-amount increment records nothing, so quiet runs stay quiet.""" - metrics.increment_counter("demo.zero", amount=0, subcommand="package") - - assert metrics.counter_value("demo.zero", subcommand="package") == 0 - assert metrics.snapshot() == {} - - def test_snapshot_and_reset() -> None: """Snapshots copy the registry and reset clears it.""" metrics.increment_counter("demo.total") From c638d71e9cd90b92000f14f302fe9c02a9ddb752 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 16 Jun 2026 20:54:18 +0200 Subject: [PATCH 17/17] Document RUFF_VERSION/RUFF in the developers guide The RUFF_VERSION and RUFF Makefile variables that pin Ruff were undocumented in the developers guide, unlike the comparable PYLINT_* variables. Add them to the "relevant Makefile variables" list, mirroring the inline Makefile comment: RUFF_VERSION is the pinned version (kept in sync with the pyproject.toml dev dependency and the CI install, bumped together to avoid version-skew lint failures), and RUFF is the uv-tool-run invocation the fmt, check-fmt, and lint targets use. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/developers-guide.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index b505f1e4..a7bf9c33 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -51,6 +51,13 @@ hygiene, and design-size limits. 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`.