diff --git a/README.md b/README.md index 14f48c82..cd52a693 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Ready for a full tour? - [User guide](docs/users-guide.md) – installation, tutorial, and full `lading.toml` reference. -- [Developer guide](docs/developers-guide.md) – implementation notes, library entry - points, and testing hooks. +- [Developer guide](docs/developers-guide.md) – implementation notes, library + entry points, and testing hooks. Fair winds and following seas! ⚓ diff --git a/docs/cmd-mox-usage-guide.md b/docs/cmd-mox-usage-guide.md index 34be2192..08a39c9c 100644 --- a/docs/cmd-mox-usage-guide.md +++ b/docs/cmd-mox-usage-guide.md @@ -159,8 +159,8 @@ def test_spy(cmd_mox): assert spy.call_count == 1 ``` -A spy expectation can also use `times_called(count)`—an alias of -`times(count)`—to require a specific call count during verification. +A spy expectation can also use `times_called(count)`—an alias of `times(count)` +—to require a specific call count during verification. A spy can also forward to the real command while recording everything: @@ -268,8 +268,9 @@ few common ones are: - `in_order()` – enforce strict ordering with other expectations. - `any_order()` – allow the expectation to be satisfied in any position. - `passthrough()` – for spies, run the real command while recording it. -- `assert_called()`, `assert_not_called()`, `assert_called_with(*args, - stdin=None, env=None)` – spy-only helpers for post-verification assertions. +- `assert_called()`, `assert_not_called()`, + `assert_called_with(*args, stdin=None, env=None)` – spy-only helpers for + post-verification assertions. Refer to the [design document](./python-native-command-mocking-design.md) for the full table of methods and examples. diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 807ed9b9..f05155b1 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -49,8 +49,8 @@ standard-library usage, file hygiene, and design-size limits. The relevant Makefile variables are: - `PYLINT_PYTHON` — Python executable used by `uv tool run`; defaults to `pypy`. -- `PYLINT_TARGETS` — directories passed to Pylint; defaults to `lading scripts - tests`. +- `PYLINT_TARGETS` — directories passed to Pylint; defaults to + `lading scripts tests`. - `PYLINT_PYPY_SHIM_REF` — pinned `pylint-pypy-shim` revision. - `PYLINT_PYPY_SHIM` — Git URL assembled from the pinned shim revision. - `PYLINT` — full `uv tool run --python $(PYLINT_PYTHON)` invocation for the @@ -269,17 +269,17 @@ distinct handling. `publish_plan.py` owns publication planning and plan rendering. Its `PublishPlan` dataclass is the immutable boundary between workspace analysis and execution: it stores the workspace root, publishable crates in the resolved -order, crates skipped by manifest/configuration, and configured exclusions -that did not match a workspace crate. `plan_publication()` builds that object -by filtering non-publishable crates, applying `publish.exclude`, validating +order, crates skipped by manifest/configuration, and configured exclusions that +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 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. +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 @@ -288,12 +288,20 @@ 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 still fatal, or in-plan and downgraded by -`allow_unpublished_workspace_deps` during dry-run publication. +`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 only +place that should inspect cargo's "no matching package/version" diagnostic +markers or dependency-name regex. Keep additional cargo-output parsing in this +adapter unless it becomes broad enough to justify a more general command-output +adapter package. + +`publish_index_check.py` owns crates.io index-lookup downgrade decisions after +cargo 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` @@ -301,10 +309,10 @@ is out-of-plan and fatal, in-plan but still fatal, or in-plan and downgraded by forwarded to every `cargo package` and `cargo publish` invocation within a single `lading publish` run. Its fields are: -| Field | Type | Default | Purpose | -| --- | --- | --- | --- | -| `live` | `bool` | — | When `True`, omits `--dry-run` from `cargo publish`. | -| `allow_dirty` | `bool` | — | Passes `--allow-dirty` to both cargo subcommands. | +| Field | Type | Default | Purpose | +| ---------------------------------- | ------ | ------- | -------------------------------------------------------------------- | +| `live` | `bool` | — | When `True`, omits `--dry-run` from `cargo publish`. | +| `allow_dirty` | `bool` | — | Passes `--allow-dirty` to both cargo subcommands. | | `allow_unpublished_workspace_deps` | `bool` | `False` | Dry-run-only override; see `allow_unpublished_workspace_deps` above. | The dataclass is an internal implementation detail; callers interact with the @@ -326,54 +334,55 @@ diagnostics, and normalizes 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, test exclusions, unit-test-only narrowing, environment overrides, and optional -stderr-tail diagnostics. `_run_preflight_checks` builds these option objects -for `cargo check` and `cargo test` so command construction stays explicit and +stderr-tail diagnostics. `_run_preflight_checks` builds these option objects for +`cargo check` and `cargo test` so command construction stays explicit and testable. Publication dispatch deliberately differs by mode. Dry-run mode keeps the historical two-phase pipeline: package every publishable crate, then run `cargo publish --dry-run` for every crate. Live mode interleaves the pipeline -per crate: package the next crate, publish it, then advance to the next entry -in `PublishPlan.publishable`. That ordering lets dependent crates resolve newly +per crate: package the next crate, publish it, then advance to the next entry in +`PublishPlan.publishable`. That ordering lets dependent crates resolve newly uploaded in-plan dependencies during a single live release train. The live 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, *, plan, options)` 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. 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 use the flag in - dry-run mode or follow the staged-publish workaround. +The index-lookup handling is split across the adapter and decision helper: + +- `parse_index_lookup_failure(crate_name, subcommand, output)` + 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(CargoIndexLookupFailure, *, plan, options)` + 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. 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 + use the flag in dry-run mode or follow the staged-publish workaround. #### Crate-name canonicalization -`_canonical_crate_name(name)` normalizes a crate name by replacing every -hyphen with an underscore. It is applied to both sides of the -`publishable_names` membership check inside `_handle_index_missing_version`: +`_canonical_crate_name(name)` normalizes a crate name by replacing every hyphen +with an underscore. It is applied to both sides of the `publishable_names` +membership check inside `_handle_index_missing_version`: ```python publishable_names = {_canonical_crate_name(entry.name) for entry in plan.publishable} @@ -387,14 +396,14 @@ 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. -`_format_cargo_failure_message(command, crate_name, exit_code, output)` assembles -the human-readable error string that is embedded in every `PublishPreflightError` -or `PublishError` raised on a non-zero cargo exit. It is a pure function with no -side effects: given the cargo subcommand string, the crate name, the numeric -exit code, and the `(stdout, stderr)` pair, it returns a formatted message that -includes all four values. Using a single function for message construction keeps -the error format consistent across the packaging and publish phases and makes -snapshot testing straightforward. +`_format_cargo_failure_message(command, crate_name, exit_code, output)` +assembles the human-readable error string that is embedded in every +`PublishPreflightError` or `PublishError` raised on a non-zero cargo exit. It +is a pure function with no side effects: given the cargo subcommand string, the +crate name, the numeric exit code, and the `(stdout, stderr)` pair, it returns +a formatted message that includes all four values. Using a single function for +message construction keeps the error format consistent across the packaging and +publish phases and makes snapshot testing straightforward. ### Command runners (`lading.runtime`) @@ -410,8 +419,8 @@ surface as `PublishPreflightError`. ### Pre-flight validation (`publish_preflight`) -`lading.commands.publish_preflight` performs workspace validation before -any crate is packaged or published. Its public entry point is: +`lading.commands.publish_preflight` performs workspace validation before any +crate is packaged or published. Its public entry point is: ```python _run_preflight_checks( @@ -423,25 +432,25 @@ _run_preflight_checks( ) -> None ``` -The function verifies the git working tree is clean (unless `allow_dirty` -is set), then executes `cargo check` and `cargo test` in a temporary -`--target-dir` to keep preflight artefacts separate from the workspace's -own target directory. A non-zero exit from any step raises -`PublishPreflightError` with a descriptive message. +The function verifies the git working tree is clean (unless `allow_dirty` is +set), then executes `cargo check` and `cargo test` in a temporary +`--target-dir` to keep preflight artefacts separate from the workspace's own +target directory. A non-zero exit from any step raises `PublishPreflightError` +with a descriptive message. -| Helper | Purpose | -| --- | --- | +| Helper | Purpose | +| ------------------------------ | --------------------------------------------------------------------------------------------- | | `_compose_preflight_arguments` | Builds the base `cargo` argument tuple for a given target directory and `--all-targets` flag. | -| `_preflight_argument_sets` | Returns `(check_args, test_args)` tuples adapted for unit-test-only mode. | -| `_run_cargo_preflight` | Executes a single `cargo check` or `cargo test` invocation and raises on failure. | -| `_verify_clean_working_tree` | Runs `git status --porcelain` and raises if the tree is dirty and `allow_dirty` is `False`. | +| `_preflight_argument_sets` | Returns `(check_args, test_args)` tuples adapted for unit-test-only mode. | +| `_run_cargo_preflight` | Executes a single `cargo check` or `cargo test` invocation and raises on failure. | +| `_verify_clean_working_tree` | Runs `git status --porcelain` and raises if the tree is dirty and `allow_dirty` is `False`. | ### Per-crate publication helpers -`_package_crate` and `_publish_crate` are the atomic units of the -publication pipeline. Both accept the crate entry, publication state, and -command runner explicitly, then execute exactly one `cargo` invocation against -the crate's staging root: +`_package_crate` and `_publish_crate` are the atomic units of the publication +pipeline. Both accept the crate entry, publication state, and command runner +explicitly, then execute exactly one `cargo` invocation against the crate's +staging root: ```python _package_crate( @@ -465,8 +474,8 @@ to each pipeline/helper function rather than being bundled into the state. `_dispatch_publication` selects the live or dry-run pipeline and delegates accordingly. It is the sole branch that decides between the interleaved -per-crate flow and the historical two-phase batch flow, keeping `run()` -free of that decision. +per-crate flow and the historical two-phase batch flow, keeping `run()` free of +that decision. `lading.commands.publish_execution` loads the optional `cmd_mox` command-runner module with `importlib.import_module("cmd_mox.command_runner")`. Keeping the diff --git a/docs/lading-design.md b/docs/lading-design.md index 53ae62fe..fb22b9b1 100644 --- a/docs/lading-design.md +++ b/docs/lading-design.md @@ -328,11 +328,11 @@ lading bump [--dry-run] listed in the configuration keeps its existing `package.version` value during the update pass. - Dependency requirements referencing workspace members are rewritten using the - workspace graph. For each crate we map dependencies to their - `[dependencies]`, `[dev-dependencies]`, or `[build-dependencies]` sections - and update the requirement string only when the target crate's version - changes. Leading operators such as `^` and `~` are preserved so caret and - tilde semantics continue to apply. + workspace graph. For each crate we map dependencies to their `[dependencies]`, + `[dev-dependencies]`, or `[build-dependencies]` sections and update the + requirement string only when the target crate's version changes. Leading + operators such as `^` and `~` are preserved so caret and tilde semantics + continue to apply. - Crates excluded via `bump.exclude` still have their dependency requirements refreshed when they point at bumped members. This keeps the workspace graph consistent without forcing the excluded crate to change its own version. diff --git a/lading/commands/cargo_output_adapter.py b/lading/commands/cargo_output_adapter.py new file mode 100644 index 00000000..09107ee5 --- /dev/null +++ b/lading/commands/cargo_output_adapter.py @@ -0,0 +1,128 @@ +"""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, and the raw subprocess output +tuple ``(exit_code, stdout, 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", + output=(exit_code, stdout, 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( + *, + crate_name: str, + subcommand: typ.Literal["package", "publish"], + output: tuple[int, str, str], +) -> CargoIndexLookupFailure | None: + """Return a structured index-lookup failure parsed from cargo output. + + Parameters + ---------- + crate_name: + Name of the crate whose cargo invocation produced the output streams. + subcommand: + Cargo subcommand that produced the output. Currently limited to the + publish workflow's ``package`` and ``publish`` phases. + output: + Raw subprocess result as ``(exit_code, stdout, stderr)``. + + 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", + output=(exit_code, stdout, stderr), + ) + if failure is not None: + print(failure.missing_dependency_name) + ``` + """ + exit_code, stdout, stderr = output + 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 f30aa9ef..7d3a5522 100644 --- a/lading/commands/publish.py +++ b/lading/commands/publish.py @@ -19,10 +19,10 @@ **Per-crate helpers** :func:`_package_crate` and :func:`_publish_crate` each invoke ``cargo`` in the -correct staged directory for a single crate. Both detect -index-missing-version failures (:func:`_is_index_missing_version_error`) and -publish-phase already-uploaded errors (:func:`_is_already_published_error`) to -support non-fatal downgrade paths. +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** @@ -55,17 +55,17 @@ 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, ) from lading.commands.publish_index_check import ( - _CargoInvocation, _format_cargo_failure_message, - _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 ( _handle_index_missing_version as _raw_handle_index_missing_version, @@ -76,28 +76,31 @@ ) 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 +_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 +_validate_lockfile_freshness = _publish_preflight._validate_lockfile_freshness +_verify_clean_working_tree = _publish_preflight._verify_clean_working_tree LOGGER = logging.getLogger(__name__) @@ -352,7 +355,7 @@ def _resolve_staged_crate_root( def _handle_index_missing_version( - invocation: _CargoInvocation, + failure: CargoIndexLookupFailure, *, plan: PublishPlan, options: _PublishExecutionOptions, @@ -363,10 +366,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, plan=plan, options=options, error_cls=error_cls + failure, plan=plan, options=options, error_cls=error_cls ) @@ -407,16 +410,13 @@ 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", + output=(exit_code, stdout, 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) @@ -494,32 +494,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( @@ -528,11 +522,16 @@ 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", + output=(exit_code, stdout, 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 cdc6d12a..ec73f3d2 100644 --- a/lading/commands/publish_index_check.py +++ b/lading/commands/publish_index_check.py @@ -1,78 +1,29 @@ """Handle cargo index-lookup failures during publish workflows. -This module keeps the error detection and downgrade logic for missing registry +This module keeps the 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. +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 LOGGER = logging.getLogger(__name__) -_INDEX_MISSING_VERSION_MARKERS: tuple[str, ...] = ( - "failed to select a version for the requirement", - "location searched: crates.io index", -) _INDEX_MISSING_VERSION_DOWNGRADE_COUNTER: collections.Counter[tuple[str, str, str]] = ( collections.Counter() ) -# 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, -) - - -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] - def _format_cargo_failure_message( command: str, @@ -100,36 +51,36 @@ def _format_cargo_failure_message( def _raise_name_extraction_failure( error_cls: type[Exception], - invocation: _CargoInvocation, - failure: str, + lookup_failure: CargoIndexLookupFailure, + failure_message: str, ) -> typ.NoReturn: """Log and raise when the missing dependency name cannot be extracted.""" LOGGER.warning( "cargo %s for crate %s matched index-missing-version markers " "but the dependency name could not be extracted; treating as fatal", - invocation.subcommand, - invocation.crate_name, + lookup_failure.subcommand, + lookup_failure.crate_name, ) - raise error_cls(failure) + raise error_cls(failure_message) def _raise_out_of_plan_dependency( error_cls: type[Exception], - invocation: _CargoInvocation, - failure: str, + lookup_failure: CargoIndexLookupFailure, + failure_message: str, missing_name: str, ) -> typ.NoReturn: """Log and raise when the unindexed dependency is outside the publish plan.""" message = ( - f"{failure}; missing dependency {missing_name!r} is not part " + f"{failure_message}; missing dependency {missing_name!r} is not part " "of the current publish plan, so --allow-unpublished-workspace-deps " "cannot help. Publish or index the dependency first." ) LOGGER.warning( "cargo %s for crate %s failed due to unindexed dependency %r " "which is not in the current publish plan; cannot continue", - invocation.subcommand, - invocation.crate_name, + lookup_failure.subcommand, + lookup_failure.crate_name, missing_name, ) raise error_cls(message) @@ -137,8 +88,8 @@ def _raise_out_of_plan_dependency( def _raise_allow_unpublished_flag_required( error_cls: type[Exception], - invocation: _CargoInvocation, - failure: str, + lookup_failure: CargoIndexLookupFailure, + failure_message: str, missing_name: str, ) -> typ.NoReturn: """Log and raise when ``--allow-unpublished-workspace-deps`` is not set. @@ -148,7 +99,7 @@ def _raise_allow_unpublished_flag_required( to a warning. """ message = ( - f"{failure}; dependency {missing_name!r} is scheduled in " + f"{failure_message}; dependency {missing_name!r} is scheduled in " "this publish run but is not yet on crates.io. Re-run with " "--allow-unpublished-workspace-deps (dry-run only) or follow the " "staged-publish workaround in the user guide." @@ -157,8 +108,8 @@ def _raise_allow_unpublished_flag_required( "cargo %s for crate %s failed due to unindexed sibling dependency %r " "(in plan); re-run with --allow-unpublished-workspace-deps to " "downgrade to a warning, or follow the staged-publish workaround", - invocation.subcommand, - invocation.crate_name, + lookup_failure.subcommand, + lookup_failure.crate_name, missing_name, ) raise error_cls(message) @@ -177,16 +128,16 @@ def _canonical_crate_name(name: str) -> str: def _record_index_missing_version_downgrade( - invocation: _CargoInvocation, missing_name: str + failure: CargoIndexLookupFailure, missing_name: str ) -> None: """Increment the downgrade counter for an index-missing-version failure.""" _INDEX_MISSING_VERSION_DOWNGRADE_COUNTER[ - invocation.subcommand, invocation.crate_name, missing_name + failure.subcommand, failure.crate_name, missing_name ] += 1 def _handle_index_missing_version( - invocation: _CargoInvocation, + failure: CargoIndexLookupFailure, *, plan: PublishPlan, options: _PublishExecutionOptions, @@ -198,39 +149,41 @@ 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), ) - missing_name = _extract_missing_dependency_name(stdout, stderr) + missing_name = failure.missing_dependency_name if missing_name is None: - _raise_name_extraction_failure(error_cls, invocation, failure) + _raise_name_extraction_failure(error_cls, failure, failure_message) publishable_names = { _canonical_crate_name(entry.name) for entry in plan.publishable } if _canonical_crate_name(missing_name) not in publishable_names: - _raise_out_of_plan_dependency(error_cls, invocation, failure, missing_name) + _raise_out_of_plan_dependency(error_cls, failure, failure_message, missing_name) if not options.allow_unpublished_workspace_deps: _raise_allow_unpublished_flag_required( - error_cls, invocation, failure, missing_name + error_cls, failure, failure_message, missing_name ) - _record_index_missing_version_downgrade(invocation, missing_name) + _record_index_missing_version_downgrade(failure, missing_name) LOGGER.warning( "cargo %s for crate %s could not resolve sibling dependency %s " "from crates.io; continuing because " "--allow-unpublished-workspace-deps is set", - invocation.subcommand, - invocation.crate_name, + failure.subcommand, + failure.crate_name, missing_name, ) LOGGER.info( "Downgraded cargo %s failure for crate %s because dependency %s is " "part of the publish plan and --allow-unpublished-workspace-deps is set", - invocation.subcommand, - invocation.crate_name, + failure.subcommand, + failure.crate_name, missing_name, ) 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..afaa1806 --- /dev/null +++ b/tests/unit/publish/test_cargo_output_adapter.py @@ -0,0 +1,120 @@ +"""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", + output=(exit_code, stdout, 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_phase_dispatch.py b/tests/unit/publish/test_phase_dispatch.py index b4394594..0b4d682c 100644 --- a/tests/unit/publish/test_phase_dispatch.py +++ b/tests/unit/publish/test_phase_dispatch.py @@ -28,6 +28,7 @@ import pytest from lading.commands import publish, publish_index_check +from lading.commands.cargo_output_adapter import CargoIndexLookupFailure from .conftest import ( INDEX_MISSING_STDERR_BETA, @@ -112,24 +113,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 ca485f1d..fa853ca0 100644 --- a/tests/unit/publish/test_snapshot_messages.py +++ b/tests/unit/publish/test_snapshot_messages.py @@ -26,6 +26,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, @@ -52,6 +56,18 @@ class _IndexMissingCase(typ.NamedTuple): 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", + output=(1, "", stderr), + ) + if failure is None: + return None + + + def _pipeline_info_records( caplog: pytest.LogCaptureFixture, ) -> tuple[tuple[str, tuple[object, ...]], ...]: @@ -104,15 +120,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, @@ -171,15 +190,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,