From 2fa8865b1d8ff070742b70d1a4766d96e09a25e2 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 7 Jun 2026 21:34:22 +0200 Subject: [PATCH 01/12] Move cargo index parsing to adapter (#69) Introduce a cargo output adapter that converts raw package and publish diagnostics into structured index lookup failures before the publish domain decides whether to downgrade or raise them. --- docs/developers-guide.md | 109 +++++------ lading/commands/cargo_output_adapter.py | 138 ++++++++++++++ lading/commands/publish.py | 142 +++++++++----- lading/commands/publish_index_check.py | 176 ++++++++---------- .../unit/publish/test_cargo_output_adapter.py | 122 ++++++++++++ tests/unit/publish/test_index_detection.py | 102 ---------- .../publish/test_index_order_properties.py | 26 +-- tests/unit/publish/test_phase_dispatch.py | 25 ++- tests/unit/publish/test_snapshot_messages.py | 35 +++- 9 files changed, 535 insertions(+), 340 deletions(-) create mode 100644 lading/commands/cargo_output_adapter.py create mode 100644 tests/unit/publish/test_cargo_output_adapter.py delete mode 100644 tests/unit/publish/test_index_detection.py diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 2804c67b..76bad949 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -335,27 +335,29 @@ did not match a workspace crate. `plan_publication()` builds that object by filtering non-publishable crates, applying `publish.exclude`, validating `publish.order` when present, or deriving a deterministic dependency order. -`publish_manifest.py` owns staging-time manifest mutations. It contains the -workspace preparation types and helpers that apply the `publish.strip_patches` -strategy to the staged `Cargo.toml`. Workspace README adoption happens during -`lading bump`, so publish staging only copies the source workspace after bump -has prepared crate README files. These operations run before any -`cargo package` or `cargo publish` command, so the command runner works against -a prepared snapshot rather than the source workspace. +`publish_manifest.py` owns staging-time manifest mutations. It contains workspace +preparation types and helpers that copy the workspace tree, stage workspace README +files for crates that opt in, and apply the `publish.strip_patches` strategy to +the staged `Cargo.toml`. These operations run before any `cargo package` or +`cargo publish` command, so the command runner works against a prepared snapshot +rather than the source workspace. `publish_diagnostics.py` owns compiletest failure enrichment. When a cargo pre-flight test failure mentions compiletest-style `*.stderr` artefacts, the -diagnostic helper locates the referenced files, tails a bounded number of -lines, and appends those snippets to the `PublishPreflightError` message. The -module is deliberately read-only: missing artefacts or unreadable files produce -diagnostic notes rather than replacing the original cargo failure. - -`publish_index_check.py` owns crates.io index-lookup classification. It contains -`_CargoInvocation`, the predicates and parsers that recognize Cargo's "no -matching package/version" diagnostics, crate-name canonicalization, and -`_handle_index_missing_version()`. That handler decides whether an index miss -is out-of-plan and fatal, in-plan but out of publish order and fatal, in-plan -but still fatal, or in-plan and downgraded by +diagnostic helper locates the referenced files, tails a bounded number of lines, +and appends those snippets to the `PublishPreflightError` message. The module is +deliberately read-only: missing artefacts or unreadable files produce diagnostic +notes rather than replacing the original cargo failure. + +`cargo_output_adapter.py` owns parsing raw cargo subprocess output into structured +command failures. `CargoIndexLookupFailure` is the value object for crates.io +index lookup failures, and `parse_index_lookup_failure()` is the primary owner of +cargo's marker-based index-miss detection. + +`publish_index_check.py` owns crates.io index-lookup downgrade decisions after +output has crossed that adapter boundary. It receives `CargoIndexLookupFailure` +instances, applies crate-name canonicalization, and decides whether an index miss +is out-of-plan and fatal, in-plan but still fatal, or in-plan and downgraded by `allow_unpublished_workspace_deps` during dry-run publication. ### `_PublishExecutionOptions` @@ -389,11 +391,12 @@ diagnostics, and normalises staging/preparation failures into `PublishPreflightError` so callers receive the same publish command error boundary. -`_handle_publish_result(invocation, crate, plan, options)` owns the result -classification for a completed `cargo publish` command. It logs success, skips -already-published crate versions, delegates in-plan crates.io index visibility -failures to `_handle_index_missing_version`, and raises `PublishError` for all -other non-zero publish exits after formatting the cargo failure message. +`_handle_publish_result(crate, exit_code, stdout, stderr, plan, options)` owns +the result classification for a completed `cargo publish` command. It logs +success, skips already-published crate versions, adapts crates.io index lookup +failures through `parse_index_lookup_failure()` before delegating to +`_handle_index_missing_version`, and raises `PublishError` for all other +non-zero publish exits after formatting the cargo failure message. `_CargoPreflightOptions` lives in `publish_preflight.py` and carries the per-invocation settings for cargo pre-flight commands: extra cargo arguments, @@ -412,40 +415,28 @@ pipeline does not roll back earlier uploads if a later crate fails; reruns rely on the already-published detection path to log and skip versions already visible in the registry. -The index-lookup handling is split across three helpers: - -- `_is_index_missing_version_error(exit_code, stdout, stderr) -> bool` checks - for both Cargo's version-selection failure marker and the crates.io index - marker after confirming the command failed. Requiring both markers minimizes - false positives from unrelated resolver, registry, or command failures. -- `_extract_missing_dependency_name(stdout, stderr) -> str | None` parses the - missing crate name from Cargo's requirement line. The regex accepts Cargo's - backtick, single-quote, and double-quote delimiters around the requirement, - captures the dependency name before `=`, and searches `stderr` before - `stdout` because Cargo normally reports this failure on the error stream. -- `_handle_index_missing_version(_CargoInvocation, *, handling, error_cls)` - applies the decision tree. The `_IndexMissingVersionHandling` context carries - the publish plan, execution options, and logger, making warning and - informational side-effects explicit at the command boundary; the index - checker does not own mutable metric state. The phase-specific `error_cls` - remains visible at the call site where the command phase is known. If name - extraction fails, the original Cargo failure stays 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 then checks projected availability by - comparing the missing dependency's publish-order index with the current - crate's index in `PublishPlan.publishable`. If the dependency is in the plan - but appears later than the current crate, the failure is fatal because a live - run would try to publish the current crate before that dependency is - available. If the parsed name has an earlier index and +The index-lookup handling is split across the adapter and decision helper: + +- `parse_index_lookup_failure(crate_name, subcommand, exit_code, stdout, stderr)` + checks for both Cargo's version-selection failure marker and the crates.io + index marker after confirming the command failed. Requiring both markers + minimizes false positives from unrelated resolver, registry, or command + failures. +- The adapter parses the missing crate name from Cargo's requirement line. The + regex accepts Cargo's backtick, single-quote, and double-quote delimiters + around the requirement, captures the dependency name before `=`, and searches + `stderr` before `stdout` because Cargo normally reports this failure on the + error stream. +- `_handle_index_missing_version(failure, *, handling, error_cls)` applies the + decision tree. If name extraction fails, the original Cargo failure stays + 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 continues; otherwise it raises with guidance to enable the dry-run unpublished workspace dependency override or follow the staged-publish workaround. -- `_raise_out_of_order_dependency(failure, missing_name) -> NoReturn` is the - dedicated fatal path for dependencies that are planned but not projected to - have been published yet. It bypasses the dry-run downgrade setting and tells - operators to fix `publish.order` or rely on the dependency-derived - topological sort. ### Supporting types @@ -497,13 +488,15 @@ type `typ.NoReturn`): #### Crate-name canonicalization `_canonical_crate_name(name)` normalises a crate name by replacing every hyphen -with an underscore. It is applied in `publish_index_check.py` when building -`publishable_name_indexes = _publishable_name_indexes(handling.plan)` and when -looking up both the current crate and a missing dependency name: +with an underscore. It is applied by building a canonical publish-order index and +looking up both the current crate and missing dependency by canonical name: ```python -current_name = _canonical_crate_name(context.invocation.crate_name) -current_index = publishable_name_indexes[current_name] +publishable_name_indexes = { + _canonical_crate_name(entry.name): index + for index, entry in enumerate(handling.plan.publishable) +} +current_index = publishable_name_indexes.get(_canonical_crate_name(context.failure.crate_name)) missing_index = publishable_name_indexes.get(_canonical_crate_name(missing_name)) ``` diff --git a/lading/commands/cargo_output_adapter.py b/lading/commands/cargo_output_adapter.py new file mode 100644 index 00000000..f74c6664 --- /dev/null +++ b/lading/commands/cargo_output_adapter.py @@ -0,0 +1,138 @@ +"""Adapt cargo subprocess output into structured command failures. + +Cargo emits registry and resolver failures as process output. Higher-level +publish logic should not need to know the exact stderr/stdout markers that +identify one diagnostic shape, so this module owns that parsing boundary and +returns typed value objects instead. + +Callers pass the crate name, cargo subcommand, exit code, stdout, and stderr to +``parse_index_lookup_failure``. When the output matches Cargo's crates.io +index-lookup diagnostic, the function returns +``CargoIndexLookupFailure`` with the original process streams and the extracted +missing dependency name. Non-matching or successful invocations return +``None``. + +Example +------- +```python +from lading.commands.cargo_output_adapter import parse_index_lookup_failure + +failure = parse_index_lookup_failure( + crate_name="beta", + subcommand="package", + exit_code=101, + stdout="", + stderr=cargo_stderr, +) +if failure is not None: + handle_index_lookup_failure(failure) +``` +""" + +from __future__ import annotations + +import dataclasses as dc +import re +import typing as typ + +_INDEX_MISSING_VERSION_MARKERS: tuple[str, ...] = ( + "failed to select a version for the requirement", + "location searched: crates.io index", +) + +# Capture the dependency crate name from cargo's index-lookup error, e.g. +# failed to select a version for the requirement `inner_crate = "^0.8.0"` +_INDEX_MISSING_VERSION_NAME_PATTERN = re.compile( + "failed to select a version for the requirement [`'\"]" # noqa: RUF039 - keeps escaped quote pattern for cargo diagnostics + r"(?P[A-Za-z_][A-Za-z0-9_-]*)\s*=", + re.IGNORECASE, +) + + +@dc.dataclass(frozen=True, slots=True) +class CargoIndexLookupFailure: + """Represents a cargo failure where the index could not resolve a dependency.""" + + crate_name: str + subcommand: typ.Literal["package", "publish"] + exit_code: int + stdout: str + stderr: str + missing_dependency_name: str | None + + +def parse_index_lookup_failure( # noqa: PLR0913 - mirrors cargo subprocess result fields. + *, + crate_name: str, + subcommand: typ.Literal["package", "publish"], + exit_code: int, + stdout: str, + stderr: str, +) -> CargoIndexLookupFailure | None: + """Return a structured index-lookup failure parsed from cargo output. + + Parameters + ---------- + crate_name: + Name of the crate whose cargo invocation produced ``stdout`` and + ``stderr``. + subcommand: + Cargo subcommand that produced the output. Currently limited to the + publish workflow's ``package`` and ``publish`` phases. + exit_code: + Numeric process exit code returned by cargo. + stdout: + Text captured from cargo's standard output stream. + stderr: + Text captured from cargo's standard error stream. + + Returns + ------- + CargoIndexLookupFailure | None + A structured failure when cargo could not resolve a dependency from + the crates.io index, otherwise :data:`None`. + + Examples + -------- + ```python + failure = parse_index_lookup_failure( + crate_name="beta", + subcommand="package", + exit_code=101, + stdout="", + stderr=cargo_stderr, + ) + if failure is not None: + print(failure.missing_dependency_name) + ``` + """ + if exit_code == 0: + return None + + haystack = f"{stdout}\n{stderr}" + if not all( + re.search(re.escape(marker), haystack, re.IGNORECASE) + for marker in _INDEX_MISSING_VERSION_MARKERS + ): + return None + + return CargoIndexLookupFailure( + crate_name=crate_name, + subcommand=subcommand, + exit_code=exit_code, + stdout=stdout, + stderr=stderr, + missing_dependency_name=_extract_missing_dependency_name(stdout, stderr), + ) + + +def _extract_missing_dependency_name(stdout: str, stderr: str) -> str | None: + """Return the missing dependency crate name parsed from cargo output.""" + # Cargo writes primary diagnostics to stderr. If both streams happen to + # match _INDEX_MISSING_VERSION_NAME_PATTERN, prefer the stderr name as the + # most relevant failure detail and leave conflicting stdout as secondary. + for stream in (stderr, stdout): + match = _INDEX_MISSING_VERSION_NAME_PATTERN.search(stream) + if match is not None: + return match.group("name") + return None diff --git a/lading/commands/publish.py b/lading/commands/publish.py index b23a847e..e03fd359 100644 --- a/lading/commands/publish.py +++ b/lading/commands/publish.py @@ -1,11 +1,47 @@ """Orchestrate the ``lading publish`` workflow. -This module is the primary entry-point for the ``lading publish`` command. -It is wired by ``lading.cli`` and delegates index-missing-version decisioning -to ``lading.commands.publish_index_check``. Publication state is tracked per -crate via ``PublishPlan`` and ``PublishPreparation`` objects produced in the -planning and staging phase, then consumed across package and publish dispatches -for both dry-run and live execution. +``run()`` is the primary entry point. It loads configuration, discovers the +workspace graph, runs pre-flight checks (:mod:`~lading.commands.publish_preflight`), +plans publication order, stages the workspace, and dispatches to one of two +**publish pipelines** depending on the ``live`` flag. + +**Pipeline dispatch** + +*Dry-run* (``live=False``) keeps the historical batched two-phase pipeline: +package every publishable crate, then ``cargo publish --dry-run`` every crate +in plan order. + +*Live* (``live=True``) interleaves packaging and publishing per crate via +:func:`_execute_live_publication_pipeline`: package the next crate, publish it, +then advance. This ordering lets dependent crates resolve newly uploaded +in-plan dependencies during a single release train. + +**Per-crate helpers** + +:func:`_package_crate` and :func:`_publish_crate` each invoke ``cargo`` in the +correct staged directory for a single crate. Both adapt cargo +index-missing-version output into structured failures and detect publish-phase +already-uploaded errors (:func:`_is_already_published_error`) to support +non-fatal downgrade paths. + +**Error boundary** + +:class:`~lading.commands.publish_errors.PublishPreflightError` signals +pre-publication failures (packaging, pre-flight checks). +:class:`~lading.commands.publish_errors.PublishError` signals post-pre-flight +publish failures and subclasses the preflight error so callers can handle all +failures through one catch boundary or distinguish the publish phase when +needed. + +**Related modules** + +* :mod:`lading.commands.publish_plan` — plan construction and formatting +* :mod:`lading.commands.publish_preflight` — ``cargo check`` / ``cargo test`` / + git-status guards +* :mod:`lading.commands.publish_errors` — :class:`PublishPreflightError` and + :class:`PublishError` +* :mod:`lading.commands.publish_execution` — subprocess invocation and cmd-mox + integration """ from __future__ import annotations @@ -19,20 +55,21 @@ from pathlib import Path from lading import config as config_module +from lading.commands import publish_preflight as _publish_preflight +from lading.commands.cargo_output_adapter import ( + CargoIndexLookupFailure, + parse_index_lookup_failure, +) from lading.commands.publish_errors import PublishError, PublishPreflightError from lading.commands.publish_execution import ( _invoke, + normalise_cmd_mox_command, + should_use_cmd_mox_stub, + split_command, ) from lading.commands.publish_index_check import ( - _CargoInvocation, - _format_cargo_failure_message, _IndexMissingVersionHandling, - _is_index_missing_version_error, -) -from lading.commands.publish_index_check import ( - _extract_missing_dependency_name as _extract_missing_dependency_name, -) -from lading.commands.publish_index_check import ( + _format_cargo_failure_message, _handle_index_missing_version as _raw_handle_index_missing_version, ) from lading.commands.publish_manifest import ( @@ -41,28 +78,33 @@ ) from lading.commands.publish_plan import ( PublishPlan, + append_section, format_plan, plan_publication, ) from lading.commands.publish_plan import ( PublishPlanError as _PublishPlanError, ) -from lading.commands.publish_preflight import ( - _apply_compiletest_externs, - _build_preflight_environment, - _CargoPreflightOptions, - _compose_preflight_arguments, - _run_aux_build_commands, - _run_cargo_preflight, - _validate_lockfile_freshness, - _verify_clean_working_tree, -) from lading.utils.path import normalise_workspace_root from lading.workspace import metadata as _metadata_module StripPatchesSetting = config_module.StripPatchesSetting metadata_module = _metadata_module PublishPlanError = _PublishPlanError +_normalise_cmd_mox_command = normalise_cmd_mox_command +_should_use_cmd_mox_stub = should_use_cmd_mox_stub +_split_command = split_command +_append_section = append_section +_format_plan = format_plan +_CargoPreflightOptions = _publish_preflight._CargoPreflightOptions +_apply_compiletest_externs = _publish_preflight._apply_compiletest_externs +_build_preflight_environment = _publish_preflight._build_preflight_environment +_build_test_arguments = _publish_preflight._build_test_arguments +_compose_preflight_arguments = _publish_preflight._compose_preflight_arguments +_normalise_test_excludes = _publish_preflight._normalise_test_excludes +_run_aux_build_commands = _publish_preflight._run_aux_build_commands +_run_cargo_preflight = _publish_preflight._run_cargo_preflight +_verify_clean_working_tree = _publish_preflight._verify_clean_working_tree LOGGER = logging.getLogger(__name__) @@ -252,7 +294,7 @@ def _resolve_staged_crate_root( def _handle_index_missing_version( - invocation: _CargoInvocation, + failure: CargoIndexLookupFailure, *, plan: PublishPlan, options: _PublishExecutionOptions, @@ -263,10 +305,10 @@ def _handle_index_missing_version( through to the relocated implementation in ``publish_index_check``. """ error_cls = ( - PublishError if invocation.subcommand == "publish" else PublishPreflightError + PublishError if failure.subcommand == "publish" else PublishPreflightError ) _raw_handle_index_missing_version( - invocation, + failure, handling=_IndexMissingVersionHandling( plan=plan, options=options, @@ -313,16 +355,15 @@ def _package_crate( if exit_code == 0: LOGGER.info("Successfully packaged crate %s", crate.name) return - if _is_index_missing_version_error(exit_code, stdout, stderr): - _handle_index_missing_version( - _CargoInvocation( - crate_name=crate.name, - subcommand="package", - output=(exit_code, stdout, stderr), - ), - plan=plan, - options=options, - ) + lookup_failure = parse_index_lookup_failure( + crate_name=crate.name, + subcommand="package", + exit_code=exit_code, + stdout=stdout, + stderr=stderr, + ) + if lookup_failure is not None: + _handle_index_missing_version(lookup_failure, plan=plan, options=options) return message = _format_cargo_failure_message( "package", crate.name, exit_code, (stdout, stderr) @@ -400,32 +441,26 @@ def _publish_crate( env=None, ) _handle_publish_result( - _CargoInvocation( - crate_name=crate.name, - subcommand="publish", - output=(exit_code, stdout, stderr), - ), - crate, - plan, - options, + crate, (exit_code, stdout, stderr), plan=plan, options=options ) def _handle_publish_result( - invocation: _CargoInvocation, crate: WorkspaceCrate, + output: tuple[int, str, str], + *, plan: PublishPlan, options: _PublishExecutionOptions, ) -> None: """Handle a completed ``cargo publish`` invocation.""" - exit_code, stdout, stderr = invocation.output + exit_code, stdout, stderr = output if exit_code == 0: success_message = ( "Successfully published crate %s" if options.live else "Dry-run publish succeeded for crate %s" ) - LOGGER.info(success_message, invocation.crate_name) + LOGGER.info(success_message, crate.name) return if _is_already_published_error(exit_code, stdout, stderr): LOGGER.warning( @@ -434,11 +469,18 @@ def _handle_publish_result( crate.version, ) return - if _is_index_missing_version_error(exit_code, stdout, stderr): + lookup_failure = parse_index_lookup_failure( + crate_name=crate.name, + subcommand="publish", + exit_code=exit_code, + stdout=stdout, + stderr=stderr, + ) + if lookup_failure is not None: # cargo publish --dry-run packages internally and hits the same # crates.io index lookup as cargo package, so honour the override # consistently across both phases. - _handle_index_missing_version(invocation, plan=plan, options=options) + _handle_index_missing_version(lookup_failure, plan=plan, options=options) return message = _format_cargo_failure_message( diff --git a/lading/commands/publish_index_check.py b/lading/commands/publish_index_check.py index 1a133f0e..9577032d 100644 --- a/lading/commands/publish_index_check.py +++ b/lading/commands/publish_index_check.py @@ -1,80 +1,37 @@ """Handle cargo index-lookup failures during publish workflows. -This module keeps the error detection and downgrade logic for missing registry -versions separate from the publish command orchestration. ``publish.py`` -imports these helpers while running ``cargo package`` and ``cargo publish`` so -both phases share the same index-missing-version checks, dependency-name -extraction, failure formatting, and override handling. +This module keeps downgrade logic for missing registry versions separate from the +publish command orchestration. ``publish.py`` imports these helpers while running +``cargo package`` and ``cargo publish`` so both phases share the same +index-missing-version failure formatting and override handling. """ from __future__ import annotations +import collections import dataclasses as dc import logging -import re import typing as typ if typ.TYPE_CHECKING: + from lading.commands.cargo_output_adapter import CargoIndexLookupFailure from lading.commands.publish import _PublishExecutionOptions from lading.commands.publish_plan import PublishPlan -_INDEX_MISSING_VERSION_MARKERS: tuple[str, ...] = ( - "failed to select a version for the requirement", - "location searched: crates.io index", -) +LOGGER = logging.getLogger(__name__) -# Capture the dependency crate name from cargo's index-lookup error, e.g. -# failed to select a version for the requirement `inner_crate = "^0.8.0"` -_INDEX_MISSING_VERSION_NAME_PATTERN = re.compile( - "failed to select a version for the requirement [`'\"]" # noqa: RUF039 - keeps escaped quote pattern for cargo diagnostics - r"(?P[A-Za-z0-9_][A-Za-z0-9_-]*)\s*=", - re.IGNORECASE, +_INDEX_MISSING_VERSION_DOWNGRADE_COUNTER: collections.Counter[tuple[str, str, str]] = ( + collections.Counter() ) -def _is_index_missing_version_error(exit_code: int, stdout: str, stderr: str) -> bool: - """Return True when ``cargo package`` failed due to an unindexed dependency. - - The cargo command exits non-zero with output that simultaneously mentions - the version selection failure and the crates.io index. Both markers are - required to minimize false positives from unrelated lookup failures. - """ - if exit_code == 0: - return False - haystack = f"{stdout}\n{stderr}".lower() - return all(marker in haystack for marker in _INDEX_MISSING_VERSION_MARKERS) - - -def _extract_missing_dependency_name(stdout: str, stderr: str) -> str | None: - """Return the missing dependency crate name parsed from cargo output. - - Searches ``stderr`` before ``stdout`` using - ``_INDEX_MISSING_VERSION_NAME_PATTERN``. Returns ``None`` when neither - stream contains a parseable dependency name. - """ - for stream in (stderr, stdout): - match = _INDEX_MISSING_VERSION_NAME_PATTERN.search(stream) - if match is not None: - return match.group("name") - return None - - -@dc.dataclass(frozen=True, slots=True) -class _CargoInvocation: - """Identifies a cargo invocation that produced an index-lookup failure.""" - - crate_name: str - subcommand: typ.Literal["package", "publish"] - output: tuple[int, str, str] - - @dc.dataclass(frozen=True, slots=True) class _IndexMissingVersionFailure: """Shared context for reporting a fatal index-lookup failure.""" error_cls: type[Exception] - invocation: _CargoInvocation - failure: str + failure: "CargoIndexLookupFailure" + failure_message: str logger: logging.Logger @@ -82,8 +39,8 @@ class _IndexMissingVersionFailure: class _IndexMissingVersionHandling: """Dependencies needed to classify and report an index-lookup failure.""" - plan: PublishPlan - options: _PublishExecutionOptions + plan: "PublishPlan" + options: "_PublishExecutionOptions" logger: logging.Logger @@ -112,16 +69,18 @@ def _format_cargo_failure_message( def _raise_name_extraction_failure( - context: _IndexMissingVersionFailure, + error_cls: type[Exception], + lookup_failure: "CargoIndexLookupFailure", + failure_message: str, ) -> typ.NoReturn: """Log and raise when the missing dependency name cannot be extracted.""" - context.logger.warning( + LOGGER.warning( "cargo %s for crate %s matched index-missing-version markers " "but the dependency name could not be extracted; treating as fatal", - context.invocation.subcommand, - context.invocation.crate_name, + lookup_failure.subcommand, + lookup_failure.crate_name, ) - raise context.error_cls(context.failure) + raise error_cls(failure_message) def _format_missing_dependency_failure( @@ -137,7 +96,7 @@ def _format_missing_dependency_failure( def _log_missing_dependency_failure( logger: logging.Logger, - invocation: _CargoInvocation, + lookup_failure: "CargoIndexLookupFailure", *, missing_name: str, detail: str, @@ -145,21 +104,22 @@ def _log_missing_dependency_failure( """Emit the shared warning shape for fatal dependency index misses.""" logger.warning( "cargo %s for crate %s failed due to unindexed dependency %r %s", - invocation.subcommand, - invocation.crate_name, + lookup_failure.subcommand, + lookup_failure.crate_name, missing_name, detail, ) def _raise_out_of_plan_dependency( - context: _IndexMissingVersionFailure, - *, + error_cls: type[Exception], + lookup_failure: "CargoIndexLookupFailure", + failure_message: str, missing_name: str, ) -> typ.NoReturn: """Log and raise when the unindexed dependency is outside the publish plan.""" message = _format_missing_dependency_failure( - context.failure, + failure_message, missing_name=missing_name, reason=( "is not part of the current publish plan, so the unpublished " @@ -168,12 +128,12 @@ def _raise_out_of_plan_dependency( guidance="Publish or index the dependency first.", ) _log_missing_dependency_failure( - context.logger, - context.invocation, + LOGGER, + lookup_failure, missing_name=missing_name, detail="which is not in the current publish plan; cannot continue", ) - raise context.error_cls(message) + raise error_cls(message) def _raise_out_of_order_dependency( @@ -183,11 +143,11 @@ def _raise_out_of_order_dependency( ) -> typ.NoReturn: """Log and raise when a planned dependency is published too late.""" message = _format_missing_dependency_failure( - context.failure, + context.failure_message, missing_name=missing_name, reason=( - f"appears after crate {context.invocation.crate_name!r} in publish order, " - "so it will not be available when this crate is published" + f"appears after crate {context.failure.crate_name!r} in publish " + "order, so it will not be available when this crate is published" ), guidance=( "Adjust publish.order so the dependency comes first, or omit " @@ -196,7 +156,7 @@ def _raise_out_of_order_dependency( ) _log_missing_dependency_failure( context.logger, - context.invocation, + context.failure, missing_name=missing_name, detail=( "which appears after the current crate in publish order; cannot continue" @@ -212,17 +172,17 @@ def _raise_self_dependency( ) -> typ.NoReturn: """Log and raise when cargo reports the current crate as its dependency.""" message = _format_missing_dependency_failure( - context.failure, + context.failure_message, missing_name=missing_name, reason=( - f"is the same crate as {context.invocation.crate_name!r}, so the " + f"is the same crate as {context.failure.crate_name!r}, so the " "publish plan cannot make it available before itself" ), guidance="Remove the self-dependency from the crate manifest.", ) _log_missing_dependency_failure( context.logger, - context.invocation, + context.failure, missing_name=missing_name, detail="which is the current crate; cannot continue", ) @@ -241,7 +201,7 @@ def _raise_unpublished_dependency_override_required( to a warning. """ message = _format_missing_dependency_failure( - context.failure, + context.failure_message, missing_name=missing_name, reason="is scheduled in this publish run but is not yet on crates.io", guidance=( @@ -251,7 +211,7 @@ def _raise_unpublished_dependency_override_required( ) _log_missing_dependency_failure( context.logger, - context.invocation, + context.failure, missing_name=missing_name, detail=( "(in plan); enable the dry-run unpublished workspace dependency " @@ -274,6 +234,15 @@ def _canonical_crate_name(name: str) -> str: return name.replace("-", "_") +def _record_index_missing_version_downgrade( + failure: "CargoIndexLookupFailure", missing_name: str +) -> None: + """Increment the downgrade counter for an index-missing-version failure.""" + _INDEX_MISSING_VERSION_DOWNGRADE_COUNTER[ + failure.subcommand, failure.crate_name, missing_name + ] += 1 + + def _validate_dependency_placement( context: _IndexMissingVersionFailure, handling: _IndexMissingVersionHandling, @@ -289,20 +258,21 @@ def _validate_dependency_placement( _canonical_crate_name(entry.name): index for index, entry in enumerate(handling.plan.publishable) } - canonical_current = _canonical_crate_name(context.invocation.crate_name) + canonical_current = _canonical_crate_name(context.failure.crate_name) current_index = publishable_name_indexes.get(canonical_current) if current_index is None: message = ( - f"cargo {context.invocation.subcommand} failed for crate " - f"{context.invocation.crate_name}, but that crate is not part " + f"cargo {context.failure.subcommand} failed for crate " + f"{context.failure.crate_name}, but that crate is not part " "of the current publish plan." ) raise context.error_cls(message) + missing_canonical_name = _canonical_crate_name(missing_name) handling.logger.debug( - "index-missing-version handler: current crate %r at publish-order " - "index %d; missing dependency canonical name %r", - context.invocation.crate_name, + "index-missing-version handler: current crate %r at publish-order index %d; " + "missing dependency canonical name %r", + context.failure.crate_name, current_index, missing_canonical_name, ) @@ -314,7 +284,12 @@ def _validate_dependency_placement( missing_index if missing_index is not None else "", ) if missing_index is None: - _raise_out_of_plan_dependency(context, missing_name=missing_name) + _raise_out_of_plan_dependency( + context.error_cls, + context.failure, + context.failure_message, + missing_name=missing_name, + ) if missing_index == current_index: _raise_self_dependency(context, missing_name=missing_name) if missing_index > current_index: @@ -323,7 +298,7 @@ def _validate_dependency_placement( def _handle_index_missing_version( - invocation: _CargoInvocation, + failure: "CargoIndexLookupFailure", *, handling: _IndexMissingVersionHandling, error_cls: type[Exception], @@ -334,20 +309,24 @@ def _handle_index_missing_version( is in the current publish plan and the caller opted into the dry-run override. """ - exit_code, stdout, stderr = invocation.output - failure = _format_cargo_failure_message( - invocation.subcommand, invocation.crate_name, exit_code, (stdout, stderr) + failure_message = _format_cargo_failure_message( + failure.subcommand, + failure.crate_name, + failure.exit_code, + (failure.stdout, failure.stderr), ) context = _IndexMissingVersionFailure( error_cls=error_cls, - invocation=invocation, failure=failure, + failure_message=failure_message, logger=handling.logger, ) - missing_name = _extract_missing_dependency_name(stdout, stderr) + missing_name = failure.missing_dependency_name if missing_name is None: - _raise_name_extraction_failure(context) + _raise_name_extraction_failure( + error_cls, failure, failure_message + ) current_index, missing_index, missing_canonical_name = ( _validate_dependency_placement(context, handling, missing_name) @@ -357,7 +336,7 @@ def _handle_index_missing_version( "index-missing-version handler: allow_unpublished_workspace_deps=%r " "for crate %r; %s", handling.options.allow_unpublished_workspace_deps, - invocation.crate_name, + failure.crate_name, "will raise" if not handling.options.allow_unpublished_workspace_deps else "will downgrade to warning", @@ -368,12 +347,13 @@ def _handle_index_missing_version( context, missing_name=missing_name ) + _record_index_missing_version_downgrade(failure, 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", - invocation.subcommand, - invocation.crate_name, + failure.subcommand, + failure.crate_name, missing_name, ) handling.logger.debug( @@ -385,8 +365,8 @@ def _handle_index_missing_version( "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", - invocation.subcommand, - invocation.crate_name, + failure.subcommand, + failure.crate_name, current_index, missing_name, missing_index, diff --git a/tests/unit/publish/test_cargo_output_adapter.py b/tests/unit/publish/test_cargo_output_adapter.py new file mode 100644 index 00000000..dc71cd11 --- /dev/null +++ b/tests/unit/publish/test_cargo_output_adapter.py @@ -0,0 +1,122 @@ +"""Unit tests for adapting cargo output into structured index failures.""" + +from __future__ import annotations + +import pytest + +from lading.commands.cargo_output_adapter import ( + CargoIndexLookupFailure, + parse_index_lookup_failure, +) + +from .conftest import ( + INDEX_MISSING_STDERR_BETA, + INDEX_MISSING_STDERR_UNPARSEABLE, +) + + +def _parse_index_lookup_failure( + exit_code: int, + stdout: str, + stderr: str, +) -> CargoIndexLookupFailure | None: + """Parse a fixed publish failure fixture through the adapter.""" + return parse_index_lookup_failure( + crate_name="beta", + subcommand="publish", + exit_code=exit_code, + stdout=stdout, + stderr=stderr, + ) + + +@pytest.mark.parametrize( + ("exit_code", "stdout", "stderr"), + [ + pytest.param(0, "", "", id="success"), + pytest.param(1, "", "", id="failure-without-markers"), + pytest.param( + 1, + "", + "failed to select a version for the requirement", + id="missing-index-marker", + ), + pytest.param( + 1, + "", + "location searched: crates.io index", + id="missing-version-marker", + ), + ], +) +def test_parse_index_lookup_failure_returns_none_for_non_index_errors( + exit_code: int, stdout: str, stderr: str +) -> None: + """Non-index failures do not produce structured lookup failures.""" + assert _parse_index_lookup_failure(exit_code, stdout, stderr) is None + + +@pytest.mark.parametrize( + ("stdout", "stderr", "expected_name"), + [ + pytest.param("", INDEX_MISSING_STDERR_BETA, "alpha", id="stderr-backticks"), + pytest.param( + "", + "failed to select a version for the requirement " + "'inner_crate = \"^0.8.0\"'\n" + "location searched: crates.io index", + "inner_crate", + id="single-quotes", + ), + pytest.param( + 'failed to select a version for the requirement "foo-bar = ^1"\n' + "location searched: crates.io index", + "", + "foo-bar", + id="hyphenated-on-stdout", + ), + pytest.param( + "", + ( + "error: failed to prepare local package for uploading\n" + "Caused by:\n" + ' failed to select a version for the requirement `my-crate = "^1"`\n' + " location searched: crates.io index\n" + ), + "my-crate", + id="hyphenated-name", + ), + pytest.param( + "", + INDEX_MISSING_STDERR_UNPARSEABLE, + None, + id="unparseable-name", + ), + pytest.param( + ( + 'failed to select a version for the requirement `stdout_dep = "^1"`\n' + "location searched: crates.io index" + ), + ( + 'failed to select a version for the requirement `stderr_dep = "^1"`\n' + "location searched: crates.io index" + ), + "stderr_dep", + id="stderr-precedence", + ), + ], +) +def test_parse_index_lookup_failure_returns_structured_failure( + stdout: str, stderr: str, expected_name: str | None +) -> None: + """Cargo index failures retain command context and parsed dependency names.""" + failure = _parse_index_lookup_failure(101, stdout, stderr) + + assert failure == CargoIndexLookupFailure( + crate_name="beta", + subcommand="publish", + exit_code=101, + stdout=stdout, + stderr=stderr, + missing_dependency_name=expected_name, + ) diff --git a/tests/unit/publish/test_index_detection.py b/tests/unit/publish/test_index_detection.py deleted file mode 100644 index 64bceffb..00000000 --- a/tests/unit/publish/test_index_detection.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Unit tests for cargo index-missing-version detection helpers. - -Tests :func:`lading.commands.publish._is_index_missing_version_error` and -:func:`lading.commands.publish._extract_missing_dependency_name`, both -extracted into ``lading.commands.publish_index_check``. - -``_is_index_missing_version_error`` is parametrised across exit-code and -marker combinations to verify the two-marker detection strategy. -``_extract_missing_dependency_name`` is parametrised across backtick, -single-quote, double-quote, and hyphenated name variants to confirm the -regex handles the full range of cargo diagnostic formatting, including -hyphenated crate names that cargo normalises differently from manifest names. -""" - -from __future__ import annotations - -import pytest - -from lading.commands import publish - -from .conftest import INDEX_MISSING_STDERR_BETA - - -@pytest.mark.parametrize( - ("exit_code", "stdout", "stderr", "expected"), - [ - pytest.param(0, "", "", False, id="success"), - pytest.param(1, "", "", False, id="failure-without-markers"), - pytest.param( - 1, - "", - "failed to select a version for the requirement", - False, - id="missing-index-marker", - ), - pytest.param( - 1, - "", - "location searched: crates.io index", - False, - id="missing-version-marker", - ), - pytest.param( - 1, - "", - INDEX_MISSING_STDERR_BETA, - True, - id="full-stderr-shape", - ), - pytest.param( - 1, - INDEX_MISSING_STDERR_BETA, - "", - True, - id="markers-on-stdout", - ), - ], -) -def test_is_index_missing_version_error( - exit_code: int, stdout: str, stderr: str, *, expected: bool -) -> None: - """Both markers must be present and the command must have failed.""" - assert ( - publish._is_index_missing_version_error(exit_code, stdout, stderr) is expected - ) - - -@pytest.mark.parametrize( - ("stdout", "stderr", "expected"), - [ - pytest.param("", INDEX_MISSING_STDERR_BETA, "alpha", id="stderr-backticks"), - pytest.param( - "", - "failed to select a version for the requirement 'inner_crate = \"^0.8.0\"'", - "inner_crate", - id="single-quotes", - ), - pytest.param( - 'failed to select a version for the requirement "foo-bar = ^1"', - "", - "foo-bar", - id="hyphenated-on-stdout", - ), - pytest.param( - "", - ( - "error: failed to prepare local package for uploading\n" - "Caused by:\n" - ' failed to select a version for the requirement `my-crate = "^1"`\n' - " location searched: crates.io index\n" - ), - "my-crate", - id="hyphenated-name", - ), - pytest.param("", "no match here", None, id="no-match"), - ], -) -def test_extract_missing_dependency_name( - stdout: str, stderr: str, expected: str | None -) -> None: - """Regex extraction handles backticks, quotes, and hyphens.""" - assert publish._extract_missing_dependency_name(stdout, stderr) == expected diff --git a/tests/unit/publish/test_index_order_properties.py b/tests/unit/publish/test_index_order_properties.py index 48e65666..675bc528 100644 --- a/tests/unit/publish/test_index_order_properties.py +++ b/tests/unit/publish/test_index_order_properties.py @@ -16,6 +16,7 @@ from hypothesis import strategies as st from lading.commands import publish +from lading.commands.cargo_output_adapter import CargoIndexLookupFailure from .conftest import make_crate @@ -51,20 +52,19 @@ def test_index_missing_dependency_requires_prior_publish_order( ) current_crate = crates[current_index] missing_crate = crates[missing_index] - invocation = publish._CargoInvocation( + failure = CargoIndexLookupFailure( crate_name=current_crate.name, subcommand="package", - output=( - 1, - "", - ( - "error: failed to prepare local package for uploading\n" - "Caused by:\n" - " failed to select a version for the requirement " - f'`{missing_crate.name} = "^0.1.0"`\n' - " location searched: crates.io index\n" - ), + exit_code=1, + stdout="", + stderr=( + "error: failed to prepare local package for uploading\n" + "Caused by:\n" + " failed to select a version for the requirement " + f'`{missing_crate.name} = "^0.1.0"`\n' + " location searched: crates.io index\n" ), + missing_dependency_name=missing_crate.name, ) options = publish._PublishExecutionOptions( live=False, @@ -74,7 +74,7 @@ def test_index_missing_dependency_requires_prior_publish_order( if missing_index < current_index: publish._handle_index_missing_version( - invocation, + failure, plan=plan, options=options, ) @@ -88,7 +88,7 @@ def test_index_missing_dependency_requires_prior_publish_order( ), ): publish._handle_index_missing_version( - invocation, + failure, plan=plan, options=options, ) diff --git a/tests/unit/publish/test_phase_dispatch.py b/tests/unit/publish/test_phase_dispatch.py index 58561383..859ed78a 100644 --- a/tests/unit/publish/test_phase_dispatch.py +++ b/tests/unit/publish/test_phase_dispatch.py @@ -29,6 +29,7 @@ import pytest from lading.commands import publish +from lading.commands.cargo_output_adapter import CargoIndexLookupFailure from .conftest import ( INDEX_MISSING_STDERR_BETA, @@ -38,7 +39,6 @@ invoke_phase, make_config, make_crate, - make_dependency, make_dependency_chain, make_failing_runner, make_workspace, @@ -137,24 +137,23 @@ def test_missing_dep_in_plan_allows_cargo_name_normalisation( plan = publish.plan_publication( make_workspace(workspace_root, alpha, beta), make_config() ) - invocation = publish._CargoInvocation( + failure = CargoIndexLookupFailure( crate_name="beta", subcommand="package", - output=( - 1, - "", - ( - "error: failed to prepare local package for uploading\n" - "Caused by:\n" - " failed to select a version for the requirement " - '`alpha_crate = "^1"`\n' - " location searched: crates.io index\n" - ), + exit_code=1, + stdout="", + stderr=( + "error: failed to prepare local package for uploading\n" + "Caused by:\n" + " failed to select a version for the requirement " + '`alpha_crate = "^1"`\n' + " location searched: crates.io index\n" ), + missing_dependency_name="alpha_crate", ) publish._handle_index_missing_version( - invocation, + failure, plan=plan, options=publish._PublishExecutionOptions( live=False, diff --git a/tests/unit/publish/test_snapshot_messages.py b/tests/unit/publish/test_snapshot_messages.py index 8549283b..d2bed1d0 100644 --- a/tests/unit/publish/test_snapshot_messages.py +++ b/tests/unit/publish/test_snapshot_messages.py @@ -28,6 +28,10 @@ import pytest from lading.commands import publish +from lading.commands.cargo_output_adapter import ( + CargoIndexLookupFailure, + parse_index_lookup_failure, +) from .conftest import ( INDEX_MISSING_STDERR_BETA, @@ -53,6 +57,19 @@ class _IndexMissingCase(typ.NamedTuple): stderr: str allow_unpublished: bool +def _missing_dependency_name(stderr: str) -> str | None: + """Return the missing dependency parsed by the cargo output adapter.""" + failure = parse_index_lookup_failure( + crate_name="beta", + subcommand="package", + exit_code=1, + stdout="", + stderr=stderr, + ) + if failure is None: + return None + return failure.missing_dependency_name + class _InPlanSnapshotCase(typ.NamedTuple): """Variable parts for in-plan fatal-path snapshot tests.""" @@ -114,15 +131,18 @@ def _handle_index_missing_version_message( ) -> str: """Return the raised index-missing-version message for snapshot tests.""" caplog.set_level(logging.WARNING, logger="lading.commands.publish") - invocation = publish._CargoInvocation( + failure = CargoIndexLookupFailure( crate_name="beta", subcommand="package", - output=(1, "", stderr), + exit_code=1, + stdout="", + stderr=stderr, + missing_dependency_name=_missing_dependency_name(stderr), ) with pytest.raises(publish.PublishPreflightError) as excinfo: publish._handle_index_missing_version( - invocation, + failure, plan=plan, options=publish._PublishExecutionOptions( live=False, @@ -186,15 +206,18 @@ def test_index_missing_in_plan_downgrade_snapshot( """Snapshot the warning emitted when the flag downgrades a failure to a warning.""" caplog.set_level(logging.INFO) plan, _preparation, _staging_root = publish_plan_and_prep - invocation = publish._CargoInvocation( + failure = CargoIndexLookupFailure( crate_name="beta", subcommand="package", - output=(1, "", INDEX_MISSING_STDERR_BETA), + exit_code=1, + stdout="", + stderr=INDEX_MISSING_STDERR_BETA, + missing_dependency_name="alpha", ) # Must not raise - the success/downgrade path returns without raising. publish._handle_index_missing_version( - invocation, + failure, plan=plan, options=publish._PublishExecutionOptions( live=False, From f6bd76daeea9587a67f325929f67beb3ad06c7f6 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 9 Jun 2026 00:37:47 +0200 Subject: [PATCH 02/12] Resolve publish rebase drift Keep the rebased publish command aligned with the command-runner changes from main by removing stale cmd-mox aliases and restoring the lockfile freshness preflight alias. Also tidy the developers guide spacing left by the conflict resolution. --- lading/commands/publish.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lading/commands/publish.py b/lading/commands/publish.py index e03fd359..ee715334 100644 --- a/lading/commands/publish.py +++ b/lading/commands/publish.py @@ -63,9 +63,6 @@ from lading.commands.publish_errors import PublishError, PublishPreflightError from lading.commands.publish_execution import ( _invoke, - normalise_cmd_mox_command, - should_use_cmd_mox_stub, - split_command, ) from lading.commands.publish_index_check import ( _IndexMissingVersionHandling, @@ -91,9 +88,6 @@ StripPatchesSetting = config_module.StripPatchesSetting metadata_module = _metadata_module PublishPlanError = _PublishPlanError -_normalise_cmd_mox_command = normalise_cmd_mox_command -_should_use_cmd_mox_stub = should_use_cmd_mox_stub -_split_command = split_command _append_section = append_section _format_plan = format_plan _CargoPreflightOptions = _publish_preflight._CargoPreflightOptions @@ -104,6 +98,7 @@ _normalise_test_excludes = _publish_preflight._normalise_test_excludes _run_aux_build_commands = _publish_preflight._run_aux_build_commands _run_cargo_preflight = _publish_preflight._run_cargo_preflight +_validate_lockfile_freshness = _publish_preflight._validate_lockfile_freshness _verify_clean_working_tree = _publish_preflight._verify_clean_working_tree LOGGER = logging.getLogger(__name__) From 7f02299f91c4406117901c31815b94f71070d405 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 9 Jun 2026 20:43:44 +0200 Subject: [PATCH 03/12] Refine cargo output failure parsing API --- docs/developers-guide.md | 40 +++++++------- lading/commands/cargo_output_adapter.py | 55 +++++++++++-------- lading/commands/publish.py | 21 ++++--- lading/commands/publish_index_check.py | 20 +++---- .../unit/publish/test_cargo_output_adapter.py | 9 ++- .../publish/test_index_order_properties.py | 2 +- tests/unit/publish/test_phase_dispatch.py | 1 + tests/unit/publish/test_snapshot_messages.py | 10 +++- 8 files changed, 90 insertions(+), 68 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 76bad949..51b6e979 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -335,30 +335,30 @@ did not match a workspace crate. `plan_publication()` builds that object by filtering non-publishable crates, applying `publish.exclude`, validating `publish.order` when present, or deriving a deterministic dependency order. -`publish_manifest.py` owns staging-time manifest mutations. It contains workspace -preparation types and helpers that copy the workspace tree, stage workspace README -files for crates that opt in, and apply the `publish.strip_patches` strategy to -the staged `Cargo.toml`. These operations run before any `cargo package` or -`cargo publish` command, so the command runner works against a prepared snapshot -rather than the source workspace. +`publish_manifest.py` owns staging-time manifest mutations. It contains +workspace preparation types and helpers that copy the workspace tree, stage +workspace README files for crates that opt in, and apply the +`publish.strip_patches` strategy to the staged `Cargo.toml`. These operations +run before any `cargo package` or `cargo publish` command, so the command +runner works against a prepared snapshot rather than the source workspace. `publish_diagnostics.py` owns compiletest failure enrichment. When a cargo pre-flight test failure mentions compiletest-style `*.stderr` artefacts, the -diagnostic helper locates the referenced files, tails a bounded number of lines, -and appends those snippets to the `PublishPreflightError` message. The module is -deliberately read-only: missing artefacts or unreadable files produce diagnostic -notes rather than replacing the original cargo failure. +diagnostic helper locates the referenced files, tails a bounded number of +lines, and appends those snippets to the `PublishPreflightError` message. The +module is deliberately read-only: missing artefacts or unreadable files produce +diagnostic notes rather than replacing the original cargo failure. -`cargo_output_adapter.py` owns parsing raw cargo subprocess output into structured -command failures. `CargoIndexLookupFailure` is the value object for crates.io -index lookup failures, and `parse_index_lookup_failure()` is the primary owner of -cargo's marker-based index-miss detection. +`cargo_output_adapter.py` owns parsing raw cargo subprocess output into +structured command failures. `CargoIndexLookupFailure` is the value object for +crates.io index lookup failures, and `parse_index_lookup_failure()` is the +primary owner of cargo's marker-based index-miss detection. `publish_index_check.py` owns crates.io index-lookup downgrade decisions after output has crossed that adapter boundary. It receives `CargoIndexLookupFailure` -instances, applies crate-name canonicalization, and decides whether an index miss -is out-of-plan and fatal, in-plan but still fatal, or in-plan and downgraded by -`allow_unpublished_workspace_deps` during dry-run publication. +instances, applies crate-name canonicalization, and decides whether an index +miss is out-of-plan and fatal, in-plan but still fatal, or in-plan and +downgraded by `allow_unpublished_workspace_deps` during dry-run publication. ### `_PublishExecutionOptions` @@ -431,9 +431,9 @@ The index-lookup handling is split across the adapter and decision helper: decision tree. If name extraction fails, the original Cargo failure stays 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 + 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 continues; otherwise it raises with guidance to enable the dry-run unpublished workspace dependency override or follow the staged-publish workaround. diff --git a/lading/commands/cargo_output_adapter.py b/lading/commands/cargo_output_adapter.py index f74c6664..6feab0f7 100644 --- a/lading/commands/cargo_output_adapter.py +++ b/lading/commands/cargo_output_adapter.py @@ -5,7 +5,7 @@ identify one diagnostic shape, so this module owns that parsing boundary and returns typed value objects instead. -Callers pass the crate name, cargo subcommand, exit code, stdout, and stderr to +Callers pass the crate name, cargo subcommand, and raw subprocess result to ``parse_index_lookup_failure``. When the output matches Cargo's crates.io index-lookup diagnostic, the function returns ``CargoIndexLookupFailure`` with the original process streams and the extracted @@ -20,9 +20,11 @@ failure = parse_index_lookup_failure( crate_name="beta", subcommand="package", - exit_code=101, - stdout="", - stderr=cargo_stderr, + result=CargoSubprocessResult( + exit_code=101, + stdout="", + stderr=cargo_stderr, + ), ) if failure is not None: handle_index_lookup_failure(failure) @@ -61,13 +63,20 @@ class CargoIndexLookupFailure: missing_dependency_name: str | None -def parse_index_lookup_failure( # noqa: PLR0913 - mirrors cargo subprocess result fields. +@dc.dataclass(frozen=True, slots=True) +class CargoSubprocessResult: + """Raw process output from a single cargo invocation.""" + + exit_code: int + stdout: str + stderr: str + + +def parse_index_lookup_failure( *, crate_name: str, subcommand: typ.Literal["package", "publish"], - exit_code: int, - stdout: str, - stderr: str, + result: CargoSubprocessResult, ) -> CargoIndexLookupFailure | None: """Return a structured index-lookup failure parsed from cargo output. @@ -79,12 +88,8 @@ def parse_index_lookup_failure( # noqa: PLR0913 - mirrors cargo subprocess resu subcommand: Cargo subcommand that produced the output. Currently limited to the publish workflow's ``package`` and ``publish`` phases. - exit_code: - Numeric process exit code returned by cargo. - stdout: - Text captured from cargo's standard output stream. - stderr: - Text captured from cargo's standard error stream. + result: + Raw process output from the cargo invocation. Returns ------- @@ -98,18 +103,20 @@ def parse_index_lookup_failure( # noqa: PLR0913 - mirrors cargo subprocess resu failure = parse_index_lookup_failure( crate_name="beta", subcommand="package", - exit_code=101, - stdout="", - stderr=cargo_stderr, + result=CargoSubprocessResult( + exit_code=101, + stdout="", + stderr=cargo_stderr, + ), ) if failure is not None: print(failure.missing_dependency_name) ``` """ - if exit_code == 0: + if result.exit_code == 0: return None - haystack = f"{stdout}\n{stderr}" + haystack = f"{result.stdout}\n{result.stderr}" if not all( re.search(re.escape(marker), haystack, re.IGNORECASE) for marker in _INDEX_MISSING_VERSION_MARKERS @@ -119,10 +126,12 @@ def parse_index_lookup_failure( # noqa: PLR0913 - mirrors cargo subprocess resu return CargoIndexLookupFailure( crate_name=crate_name, subcommand=subcommand, - exit_code=exit_code, - stdout=stdout, - stderr=stderr, - missing_dependency_name=_extract_missing_dependency_name(stdout, stderr), + exit_code=result.exit_code, + stdout=result.stdout, + stderr=result.stderr, + missing_dependency_name=_extract_missing_dependency_name( + result.stdout, result.stderr + ), ) diff --git a/lading/commands/publish.py b/lading/commands/publish.py index ee715334..f9cfd12d 100644 --- a/lading/commands/publish.py +++ b/lading/commands/publish.py @@ -58,6 +58,7 @@ from lading.commands import publish_preflight as _publish_preflight from lading.commands.cargo_output_adapter import ( CargoIndexLookupFailure, + CargoSubprocessResult, parse_index_lookup_failure, ) from lading.commands.publish_errors import PublishError, PublishPreflightError @@ -65,8 +66,10 @@ _invoke, ) from lading.commands.publish_index_check import ( - _IndexMissingVersionHandling, _format_cargo_failure_message, + _IndexMissingVersionHandling, +) +from lading.commands.publish_index_check import ( _handle_index_missing_version as _raw_handle_index_missing_version, ) from lading.commands.publish_manifest import ( @@ -353,9 +356,11 @@ def _package_crate( lookup_failure = parse_index_lookup_failure( crate_name=crate.name, subcommand="package", - exit_code=exit_code, - stdout=stdout, - stderr=stderr, + result=CargoSubprocessResult( + exit_code=exit_code, + stdout=stdout, + stderr=stderr, + ), ) if lookup_failure is not None: _handle_index_missing_version(lookup_failure, plan=plan, options=options) @@ -467,9 +472,11 @@ def _handle_publish_result( lookup_failure = parse_index_lookup_failure( crate_name=crate.name, subcommand="publish", - exit_code=exit_code, - stdout=stdout, - stderr=stderr, + result=CargoSubprocessResult( + exit_code=exit_code, + stdout=stdout, + stderr=stderr, + ), ) if lookup_failure is not None: # cargo publish --dry-run packages internally and hits the same diff --git a/lading/commands/publish_index_check.py b/lading/commands/publish_index_check.py index 9577032d..d98e40f9 100644 --- a/lading/commands/publish_index_check.py +++ b/lading/commands/publish_index_check.py @@ -30,7 +30,7 @@ class _IndexMissingVersionFailure: """Shared context for reporting a fatal index-lookup failure.""" error_cls: type[Exception] - failure: "CargoIndexLookupFailure" + failure: CargoIndexLookupFailure failure_message: str logger: logging.Logger @@ -39,8 +39,8 @@ class _IndexMissingVersionFailure: class _IndexMissingVersionHandling: """Dependencies needed to classify and report an index-lookup failure.""" - plan: "PublishPlan" - options: "_PublishExecutionOptions" + plan: PublishPlan + options: _PublishExecutionOptions logger: logging.Logger @@ -70,7 +70,7 @@ def _format_cargo_failure_message( def _raise_name_extraction_failure( error_cls: type[Exception], - lookup_failure: "CargoIndexLookupFailure", + lookup_failure: CargoIndexLookupFailure, failure_message: str, ) -> typ.NoReturn: """Log and raise when the missing dependency name cannot be extracted.""" @@ -96,7 +96,7 @@ def _format_missing_dependency_failure( def _log_missing_dependency_failure( logger: logging.Logger, - lookup_failure: "CargoIndexLookupFailure", + lookup_failure: CargoIndexLookupFailure, *, missing_name: str, detail: str, @@ -113,7 +113,7 @@ def _log_missing_dependency_failure( def _raise_out_of_plan_dependency( error_cls: type[Exception], - lookup_failure: "CargoIndexLookupFailure", + lookup_failure: CargoIndexLookupFailure, failure_message: str, missing_name: str, ) -> typ.NoReturn: @@ -235,7 +235,7 @@ def _canonical_crate_name(name: str) -> str: def _record_index_missing_version_downgrade( - failure: "CargoIndexLookupFailure", missing_name: str + failure: CargoIndexLookupFailure, missing_name: str ) -> None: """Increment the downgrade counter for an index-missing-version failure.""" _INDEX_MISSING_VERSION_DOWNGRADE_COUNTER[ @@ -298,7 +298,7 @@ def _validate_dependency_placement( def _handle_index_missing_version( - failure: "CargoIndexLookupFailure", + failure: CargoIndexLookupFailure, *, handling: _IndexMissingVersionHandling, error_cls: type[Exception], @@ -324,9 +324,7 @@ def _handle_index_missing_version( missing_name = failure.missing_dependency_name if missing_name is None: - _raise_name_extraction_failure( - error_cls, failure, failure_message - ) + _raise_name_extraction_failure(error_cls, failure, failure_message) current_index, missing_index, missing_canonical_name = ( _validate_dependency_placement(context, handling, missing_name) diff --git a/tests/unit/publish/test_cargo_output_adapter.py b/tests/unit/publish/test_cargo_output_adapter.py index dc71cd11..769774ec 100644 --- a/tests/unit/publish/test_cargo_output_adapter.py +++ b/tests/unit/publish/test_cargo_output_adapter.py @@ -6,6 +6,7 @@ from lading.commands.cargo_output_adapter import ( CargoIndexLookupFailure, + CargoSubprocessResult, parse_index_lookup_failure, ) @@ -24,9 +25,11 @@ def _parse_index_lookup_failure( return parse_index_lookup_failure( crate_name="beta", subcommand="publish", - exit_code=exit_code, - stdout=stdout, - stderr=stderr, + result=CargoSubprocessResult( + exit_code=exit_code, + stdout=stdout, + stderr=stderr, + ), ) diff --git a/tests/unit/publish/test_index_order_properties.py b/tests/unit/publish/test_index_order_properties.py index 675bc528..d17b2f88 100644 --- a/tests/unit/publish/test_index_order_properties.py +++ b/tests/unit/publish/test_index_order_properties.py @@ -33,7 +33,7 @@ def _publish_order_cases( @given(case=_publish_order_cases()) -@settings(max_examples=80) +@settings(max_examples=80, deadline=None) def test_index_missing_dependency_requires_prior_publish_order( case: tuple[int, int, int], ) -> None: diff --git a/tests/unit/publish/test_phase_dispatch.py b/tests/unit/publish/test_phase_dispatch.py index 859ed78a..91acd6be 100644 --- a/tests/unit/publish/test_phase_dispatch.py +++ b/tests/unit/publish/test_phase_dispatch.py @@ -39,6 +39,7 @@ invoke_phase, make_config, make_crate, + make_dependency, make_dependency_chain, make_failing_runner, make_workspace, diff --git a/tests/unit/publish/test_snapshot_messages.py b/tests/unit/publish/test_snapshot_messages.py index d2bed1d0..ef8746e4 100644 --- a/tests/unit/publish/test_snapshot_messages.py +++ b/tests/unit/publish/test_snapshot_messages.py @@ -30,6 +30,7 @@ from lading.commands import publish from lading.commands.cargo_output_adapter import ( CargoIndexLookupFailure, + CargoSubprocessResult, parse_index_lookup_failure, ) @@ -57,14 +58,17 @@ class _IndexMissingCase(typ.NamedTuple): stderr: str allow_unpublished: bool + def _missing_dependency_name(stderr: str) -> str | None: """Return the missing dependency parsed by the cargo output adapter.""" failure = parse_index_lookup_failure( crate_name="beta", subcommand="package", - exit_code=1, - stdout="", - stderr=stderr, + result=CargoSubprocessResult( + exit_code=1, + stdout="", + stderr=stderr, + ), ) if failure is None: return None From 60d6d0a8d76e3852f7061071e2e85e80d236e728 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 9 Jun 2026 20:51:51 +0200 Subject: [PATCH 04/12] Refine cargo output parser parameter object Add a dedicated CargoSubprocessResult value object for parser inputs. Move the new parameter object ahead of existing failure types and wire the parser to use it, preserving the existing CargoIndexLookupFailure surface. --- lading/commands/cargo_output_adapter.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lading/commands/cargo_output_adapter.py b/lading/commands/cargo_output_adapter.py index 6feab0f7..251bef08 100644 --- a/lading/commands/cargo_output_adapter.py +++ b/lading/commands/cargo_output_adapter.py @@ -52,24 +52,24 @@ @dc.dataclass(frozen=True, slots=True) -class CargoIndexLookupFailure: - """Represents a cargo failure where the index could not resolve a dependency.""" +class CargoSubprocessResult: + """Raw process output from a single cargo invocation.""" - crate_name: str - subcommand: typ.Literal["package", "publish"] exit_code: int stdout: str stderr: str - missing_dependency_name: str | None @dc.dataclass(frozen=True, slots=True) -class CargoSubprocessResult: - """Raw process output from a single cargo invocation.""" +class CargoIndexLookupFailure: + """Represents a cargo failure where the index could not resolve a dependency.""" + crate_name: str + subcommand: typ.Literal["package", "publish"] exit_code: int stdout: str stderr: str + missing_dependency_name: str | None def parse_index_lookup_failure( From 19bae62013008e6a0f99147dab711276499f92a5 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 10 Jun 2026 00:28:01 +0200 Subject: [PATCH 05/12] Address review feedback on cargo output adapter Three review fixes for the cargo-output-parsing-to-adapter work: - developers-guide.md: correct the publish_manifest.py ownership sentence. README adoption is performed by `lading bump`, not publish_manifest.py, which only handles staging-time manifest mutations, workspace copying, and publish.strip_patches. - cargo_output_adapter.py: convert the parse_index_lookup_failure Examples block to an interactive numpy-style doctest that constructs a CargoSubprocessResult and asserts the extracted missing_dependency_name. The docstring becomes raw to satisfy D301. - publish_index_check.py: route the two fatal-path helpers (_raise_name_extraction_failure, _raise_out_of_plan_dependency) through the injected publish logger via the shared failure context instead of the module-level LOGGER, matching their siblings, and drop the now-unused LOGGER. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/developers-guide.md | 12 +++++---- lading/commands/cargo_output_adapter.py | 32 ++++++++++++---------- lading/commands/publish_index_check.py | 36 +++++++++---------------- 3 files changed, 38 insertions(+), 42 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 51b6e979..b28c87de 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -336,11 +336,13 @@ filtering non-publishable crates, applying `publish.exclude`, validating `publish.order` when present, or deriving a deterministic dependency order. `publish_manifest.py` owns staging-time manifest mutations. It contains -workspace preparation types and helpers that copy the workspace tree, stage -workspace README files for crates that opt in, and apply the -`publish.strip_patches` strategy to the staged `Cargo.toml`. These operations -run before any `cargo package` or `cargo publish` command, so the command -runner works against a prepared snapshot rather than the source workspace. +workspace preparation types and helpers that copy the workspace tree and apply +the `publish.strip_patches` strategy to the staged `Cargo.toml`. These +operations run before any `cargo package` or `cargo publish` command, so the +command runner works against a prepared snapshot rather than the source +workspace. Workspace README adoption is not performed here; the `lading bump` +command transposes the workspace README into each opted-in crate before +publication. `publish_diagnostics.py` owns compiletest failure enrichment. When a cargo pre-flight test failure mentions compiletest-style `*.stderr` artefacts, the diff --git a/lading/commands/cargo_output_adapter.py b/lading/commands/cargo_output_adapter.py index 251bef08..c81f328f 100644 --- a/lading/commands/cargo_output_adapter.py +++ b/lading/commands/cargo_output_adapter.py @@ -78,7 +78,7 @@ def parse_index_lookup_failure( subcommand: typ.Literal["package", "publish"], result: CargoSubprocessResult, ) -> CargoIndexLookupFailure | None: - """Return a structured index-lookup failure parsed from cargo output. + r"""Return a structured index-lookup failure parsed from cargo output. Parameters ---------- @@ -99,19 +99,23 @@ def parse_index_lookup_failure( Examples -------- - ```python - failure = parse_index_lookup_failure( - crate_name="beta", - subcommand="package", - result=CargoSubprocessResult( - exit_code=101, - stdout="", - stderr=cargo_stderr, - ), - ) - if failure is not None: - print(failure.missing_dependency_name) - ``` + >>> cargo_stderr = ( + ... "error: failed to select a version for the requirement " + ... '`inner_crate = "^0.8.0"`\n' + ... "location searched: crates.io index" + ... ) + >>> result = CargoSubprocessResult( + ... exit_code=101, + ... stdout="", + ... stderr=cargo_stderr, + ... ) + >>> failure = parse_index_lookup_failure( + ... crate_name="beta", + ... subcommand="package", + ... result=result, + ... ) + >>> failure.missing_dependency_name + 'inner_crate' """ if result.exit_code == 0: return None diff --git a/lading/commands/publish_index_check.py b/lading/commands/publish_index_check.py index d98e40f9..2696600e 100644 --- a/lading/commands/publish_index_check.py +++ b/lading/commands/publish_index_check.py @@ -18,8 +18,6 @@ from lading.commands.publish import _PublishExecutionOptions from lading.commands.publish_plan import PublishPlan -LOGGER = logging.getLogger(__name__) - _INDEX_MISSING_VERSION_DOWNGRADE_COUNTER: collections.Counter[tuple[str, str, str]] = ( collections.Counter() ) @@ -69,18 +67,16 @@ def _format_cargo_failure_message( def _raise_name_extraction_failure( - error_cls: type[Exception], - lookup_failure: CargoIndexLookupFailure, - failure_message: str, + context: _IndexMissingVersionFailure, ) -> typ.NoReturn: """Log and raise when the missing dependency name cannot be extracted.""" - LOGGER.warning( + context.logger.warning( "cargo %s for crate %s matched index-missing-version markers " "but the dependency name could not be extracted; treating as fatal", - lookup_failure.subcommand, - lookup_failure.crate_name, + context.failure.subcommand, + context.failure.crate_name, ) - raise error_cls(failure_message) + raise context.error_cls(context.failure_message) def _format_missing_dependency_failure( @@ -112,14 +108,13 @@ def _log_missing_dependency_failure( def _raise_out_of_plan_dependency( - error_cls: type[Exception], - lookup_failure: CargoIndexLookupFailure, - failure_message: str, + context: _IndexMissingVersionFailure, + *, missing_name: str, ) -> typ.NoReturn: """Log and raise when the unindexed dependency is outside the publish plan.""" message = _format_missing_dependency_failure( - failure_message, + context.failure_message, missing_name=missing_name, reason=( "is not part of the current publish plan, so the unpublished " @@ -128,12 +123,12 @@ def _raise_out_of_plan_dependency( guidance="Publish or index the dependency first.", ) _log_missing_dependency_failure( - LOGGER, - lookup_failure, + context.logger, + context.failure, missing_name=missing_name, detail="which is not in the current publish plan; cannot continue", ) - raise error_cls(message) + raise context.error_cls(message) def _raise_out_of_order_dependency( @@ -284,12 +279,7 @@ def _validate_dependency_placement( missing_index if missing_index is not None else "", ) if missing_index is None: - _raise_out_of_plan_dependency( - context.error_cls, - context.failure, - context.failure_message, - missing_name=missing_name, - ) + _raise_out_of_plan_dependency(context, missing_name=missing_name) if missing_index == current_index: _raise_self_dependency(context, missing_name=missing_name) if missing_index > current_index: @@ -324,7 +314,7 @@ def _handle_index_missing_version( missing_name = failure.missing_dependency_name if missing_name is None: - _raise_name_extraction_failure(error_cls, failure, failure_message) + _raise_name_extraction_failure(context) current_index, missing_index, missing_canonical_name = ( _validate_dependency_placement(context, handling, missing_name) From 26974199b9a7bd27937219dd2096cdbdf672c4ce Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 10 Jun 2026 02:09:03 +0200 Subject: [PATCH 06/12] Address further review feedback on cargo output adapter Three remaining issues from the CI quality check: - publish_index_check.py: remove the module-level mutable _INDEX_MISSING_VERSION_DOWNGRADE_COUNTER and its _record_index_missing_version_downgrade helper. The counter was never read outside the module, accumulated state with no observable effect, was not synchronised for concurrent access, and was not reset between tests, violating test isolation. The downgrade is already recorded via the logger.warning call that follows. - developers-guide.md: update three stale API signatures in the index-lookup handling section: - parse_index_lookup_failure now takes keyword-only (*, crate_name, subcommand, result: CargoSubprocessResult); document CargoSubprocessResult and CargoIndexLookupFailure as the input/output types. - _IndexMissingVersionFailure fields: replace stale 'invocation' with 'failure: CargoIndexLookupFailure' and add 'failure_message'. - _log_missing_dependency_failure second parameter renamed from 'invocation' to 'lookup_failure'. - test_cargo_output_adapter.py: add four Hypothesis property tests per AGENTS.md line 140 (property tests required when a change introduces parsing over varied input): - exit_code=0 always returns None regardless of output content - both markers + nonzero exit code always produces a failure - absence of the crates.io index marker always returns None - extracted missing_dependency_name matches the valid crate-name pattern when present Co-Authored-By: Claude Sonnet 4.6 (1M context) --- docs/developers-guide.md | 22 ++--- lading/commands/publish_index_check.py | 15 ---- .../unit/publish/test_cargo_output_adapter.py | 82 +++++++++++++++++++ 3 files changed, 94 insertions(+), 25 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index b28c87de..d8c341d2 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -419,11 +419,13 @@ visible in the registry. The index-lookup handling is split across the adapter and decision helper: -- `parse_index_lookup_failure(crate_name, subcommand, exit_code, stdout, stderr)` +- `parse_index_lookup_failure(*, crate_name, subcommand, result: CargoSubprocessResult)` checks for both Cargo's version-selection failure marker and the crates.io - index marker after confirming the command failed. Requiring both markers - minimizes false positives from unrelated resolver, registry, or command - failures. + index marker after confirming the command failed. It accepts a + `CargoSubprocessResult` value object (carrying `exit_code`, `stdout`, and + `stderr`) and returns a `CargoIndexLookupFailure` or `None`. Requiring both + markers minimizes false positives from unrelated resolver, registry, or + command failures. - The adapter parses the missing crate name from Cargo's requirement line. The regex accepts Cargo's backtick, single-quote, and double-quote delimiters around the requirement, captures the dependency name before `=`, and searches @@ -444,11 +446,11 @@ The index-lookup handling is split across the adapter and decision helper: `_IndexMissingVersionFailure` (frozen dataclass) Carries the four values required by every fatal-path helper: `error_cls` (the exception type to raise), -`invocation` (the failing `cargo` invocation), `failure` (the pre-formatted -failure message), and `logger`. Constructing it once in -`_handle_index_missing_version` and passing it to helpers eliminates argument -repetition and keeps each helper to two parameters, satisfying the PLR0913 -argument-count threshold. +`failure` (the `CargoIndexLookupFailure` that triggered the handler), +`failure_message` (the pre-formatted human-readable error string), and +`logger`. Constructing it once in `_handle_index_missing_version` and passing +it to helpers eliminates argument repetition and keeps each helper to two +parameters, satisfying the PLR0913 argument-count threshold. `_IndexMissingVersionHandling` (frozen dataclass) Carries the ambient context for the entire handler call: the active `PublishPlan`, the @@ -463,7 +465,7 @@ name, a domain-language reason clause, and an operator guidance sentence. All fatal-path helpers delegate message construction here, centralizing the text format. -`_log_missing_dependency_failure(logger, invocation, *, missing_name, detail)` +`_log_missing_dependency_failure(logger, lookup_failure, *, missing_name, detail)` -> `None` Emits a WARNING-level log entry for fatal index-missing-version paths, providing consistent phrasing across all raise helpers so log aggregation can match on a stable prefix. diff --git a/lading/commands/publish_index_check.py b/lading/commands/publish_index_check.py index 2696600e..fffef538 100644 --- a/lading/commands/publish_index_check.py +++ b/lading/commands/publish_index_check.py @@ -8,7 +8,6 @@ from __future__ import annotations -import collections import dataclasses as dc import logging import typing as typ @@ -18,10 +17,6 @@ from lading.commands.publish import _PublishExecutionOptions from lading.commands.publish_plan import PublishPlan -_INDEX_MISSING_VERSION_DOWNGRADE_COUNTER: collections.Counter[tuple[str, str, str]] = ( - collections.Counter() -) - @dc.dataclass(frozen=True, slots=True) class _IndexMissingVersionFailure: @@ -229,15 +224,6 @@ def _canonical_crate_name(name: str) -> str: return name.replace("-", "_") -def _record_index_missing_version_downgrade( - failure: CargoIndexLookupFailure, missing_name: str -) -> None: - """Increment the downgrade counter for an index-missing-version failure.""" - _INDEX_MISSING_VERSION_DOWNGRADE_COUNTER[ - failure.subcommand, failure.crate_name, missing_name - ] += 1 - - def _validate_dependency_placement( context: _IndexMissingVersionFailure, handling: _IndexMissingVersionHandling, @@ -335,7 +321,6 @@ def _handle_index_missing_version( context, missing_name=missing_name ) - _record_index_missing_version_downgrade(failure, 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/tests/unit/publish/test_cargo_output_adapter.py b/tests/unit/publish/test_cargo_output_adapter.py index 769774ec..42cbcef0 100644 --- a/tests/unit/publish/test_cargo_output_adapter.py +++ b/tests/unit/publish/test_cargo_output_adapter.py @@ -2,7 +2,11 @@ from __future__ import annotations +import re + import pytest +from hypothesis import given, settings +from hypothesis import strategies as st from lading.commands.cargo_output_adapter import ( CargoIndexLookupFailure, @@ -15,6 +19,36 @@ INDEX_MISSING_STDERR_UNPARSEABLE, ) +_MARKER_VERSION = "failed to select a version for the requirement" +_MARKER_INDEX = "location searched: crates.io index" +_VALID_CRATE_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$") + +_crate_names = st.from_regex(r"[A-Za-z_][A-Za-z0-9_-]*", fullmatch=True) +_quote_pairs = st.sampled_from([ + ("backtick", "`", "`"), + ("single", "'", "'"), + ("double", '"', '"'), +]) + + +@st.composite +def _both_markers_stderr( + draw: st.DrawFn, +) -> tuple[str, str]: + """Generate ``(crate_name, stderr)`` with both index-miss markers present.""" + name = draw(_crate_names) + _label, open_q, close_q = draw(_quote_pairs) + version = draw(st.from_regex(r"\^[0-9]+(\.[0-9]+){0,2}", fullmatch=True)) + prefix = draw(st.text(max_size=40)) + suffix = draw(st.text(max_size=40)) + stderr = ( + f"{prefix}" + f'{_MARKER_VERSION} {open_q}{name} = "{version}"{close_q}\n' + f"{_MARKER_INDEX}\n" + f"{suffix}" + ) + return name, stderr + def _parse_index_lookup_failure( exit_code: int, @@ -123,3 +157,51 @@ def test_parse_index_lookup_failure_returns_structured_failure( stderr=stderr, missing_dependency_name=expected_name, ) + + +@given(stdout=st.text(), stderr=st.text()) +@settings(max_examples=100, deadline=None) +def test_parse_index_lookup_failure_success_always_returns_none( + stdout: str, stderr: str +) -> None: + """A zero exit code always produces None regardless of output content.""" + assert _parse_index_lookup_failure(0, stdout, stderr) is None + + +@given(case=_both_markers_stderr(), exit_code=st.integers(min_value=1, max_value=255)) +@settings(max_examples=80, deadline=None) +def test_parse_index_lookup_failure_both_markers_nonzero_returns_failure( + case: tuple[str, str], exit_code: int +) -> None: + """Both index-miss markers with a non-zero exit code produce a failure.""" + _name, stderr = case + result = _parse_index_lookup_failure(exit_code, "", stderr) + assert result is not None + + +@given( + stdout=st.text(), + stderr=st.text().filter(lambda s: _MARKER_INDEX.lower() not in s.lower()), + exit_code=st.integers(min_value=1, max_value=255), +) +@settings(max_examples=80, deadline=None) +def test_parse_index_lookup_failure_missing_index_marker_returns_none( + stdout: str, stderr: str, exit_code: int +) -> None: + """Absence of the crates.io index marker always produces None.""" + combined = f"{stdout}\n{stderr}" + if _MARKER_INDEX.lower() in combined.lower(): + return + assert _parse_index_lookup_failure(exit_code, stdout, stderr) is None + + +@given(case=_both_markers_stderr(), exit_code=st.integers(min_value=1, max_value=255)) +@settings(max_examples=80, deadline=None) +def test_parse_index_lookup_failure_extracted_name_matches_crate_name_pattern( + case: tuple[str, str], exit_code: int +) -> None: + """When a name is extracted it always matches the valid crate-name pattern.""" + _name, stderr = case + result = _parse_index_lookup_failure(exit_code, "", stderr) + if result is not None and result.missing_dependency_name is not None: + assert _VALID_CRATE_NAME_RE.match(result.missing_dependency_name) From 953660ae2694ca69603f4d859e87592838f7ab94 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 10 Jun 2026 12:27:29 +0200 Subject: [PATCH 07/12] Add case-insensitive marker property test and adapter boundary logging Address two of three automated review findings; the third (missing integration tests) is already covered. - test_cargo_output_adapter.py: add a Hypothesis property test that randomly toggles the case of both index-miss markers and asserts parse_index_lookup_failure still classifies the failure and extracts the dependency name. This exercises the re.IGNORECASE contract that the existing composite strategy (exact-case markers) never covered. - cargo_output_adapter.py: add debug-level logging at the parsing boundary so the index-miss classification decision is observable. parse_index_lookup_failure now logs when a non-zero cargo exit fails to match the markers (passed through as a generic failure) and when it is classified as an index-lookup failure (with the parsed dependency name). Uses a module-level logger, consistent with publish.py. The downgrade-counter removal from the prior commit is deliberately not reverted: re-introducing unsynchronised module-global mutable state contradicts the earlier accepted review and the downgrade is already observable through the structured warning/info logging in _handle_index_missing_version. Integration coverage for the _package_crate/_publish_crate subprocess boundary already exists in test_phase_dispatch.py, which drives _package_publishable_crates/_publish_crates with a runner returning index-marker stderr and verifies routing to _handle_index_missing_version across the downgrade, fatal, out-of-plan, and canonicalisation paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/developers-guide.md | 4 +-- lading/commands/cargo_output_adapter.py | 26 +++++++++++++-- .../unit/publish/test_cargo_output_adapter.py | 32 +++++++++++++++++++ 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index d8c341d2..093bc84d 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -492,8 +492,8 @@ type `typ.NoReturn`): #### Crate-name canonicalization `_canonical_crate_name(name)` normalises a crate name by replacing every hyphen -with an underscore. It is applied by building a canonical publish-order index and -looking up both the current crate and missing dependency by canonical name: +with an underscore. It is applied by building a canonical publish-order index +and looking up both the current crate and missing dependency by canonical name: ```python publishable_name_indexes = { diff --git a/lading/commands/cargo_output_adapter.py b/lading/commands/cargo_output_adapter.py index c81f328f..48659e64 100644 --- a/lading/commands/cargo_output_adapter.py +++ b/lading/commands/cargo_output_adapter.py @@ -34,9 +34,12 @@ from __future__ import annotations import dataclasses as dc +import logging import re import typing as typ +LOGGER = logging.getLogger(__name__) + _INDEX_MISSING_VERSION_MARKERS: tuple[str, ...] = ( "failed to select a version for the requirement", "location searched: crates.io index", @@ -125,17 +128,34 @@ def parse_index_lookup_failure( re.search(re.escape(marker), haystack, re.IGNORECASE) for marker in _INDEX_MISSING_VERSION_MARKERS ): + LOGGER.debug( + "cargo %s for crate %s exited %d without index-lookup markers; " + "not classifying as an index-miss failure", + subcommand, + crate_name, + result.exit_code, + ) return None + missing_dependency_name = _extract_missing_dependency_name( + result.stdout, result.stderr + ) + LOGGER.debug( + "cargo %s for crate %s classified as an index-lookup failure " + "(missing dependency: %s)", + subcommand, + crate_name, + missing_dependency_name + if missing_dependency_name is not None + else "", + ) return CargoIndexLookupFailure( crate_name=crate_name, subcommand=subcommand, exit_code=result.exit_code, stdout=result.stdout, stderr=result.stderr, - missing_dependency_name=_extract_missing_dependency_name( - result.stdout, result.stderr - ), + missing_dependency_name=missing_dependency_name, ) diff --git a/tests/unit/publish/test_cargo_output_adapter.py b/tests/unit/publish/test_cargo_output_adapter.py index 42cbcef0..6e2ee103 100644 --- a/tests/unit/publish/test_cargo_output_adapter.py +++ b/tests/unit/publish/test_cargo_output_adapter.py @@ -50,6 +50,16 @@ def _both_markers_stderr( return name, stderr +def _randomly_cased(text: str) -> st.SearchStrategy[str]: + """Return a strategy yielding ``text`` with each letter's case toggled.""" + return st.lists(st.booleans(), min_size=len(text), max_size=len(text)).map( + lambda flags: "".join( + char.upper() if flag else char.lower() + for char, flag in zip(text, flags, strict=True) + ) + ) + + def _parse_index_lookup_failure( exit_code: int, stdout: str, @@ -205,3 +215,25 @@ def test_parse_index_lookup_failure_extracted_name_matches_crate_name_pattern( result = _parse_index_lookup_failure(exit_code, "", stderr) if result is not None and result.missing_dependency_name is not None: assert _VALID_CRATE_NAME_RE.match(result.missing_dependency_name) + + +@given( + version_marker=_randomly_cased(_MARKER_VERSION), + index_marker=_randomly_cased(_MARKER_INDEX), + name=_crate_names, + exit_code=st.integers(min_value=1, max_value=255), +) +@settings(max_examples=80, deadline=None) +def test_parse_index_lookup_failure_matches_markers_case_insensitively( + version_marker: str, index_marker: str, name: str, exit_code: int +) -> None: + """Marker matching and name extraction honour the re.IGNORECASE contract. + + The production markers are matched case-insensitively, so cargo output with + arbitrarily cased markers must still classify as an index-lookup failure and + yield the parsed dependency name. + """ + stderr = f'{version_marker} `{name} = "^1.0"`\n{index_marker}' + result = _parse_index_lookup_failure(exit_code, "", stderr) + assert result is not None + assert result.missing_dependency_name == name From 0fb75f285f07a1ddfe419d428a958547cc75bfa5 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 10 Jun 2026 12:30:45 +0200 Subject: [PATCH 08/12] Use Oxford -ize spelling in crate-name canonicalization docs The repo follows en-GB Oxford spelling (en-GB-oxendict: -ize/-ization, -yse, -our), so "normalizes"/"normalization" is correct. The rebase resolution had adopted the -ise forms; restore the -ize forms in the crate-name canonicalization section. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/developers-guide.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 093bc84d..7b4eda70 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -491,7 +491,7 @@ type `typ.NoReturn`): #### Crate-name canonicalization -`_canonical_crate_name(name)` normalises a crate name by replacing every hyphen +`_canonical_crate_name(name)` normalizes a crate name by replacing every hyphen with an underscore. It is applied by building a canonical publish-order index and looking up both the current crate and missing dependency by canonical name: @@ -507,7 +507,7 @@ missing_index = publishable_name_indexes.get(_canonical_crate_name(missing_name) This is necessary because Cargo error diagnostics may report a missing dependency using hyphens (e.g. `my-crate`), while the corresponding `Cargo.toml` entry and the `PublishPlan` store the same package name with -underscores (e.g. `my_crate`). Without normalisation, a hyphenated cargo +underscores (e.g. `my_crate`). Without normalization, a hyphenated cargo diagnostic would be incorrectly classified as an out-of-plan dependency and raise a fatal error instead of triggering the downgrade path. From 2a03a9cc0fc6fb301a23940fc51ce7989a655339 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 10 Jun 2026 12:59:42 +0200 Subject: [PATCH 09/12] Constrain both strategies in missing-index-marker property test Mirror the stderr strategy's filter on the stdout strategy so both exclude the crates.io index marker, then drop the now-redundant runtime guard in favour of a direct assertion. The marker contains no newline, so excluding it from each stream individually guarantees its absence from the newline-joined haystack. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/unit/publish/test_cargo_output_adapter.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/unit/publish/test_cargo_output_adapter.py b/tests/unit/publish/test_cargo_output_adapter.py index 6e2ee103..b49894de 100644 --- a/tests/unit/publish/test_cargo_output_adapter.py +++ b/tests/unit/publish/test_cargo_output_adapter.py @@ -190,7 +190,7 @@ def test_parse_index_lookup_failure_both_markers_nonzero_returns_failure( @given( - stdout=st.text(), + stdout=st.text().filter(lambda s: _MARKER_INDEX.lower() not in s.lower()), stderr=st.text().filter(lambda s: _MARKER_INDEX.lower() not in s.lower()), exit_code=st.integers(min_value=1, max_value=255), ) @@ -199,9 +199,6 @@ def test_parse_index_lookup_failure_missing_index_marker_returns_none( stdout: str, stderr: str, exit_code: int ) -> None: """Absence of the crates.io index marker always produces None.""" - combined = f"{stdout}\n{stderr}" - if _MARKER_INDEX.lower() in combined.lower(): - return assert _parse_index_lookup_failure(exit_code, stdout, stderr) is None From 0b8d9e53abbba1926a4de381ace12b2af2df35c1 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 10 Jun 2026 13:06:26 +0200 Subject: [PATCH 10/12] Add canonicalisation property tests and real cargo transcript golden tests Address two automated review warnings. Testing (Property / Proof): _canonical_crate_name was only covered by example-based behaviour tests in test_phase_dispatch.py. Add test_canonicalisation_properties.py with three Hypothesis properties: - canonicalisation drops every hyphen, preserves length, and is idempotent; - hyphen- and underscore-joined spellings of the same segments canonicalise equal (the unification that prevents false out-of-plan errors); - a publish-order index keyed by canonical name resolves a crate looked up with any separator styling, mirroring _validate_dependency_placement. Testing (Unit And Behavioural): add a parametrised golden test feeding verbatim, authentic full cargo index-miss transcripts (cargo package's "failed to prepare local package for uploading" form and cargo publish's "failed to verify package tarball" form, both with the candidate-versions and required-by context lines) through the public parse_index_lookup_failure API, asserting the structured failure and extracted dependency name. This guards the adapter's markers and name regex against drift from real cargo output and covers the package-phase path at the adapter level. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test_canonicalisation_properties.py | 69 +++++++++++++++++++ .../unit/publish/test_cargo_output_adapter.py | 51 ++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 tests/unit/publish/test_canonicalisation_properties.py diff --git a/tests/unit/publish/test_canonicalisation_properties.py b/tests/unit/publish/test_canonicalisation_properties.py new file mode 100644 index 00000000..4a8cf384 --- /dev/null +++ b/tests/unit/publish/test_canonicalisation_properties.py @@ -0,0 +1,69 @@ +"""Hypothesis property tests for crate-name canonicalisation. + +``_canonical_crate_name`` is documented as critical to prevent false +out-of-plan classifications: Cargo diagnostics report dependency names with +hyphens (e.g. ``my-crate``) while manifests and the publish plan store them +with underscores (e.g. ``my_crate``). These properties verify that +canonicalisation unifies the two spellings and that a publish-order index keyed +by canonical name resolves regardless of the separator styling used to query +it. +""" + +from __future__ import annotations + +from hypothesis import given, settings +from hypothesis import strategies as st + +from lading.commands.publish_index_check import _canonical_crate_name + +# Canonical (underscore-only) crate names, e.g. ``alpha`` or ``my_crate_core``. +_canonical_names = st.from_regex(r"[a-z][a-z0-9]*(_[a-z0-9]+)*", fullmatch=True) +# Arbitrary names spanning the characters Cargo and manifests may use. +_arbitrary_names = st.from_regex(r"[A-Za-z0-9_-]+", fullmatch=True) + + +@given(name=_arbitrary_names) +@settings(max_examples=100, deadline=None) +def test_canonical_crate_name_removes_all_hyphens(name: str) -> None: + """Canonicalisation drops every hyphen, preserves length, and is idempotent.""" + canonical = _canonical_crate_name(name) + assert "-" not in canonical + assert len(canonical) == len(name) + assert _canonical_crate_name(canonical) == canonical + + +@given(segments=st.lists(_canonical_names, min_size=1, max_size=4)) +@settings(max_examples=100, deadline=None) +def test_canonical_crate_name_unifies_separators(segments: list[str]) -> None: + """Hyphen- and underscore-joined spellings of the segments canonicalise equal.""" + hyphenated = "-".join(segments) + underscored = "_".join(segments) + assert _canonical_crate_name(hyphenated) == _canonical_crate_name(underscored) + assert _canonical_crate_name(hyphenated) == underscored + + +@given( + canonical_names=st.lists(_canonical_names, min_size=1, max_size=6, unique=True), + data=st.data(), +) +@settings(max_examples=100, deadline=None) +def test_canonicalisation_enables_restyled_publish_order_lookup( + canonical_names: list[str], data: st.DataObject +) -> None: + """A publish-order index keyed by canonical name resolves any separator styling. + + Mirrors how ``_validate_dependency_placement`` builds + ``publishable_name_indexes`` and looks up crate positions, confirming that a + differently-styled (hyphenated) query for a planned crate still maps to the + crate's true publish-order position. + """ + index_by_canonical = { + _canonical_crate_name(name): position + for position, name in enumerate(canonical_names) + } + for position, canonical in enumerate(canonical_names): + restyled = "".join( + data.draw(st.sampled_from(("-", "_"))) if char == "_" else char + for char in canonical + ) + assert index_by_canonical[_canonical_crate_name(restyled)] == position diff --git a/tests/unit/publish/test_cargo_output_adapter.py b/tests/unit/publish/test_cargo_output_adapter.py index b49894de..3d9f0858 100644 --- a/tests/unit/publish/test_cargo_output_adapter.py +++ b/tests/unit/publish/test_cargo_output_adapter.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +import typing as typ import pytest from hypothesis import given, settings @@ -169,6 +170,56 @@ def test_parse_index_lookup_failure_returns_structured_failure( ) +# Verbatim transcripts as cargo emits them on a crates.io index miss, including +# the full diagnostic preamble and trailing context lines. These guard the +# adapter's markers and name regex against drift from real cargo output. +_REAL_CARGO_PACKAGE_STDERR = ( + "error: failed to prepare local package for uploading\n" + "\n" + "Caused by:\n" + ' failed to select a version for the requirement `alpha = "^0.1.0"`\n' + " candidate versions found which didn't match: 0.0.1\n" + " location searched: crates.io index\n" + " required by package `beta v0.1.0`\n" +) +_REAL_CARGO_PUBLISH_STDERR = ( + "error: failed to verify package tarball\n" + "\n" + "Caused by:\n" + ' failed to select a version for the requirement `alpha = "^0.1.0"`\n' + " candidate versions found which didn't match: 0.0.1\n" + " location searched: crates.io index\n" + " required by package `beta v0.1.0 (/work/beta)`\n" +) + + +@pytest.mark.parametrize( + ("subcommand", "stderr"), + [ + pytest.param("package", _REAL_CARGO_PACKAGE_STDERR, id="cargo-package"), + pytest.param("publish", _REAL_CARGO_PUBLISH_STDERR, id="cargo-publish"), + ], +) +def test_parse_index_lookup_failure_parses_real_cargo_transcript( + subcommand: typ.Literal["package", "publish"], stderr: str +) -> None: + """Authentic full cargo index-miss transcripts parse through the adapter.""" + failure = parse_index_lookup_failure( + crate_name="beta", + subcommand=subcommand, + result=CargoSubprocessResult(exit_code=101, stdout="", stderr=stderr), + ) + + assert failure == CargoIndexLookupFailure( + crate_name="beta", + subcommand=subcommand, + exit_code=101, + stdout="", + stderr=stderr, + missing_dependency_name="alpha", + ) + + @given(stdout=st.text(), stderr=st.text()) @settings(max_examples=100, deadline=None) def test_parse_index_lookup_failure_success_always_returns_none( From 2fbc00405f8ddd1d2e775ba2d9cf07a41cfe99de Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 10 Jun 2026 13:19:22 +0200 Subject: [PATCH 11/12] Extract helpers from parse_index_lookup_failure to cut method length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse_index_lookup_failure exceeded CodeScene's 70-line method threshold. Extract the two distinct concerns in its body via Extract Function: - _output_matches_index_markers(haystack): "does this output indicate a crates.io index miss?" — the marker-presence check. - _build_index_lookup_failure(...): "construct the structured value object" — name extraction, classification debug log, and CargoIndexLookupFailure assembly. The top-level function now reads as a clear decision flow (success exit, non-matching output, matched failure) and falls below the threshold. No behavioural change; existing adapter and integration tests cover both helpers through the public API. Co-Authored-By: Claude Opus 4.8 (1M context) --- lading/commands/cargo_output_adapter.py | 27 +++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/lading/commands/cargo_output_adapter.py b/lading/commands/cargo_output_adapter.py index 48659e64..65e3eab6 100644 --- a/lading/commands/cargo_output_adapter.py +++ b/lading/commands/cargo_output_adapter.py @@ -124,10 +124,7 @@ def parse_index_lookup_failure( return None haystack = f"{result.stdout}\n{result.stderr}" - if not all( - re.search(re.escape(marker), haystack, re.IGNORECASE) - for marker in _INDEX_MISSING_VERSION_MARKERS - ): + if not _output_matches_index_markers(haystack): LOGGER.debug( "cargo %s for crate %s exited %d without index-lookup markers; " "not classifying as an index-miss failure", @@ -137,6 +134,28 @@ def parse_index_lookup_failure( ) return None + return _build_index_lookup_failure( + crate_name=crate_name, + subcommand=subcommand, + result=result, + ) + + +def _output_matches_index_markers(haystack: str) -> bool: + """Return whether both crates.io index-miss markers appear in cargo output.""" + return all( + re.search(re.escape(marker), haystack, re.IGNORECASE) + for marker in _INDEX_MISSING_VERSION_MARKERS + ) + + +def _build_index_lookup_failure( + *, + crate_name: str, + subcommand: typ.Literal["package", "publish"], + result: CargoSubprocessResult, +) -> CargoIndexLookupFailure: + """Assemble the structured failure once cargo output matched the markers.""" missing_dependency_name = _extract_missing_dependency_name( result.stdout, result.stderr ) From c5b8d089c6616e63ad793ea84565a0daf440a88c Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 10 Jun 2026 14:03:43 +0200 Subject: [PATCH 12/12] Add NumPy-style Attributes docstrings to public cargo dataclasses CargoSubprocessResult and CargoIndexLookupFailure are public API consumed by publish.py and publish_index_check.py but carried only single-line docstrings. Expand both to full NumPy-style docstrings with an Attributes section documenting every field, per the project's public-interface documentation guideline. Co-Authored-By: Claude Opus 4.8 (1M context) --- lading/commands/cargo_output_adapter.py | 31 +++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/lading/commands/cargo_output_adapter.py b/lading/commands/cargo_output_adapter.py index 65e3eab6..ca225573 100644 --- a/lading/commands/cargo_output_adapter.py +++ b/lading/commands/cargo_output_adapter.py @@ -56,7 +56,17 @@ @dc.dataclass(frozen=True, slots=True) class CargoSubprocessResult: - """Raw process output from a single cargo invocation.""" + """Raw process output from a single cargo invocation. + + Attributes + ---------- + exit_code : int + Process exit code returned by the cargo subprocess. + stdout : str + Standard output stream captured from the cargo subprocess. + stderr : str + Standard error stream captured from the cargo subprocess. + """ exit_code: int stdout: str @@ -65,7 +75,24 @@ class CargoSubprocessResult: @dc.dataclass(frozen=True, slots=True) class CargoIndexLookupFailure: - """Represents a cargo failure where the index could not resolve a dependency.""" + """Represents a cargo failure where the index could not resolve a dependency. + + Attributes + ---------- + crate_name : str + Name of the crate whose cargo invocation failed. + subcommand : Literal["package", "publish"] + Cargo subcommand that produced the failure. + exit_code : int + Non-zero process exit code from the failed cargo subprocess. + stdout : str + Standard output stream captured from the failed subprocess. + stderr : str + Standard error stream captured from the failed subprocess. + missing_dependency_name : str | None + Name of the dependency crate that could not be resolved from the + crates.io index, or :data:`None` if name extraction failed. + """ crate_name: str subcommand: typ.Literal["package", "publish"]