diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 2804c67b..7b4eda70 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -335,13 +335,14 @@ 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 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 @@ -350,13 +351,16 @@ 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 -`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 +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 +393,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,50 +417,40 @@ 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 - `allow_unpublished_workspace_deps` is set, the helper logs a warning and +The index-lookup handling is split across the adapter and decision helper: + +- `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. 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 + `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 `_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 @@ -470,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. @@ -496,21 +491,23 @@ 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: +`_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: ```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)) ``` 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. diff --git a/lading/commands/cargo_output_adapter.py b/lading/commands/cargo_output_adapter.py new file mode 100644 index 00000000..ca225573 --- /dev/null +++ b/lading/commands/cargo_output_adapter.py @@ -0,0 +1,217 @@ +"""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 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 +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", + result=CargoSubprocessResult( + 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 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", +) + +# 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 CargoSubprocessResult: + """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 + stderr: str + + +@dc.dataclass(frozen=True, slots=True) +class CargoIndexLookupFailure: + """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"] + 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"], + result: CargoSubprocessResult, +) -> CargoIndexLookupFailure | None: + r"""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. + result: + Raw process output from the cargo invocation. + + Returns + ------- + CargoIndexLookupFailure | None + A structured failure when cargo could not resolve a dependency from + the crates.io index, otherwise :data:`None`. + + Examples + -------- + >>> 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 + + haystack = f"{result.stdout}\n{result.stderr}" + 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", + subcommand, + crate_name, + result.exit_code, + ) + 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 + ) + 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=missing_dependency_name, + ) + + +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..f9cfd12d 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,18 +55,19 @@ 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, + CargoSubprocessResult, + 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, _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 ( _handle_index_missing_version as _raw_handle_index_missing_version, @@ -41,28 +78,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__) @@ -252,7 +292,7 @@ def _resolve_staged_crate_root( def _handle_index_missing_version( - invocation: _CargoInvocation, + failure: CargoIndexLookupFailure, *, plan: PublishPlan, options: _PublishExecutionOptions, @@ -263,10 +303,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 +353,17 @@ 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", + 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) 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,20 @@ 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", + 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 # 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..fffef538 100644 --- a/lading/commands/publish_index_check.py +++ b/lading/commands/publish_index_check.py @@ -1,80 +1,30 @@ """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 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", -) - -# 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] - @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 @@ -118,10 +68,10 @@ def _raise_name_extraction_failure( context.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, + context.failure.subcommand, + context.failure.crate_name, ) - raise context.error_cls(context.failure) + raise context.error_cls(context.failure_message) def _format_missing_dependency_failure( @@ -137,7 +87,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,8 +95,8 @@ 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, ) @@ -159,7 +109,7 @@ def _raise_out_of_plan_dependency( ) -> typ.NoReturn: """Log and raise when the unindexed dependency is outside the publish plan.""" message = _format_missing_dependency_failure( - context.failure, + context.failure_message, missing_name=missing_name, reason=( "is not part of the current publish plan, so the unpublished " @@ -169,7 +119,7 @@ def _raise_out_of_plan_dependency( ) _log_missing_dependency_failure( context.logger, - context.invocation, + context.failure, missing_name=missing_name, detail="which is not in the current publish plan; cannot continue", ) @@ -183,11 +133,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 +146,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 +162,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 +191,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 +201,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 " @@ -289,20 +239,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, ) @@ -323,7 +274,7 @@ def _validate_dependency_placement( def _handle_index_missing_version( - invocation: _CargoInvocation, + failure: CargoIndexLookupFailure, *, handling: _IndexMissingVersionHandling, error_cls: type[Exception], @@ -334,18 +285,20 @@ 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) @@ -357,7 +310,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", @@ -372,8 +325,8 @@ def _handle_index_missing_version( "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 +338,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_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 new file mode 100644 index 00000000..3d9f0858 --- /dev/null +++ b/tests/unit/publish/test_cargo_output_adapter.py @@ -0,0 +1,287 @@ +"""Unit tests for adapting cargo output into structured index failures.""" + +from __future__ import annotations + +import re +import typing as typ + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from lading.commands.cargo_output_adapter import ( + CargoIndexLookupFailure, + CargoSubprocessResult, + parse_index_lookup_failure, +) + +from .conftest import ( + INDEX_MISSING_STDERR_BETA, + 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 _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, + stderr: str, +) -> CargoIndexLookupFailure | None: + """Parse a fixed publish failure fixture through the adapter.""" + return parse_index_lookup_failure( + crate_name="beta", + subcommand="publish", + result=CargoSubprocessResult( + 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, + ) + + +# 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( + 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().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), +) +@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.""" + 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) + + +@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 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..d17b2f88 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 @@ -32,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: @@ -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..91acd6be 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, @@ -137,24 +138,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..ef8746e4 100644 --- a/tests/unit/publish/test_snapshot_messages.py +++ b/tests/unit/publish/test_snapshot_messages.py @@ -28,6 +28,11 @@ import pytest from lading.commands import publish +from lading.commands.cargo_output_adapter import ( + CargoIndexLookupFailure, + CargoSubprocessResult, + parse_index_lookup_failure, +) from .conftest import ( INDEX_MISSING_STDERR_BETA, @@ -54,6 +59,22 @@ 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", + result=CargoSubprocessResult( + 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 +135,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 +210,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,