diff --git a/docs/developers-guide.md b/docs/developers-guide.md index e13cc649..70ac7700 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -622,14 +622,13 @@ did not match a workspace crate. `plan_publication()` builds that object by filtering non-publishable crates, applying `publish.exclude`, validating `publish.order` when present, or deriving a deterministic dependency order. -`publish_manifest.py` owns staging-time manifest mutations. It contains -workspace preparation types and helpers that copy the workspace tree 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_staging.py` owns workspace preparation types and helpers that copy the +workspace tree. `publish_manifest.py` owns the `publish.strip_patches` +staging-time manifest mutation. 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 @@ -860,6 +859,12 @@ responsibilities live in dedicated modules, imported by their original homes: - `lading.commands.bump_manifests` — per-manifest version and dependency-section rewriting plus the `_BumpContext` construction contract (imported by `bump`). +- `lading.commands.bump_pipeline` — bump run-sequence orchestration across + manifests, documentation, readmes, and lockfiles (imported by `bump`). +- `lading.commands.publish_pipeline` — per-crate package and publish execution, + result handling, and live/dry-run dispatch (imported by `publish`). +- `lading.commands.publish_staging` — workspace copy preparation, cleanup, and + staged crate path resolution (imported by `publish`). - `lading.workspace.graph_build` — builders converting `cargo metadata` output into workspace models; the error-bound coercion helpers live in `lading.workspace._coercion` (both imported by `workspace`). @@ -963,6 +968,11 @@ with a descriptive message. ### Per-crate publication helpers +`lading.commands.publish_pipeline` owns `_package_crate`, `_publish_crate`, +`_CrateAction`, `_for_each_publishable_crate`, `_PublicationPipelineState`, and +`_dispatch_publication`. Tests patch these symbols at their canonical module; +`publish.py` does not provide compatibility aliases. + `_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 @@ -1009,9 +1019,9 @@ 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. -`lading.commands.publish_execution` loads the optional `cmd_mox` command-runner -module with `importlib.import_module("cmd_mox.command_runner")`. Keeping the -module in an `object | None` variable avoids relying on +`lading.cli` loads the optional `cmd_mox` command-runner module with +`importlib.import_module("cmd_mox.command_runner")`. Keeping the module in an +`object | None` variable avoids relying on `from cmd_mox import ... # type: ignore` when the package is absent, and it prevents conflicting type declarations when `cmd_mox` is present in the type checker environment. diff --git a/docs/lading-design.md b/docs/lading-design.md index 2b24cdc1..0a5208e2 100644 --- a/docs/lading-design.md +++ b/docs/lading-design.md @@ -186,16 +186,18 @@ available during packaging. Any parse errors or missing manifests surface as #### Publish data flow -The publish data flow shows how the publish command orchestrates manifest -preparation, crate planning, and command execution. The workflow splits -configuration-driven patch stripping logic (all vs. per-crate) based on dry-run -and live modes, and feeds the resulting plan to execution helpers. +The publish data flow shows how the publish command coordinates crate planning, +workspace staging, manifest preparation, and command execution. The coordinator +delegates live and dry-run sequencing to `publish_pipeline`, which uses the +`publish_execution` command adapter through an injected runner. ```mermaid graph TD A["CLI: lading publish"] --> B["Module: lading.commands.publish"] B --> C["Module: lading.commands.publish_plan (publish_plan.py)"] C --> C1["Build PublishPlan with publishable_names"] + B --> S["Module: lading.commands.publish_staging (publish_staging.py)"] + S --> S1["Copy workspace and resolve staged crate paths"] B --> D["Module: lading.commands.publish_manifest (publish_manifest.py)"] D --> D1["_apply_strip_patch_strategy(staging_root, plan, strategy)"] D1 --> D2{"strip_patches configuration"} @@ -206,11 +208,10 @@ graph TD G --> H H --> I["Updated staged manifest used for publish"] - B --> J["Module: lading.commands.publish_execution (publish_execution.py)"] - J --> K["_invoke: subprocess execution with error adaptation"] - - B --> N["Module: lading.commands.publish_preflight (publish_preflight.py)"] - N --> O["Compose final publish plan and execute commands"] + B --> P["Module: lading.commands.publish_pipeline (publish_pipeline.py)"] + P --> P1["Dispatch live or dry-run package/publish pipeline"] + P --> J["Module: lading.commands.publish_execution (publish_execution.py)"] + J --> K["Run cargo command through the subprocess adapter"] ``` ### 2.3. Workspace Discovery and Model @@ -528,25 +529,28 @@ crate-by-crate publishing. sequenceDiagram participant Caller participant publish.py - participant publish_execution + participant publish_preflight participant publish_plan - participant publish_diagnostics + participant publish_staging + participant publish_pipeline + participant publish_execution Caller->>publish.py: publish(…, forbid_dirty=…) - publish.py->>publish.py: _build_preflight_environment(config.preflight) - alt aux_build configured - publish.py->>publish_execution: _run_aux_build_commands(...) - publish_execution-->>publish.py: (rc, stdout, stderr) - end - publish.py->>publish_execution: _invoke(cargo test --lib, ...) - publish_execution-->>publish.py: (rc, stdout, stderr) + publish.py->>publish_preflight: _run_preflight_checks(...) + publish_preflight->>publish_execution: _invoke(cargo check/test, ...) + publish_execution-->>publish_preflight: (rc, stdout, stderr) alt rc != 0 - publish.py->>publish_diagnostics: _append_compiletest_diagnostics(...) - publish_diagnostics-->>publish.py: augmented_message - publish.py-->>Caller: PublishPreflightError(augmented_message) + publish_preflight-->>publish.py: PublishPreflightError + publish.py-->>Caller: PublishPreflightError else rc == 0 publish.py->>publish_plan: plan_publication(workspace, configuration, workspace_root) publish_plan-->>publish.py: PublishPlan + publish.py->>publish_staging: prepare_workspace(plan) + publish_staging-->>publish.py: PublishPreparation + publish.py->>publish_pipeline: _dispatch_publication(plan, preparation) + publish_pipeline->>publish_execution: _invoke(cargo package/publish, ...) + publish_execution-->>publish_pipeline: (rc, stdout, stderr) + publish_pipeline-->>publish.py: Success publish.py-->>Caller: Success end ``` diff --git a/lading/commands/bump.py b/lading/commands/bump.py index 456d0566..0dfe28be 100644 --- a/lading/commands/bump.py +++ b/lading/commands/bump.py @@ -1,57 +1,16 @@ -"""Version bumping command implementation. - -This module is the coordinator for the ``bump`` command. :func:`run` is the -entry point invoked by the CLI command layer: given a workspace root and a -target version, it orchestrates every manifest, documentation, README, and -lockfile update and returns a formatted, human-readable summary of the run. - -:class:`BumpOptions` is the input configuration (dry-run flag, lockfile-rebuild -override, and the resolved configuration and workspace graph), and -:class:`BumpChanges` is the aggregated result record collecting the files a run -altered; :func:`run` builds a ``BumpChanges`` internally and renders it into the -returned summary message. - -Crate-set derivation is a single source of truth. The ``excluded`` and -``updated_crate_names`` sets are computed exactly once in -:func:`_initialize_bump_context` and threaded through -:func:`_apply_crate_manifest_update` via the :class:`_BumpContext`, rather than -being recomputed per crate (issue #97). See ``docs/developers-guide.md`` for the -rule; do not re-derive these sets in per-crate helpers. - -This module coordinates rather than implementing file-format specifics, which it -delegates to sibling modules: - -- :mod:`lading.commands.bump_docs` — documentation version rewrites. -- :mod:`lading.commands.bump_lockfiles` — lockfile regeneration. -- :mod:`lading.commands.bump_readme` — workspace README transposition. -""" +"""Coordinate the ``lading bump`` workflow.""" from __future__ import annotations import collections.abc as cabc import dataclasses as dc -import enum import logging import types import typing as typ from lading import config as config_module -from lading.commands import ( - bump_docs, - bump_lockfiles, - bump_manifests, - bump_readme, -) -from lading.commands.bump_manifests import ( - _WORKSPACE_SELECTORS, - _dependency_sections_for_crate, - _determine_package_selectors, - _freeze_dependency_sections, - _should_skip_crate_update, - _update_manifest, - _workspace_dependency_sections, -) -from lading.commands.bump_output import BumpChanges, _format_result_message +from lading.commands import bump_lockfiles, bump_manifests, bump_pipeline +from lading.commands.bump_output import _format_result_message from lading.utils import normalise_workspace_root if typ.TYPE_CHECKING: @@ -59,46 +18,14 @@ from lading.commands.bump_manifests import _BumpContext from lading.config import LadingConfig - from lading.workspace import WorkspaceCrate, WorkspaceGraph + from lading.workspace import WorkspaceGraph LOGGER = logging.getLogger(__name__) @dc.dataclass(frozen=True, slots=True) class BumpOptions: - """Configuration options for bump operations. - - Attributes - ---------- - dry_run : bool, default False - Preview manifest, documentation, and lockfile changes without writing - files or running state-changing lockfile rebuild commands. Dry-runs - report the lockfiles that would be rebuilt. - rebuild_lockfiles : bool | None, default None - Controls lockfile regeneration after manifest updates. ``None`` inherits - ``configuration.bump.rebuild_lockfiles``; ``True`` and ``False`` - override configuration for this run. - configuration : LadingConfig | None, default None - Loaded lading configuration. Programmatic callers may omit it only when - they want ``run`` to load configuration from the workspace root. - workspace : WorkspaceGraph | None, default None - Loaded workspace graph. Programmatic callers may omit it only when they - want ``run`` to inspect the workspace. - lockfile_repository : bump_lockfiles.LockfileRepository | None, default None - Port used for lockfile projection and regeneration. ``None`` selects - the cargo-backed adapter with the default subprocess runner. Tests - inject a repository so lockfile behaviour can be observed without - invoking real Cargo processes. - dependency_sections : Mapping[str, Collection[str]] - Explicit dependency sections to rewrite by crate name. - include_workspace_sections : bool, default False - Whether workspace dependency tables should be rewritten as well. - - Notes - ----- - Instances are frozen and slot-based. Options are immutable after creation - and compact, but callers should not rely on dynamic attributes or mutation. - """ + """Configuration options for bump operations.""" dry_run: bool = False rebuild_lockfiles: bool | None = None @@ -111,18 +38,6 @@ class BumpOptions: include_workspace_sections: bool = False -class _CrateManifestOutcome(enum.Enum): - """Closed outcome of processing a single crate manifest. - - Replaces a two-boolean record so the skipped/updated/unchanged states are - mutually exclusive and the ``both true`` state is unrepresentable. - """ - - SKIPPED = enum.auto() - UPDATED = enum.auto() - UNCHANGED = enum.auto() - - def run( workspace_root: Path | str, target_version: str, @@ -137,17 +52,25 @@ def run( len(context.updated_crate_names), ) changed_manifests: set[Path] = set() - _process_workspace_manifest(context, target_version, changed_manifests) - _process_crate_manifests(context, target_version, changed_manifests) - changed_documents = _process_documentation_files(context, target_version) - changed_readmes = _process_readme_transposition( + bump_pipeline._process_workspace_manifest( + context, target_version, changed_manifests + ) + bump_pipeline._process_crate_manifests(context, target_version, changed_manifests) + changed_documents = bump_pipeline._process_documentation_files( + context, target_version + ) + changed_readmes = bump_pipeline._process_readme_transposition( context, dry_run=context.base_options.dry_run ) - changed_lockfiles = _process_lockfiles(context, changed_manifests) - changes = _prepare_sorted_changes( + changed_lockfiles = bump_pipeline._process_lockfiles(context, changed_manifests) + changes = bump_pipeline._prepare_sorted_changes( context, changed_manifests, - (changed_documents, changed_readmes, changed_lockfiles), + bump_pipeline._BumpAuxiliaryChanges( + documents=tuple(changed_documents), + readmes=tuple(changed_readmes), + lockfiles=changed_lockfiles, + ), ) return _format_result_message( changes, @@ -167,13 +90,11 @@ def _initialize_bump_context( configuration = resolved_options.configuration if configuration is None: configuration = config_module.current_configuration() - workspace = resolved_options.workspace if workspace is None: from lading.workspace import load_workspace workspace = load_workspace(root_path) - rebuild_lockfiles = ( configuration.bump.rebuild_lockfiles if resolved_options.rebuild_lockfiles is None @@ -198,217 +119,12 @@ def _initialize_bump_context( updated_crate_names = frozenset( crate.name for crate in workspace.crates if crate.name not in excluded ) - workspace_manifest = root_path / "Cargo.toml" return bump_manifests._BumpContext( root_path=root_path, configuration=configuration, workspace=workspace, base_options=base_options, - workspace_manifest=workspace_manifest, + workspace_manifest=root_path / "Cargo.toml", excluded=excluded, updated_crate_names=updated_crate_names, ) - - -def _process_workspace_manifest( - context: _BumpContext, - target_version: str, - changed_manifests: set[Path], -) -> None: - """Update the workspace manifest when necessary.""" - dependency_sections = _workspace_dependency_sections(context.updated_crate_names) - workspace_options = dc.replace( - context.base_options, - dependency_sections=_freeze_dependency_sections(dependency_sections), - include_workspace_sections=True, - ) - if _update_manifest( - context.workspace_manifest, - _WORKSPACE_SELECTORS, - target_version, - workspace_options, - ): - changed_manifests.add(context.workspace_manifest) - - -def _process_crate_manifests( - context: _BumpContext, - target_version: str, - changed_manifests: set[Path], -) -> None: - """Update member crate manifests for the workspace.""" - processed_count = 0 - skipped_count = 0 - updated_count = 0 - for crate in context.workspace.crates: - processed_count += 1 - match _apply_crate_manifest_update(crate, target_version, context): - case _CrateManifestOutcome.SKIPPED: - skipped_count += 1 - case _CrateManifestOutcome.UPDATED: - changed_manifests.add(crate.manifest_path) - updated_count += 1 - case _CrateManifestOutcome.UNCHANGED: - pass - LOGGER.debug( - "Crate manifest processing complete: %d processed, %d skipped, %d updated", - processed_count, - skipped_count, - updated_count, - ) - - -def _process_documentation_files( - context: _BumpContext, - target_version: str, -) -> set[Path]: - """Update configured documentation targets for the workspace.""" - documentation_paths = bump_docs.resolve_documentation_targets( - context.root_path, context.configuration.bump.documentation - ) - return bump_docs.update_documentation_files( - documentation_paths, - target_version, - context.updated_crate_names, - dry_run=context.base_options.dry_run, - ) - - -def _process_readme_transposition(context: _BumpContext, *, dry_run: bool) -> set[Path]: - """Transpose workspace README files into opted-in member crates.""" - LOGGER.debug("Starting workspace README transposition") - changed_readmes: set[Path] = set() - transposed_entry_count = 0 - source_readme_path = context.root_path / "README.md" - cached_text: str | None = ( - source_readme_path.read_text(encoding="utf-8") - if source_readme_path.exists() - else None - ) - for crate in context.workspace.crates: - if not crate.readme_is_workspace: - continue - transposed_entry_count += 1 - try: - changed_path = bump_readme.transpose_readme_to_crate( - context.root_path, - crate, - dry_run=dry_run, - _source_text=cached_text, - ) - except bump_readme.ReadmeTranspositionError: - LOGGER.exception("README transposition failed for crate %r", crate.name) - raise - if changed_path is not None: - changed_readmes.add(changed_path) - LOGGER.debug( - "README transposition complete: %d entries, %d file(s) changed", - transposed_entry_count, - len(changed_readmes), - ) - return changed_readmes - - -def _process_lockfiles( - context: _BumpContext, - changed_manifests: set[Path], -) -> tuple[Path, ...]: - """Regenerate Cargo lockfiles when bump changes manifest content.""" - if context.base_options.rebuild_lockfiles is not True or not changed_manifests: - return () - lockfile_manifests = context.configuration.bump.lockfile_manifests - repository = ( - context.base_options.lockfile_repository - or bump_lockfiles.CargoLockfileRepository() - ) - if context.base_options.dry_run: - return repository.resolve_lockfile_paths(context.root_path, lockfile_manifests) - return repository.regenerate_lockfiles(context.root_path, lockfile_manifests) - - -def _prepare_sorted_changes( - context: _BumpContext, - changed_manifests: set[Path], - changed_aux: tuple[set[Path], set[Path], cabc.Sequence[Path]], -) -> BumpChanges: - """Return ordered :class:`BumpChanges` suitable for result rendering.""" - changed_documents, changed_readmes, changed_lockfiles = changed_aux - ordered_manifests = tuple( - sorted( - changed_manifests, - key=lambda path: (path != context.workspace_manifest, str(path)), - ) - ) - ordered_documents: tuple[Path, ...] = tuple( - sorted(changed_documents, key=lambda path: str(path)) - ) - ordered_lockfiles: tuple[Path, ...] = tuple( - sorted( - changed_lockfiles, - key=lambda path: (path != context.root_path / "Cargo.lock", str(path)), - ) - ) - ordered_readmes: tuple[Path, ...] = tuple( - sorted(changed_readmes, key=lambda path: str(path)) - ) - return BumpChanges( - manifests=ordered_manifests, - documents=ordered_documents, - transposed_readmes=ordered_readmes, - lockfiles=ordered_lockfiles, - ) - - -def _apply_crate_manifest_update( - crate: WorkspaceCrate, - target_version: str, - context: _BumpContext, -) -> _CrateManifestOutcome: - """Apply updates for ``crate`` and return the closed manifest outcome. - - The crate sets are read from ``context`` — they are derived exactly once - in :func:`_initialize_bump_context` (issue #97); helpers must not - recompute them per crate. - """ - selectors = _determine_package_selectors(crate.name, context.excluded) - dependency_sections = _dependency_sections_for_crate( - crate, context.updated_crate_names - ) - - if _should_skip_crate_update(selectors, dependency_sections): - LOGGER.debug("Skipping crate manifest update for excluded crate %r", crate.name) - return _CrateManifestOutcome.SKIPPED - - crate_options = dc.replace( - context.base_options, - dependency_sections=_freeze_dependency_sections(dependency_sections), - ) - was_updated = _update_manifest( - crate.manifest_path, - selectors, - target_version, - crate_options, - ) - if was_updated: - LOGGER.debug( - "Updated crate manifest for crate %r: manifest=%s", - crate.name, - crate.manifest_path, - ) - if not crate_options.dry_run: - LOGGER.debug( - "Wrote crate manifest for crate %r: manifest=%s", - crate.name, - crate.manifest_path, - ) - else: - LOGGER.debug( - "Crate manifest already up to date for crate %r: manifest=%s", - crate.name, - crate.manifest_path, - ) - return ( - _CrateManifestOutcome.UPDATED - if was_updated - else _CrateManifestOutcome.UNCHANGED - ) diff --git a/lading/commands/bump_manifests.py b/lading/commands/bump_manifests.py index e1903358..da72d0a0 100644 --- a/lading/commands/bump_manifests.py +++ b/lading/commands/bump_manifests.py @@ -44,8 +44,9 @@ class _BumpContext: """Initialisation context for bump operations. Derived once by ``bump._initialize_bump_context`` and consumed by - :func:`bump._apply_crate_manifest_update`; the manifest-mutation contract lives with - this extracted module rather than reaching back into ``bump`` internals. + :func:`bump_pipeline._apply_crate_manifest_update`; the manifest-mutation + contract lives with this extracted module rather than reaching back into + ``bump`` internals. """ root_path: Path diff --git a/lading/commands/bump_pipeline.py b/lading/commands/bump_pipeline.py new file mode 100644 index 00000000..cead3df6 --- /dev/null +++ b/lading/commands/bump_pipeline.py @@ -0,0 +1,252 @@ +"""Run the ordered stages of a version bump. + +The coordinator initializes shared context, then calls this module to update +the workspace manifest, member manifests, documentation, readmes, and +lockfiles. The resulting paths flow through :func:`_prepare_sorted_changes` +before the coordinator renders the user-facing summary. +""" + +from __future__ import annotations + +import collections.abc as cabc +import dataclasses as dc +import enum +import logging +import typing as typ + +from lading.commands import bump_docs, bump_lockfiles, bump_readme +from lading.commands.bump_manifests import ( + _WORKSPACE_SELECTORS, + _dependency_sections_for_crate, + _determine_package_selectors, + _freeze_dependency_sections, + _should_skip_crate_update, + _update_manifest, + _workspace_dependency_sections, +) +from lading.commands.bump_output import BumpChanges + +if typ.TYPE_CHECKING: + from pathlib import Path + + from lading.commands.bump_manifests import _BumpContext + from lading.workspace import WorkspaceCrate + +_log = logging.getLogger(__name__) + + +class _CrateManifestOutcome(enum.Enum): + """Closed outcome of processing a single crate manifest. + + Replaces a two-boolean record so the skipped/updated/unchanged states are + mutually exclusive and the ``both true`` state is unrepresentable. + """ + + SKIPPED = enum.auto() + UPDATED = enum.auto() + UNCHANGED = enum.auto() + + +@dc.dataclass(frozen=True, slots=True) +class _BumpAuxiliaryChanges: + """Changed non-manifest paths produced by the bump pipeline.""" + + documents: cabc.Sequence[Path] + readmes: cabc.Sequence[Path] + lockfiles: cabc.Sequence[Path] + + +def _process_workspace_manifest( + context: _BumpContext, + target_version: str, + changed_manifests: set[Path], +) -> None: + """Update the workspace manifest when necessary.""" + dependency_sections = _workspace_dependency_sections(context.updated_crate_names) + workspace_options = dc.replace( + context.base_options, + dependency_sections=_freeze_dependency_sections(dependency_sections), + include_workspace_sections=True, + ) + if _update_manifest( + context.workspace_manifest, + _WORKSPACE_SELECTORS, + target_version, + workspace_options, + ): + changed_manifests.add(context.workspace_manifest) + + +def _process_crate_manifests( + context: _BumpContext, + target_version: str, + changed_manifests: set[Path], +) -> None: + """Update member crate manifests for the workspace.""" + processed_count = 0 + skipped_count = 0 + updated_count = 0 + for crate in context.workspace.crates: + processed_count += 1 + match _apply_crate_manifest_update(crate, target_version, context): + case _CrateManifestOutcome.SKIPPED: + skipped_count += 1 + case _CrateManifestOutcome.UPDATED: + changed_manifests.add(crate.manifest_path) + updated_count += 1 + case _CrateManifestOutcome.UNCHANGED: + pass + _log.debug( + "Crate manifest processing complete: %d processed, %d skipped, %d updated", + processed_count, + skipped_count, + updated_count, + ) + + +def _process_documentation_files( + context: _BumpContext, + target_version: str, +) -> set[Path]: + """Update configured documentation targets for the workspace.""" + documentation_paths = bump_docs.resolve_documentation_targets( + context.root_path, context.configuration.bump.documentation + ) + return bump_docs.update_documentation_files( + documentation_paths, + target_version, + context.updated_crate_names, + dry_run=context.base_options.dry_run, + ) + + +def _process_readme_transposition(context: _BumpContext, *, dry_run: bool) -> set[Path]: + """Transpose workspace README files into opted-in member crates.""" + _log.debug("Starting workspace README transposition") + changed_readmes: set[Path] = set() + transposed_entry_count = 0 + source_readme_path = context.root_path / "README.md" + cached_text: str | None = ( + source_readme_path.read_text(encoding="utf-8") + if source_readme_path.exists() + else None + ) + for crate in context.workspace.crates: + if not crate.readme_is_workspace: + continue + transposed_entry_count += 1 + changed_path = bump_readme.transpose_readme_to_crate( + context.root_path, + crate, + dry_run=dry_run, + _source_text=cached_text, + ) + if changed_path is not None: + changed_readmes.add(changed_path) + _log.debug( + "README transposition complete: %d entries, %d file(s) changed", + transposed_entry_count, + len(changed_readmes), + ) + return changed_readmes + + +def _process_lockfiles( + context: _BumpContext, + changed_manifests: set[Path], +) -> tuple[Path, ...]: + """Regenerate Cargo lockfiles when bump changes manifest content.""" + if context.base_options.rebuild_lockfiles is not True or not changed_manifests: + return () + lockfile_manifests = context.configuration.bump.lockfile_manifests + repository = ( + context.base_options.lockfile_repository + or bump_lockfiles.CargoLockfileRepository() + ) + if context.base_options.dry_run: + return repository.resolve_lockfile_paths(context.root_path, lockfile_manifests) + return repository.regenerate_lockfiles(context.root_path, lockfile_manifests) + + +def _prepare_sorted_changes( + context: _BumpContext, + changed_manifests: set[Path], + auxiliary_changes: _BumpAuxiliaryChanges, +) -> BumpChanges: + """Return ordered :class:`BumpChanges` suitable for result rendering.""" + ordered_manifests = tuple( + sorted( + changed_manifests, + key=lambda path: (path != context.workspace_manifest, str(path)), + ) + ) + ordered_documents: tuple[Path, ...] = tuple( + sorted(auxiliary_changes.documents, key=lambda path: str(path)) + ) + ordered_lockfiles: tuple[Path, ...] = tuple( + sorted( + auxiliary_changes.lockfiles, + key=lambda path: (path != context.root_path / "Cargo.lock", str(path)), + ) + ) + ordered_readmes: tuple[Path, ...] = tuple( + sorted(auxiliary_changes.readmes, key=lambda path: str(path)) + ) + return BumpChanges( + manifests=ordered_manifests, + documents=ordered_documents, + transposed_readmes=ordered_readmes, + lockfiles=ordered_lockfiles, + ) + + +def _apply_crate_manifest_update( + crate: WorkspaceCrate, + target_version: str, + context: _BumpContext, +) -> _CrateManifestOutcome: + """Apply updates for ``crate`` and return the closed manifest outcome.""" + # These crate sets are derived once in `_initialize_bump_context` + # (issue #97); per-crate processing must not recompute them. + selectors = _determine_package_selectors(crate.name, context.excluded) + dependency_sections = _dependency_sections_for_crate( + crate, context.updated_crate_names + ) + + if _should_skip_crate_update(selectors, dependency_sections): + _log.debug("Skipping crate manifest update for excluded crate %r", crate.name) + return _CrateManifestOutcome.SKIPPED + + crate_options = dc.replace( + context.base_options, + dependency_sections=_freeze_dependency_sections(dependency_sections), + ) + was_updated = _update_manifest( + crate.manifest_path, + selectors, + target_version, + crate_options, + ) + if was_updated: + _log.debug( + "Updated crate manifest for crate %r: manifest=%s", + crate.name, + crate.manifest_path, + ) + if not crate_options.dry_run: + _log.debug( + "Wrote crate manifest for crate %r: manifest=%s", + crate.name, + crate.manifest_path, + ) + else: + _log.debug( + "Crate manifest already up to date for crate %r: manifest=%s", + crate.name, + crate.manifest_path, + ) + return ( + _CrateManifestOutcome.UPDATED + if was_updated + else _CrateManifestOutcome.UNCHANGED + ) diff --git a/lading/commands/publish.py b/lading/commands/publish.py index 7b0aaea2..ad8d8464 100644 --- a/lading/commands/publish.py +++ b/lading/commands/publish.py @@ -1,165 +1,31 @@ -"""Orchestrate the ``lading publish`` workflow. - -``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` — canonical home of the publish - pre-flight checks (``cargo check`` / ``cargo test`` / git-status guards). - Callers and tests use that module directly; this module holds no - compatibility aliases for its helpers. -* :mod:`lading.commands.publish_errors` — :class:`PublishPreflightError` and - :class:`PublishError` -* :mod:`lading.commands.publish_execution` — subprocess invocation and cmd-mox - integration -""" +"""Coordinate planning, preflight checks, staging, and publication.""" from __future__ import annotations -import atexit import dataclasses as dc import logging -import shutil -import tempfile import typing as typ from pathlib import Path from lading import config as config_module -from lading.commands import 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 ( - _format_cargo_failure_message, - _IndexMissingVersionHandling, -) -from lading.commands.publish_index_check import ( - _handle_index_missing_version as _raw_handle_index_missing_version, -) -from lading.commands.publish_manifest import ( - PublishPreparationError, - _apply_strip_patch_strategy, -) -from lading.commands.publish_plan import ( - PublishPlan, - format_plan, - plan_publication, -) -from lading.commands.publish_plan import ( - PublishPlanError as PublishPlanError, # public re-export for plan_publication -) +from lading.commands import publish_pipeline, publish_preflight, publish_staging +from lading.commands.publish_errors import PublishPreflightError +from lading.commands.publish_manifest import _apply_strip_patch_strategy +from lading.commands.publish_plan import PublishPlanError as PublishPlanError +from lading.commands.publish_plan import format_plan, plan_publication from lading.utils.path import normalise_workspace_root -LOGGER = logging.getLogger(__name__) - if typ.TYPE_CHECKING: from lading.config import LadingConfig from lading.runtime import CommandRunner - from lading.workspace import WorkspaceCrate, WorkspaceGraph - - -@dc.dataclass(frozen=True, slots=True) -class _PublishExecutionOptions: - """Runtime flags that affect cargo package/publish invocations.""" + from lading.workspace import WorkspaceGraph - live: bool - allow_dirty: bool - allow_unpublished_workspace_deps: bool = False - - -@dc.dataclass(frozen=True, slots=True) -class _PublicationPipelineState: - """Shared publish state for cargo package and publish invocations. - - Design note (issue #72): this bundle is deliberate. The per-crate - helpers (``_package_crate``, ``_publish_crate``) would otherwise need - ``plan``, ``preparation``, and ``options`` threaded individually, - pushing their signatures past the argument-count lint ceiling and - inviting positional mix-ups. The three fields are constructed together - in each pipeline entry point and are immutable for the pipeline's - lifetime, which is the invariant the dataclass enforces. - """ - - plan: PublishPlan - preparation: PublishPreparation - options: _PublishExecutionOptions +LOGGER = logging.getLogger(__name__) @dc.dataclass(frozen=True, slots=True) class PublishOptions: - """Runtime configuration for publish planning, staging, and checks. - - Parameters - ---------- - allow_dirty: - When ``True`` the git cleanliness guard is skipped. - live: - When :data:`True`, execute ``cargo publish`` without ``--dry-run``. - Defaults to :data:`False` so publishing remains a dry-run unless - explicitly enabled. - build_directory: - Optional directory used to stage workspace artifacts. When ``None``, - a temporary directory is created for each invocation. - preserve_symlinks: - Control whether staging preserves symbolic links in the workspace - clone instead of dereferencing them. - cleanup: - When :data:`True`, the staged workspace is removed automatically on - process exit. - configuration: - Optional :class:`~lading.config.LadingConfig` instance to reuse instead - of loading from disk. - workspace: - Optional pre-loaded workspace graph to reuse for planning. - command_runner: - Optional callable used to execute shell commands. Primarily intended - for tests and dependency injection. - allow_unpublished_workspace_deps: - When :data:`True`, downgrade ``cargo package`` failures caused by a - sibling workspace crate version not yet visible on the crates.io index - to a warning, provided the missing crate is part of the planned - publish set. Only valid in dry-run mode (``live=False``); combining it - with ``live=True`` raises :class:`PublishPreflightError`. - - """ + """Runtime configuration for publish planning, staging, and checks.""" allow_dirty: bool = True live: bool = False @@ -172,393 +38,12 @@ class PublishOptions: allow_unpublished_workspace_deps: bool = False -@dc.dataclass(frozen=True, slots=True) -class PublishPreparation: - """Details about the staged workspace copy.""" - - staging_root: Path - copied_readmes: tuple[Path, ...] - - -def _normalise_build_directory( - workspace_root: Path, build_directory: Path | None -) -> Path: - """Return a directory suitable for staging workspace artifacts.""" - if build_directory is None: - return Path(tempfile.mkdtemp(prefix="lading-publish-")) - - candidate = Path(build_directory).expanduser() - candidate = candidate.resolve(strict=False) - - workspace_root = workspace_root.resolve(strict=True) - if candidate.is_relative_to(workspace_root): - message = "Publish build directory cannot reside within the workspace root" - raise PublishPreparationError(message) - - candidate.mkdir(parents=True, exist_ok=True) - return candidate - - -def _copy_workspace_tree( - workspace_root: Path, build_directory: Path, *, preserve_symlinks: bool -) -> Path: - """Copy ``workspace_root`` into ``build_directory`` and return the clone. - - When ``preserve_symlinks`` is :data:`True`, the cloned tree keeps symbolic - links instead of dereferencing them. This avoids unexpectedly copying large - directories outside the workspace while still allowing callers to opt into - dereferencing if required. - """ - workspace_root = workspace_root.resolve(strict=True) - staging_root = build_directory / workspace_root.name - if staging_root.resolve(strict=False).is_relative_to(workspace_root): - message = "Publish staging directory cannot be nested inside the workspace root" - raise PublishPreparationError(message) - if staging_root.exists(): - shutil.rmtree(staging_root) - shutil.copytree(workspace_root, staging_root, symlinks=preserve_symlinks) - return staging_root - - -def prepare_workspace( - plan: PublishPlan, - workspace: WorkspaceGraph, - *, - options: PublishOptions | None = None, -) -> PublishPreparation: - """Stage a workspace copy for publishing.""" - active_options = PublishOptions() if options is None else options - build_directory = _normalise_build_directory( - plan.workspace_root, active_options.build_directory - ) - LOGGER.info( - "Preparing staged workspace for publication under %s", - build_directory, - ) - staging_root = _copy_workspace_tree( - plan.workspace_root, - build_directory, - preserve_symlinks=active_options.preserve_symlinks, - ) - LOGGER.info("Staged workspace created at %s", staging_root) - LOGGER.info("Workspace README staging skipped; handled by lading bump") - preparation = PublishPreparation(staging_root=staging_root, copied_readmes=()) - if active_options.cleanup: - build_root = staging_root.parent - - def _cleanup() -> None: - """Remove the staged build directory on process exit.""" - shutil.rmtree(build_root, ignore_errors=True) - - atexit.register(_cleanup) - return preparation - - -def _format_preparation_summary(preparation: PublishPreparation) -> tuple[str, ...]: - """Return formatted summary lines for staging results.""" - lines = [f"Staged workspace at: {preparation.staging_root}"] - lines.append("Workspace READMEs are handled by lading bump.") - return tuple(lines) - - -def _resolve_staged_crate_root( - crate: WorkspaceCrate, - plan: PublishPlan, - staging_root: Path, -) -> Path: - """Return the staged crate root, ensuring it resides within the workspace.""" - try: - relative_root = crate.root_path.relative_to(plan.workspace_root) - except ValueError as exc: # pragma: no cover - defensive guard - message = ( - f"Crate {crate.name!r} root {crate.root_path} is outside workspace " - f"{plan.workspace_root}" - ) - raise PublishPreparationError(message) from exc - - staged_root = staging_root / relative_root - if not staged_root.exists(): # pragma: no cover - defensive guard - message = f"Staged crate root not found for {crate.name!r}: {staged_root}" - raise PublishPreparationError(message) - - return staged_root - - -def _handle_index_missing_version( - failure: CargoIndexLookupFailure, - *, - plan: PublishPlan, - options: _PublishExecutionOptions, -) -> None: - """Pick the phase-appropriate error class and delegate to the helper. - - Resolve the error class here based on the cargo subcommand and pass it - through to the relocated implementation in ``publish_index_check``. - """ - error_cls = ( - PublishError if failure.subcommand == "publish" else PublishPreflightError - ) - _raw_handle_index_missing_version( - failure, - handling=_IndexMissingVersionHandling( - plan=plan, - options=options, - logger=LOGGER, - ), - error_cls=error_cls, - ) - - -class _CrateAction(typ.Protocol): - """Action applied to each crate in the publication pipeline.""" - - def __call__( - self, - crate: WorkspaceCrate, - state: _PublicationPipelineState, - *, - runner: CommandRunner, - ) -> None: - """Process a single staged crate from the pipeline.""" - - -def _for_each_publishable_crate( - state: _PublicationPipelineState, - *, - runner: CommandRunner, - action: _CrateAction, -) -> None: - """Apply *action* to every publishable crate in pipeline order.""" - for crate in state.plan.publishable: - action(crate, state, runner=runner) - - -def _package_publishable_crates( - plan: PublishPlan, - preparation: PublishPreparation, - *, - options: _PublishExecutionOptions, - runner: CommandRunner, -) -> None: - """Package each publishable crate in order using the staged workspace.""" - _for_each_publishable_crate( - _PublicationPipelineState(plan, preparation, options), - runner=runner, - action=_package_crate, - ) - - -def _package_crate( - crate: WorkspaceCrate, - state: _PublicationPipelineState, - *, - runner: CommandRunner, -) -> None: - """Package one publishable crate using the staged workspace.""" - plan = state.plan - options = state.options - package_args: tuple[str, ...] = ("--allow-dirty",) if options.allow_dirty else () - crate_root = _resolve_staged_crate_root(crate, plan, state.preparation.staging_root) - LOGGER.info("Running cargo package for crate %s", crate.name) - exit_code, stdout, stderr = runner( - ("cargo", "package", *package_args), - cwd=crate_root, - env=None, - ) - if exit_code == 0: - LOGGER.info("Successfully packaged crate %s", crate.name) - return - 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) - ) - LOGGER.error(message) - raise PublishPreflightError(message) - - -_ALREADY_PUBLISHED_MARKERS: tuple[str, ...] = ( - "already uploaded", - "already published", - "already exists on crates.io", - "already exists on crates.io index", -) - -_CARGO_REGISTRY_ERROR_CODE = 101 - - -def _is_already_published_error(exit_code: int, stdout: str, stderr: str) -> bool: - """Return True when ``cargo publish`` failed because the version exists. - - Cargo returns exit code 101 for registry errors including already-published - versions. This function checks both the exit code and output to minimise - false positives from unrelated failures. - """ - # Only consider exit code 101 (cargo registry error) - if exit_code != _CARGO_REGISTRY_ERROR_CODE: - return False - - haystack = f"{stdout}\n{stderr}".lower() - return any(marker in haystack for marker in _ALREADY_PUBLISHED_MARKERS) - - -def _publish_crates( - plan: PublishPlan, - preparation: PublishPreparation, - *, - runner: CommandRunner, - options: _PublishExecutionOptions, -) -> None: - """Publish each crate in order, respecting dry-run vs live mode.""" - _for_each_publishable_crate( - _PublicationPipelineState(plan, preparation, options), - runner=runner, - action=_publish_crate, - ) - - -def _publish_crate( - crate: WorkspaceCrate, - state: _PublicationPipelineState, - *, - runner: CommandRunner, -) -> None: - """Publish one crate from the staged workspace.""" - plan = state.plan - options = state.options - publish_args: list[str] = [] - if options.allow_dirty: - publish_args.append("--allow-dirty") - if not options.live: - publish_args.append("--dry-run") - publish_args_tuple = tuple(publish_args) - crate_root = _resolve_staged_crate_root(crate, plan, state.preparation.staging_root) - LOGGER.info( - "Running cargo publish%s for crate %s", - "" if options.live else " --dry-run", - crate.name, - ) - exit_code, stdout, stderr = runner( - ("cargo", "publish", *publish_args_tuple), - cwd=crate_root, - env=None, - ) - _handle_publish_result( - crate, (exit_code, stdout, stderr), plan=plan, options=options - ) - - -def _handle_publish_result( - crate: WorkspaceCrate, - output: tuple[int, str, str], - *, - plan: PublishPlan, - options: _PublishExecutionOptions, -) -> None: - """Handle a completed ``cargo publish`` invocation.""" - 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, crate.name) - return - if _is_already_published_error(exit_code, stdout, stderr): - LOGGER.warning( - "Crate %s @ %s is already published; skipping", - crate.name, - crate.version, - ) - return - 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(lookup_failure, plan=plan, options=options) - return - - message = _format_cargo_failure_message( - "publish", crate.name, exit_code, (stdout, stderr) - ) - LOGGER.error(message) - raise PublishError(message) - - -def _execute_live_publication_pipeline( - plan: PublishPlan, - preparation: PublishPreparation, - *, - options: _PublishExecutionOptions, - runner: CommandRunner, -) -> None: - """Package and publish each crate before moving to the next crate.""" - state = _PublicationPipelineState(plan, preparation, options) - completed: list[str] = [] - for crate in plan.publishable: - LOGGER.info("Live pipeline: starting crate %s", crate.name) - try: - _package_crate( - crate, - state, - runner=runner, - ) - _publish_crate( - crate, - state, - runner=runner, - ) - except PublishPreparationError as exc: - # Preparation failures escape the preflight/publish error taxonomy; - # normalise them so the live pipeline reports a single abort class. - LOGGER.exception( - "Live pipeline: aborted on crate %s — %d/%d crates completed (%s)", - crate.name, - len(completed), - len(plan.publishable), - ", ".join(completed) if completed else "none", - ) - raise PublishPreflightError(str(exc)) from exc - except (PublishPreflightError, PublishError): - LOGGER.exception( - "Live pipeline: aborted on crate %s — %d/%d crates completed (%s)", - crate.name, - len(completed), - len(plan.publishable), - ", ".join(completed) if completed else "none", - ) - raise - LOGGER.info("Live pipeline: completed crate %s", crate.name) - completed.append(crate.name) - - def _ensure_configuration( configuration: LadingConfig | None, workspace_root: Path ) -> LadingConfig: """Return the active configuration, loading it from disk when required.""" if configuration is not None: return configuration - try: return config_module.current_configuration() except config_module.ConfigurationNotLoadedError: @@ -571,7 +56,6 @@ def _ensure_workspace( """Return the workspace graph rooted at ``workspace_root``.""" if workspace is not None: return workspace - from lading.workspace import WorkspaceModelError, load_workspace try: @@ -582,7 +66,7 @@ def _ensure_workspace( def _validate_publication_options(options: PublishOptions) -> None: - """Raise :class:`PublishPreflightError` for invalid option combinations.""" + """Raise for invalid publish option combinations.""" if options.live and options.allow_unpublished_workspace_deps: message = ( "Unpublished workspace dependency override is only valid in dry-run " @@ -597,47 +81,6 @@ def _validate_publication_options(options: PublishOptions) -> None: ) -def _dispatch_publication( - plan: PublishPlan, - preparation: PublishPreparation, - *, - options: _PublishExecutionOptions, - runner: CommandRunner, -) -> None: - """Route to the live or dry-run publication pipeline. - - Design note (issue #72): this helper is more than a relocated branch. - It owns the operator-facing pipeline-mode log line, sequences the - dry-run two-phase pipeline (package everything, then publish - everything), and gives tests a single seam to exercise mode dispatch - without driving ``run()`` end to end. Inlining it would push ``run()`` - back toward the complexity ceiling that prompted the extraction. - """ - if options.live: - LOGGER.info("Publication mode: live (interleaved per-crate pipeline)") - _execute_live_publication_pipeline( - plan, - preparation, - options=options, - runner=runner, - ) - else: - LOGGER.info("Publication mode: dry-run (batched two-phase pipeline)") - _package_publishable_crates( - plan, - preparation, - options=options, - runner=runner, - ) - LOGGER.info("Dry-run pipeline: packaging complete; starting publish phase") - _publish_crates( - plan, - preparation, - runner=runner, - options=options, - ) - - def run( workspace_root: Path, configuration: LadingConfig | None = None, @@ -645,17 +88,18 @@ def run( *, options: PublishOptions | None = None, ) -> str: - """Run pre-flight checks, package crates, and publish from ``workspace_root``.""" + """Run preflight checks, package crates, and publish from ``workspace_root``.""" root_path = normalise_workspace_root(workspace_root) LOGGER.info("Starting publish workflow for workspace %s", root_path) effective_options = PublishOptions() if options is None else options _validate_publication_options(effective_options) - configuration_override = configuration or effective_options.configuration - workspace_override = workspace or effective_options.workspace - command_runner = effective_options.command_runner or _invoke - active_configuration = _ensure_configuration(configuration_override, root_path) - active_workspace = _ensure_workspace(workspace_override, root_path) - + active_configuration = _ensure_configuration( + configuration or effective_options.configuration, root_path + ) + active_workspace = _ensure_workspace( + workspace or effective_options.workspace, root_path + ) + command_runner = effective_options.command_runner or publish_pipeline._invoke publish_preflight._run_preflight_checks( root_path, allow_dirty=effective_options.allow_dirty, @@ -665,28 +109,21 @@ def run( plan = plan_publication( active_workspace, active_configuration, workspace_root=root_path ) - preparation = prepare_workspace(plan, active_workspace, options=options) + preparation = publish_staging.prepare_workspace(plan, options=effective_options) _apply_strip_patch_strategy( - preparation.staging_root, - plan, - active_configuration.publish.strip_patches, + preparation.staging_root, plan, active_configuration.publish.strip_patches ) - execution_options = _PublishExecutionOptions( + execution_options = publish_pipeline._PublishExecutionOptions( live=effective_options.live, allow_dirty=effective_options.allow_dirty, - allow_unpublished_workspace_deps=( - effective_options.allow_unpublished_workspace_deps - ), + allow_unpublished_workspace_deps=effective_options.allow_unpublished_workspace_deps, ) - _dispatch_publication( - plan, - preparation, - options=execution_options, - runner=command_runner, + publish_pipeline._dispatch_publication( + plan, preparation, options=execution_options, runner=command_runner ) plan_message = format_plan( plan, strip_patches=active_configuration.publish.strip_patches ) - summary_lines = _format_preparation_summary(preparation) + summary_lines = publish_staging._format_preparation_summary(preparation) LOGGER.info("Publish workflow completed successfully for workspace %s", root_path) return f"{plan_message}\n\n" + "\n".join(summary_lines) diff --git a/lading/commands/publish_index_check.py b/lading/commands/publish_index_check.py index 32e170eb..60e6bfda 100644 --- a/lading/commands/publish_index_check.py +++ b/lading/commands/publish_index_check.py @@ -17,7 +17,7 @@ if typ.TYPE_CHECKING: from lading.commands.cargo_output_adapter import CargoIndexLookupFailure - from lading.commands.publish import _PublishExecutionOptions + from lading.commands.publish_pipeline import _PublishExecutionOptions from lading.commands.publish_plan import PublishPlan # Counter incremented each time an index-lookup failure is downgraded to a diff --git a/lading/commands/publish_manifest.py b/lading/commands/publish_manifest.py index a3d722f7..34560a4c 100644 --- a/lading/commands/publish_manifest.py +++ b/lading/commands/publish_manifest.py @@ -60,6 +60,9 @@ class PublishPreparationError(LadingError): """Publish staging failed to prepare required assets. Raised when: + * the build or staging directory is nested unsafely. + * the build directory or staged workspace cannot be created or copied. + * a staged crate root is missing. * the staged workspace manifest cannot be read or parsed. * manifest writes fail while applying patch stripping. diff --git a/lading/commands/publish_pipeline.py b/lading/commands/publish_pipeline.py new file mode 100644 index 00000000..e61a6a50 --- /dev/null +++ b/lading/commands/publish_pipeline.py @@ -0,0 +1,398 @@ +"""Execute per-crate publication pipelines for :mod:`lading.commands.publish`. + +The coordinator prepares a plan and staged workspace, then delegates live and +dry-run sequencing here. This module applies package/publish actions through +an injected runner; :mod:`lading.commands.publish_execution` owns the concrete +subprocess adapter used by the default runner. +""" + +from __future__ import annotations + +import collections.abc as cabc +import dataclasses as dc +import logging +import typing as typ + +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 as _invoke +from lading.commands.publish_index_check import ( + _format_cargo_failure_message, + _IndexMissingVersionHandling, +) +from lading.commands.publish_index_check import ( + _handle_index_missing_version as _raw_handle_index_missing_version, +) +from lading.commands.publish_manifest import PublishPreparationError +from lading.commands.publish_staging import ( + PublishPreparation, + _resolve_staged_crate_root, +) + +if typ.TYPE_CHECKING: + from lading.commands.publish_plan import PublishPlan + from lading.runtime import CommandRunner + from lading.workspace import WorkspaceCrate + +LOGGER = logging.getLogger(__name__) + + +@dc.dataclass(frozen=True, slots=True) +class _PublishExecutionOptions: + """Runtime flags that affect cargo package/publish invocations.""" + + live: bool + allow_dirty: bool + allow_unpublished_workspace_deps: bool = False + + +@dc.dataclass(frozen=True, slots=True) +class _PublicationPipelineState: + """Shared publish state for cargo package and publish invocations. + + Design note (issue #72): this bundle is deliberate. The per-crate + helpers (``_package_crate``, ``_publish_crate``) would otherwise need + ``plan``, ``preparation``, and ``options`` threaded individually, + pushing their signatures past the argument-count lint ceiling and + inviting positional mix-ups. The three fields are constructed together + in each pipeline entry point and are immutable for the pipeline's + lifetime, which is the invariant the dataclass enforces. + """ + + plan: PublishPlan + preparation: PublishPreparation + options: _PublishExecutionOptions + + +def _handle_index_missing_version( + failure: CargoIndexLookupFailure, + *, + plan: PublishPlan, + options: _PublishExecutionOptions, +) -> None: + """Pick the phase-appropriate error class and delegate to the helper. + + Resolve the error class here based on the cargo subcommand and pass it + through to the relocated implementation in ``publish_index_check``. + """ + error_cls = ( + PublishError if failure.subcommand == "publish" else PublishPreflightError + ) + _raw_handle_index_missing_version( + failure, + handling=_IndexMissingVersionHandling( + plan=plan, + options=options, + logger=LOGGER, + ), + error_cls=error_cls, + ) + + +class _CrateAction(typ.Protocol): + """Action applied to each crate in the publication pipeline.""" + + def __call__( + self, + crate: WorkspaceCrate, + state: _PublicationPipelineState, + *, + runner: CommandRunner, + ) -> None: + """Process a single staged crate from the pipeline.""" + + +def _for_each_publishable_crate( + state: _PublicationPipelineState, + *, + runner: CommandRunner, + action: _CrateAction, +) -> None: + """Apply *action* to every publishable crate in pipeline order.""" + for crate in state.plan.publishable: + action(crate, state, runner=runner) + + +def _package_publishable_crates( + plan: PublishPlan, + preparation: PublishPreparation, + *, + options: _PublishExecutionOptions, + runner: CommandRunner, +) -> None: + """Package each publishable crate in order using the staged workspace.""" + _for_each_publishable_crate( + _PublicationPipelineState(plan, preparation, options), + runner=runner, + action=_package_crate, + ) + + +def _package_crate( + crate: WorkspaceCrate, + state: _PublicationPipelineState, + *, + runner: CommandRunner, +) -> None: + """Package one publishable crate using the staged workspace.""" + plan = state.plan + options = state.options + package_args: tuple[str, ...] = ("--allow-dirty",) if options.allow_dirty else () + crate_root = _resolve_staged_crate_root(crate, plan, state.preparation.staging_root) + LOGGER.info("Running cargo package for crate %s", crate.name) + exit_code, stdout, stderr = runner( + ("cargo", "package", *package_args), + cwd=crate_root, + env=None, + ) + if exit_code == 0: + LOGGER.info("Successfully packaged crate %s", crate.name) + return + 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) + ) + LOGGER.error(message) + raise PublishPreflightError(message) + + +_ALREADY_PUBLISHED_MARKERS: tuple[str, ...] = ( + "already uploaded", + "already published", + "already exists on crates.io", + "already exists on crates.io index", +) + +_CARGO_REGISTRY_ERROR_CODE = 101 + + +def _is_already_published_error(exit_code: int, stdout: str, stderr: str) -> bool: + """Return True when ``cargo publish`` failed because the version exists. + + Cargo returns exit code 101 for registry errors including already-published + versions. This function checks both the exit code and output to minimise + false positives from unrelated failures. + """ + # Only consider exit code 101 (cargo registry error) + if exit_code != _CARGO_REGISTRY_ERROR_CODE: + return False + + haystack = f"{stdout}\n{stderr}".lower() + return any(marker in haystack for marker in _ALREADY_PUBLISHED_MARKERS) + + +def _publish_crates( + plan: PublishPlan, + preparation: PublishPreparation, + *, + runner: CommandRunner, + options: _PublishExecutionOptions, +) -> None: + """Publish each crate in order, respecting dry-run vs live mode.""" + _for_each_publishable_crate( + _PublicationPipelineState(plan, preparation, options), + runner=runner, + action=_publish_crate, + ) + + +def _publish_crate( + crate: WorkspaceCrate, + state: _PublicationPipelineState, + *, + runner: CommandRunner, +) -> None: + """Publish one crate from the staged workspace.""" + plan = state.plan + options = state.options + publish_args: list[str] = [] + if options.allow_dirty: + publish_args.append("--allow-dirty") + if not options.live: + publish_args.append("--dry-run") + publish_args_tuple = tuple(publish_args) + crate_root = _resolve_staged_crate_root(crate, plan, state.preparation.staging_root) + LOGGER.info( + "Running cargo publish%s for crate %s", + "" if options.live else " --dry-run", + crate.name, + ) + exit_code, stdout, stderr = runner( + ("cargo", "publish", *publish_args_tuple), + cwd=crate_root, + env=None, + ) + _handle_publish_result( + crate, (exit_code, stdout, stderr), plan=plan, options=options + ) + + +def _handle_publish_result( + crate: WorkspaceCrate, + output: tuple[int, str, str], + *, + plan: PublishPlan, + options: _PublishExecutionOptions, +) -> None: + """Handle a completed ``cargo publish`` invocation.""" + 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, crate.name) + return + if _is_already_published_error(exit_code, stdout, stderr): + LOGGER.warning( + "Crate %s @ %s is already published; skipping", + crate.name, + crate.version, + ) + return + 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(lookup_failure, plan=plan, options=options) + return + + message = _format_cargo_failure_message( + "publish", crate.name, exit_code, (stdout, stderr) + ) + LOGGER.error(message) + raise PublishError(message) + + +def _execute_live_publication_pipeline( + plan: PublishPlan, + preparation: PublishPreparation, + *, + options: _PublishExecutionOptions, + runner: CommandRunner, +) -> None: + """Package and publish each crate before moving to the next crate.""" + state = _PublicationPipelineState(plan, preparation, options) + completed: list[str] = [] + for crate in plan.publishable: + LOGGER.info("Live pipeline: starting crate %s", crate.name) + try: + _package_crate(crate, state, runner=runner) + _publish_crate(crate, state, runner=runner) + except PublishPreparationError as exc: + # Preparation failures escape the preflight/publish error taxonomy; + # normalise them so the live pipeline reports a single abort class. + LOGGER.exception(*_live_pipeline_abort_log_args(crate, completed, plan)) + raise PublishPreflightError(str(exc)) from exc + except PublishPreflightError: + LOGGER.exception(*_live_pipeline_abort_log_args(crate, completed, plan)) + raise + LOGGER.info("Live pipeline: completed crate %s", crate.name) + completed.append(crate.name) + + +def _live_pipeline_abort_log_args( + crate: WorkspaceCrate, + completed: cabc.Sequence[str], + plan: PublishPlan, +) -> tuple[str, str, int, int, str]: + """Return structured log arguments for a live-pipeline abort.""" + return ( + "Live pipeline: aborted on crate %s — %d/%d crates completed (%s)", + crate.name, + len(completed), + len(plan.publishable), + ", ".join(completed) if completed else "none", + ) + + +def _run_dry_run_phase( + phase_name: str, + action: cabc.Callable[[], None], +) -> None: + """Run one dry-run phase, normalising staging errors at the boundary. + + The shared error handling keeps the two-phase dispatcher focused on phase + ordering while retaining its single public preflight-error contract. + """ + try: + action() + except PublishPreparationError as exc: + LOGGER.exception("Dry-run pipeline: %s phase failed", phase_name) + raise PublishPreflightError(str(exc)) from exc + except PublishPreflightError: + LOGGER.exception("Dry-run pipeline: %s phase failed", phase_name) + raise + + +def _dispatch_publication( + plan: PublishPlan, + preparation: PublishPreparation, + *, + options: _PublishExecutionOptions, + runner: CommandRunner, +) -> None: + """Route to the live or dry-run publication pipeline. + + Design note (issue #72): this helper is more than a relocated branch. + It owns the operator-facing pipeline-mode log line, sequences the + dry-run two-phase pipeline (package everything, then publish + everything), and gives tests a single seam to exercise mode dispatch + without driving ``run()`` end to end. Inlining it would push ``run()`` + back toward the complexity ceiling that prompted the extraction. + """ + if options.live: + LOGGER.info("Publication mode: live (interleaved per-crate pipeline)") + _execute_live_publication_pipeline( + plan, + preparation, + options=options, + runner=runner, + ) + return + + LOGGER.info("Publication mode: dry-run (batched two-phase pipeline)") + _run_dry_run_phase( + "packaging", + lambda: _package_publishable_crates( + plan, + preparation, + options=options, + runner=runner, + ), + ) + LOGGER.info("Dry-run pipeline: packaging complete; starting publish phase") + _run_dry_run_phase( + "publish", + lambda: _publish_crates( + plan, + preparation, + runner=runner, + options=options, + ), + ) diff --git a/lading/commands/publish_staging.py b/lading/commands/publish_staging.py new file mode 100644 index 00000000..9abff595 --- /dev/null +++ b/lading/commands/publish_staging.py @@ -0,0 +1,174 @@ +"""Prepare an isolated workspace tree for publication. + +The helpers in this module copy a planned workspace into a build directory, +resolve crate paths within that copy, and optionally register staged-tree +cleanup. Call :func:`prepare_workspace` before applying staging-time manifest +changes or invoking cargo. + +Examples +-------- +>>> preparation = prepare_workspace(plan, options=options) +>>> preparation.staging_root.is_dir() +True +""" + +from __future__ import annotations + +import atexit +import dataclasses as dc +import logging +import shutil +import tempfile +import typing as typ +from pathlib import Path + +from lading.commands.publish_manifest import PublishPreparationError + +if typ.TYPE_CHECKING: + from lading.commands.publish import PublishOptions + from lading.commands.publish_plan import PublishPlan + from lading.workspace import WorkspaceCrate + +LOGGER = logging.getLogger(__name__) + + +@dc.dataclass(frozen=True, slots=True) +class PublishPreparation: + """Details about the staged workspace copy. + + Attributes + ---------- + staging_root: + Root of the copied workspace used by publication commands. + """ + + staging_root: Path + + +def _normalise_build_directory( + workspace_root: Path, build_directory: Path | None +) -> Path: + """Return a directory suitable for staging workspace artifacts.""" + if build_directory is None: + return Path(tempfile.mkdtemp(prefix="lading-publish-")) + + candidate = Path(build_directory).expanduser() + candidate = candidate.resolve(strict=False) + + workspace_root = workspace_root.resolve(strict=True) + if candidate.is_relative_to(workspace_root): + message = "Publish build directory cannot reside within the workspace root" + raise PublishPreparationError(message) + + try: + candidate.mkdir(parents=True, exist_ok=True) + except OSError as exc: + message = f"Cannot create publish build directory: {candidate}" + raise PublishPreparationError(message) from exc + return candidate + + +def _copy_workspace_tree( + workspace_root: Path, build_directory: Path, *, preserve_symlinks: bool +) -> Path: + """Copy ``workspace_root`` into ``build_directory`` and return the clone. + + When ``preserve_symlinks`` is :data:`True`, the cloned tree keeps symbolic + links instead of dereferencing them. This avoids unexpectedly copying large + directories outside the workspace while still allowing callers to opt into + dereferencing if required. + """ + workspace_root = workspace_root.resolve(strict=True) + staging_root = build_directory / workspace_root.name + if staging_root.resolve(strict=False).is_relative_to(workspace_root): + message = "Publish staging directory cannot be nested inside the workspace root" + raise PublishPreparationError(message) + if staging_root.exists(): + shutil.rmtree(staging_root) + try: + shutil.copytree(workspace_root, staging_root, symlinks=preserve_symlinks) + except OSError as exc: + message = f"Cannot copy workspace into staging directory: {staging_root}" + raise PublishPreparationError(message) from exc + return staging_root + + +def prepare_workspace( + plan: PublishPlan, + *, + options: PublishOptions | None = None, +) -> PublishPreparation: + """Stage a workspace copy for publishing. + + Parameters + ---------- + plan: + Publication plan containing the workspace root. + options: + Staging options. Defaults to :class:`PublishOptions` values. + + Returns + ------- + PublishPreparation + The staged workspace location. + """ + if options is None: + from lading.commands.publish import PublishOptions + + active_options = PublishOptions() + else: + active_options = options + build_directory = _normalise_build_directory( + plan.workspace_root, active_options.build_directory + ) + LOGGER.info( + "Preparing staged workspace for publication under %s", + build_directory, + ) + staging_root = _copy_workspace_tree( + plan.workspace_root, + build_directory, + preserve_symlinks=active_options.preserve_symlinks, + ) + LOGGER.info("Staged workspace created at %s", staging_root) + LOGGER.info("Workspace README staging skipped; handled by lading bump") + preparation = PublishPreparation(staging_root=staging_root) + if active_options.cleanup: + cleanup_target = staging_root + + def _cleanup() -> None: + """Remove the staged workspace tree on process exit.""" + shutil.rmtree(cleanup_target, ignore_errors=True) + + atexit.register(_cleanup) + return preparation + + +def _format_preparation_summary(preparation: PublishPreparation) -> tuple[str, ...]: + """Return formatted summary lines for staging results.""" + lines = [f"Staged workspace at: {preparation.staging_root}"] + lines.append("Workspace READMEs are handled by lading bump.") + return tuple(lines) + + +def _resolve_staged_crate_root( + crate: WorkspaceCrate, + plan: PublishPlan, + staging_root: Path, +) -> Path: + """Return the staged crate root, ensuring it resides within the workspace.""" + try: + relative_root = crate.root_path.relative_to(plan.workspace_root) + except ValueError as exc: # pragma: no cover - defensive guard + message = ( + f"Crate {crate.name!r} root {crate.root_path} is outside workspace " + f"{plan.workspace_root}" + ) + raise PublishPreparationError(message) from exc + + staged_root = staging_root / relative_root + if not staged_root.exists(): # pragma: no cover - defensive guard + message = f"Staged crate root not found for {crate.name!r}: {staged_root}" + raise PublishPreparationError(message) + + return staged_root diff --git a/tests/unit/publish/conftest.py b/tests/unit/publish/conftest.py index 5cd2836e..dc8a9cbd 100644 --- a/tests/unit/publish/conftest.py +++ b/tests/unit/publish/conftest.py @@ -10,7 +10,13 @@ import pytest from lading import config as config_module -from lading.commands import publish, publish_preflight +from lading.commands import ( + publish, + publish_pipeline, + publish_plan, + publish_preflight, + publish_staging, +) from lading.workspace import WorkspaceCrate, WorkspaceDependency, WorkspaceGraph if typ.TYPE_CHECKING: @@ -214,7 +220,7 @@ def plan_with_crates( tmp_path: Path, crates: tuple[WorkspaceCrate, ...], **config_overrides: object, -) -> publish.PublishPlan: +) -> publish_plan.PublishPlan: """Plan publication for ``crates`` using ``tmp_path`` as the workspace root.""" root = tmp_path.resolve() workspace = make_workspace(root, *crates) @@ -222,7 +228,7 @@ def plan_with_crates( return publish.plan_publication(workspace, configuration) -def prepare_staging_root(plan: publish.PublishPlan, base_dir: Path) -> Path: +def prepare_staging_root(plan: publish_plan.PublishPlan, base_dir: Path) -> Path: """Create a staged workspace tree matching ``plan`` under ``base_dir``.""" staging_root = base_dir / "staging" / plan.workspace_root.name for crate in plan.publishable: @@ -245,7 +251,7 @@ def _warning_records( @pytest.fixture def publish_plan_and_prep( tmp_path: Path, -) -> tuple[publish.PublishPlan, publish.PublishPreparation, Path]: +) -> tuple[publish_plan.PublishPlan, publish_staging.PublishPreparation, Path]: """Provide a publish plan, preparation object, and staging root.""" workspace_root = tmp_path / "workspace" crates = make_dependency_chain(workspace_root) @@ -253,14 +259,13 @@ def publish_plan_and_prep( make_workspace(workspace_root, *crates), make_config() ) staging_root = prepare_staging_root(plan, tmp_path) - preparation = publish.PublishPreparation( + preparation = publish_staging.PublishPreparation( staging_root=staging_root, - copied_readmes=(), ) return plan, preparation, staging_root -ORIGINAL_INVOKE = publish._invoke +ORIGINAL_INVOKE = publish_pipeline._invoke ORIGINAL_PREFLIGHT = publish_preflight._run_preflight_checks @@ -271,7 +276,7 @@ def disable_preflight(monkeypatch: pytest.MonkeyPatch) -> None: publish_preflight, "_run_preflight_checks", lambda *_args, **_kwargs: None ) monkeypatch.setattr( - publish, + publish_pipeline, "_invoke", lambda *_args, **_kwargs: (0, "", ""), ) @@ -280,7 +285,7 @@ def disable_preflight(monkeypatch: pytest.MonkeyPatch) -> None: @pytest.fixture def use_real_invoke(monkeypatch: pytest.MonkeyPatch) -> None: """Restore the original _invoke helper for tests that exercise it.""" - monkeypatch.setattr(publish, "_invoke", ORIGINAL_INVOKE) + monkeypatch.setattr(publish_pipeline, "_invoke", ORIGINAL_INVOKE) class CallTrackingRunner: @@ -312,20 +317,20 @@ def __call__( class PhaseContext: """Execution context shared across both cargo phase dispatches.""" - plan: publish.PublishPlan - preparation: publish.PublishPreparation + plan: publish_plan.PublishPlan + preparation: publish_staging.PublishPreparation runner: cabc.Callable[..., tuple[int, str, str]] - options: publish._PublishExecutionOptions + options: publish_pipeline._PublishExecutionOptions def invoke_phase(phase_name: str, ctx: PhaseContext) -> None: """Dispatch to the appropriate cargo sub-command under test.""" if phase_name == "package": - publish._package_publishable_crates( + publish_pipeline._package_publishable_crates( ctx.plan, ctx.preparation, options=ctx.options, runner=ctx.runner ) elif phase_name == "publish": - publish._publish_crates( + publish_pipeline._publish_crates( ctx.plan, ctx.preparation, runner=ctx.runner, options=ctx.options ) else: diff --git a/tests/unit/publish/preflight_test_utils.py b/tests/unit/publish/preflight_test_utils.py index 878f3b47..d19446f9 100644 --- a/tests/unit/publish/preflight_test_utils.py +++ b/tests/unit/publish/preflight_test_utils.py @@ -6,7 +6,7 @@ import typing as typ from pathlib import Path -from lading.commands import publish, publish_preflight +from lading.commands import publish, publish_pipeline, publish_preflight from .conftest import ORIGINAL_PREFLIGHT, make_crate, make_workspace @@ -46,7 +46,7 @@ def recording_invoke( calls.append((tuple(command), cwd, env)) return 0, "", "" - monkeypatch.setattr(publish, "_invoke", recording_invoke) + monkeypatch.setattr(publish_pipeline, "_invoke", recording_invoke) publish.run(root, configuration, workspace) return root, workspace, calls diff --git a/tests/unit/publish/test_command_logging.py b/tests/unit/publish/test_command_logging.py index 87712b89..29e7224b 100644 --- a/tests/unit/publish/test_command_logging.py +++ b/tests/unit/publish/test_command_logging.py @@ -6,7 +6,7 @@ import sys import typing as typ -from lading.commands import publish +from lading.commands import publish_execution from lading.testing import cmd_mox_runner if typ.TYPE_CHECKING: @@ -34,7 +34,9 @@ def test_invoke_logs_command_with_cwd( ) -> None: """``_invoke`` should log the command line and working directory.""" caplog.set_level(logging.INFO, logger="lading.runtime.subprocess_runner") - exit_code, stdout, stderr = publish._invoke(("echo", "hello"), cwd=tmp_path) + exit_code, stdout, stderr = publish_execution._invoke( + ("echo", "hello"), cwd=tmp_path + ) assert exit_code == 0 assert stdout.strip() == "hello" @@ -49,7 +51,7 @@ def test_invoke_logs_command_without_cwd( """``_invoke`` should omit ``cwd`` details when not provided.""" caplog.set_level(logging.INFO, logger="lading.runtime.subprocess_runner") - exit_code, stdout, stderr = publish._invoke(("echo", "hello")) + exit_code, stdout, stderr = publish_execution._invoke(("echo", "hello")) assert exit_code == 0 assert stdout.strip() == "hello" @@ -70,7 +72,11 @@ def test_invoke_proxies_command_output( sys.stderr.flush() """ - exit_code, stdout, stderr = publish._invoke((sys.executable, "-c", script)) + exit_code, stdout, stderr = publish_execution._invoke(( + sys.executable, + "-c", + script, + )) assert exit_code == 0 assert stdout == "alpha" diff --git a/tests/unit/publish/test_downgrade_metrics.py b/tests/unit/publish/test_downgrade_metrics.py index 4125cb82..93fc2db6 100644 --- a/tests/unit/publish/test_downgrade_metrics.py +++ b/tests/unit/publish/test_downgrade_metrics.py @@ -9,7 +9,7 @@ import pytest -from lading.commands import publish, publish_index_check +from lading.commands import publish, publish_index_check, publish_pipeline from lading.commands.cargo_output_adapter import CargoIndexLookupFailure from lading.utils import metrics @@ -56,10 +56,10 @@ def _invoke_handler( stderr=stderr, missing_dependency_name=missing_crate, ) - publish._handle_index_missing_version( + publish_pipeline._handle_index_missing_version( failure, plan=plan, - options=publish._PublishExecutionOptions( + options=publish_pipeline._PublishExecutionOptions( live=False, allow_dirty=True, allow_unpublished_workspace_deps=allow_unpublished_workspace_deps, diff --git a/tests/unit/publish/test_index_order_properties.py b/tests/unit/publish/test_index_order_properties.py index d17b2f88..cf8a7670 100644 --- a/tests/unit/publish/test_index_order_properties.py +++ b/tests/unit/publish/test_index_order_properties.py @@ -15,7 +15,7 @@ from hypothesis import given, settings from hypothesis import strategies as st -from lading.commands import publish +from lading.commands import publish, publish_pipeline, publish_plan from lading.commands.cargo_output_adapter import CargoIndexLookupFailure from .conftest import make_crate @@ -44,7 +44,7 @@ def test_index_missing_dependency_requires_prior_publish_order( crates = tuple( make_crate(workspace_root, f"crate_{index}") for index in range(crate_count) ) - plan = publish.PublishPlan( + plan = publish_plan.PublishPlan( workspace_root=workspace_root, publishable=crates, skipped_manifest=(), @@ -66,14 +66,14 @@ def test_index_missing_dependency_requires_prior_publish_order( ), missing_dependency_name=missing_crate.name, ) - options = publish._PublishExecutionOptions( + options = publish_pipeline._PublishExecutionOptions( live=False, allow_dirty=True, allow_unpublished_workspace_deps=True, ) if missing_index < current_index: - publish._handle_index_missing_version( + publish_pipeline._handle_index_missing_version( failure, plan=plan, options=options, @@ -87,7 +87,7 @@ def test_index_missing_dependency_requires_prior_publish_order( else "is the same crate" ), ): - publish._handle_index_missing_version( + publish_pipeline._handle_index_missing_version( failure, plan=plan, options=options, diff --git a/tests/unit/publish/test_packaging.py b/tests/unit/publish/test_packaging.py index 56d1d9f6..b1f207e2 100644 --- a/tests/unit/publish/test_packaging.py +++ b/tests/unit/publish/test_packaging.py @@ -3,12 +3,12 @@ Exercises the per-crate publication helpers and the interleaved live pipeline introduced in :mod:`lading.commands.publish`: -- :func:`~lading.commands.publish._package_crate` — packages one crate +- :func:`~lading.commands.publish_pipeline._package_crate` — packages one crate from the staged workspace via ``cargo package``. -- :func:`~lading.commands.publish._publish_crate` — publishes one crate +- :func:`~lading.commands.publish_pipeline._publish_crate` — publishes one crate via ``cargo publish``, with ``--dry-run`` injected when not in live mode. -- :func:`~lading.commands.publish._execute_live_publication_pipeline` — +- :func:`~lading.commands.publish_pipeline._execute_live_publication_pipeline` — orchestrates the interleaved per-crate package-then-publish flow. Test helpers @@ -51,7 +51,7 @@ import pytest -from lading.commands import publish +from lading.commands import publish, publish_pipeline, publish_plan, publish_staging from .conftest import ( CallTrackingRunner, @@ -65,7 +65,7 @@ class _SnapshotCase(typ.NamedTuple): fn: cabc.Callable[..., None] - options: publish._PublishExecutionOptions + options: publish_pipeline._PublishExecutionOptions exc_type: type[Exception] stderr_text: str @@ -79,8 +79,15 @@ class _FailureCase(typ.NamedTuple): output_fragment: str +class _PackagingFailureDetail(typ.NamedTuple): + stdout: str + stderr: str + expected_in_message: str + not_expected_in_message: str | None + + def _assert_packaging_failure_message_contains( - plan_and_prep: tuple[publish.PublishPlan, publish.PublishPreparation], + plan_and_prep: tuple[publish_plan.PublishPlan, publish_staging.PublishPreparation], runner: cabc.Callable[..., tuple[int, str, str]], expected_in_message: str, not_expected_in_message: str | None = None, @@ -89,10 +96,12 @@ def _assert_packaging_failure_message_contains( plan, preparation = plan_and_prep with pytest.raises(publish.PublishPreflightError) as excinfo: - publish._package_publishable_crates( + publish_pipeline._package_publishable_crates( plan, preparation, - options=publish._PublishExecutionOptions(live=False, allow_dirty=True), + options=publish_pipeline._PublishExecutionOptions( + live=False, allow_dirty=True + ), runner=runner, ) @@ -104,18 +113,20 @@ def _assert_packaging_failure_message_contains( def test_package_publishable_crates_runs_in_plan_order( - publish_plan_and_prep: tuple[publish.PublishPlan, publish.PublishPreparation, Path], + publish_plan_and_prep: tuple[ + publish_plan.PublishPlan, publish_staging.PublishPreparation, Path + ], caplog: pytest.LogCaptureFixture, ) -> None: """Cargo package is invoked for every publishable crate in plan order.""" - caplog.set_level(logging.INFO, logger="lading.commands.publish") + caplog.set_level(logging.INFO, logger=publish_pipeline.LOGGER.name) plan, preparation, staging_root = publish_plan_and_prep runner = CallTrackingRunner() - publish._package_publishable_crates( + publish_pipeline._package_publishable_crates( plan, preparation, - options=publish._PublishExecutionOptions(live=False, allow_dirty=True), + options=publish_pipeline._PublishExecutionOptions(live=False, allow_dirty=True), runner=runner, ) @@ -140,30 +151,32 @@ def test_package_publishable_crates_runs_in_plan_order( ("fn", "options", "expected_cmd"), [ pytest.param( - publish._package_crate, - publish._PublishExecutionOptions(live=False, allow_dirty=True), + publish_pipeline._package_crate, + publish_pipeline._PublishExecutionOptions(live=False, allow_dirty=True), ("cargo", "package", "--allow-dirty"), id="package", ), pytest.param( - publish._publish_crate, - publish._PublishExecutionOptions(live=True, allow_dirty=True), + publish_pipeline._publish_crate, + publish_pipeline._PublishExecutionOptions(live=True, allow_dirty=True), ("cargo", "publish", "--allow-dirty"), id="publish", ), ], ) def test_single_crate_helper_invokes_correct_cargo_command( - publish_plan_and_prep: tuple[publish.PublishPlan, publish.PublishPreparation, Path], + publish_plan_and_prep: tuple[ + publish_plan.PublishPlan, publish_staging.PublishPreparation, Path + ], fn: cabc.Callable[..., None], - options: publish._PublishExecutionOptions, + options: publish_pipeline._PublishExecutionOptions, expected_cmd: tuple[str, ...], ) -> None: """Each single-crate helper invokes the correct cargo subcommand.""" plan, preparation, staging_root = publish_plan_and_prep runner = CallTrackingRunner() crate = plan.publishable[1] - state = publish._PublicationPipelineState(plan, preparation, options) + state = publish_pipeline._PublicationPipelineState(plan, preparation, options) fn( crate, @@ -180,7 +193,7 @@ def test_single_crate_helper_invokes_correct_cargo_command( [ pytest.param( _FailureCase( - fn=publish._package_crate, + fn=publish_pipeline._package_crate, live=False, exc_type=publish.PublishPreflightError, runner_kwargs={"stderr": "packaging failed"}, @@ -191,9 +204,9 @@ def test_single_crate_helper_invokes_correct_cargo_command( ), pytest.param( _FailureCase( - fn=publish._publish_crate, + fn=publish_pipeline._publish_crate, live=True, - exc_type=publish.PublishError, + exc_type=publish_pipeline.PublishError, runner_kwargs={"stdout": "network offline"}, subcommand="publish", output_fragment="network offline", @@ -203,15 +216,17 @@ def test_single_crate_helper_invokes_correct_cargo_command( ], ) def test_crate_helper_raises_on_failure( - publish_plan_and_prep: tuple[publish.PublishPlan, publish.PublishPreparation, Path], + publish_plan_and_prep: tuple[ + publish_plan.PublishPlan, publish_staging.PublishPreparation, Path + ], case: _FailureCase, ) -> None: """Each single-crate helper raises the correct exception type on failure.""" plan, preparation, _staging_root = publish_plan_and_prep - state = publish._PublicationPipelineState( + state = publish_pipeline._PublicationPipelineState( plan, preparation, - publish._PublishExecutionOptions(live=case.live, allow_dirty=True), + publish_pipeline._PublishExecutionOptions(live=case.live, allow_dirty=True), ) with pytest.raises(case.exc_type) as excinfo: @@ -232,7 +247,9 @@ def test_crate_helper_raises_on_failure( def test_package_publishable_crates_stops_on_failure( - publish_plan_and_prep: tuple[publish.PublishPlan, publish.PublishPreparation, Path], + publish_plan_and_prep: tuple[ + publish_plan.PublishPlan, publish_staging.PublishPreparation, Path + ], ) -> None: """Failures during packaging abort the workflow with crate context.""" plan, preparation, _staging_root = publish_plan_and_prep @@ -249,10 +266,12 @@ def tracked_runner( return failing_runner(command, cwd=cwd, env=env) with pytest.raises(publish.PublishPreflightError) as excinfo: - publish._package_publishable_crates( + publish_pipeline._package_publishable_crates( plan, preparation, - options=publish._PublishExecutionOptions(live=False, allow_dirty=True), + options=publish_pipeline._PublishExecutionOptions( + live=False, allow_dirty=True + ), runner=tracked_runner, ) @@ -261,49 +280,63 @@ def tracked_runner( assert "packaging failed" in str(excinfo.value) -def test_package_publishable_crates_reports_stdout_on_failure( - publish_plan_and_prep: tuple[publish.PublishPlan, publish.PublishPreparation, Path], -) -> None: - """Failure details fall back to stdout when stderr is empty.""" - plan_and_prep = publish_plan_and_prep[:2] - stdout_failure = make_failing_runner(stdout="stdout failure details") - - _assert_packaging_failure_message_contains( - plan_and_prep, - stdout_failure, - expected_in_message="stdout failure details", - ) - - -def test_package_publishable_crates_prefers_stderr_over_stdout( - publish_plan_and_prep: tuple[publish.PublishPlan, publish.PublishPreparation, Path], +@pytest.mark.parametrize( + "case", + [ + pytest.param( + _PackagingFailureDetail( + stdout="stdout failure details", + stderr="", + expected_in_message="stdout failure details", + not_expected_in_message=None, + ), + id="stdout_fallback_when_stderr_empty", + ), + pytest.param( + _PackagingFailureDetail( + stdout="stdout detail", + stderr="stderr detail", + expected_in_message="stderr detail", + not_expected_in_message="stdout detail", + ), + id="stderr_takes_precedence_over_stdout", + ), + ], +) +def test_package_publishable_crates_reports_failure_detail( + publish_plan_and_prep: tuple[ + publish_plan.PublishPlan, publish_staging.PublishPreparation, Path + ], + case: _PackagingFailureDetail, ) -> None: - """Error detail prefers stderr when both streams are populated.""" + """Failure details fall back to stdout or prefer populated stderr.""" plan_and_prep = publish_plan_and_prep[:2] - both_populated = make_failing_runner(stdout="stdout detail", stderr="stderr detail") + runner = make_failing_runner(stdout=case.stdout, stderr=case.stderr) _assert_packaging_failure_message_contains( plan_and_prep, - both_populated, - expected_in_message="stderr detail", - not_expected_in_message="stdout detail", + runner, + expected_in_message=case.expected_in_message, + not_expected_in_message=case.not_expected_in_message, ) def test_publish_crates_run_dry_run_in_order( - publish_plan_and_prep: tuple[publish.PublishPlan, publish.PublishPreparation, Path], + publish_plan_and_prep: tuple[ + publish_plan.PublishPlan, publish_staging.PublishPreparation, Path + ], caplog: pytest.LogCaptureFixture, ) -> None: """Cargo publish --dry-run runs for each crate in publish order.""" - caplog.set_level(logging.INFO, logger="lading.commands.publish") + caplog.set_level(logging.INFO, logger=publish_pipeline.LOGGER.name) plan, preparation, staging_root = publish_plan_and_prep runner = CallTrackingRunner() - publish._publish_crates( + publish_pipeline._publish_crates( plan, preparation, runner=runner, - options=publish._PublishExecutionOptions(live=False, allow_dirty=True), + options=publish_pipeline._PublishExecutionOptions(live=False, allow_dirty=True), ) expected_roots = [ @@ -318,17 +351,19 @@ def test_publish_crates_run_dry_run_in_order( def test_publish_crates_run_live_without_dry_run( - publish_plan_and_prep: tuple[publish.PublishPlan, publish.PublishPreparation, Path], + publish_plan_and_prep: tuple[ + publish_plan.PublishPlan, publish_staging.PublishPreparation, Path + ], ) -> None: """Live mode omits the --dry-run flag when publishing crates.""" plan, preparation, staging_root = publish_plan_and_prep runner = CallTrackingRunner() - publish._publish_crates( + publish_pipeline._publish_crates( plan, preparation, runner=runner, - options=publish._PublishExecutionOptions(live=True, allow_dirty=True), + options=publish_pipeline._PublishExecutionOptions(live=True, allow_dirty=True), ) expected_roots = [ @@ -341,11 +376,13 @@ def test_publish_crates_run_live_without_dry_run( def test_publish_crate_continues_when_version_already_uploaded( - publish_plan_and_prep: tuple[publish.PublishPlan, publish.PublishPreparation, Path], + publish_plan_and_prep: tuple[ + publish_plan.PublishPlan, publish_staging.PublishPreparation, Path + ], caplog: pytest.LogCaptureFixture, ) -> None: """The single-crate publish helper keeps already-published handling.""" - caplog.set_level(logging.WARNING, logger="lading.commands.publish") + caplog.set_level(logging.WARNING, logger=publish_pipeline.LOGGER.name) plan, preparation, _staging_root = publish_plan_and_prep def already_uploaded_runner( @@ -357,13 +394,13 @@ def already_uploaded_runner( del command, cwd, env return 101, "", "error: crate version `alpha v0.1.0` is already uploaded" - state = publish._PublicationPipelineState( + state = publish_pipeline._PublicationPipelineState( plan, preparation, - publish._PublishExecutionOptions(live=True, allow_dirty=True), + publish_pipeline._PublishExecutionOptions(live=True, allow_dirty=True), ) - publish._publish_crate( + publish_pipeline._publish_crate( plan.publishable[0], state, runner=already_uploaded_runner, @@ -380,16 +417,15 @@ def test_publish_crates_continue_when_version_already_uploaded( tmp_path: Path, caplog: pytest.LogCaptureFixture, *, live: bool ) -> None: """Already-published versions log a warning and continue.""" - caplog.set_level(logging.WARNING, logger="lading.commands.publish") + caplog.set_level(logging.WARNING, logger=publish_pipeline.LOGGER.name) workspace_root = tmp_path / "workspace" alpha, beta, _gamma = make_dependency_chain(workspace_root) plan = publish.plan_publication( make_workspace(workspace_root, alpha, beta), make_config() ) staging_root = prepare_staging_root(plan, tmp_path) - preparation = publish.PublishPreparation( + preparation = publish_staging.PublishPreparation( staging_root=staging_root, - copied_readmes=(), ) calls: list[str] = [] @@ -411,11 +447,11 @@ def runner( ) return (0, "", "") - publish._publish_crates( + publish_pipeline._publish_crates( plan, preparation, runner=runner, - options=publish._PublishExecutionOptions(live=live, allow_dirty=True), + options=publish_pipeline._PublishExecutionOptions(live=live, allow_dirty=True), ) assert calls == ["alpha", "beta"] @@ -423,16 +459,18 @@ def runner( def test_execute_live_publication_pipeline_interleaves_package_and_publish( - publish_plan_and_prep: tuple[publish.PublishPlan, publish.PublishPreparation, Path], + publish_plan_and_prep: tuple[ + publish_plan.PublishPlan, publish_staging.PublishPreparation, Path + ], ) -> None: """Live publication packages and publishes each crate before continuing.""" plan, preparation, staging_root = publish_plan_and_prep runner = CallTrackingRunner() - publish._execute_live_publication_pipeline( + publish_pipeline._execute_live_publication_pipeline( plan, preparation, - options=publish._PublishExecutionOptions(live=True, allow_dirty=True), + options=publish_pipeline._PublishExecutionOptions(live=True, allow_dirty=True), runner=runner, ) @@ -448,7 +486,9 @@ def test_execute_live_publication_pipeline_interleaves_package_and_publish( def test_execute_live_publication_pipeline_stops_after_partial_publish( - publish_plan_and_prep: tuple[publish.PublishPlan, publish.PublishPreparation, Path], + publish_plan_and_prep: tuple[ + publish_plan.PublishPlan, publish_staging.PublishPreparation, Path + ], ) -> None: """A later live failure leaves earlier publish attempts completed.""" plan, preparation, staging_root = publish_plan_and_prep @@ -471,10 +511,12 @@ def runner( return 0, "", "" with pytest.raises(publish.PublishPreflightError): - publish._execute_live_publication_pipeline( + publish_pipeline._execute_live_publication_pipeline( plan, preparation, - options=publish._PublishExecutionOptions(live=True, allow_dirty=True), + options=publish_pipeline._PublishExecutionOptions( + live=True, allow_dirty=True + ), runner=runner, ) @@ -489,11 +531,13 @@ def runner( def test_execute_live_publication_pipeline_wraps_preparation_errors( - publish_plan_and_prep: tuple[publish.PublishPlan, publish.PublishPreparation, Path], + publish_plan_and_prep: tuple[ + publish_plan.PublishPlan, publish_staging.PublishPreparation, Path + ], caplog: pytest.LogCaptureFixture, ) -> None: """Preparation failures surface as ``PublishPreflightError`` for the caller.""" - caplog.set_level(logging.ERROR, logger="lading.commands.publish") + caplog.set_level(logging.ERROR, logger=publish_pipeline.LOGGER.name) plan, preparation, staging_root = publish_plan_and_prep # Remove the staged tree for the second crate so resolving its root fails # mid-pipeline, after the first crate has packaged and published cleanly. @@ -504,14 +548,16 @@ def test_execute_live_publication_pipeline_wraps_preparation_errors( runner = CallTrackingRunner() with pytest.raises(publish.PublishPreflightError) as excinfo: - publish._execute_live_publication_pipeline( + publish_pipeline._execute_live_publication_pipeline( plan, preparation, - options=publish._PublishExecutionOptions(live=True, allow_dirty=True), + options=publish_pipeline._PublishExecutionOptions( + live=True, allow_dirty=True + ), runner=runner, ) - assert isinstance(excinfo.value.__cause__, publish.PublishPreparationError) + assert isinstance(excinfo.value.__cause__, publish_staging.PublishPreparationError) assert str(beta_root) in str(excinfo.value) alpha_root = staging_root / plan.publishable[0].root_path.relative_to( plan.workspace_root @@ -530,18 +576,22 @@ def test_execute_live_publication_pipeline_wraps_preparation_errors( def test_publish_crates_raise_on_failure( - publish_plan_and_prep: tuple[publish.PublishPlan, publish.PublishPreparation, Path], + publish_plan_and_prep: tuple[ + publish_plan.PublishPlan, publish_staging.PublishPreparation, Path + ], ) -> None: """Unexpected cargo publish failures abort the workflow.""" plan, preparation, _staging_root = publish_plan_and_prep failing_runner = make_failing_runner(stdout="network offline") with pytest.raises(publish.PublishPreflightError) as excinfo: - publish._publish_crates( + publish_pipeline._publish_crates( plan, preparation, runner=failing_runner, - options=publish._PublishExecutionOptions(live=False, allow_dirty=True), + options=publish_pipeline._PublishExecutionOptions( + live=False, allow_dirty=True + ), ) message = str(excinfo.value) @@ -554,8 +604,10 @@ def test_publish_crates_raise_on_failure( [ pytest.param( _SnapshotCase( - fn=publish._package_crate, - options=publish._PublishExecutionOptions(live=False, allow_dirty=True), + fn=publish_pipeline._package_crate, + options=publish_pipeline._PublishExecutionOptions( + live=False, allow_dirty=True + ), exc_type=publish.PublishPreflightError, stderr_text="packaging failed", ), @@ -563,9 +615,11 @@ def test_publish_crates_raise_on_failure( ), pytest.param( _SnapshotCase( - fn=publish._publish_crate, - options=publish._PublishExecutionOptions(live=True, allow_dirty=True), - exc_type=publish.PublishError, + fn=publish_pipeline._publish_crate, + options=publish_pipeline._PublishExecutionOptions( + live=True, allow_dirty=True + ), + exc_type=publish_pipeline.PublishError, stderr_text="publish failed", ), id="publish", @@ -573,13 +627,15 @@ def test_publish_crates_raise_on_failure( ], ) def test_crate_helper_error_message_snapshot( - publish_plan_and_prep: tuple[publish.PublishPlan, publish.PublishPreparation, Path], + publish_plan_and_prep: tuple[ + publish_plan.PublishPlan, publish_staging.PublishPreparation, Path + ], snapshot: SnapshotAssertion, case: _SnapshotCase, ) -> None: """Snapshot the error message raised by each single-crate helper on failure.""" plan, preparation, _staging_root = publish_plan_and_prep - state = publish._PublicationPipelineState(plan, preparation, case.options) + state = publish_pipeline._PublicationPipelineState(plan, preparation, case.options) with pytest.raises(case.exc_type) as excinfo: case.fn( diff --git a/tests/unit/publish/test_phase_dispatch.py b/tests/unit/publish/test_phase_dispatch.py index 2875ff12..2f05250a 100644 --- a/tests/unit/publish/test_phase_dispatch.py +++ b/tests/unit/publish/test_phase_dispatch.py @@ -1,7 +1,7 @@ """Unit tests for publish-phase dispatch behaviour under index-missing-version failures. -Exercises ``lading.commands.publish._package_publishable_crates`` and -``lading.commands.publish._publish_crates`` through the shared +Exercises ``lading.commands.publish_pipeline._package_publishable_crates`` and +``lading.commands.publish_pipeline._publish_crates`` through the shared ``invoke_phase`` dispatcher from ``tests.unit.publish.conftest``. Three parametrised scenarios (package phase and publish-dry-run phase) verify: @@ -28,7 +28,7 @@ import pytest -from lading.commands import publish +from lading.commands import publish, publish_pipeline, publish_plan, publish_staging from lading.commands.cargo_output_adapter import CargoIndexLookupFailure from .conftest import ( @@ -48,13 +48,64 @@ _PHASE_IDS: list[pytest.mark.ParameterSet] = [ pytest.param("package", publish.PublishPreflightError, id="packaging"), - pytest.param("publish", publish.PublishError, id="publish-dry-run"), + pytest.param("publish", publish_pipeline.PublishError, id="publish-dry-run"), ] +def test_run_dry_run_phase_normalises_packaging_preparation_failure( + caplog: pytest.LogCaptureFixture, +) -> None: + """A packaging preparation error becomes a preflight error.""" + caplog.set_level(logging.ERROR, logger="lading.commands.publish") + failure = publish_pipeline.PublishPreparationError("staging failed") + + with pytest.raises( + publish.PublishPreflightError, match="staging failed" + ) as excinfo: + publish_pipeline._run_dry_run_phase( + "packaging", + lambda: (_ for _ in ()).throw(failure), + ) + + assert excinfo.value.__cause__ is failure + assert caplog.messages == ["Dry-run pipeline: packaging phase failed"] + + +def test_run_dry_run_phase_reraises_publish_preflight_failure( + caplog: pytest.LogCaptureFixture, +) -> None: + """A publish preflight error retains its type and traceback.""" + caplog.set_level(logging.ERROR, logger="lading.commands.publish") + failure = publish.PublishPreflightError("publish failed") + + with pytest.raises(publish.PublishPreflightError) as excinfo: + publish_pipeline._run_dry_run_phase( + "publish", + lambda: (_ for _ in ()).throw(failure), + ) + + assert excinfo.value is failure + assert caplog.messages == ["Dry-run pipeline: publish phase failed"] + + +def test_run_dry_run_phase_succeeds_without_logging( + caplog: pytest.LogCaptureFixture, +) -> None: + """A successful phase invokes its action without emitting an error.""" + caplog.set_level(logging.ERROR, logger="lading.commands.publish") + calls: list[str] = [] + + publish_pipeline._run_dry_run_phase("packaging", lambda: calls.append("ran")) + + assert calls == ["ran"] + assert caplog.messages == [] + + @pytest.mark.parametrize("phase_name", ["package", "publish"]) def test_missing_dep_in_plan_and_flag_continues( - publish_plan_and_prep: tuple[publish.PublishPlan, publish.PublishPreparation, Path], + publish_plan_and_prep: tuple[ + publish_plan.PublishPlan, publish_staging.PublishPreparation, Path + ], caplog: pytest.LogCaptureFixture, snapshot: SnapshotAssertion, phase_name: str, @@ -83,7 +134,7 @@ def runner( plan=plan, preparation=preparation, runner=runner, - options=publish._PublishExecutionOptions( + options=publish_pipeline._PublishExecutionOptions( live=False, allow_dirty=True, allow_unpublished_workspace_deps=True, @@ -97,7 +148,9 @@ def runner( @pytest.mark.parametrize(("phase_name", "exc_type"), _PHASE_IDS) def test_missing_dep_later_in_publish_order_raises( - publish_plan_and_prep: tuple[publish.PublishPlan, publish.PublishPreparation, Path], + publish_plan_and_prep: tuple[ + publish_plan.PublishPlan, publish_staging.PublishPreparation, Path + ], phase_name: str, exc_type: type[Exception], ) -> None: @@ -105,9 +158,8 @@ def test_missing_dep_later_in_publish_order_raises( original_plan, _preparation, staging_root = publish_plan_and_prep alpha, beta, gamma = original_plan.publishable plan = dc.replace(original_plan, publishable=(beta, alpha, gamma)) - preparation = publish.PublishPreparation( + preparation = publish_staging.PublishPreparation( staging_root=prepare_staging_root(plan, staging_root.parent.parent), - copied_readmes=(), ) with pytest.raises(exc_type, match=r"appears after .* in publish order"): @@ -117,7 +169,7 @@ def test_missing_dep_later_in_publish_order_raises( plan=plan, preparation=preparation, runner=make_failing_runner(stderr=INDEX_MISSING_STDERR_BETA), - options=publish._PublishExecutionOptions( + options=publish_pipeline._PublishExecutionOptions( live=False, allow_dirty=True, allow_unpublished_workspace_deps=True, @@ -153,10 +205,10 @@ def test_missing_dep_in_plan_allows_cargo_name_normalisation( missing_dependency_name="alpha_crate", ) - publish._handle_index_missing_version( + publish_pipeline._handle_index_missing_version( failure, plan=plan, - options=publish._PublishExecutionOptions( + options=publish_pipeline._PublishExecutionOptions( live=False, allow_dirty=True, allow_unpublished_workspace_deps=True, @@ -175,14 +227,16 @@ def test_missing_dep_in_plan_allows_cargo_name_normalisation( pytest.param("package", publish.PublishPreflightError, "alpha", id="packaging"), pytest.param( "publish", - publish.PublishError, + publish_pipeline.PublishError, "cargo publish failed for crate beta", id="publish-dry-run", ), ], ) def test_missing_dep_in_plan_without_flag_raises( - publish_plan_and_prep: tuple[publish.PublishPlan, publish.PublishPreparation, Path], + publish_plan_and_prep: tuple[ + publish_plan.PublishPlan, publish_staging.PublishPreparation, Path + ], phase_name: str, exc_type: type[Exception], expected_fragment: str, @@ -209,7 +263,7 @@ def runner( plan=plan, preparation=preparation, runner=runner, - options=publish._PublishExecutionOptions( + options=publish_pipeline._PublishExecutionOptions( live=False, allow_dirty=True, allow_unpublished_workspace_deps=False, @@ -234,9 +288,8 @@ def test_missing_dep_not_in_plan_raises( make_workspace(workspace_root, alpha, beta), make_config() ) staging_root = prepare_staging_root(plan, tmp_path) - preparation = publish.PublishPreparation( + preparation = publish_staging.PublishPreparation( staging_root=staging_root, - copied_readmes=(), ) with pytest.raises(exc_type, match=r"external_crate") as excinfo: invoke_phase( @@ -245,7 +298,7 @@ def test_missing_dep_not_in_plan_raises( plan=plan, preparation=preparation, runner=make_failing_runner(stderr=INDEX_MISSING_STDERR_EXTERNAL), - options=publish._PublishExecutionOptions( + options=publish_pipeline._PublishExecutionOptions( live=False, allow_dirty=True, allow_unpublished_workspace_deps=True, @@ -276,9 +329,8 @@ def test_hyphenated_dep_in_plan_matches_with_canonicalisation( make_workspace(workspace_root, dependency, dependent), make_config() ) staging_root = prepare_staging_root(plan, tmp_path) - preparation = publish.PublishPreparation( + preparation = publish_staging.PublishPreparation( staging_root=staging_root, - copied_readmes=(), ) hyphenated_stderr = ( "error: failed to prepare local package for uploading\n" @@ -302,7 +354,7 @@ def runner( plan=plan, preparation=preparation, runner=runner, - options=publish._PublishExecutionOptions( + options=publish_pipeline._PublishExecutionOptions( live=False, allow_dirty=True, allow_unpublished_workspace_deps=True, @@ -315,7 +367,9 @@ def runner( def test_package_and_publish_dispatch_through_shared_helper( - publish_plan_and_prep: tuple[publish.PublishPlan, publish.PublishPreparation, Path], + publish_plan_and_prep: tuple[ + publish_plan.PublishPlan, publish_staging.PublishPreparation, Path + ], monkeypatch: pytest.MonkeyPatch, ) -> None: """Both crate-iteration wrappers delegate to ``_for_each_publishable_crate``.""" @@ -323,26 +377,26 @@ def test_package_and_publish_dispatch_through_shared_helper( calls: list[dict[str, object]] = [] def fake_for_each( - state: publish._PublicationPipelineState, + state: publish_pipeline._PublicationPipelineState, *, runner: object, - action: publish._CrateAction, + action: publish_pipeline._CrateAction, ) -> None: """Record each dispatch instead of iterating over crates.""" calls.append({"state": state, "runner": runner, "action": action}) - monkeypatch.setattr(publish, "_for_each_publishable_crate", fake_for_each) + monkeypatch.setattr(publish_pipeline, "_for_each_publishable_crate", fake_for_each) - options = publish._PublishExecutionOptions(live=False, allow_dirty=True) + options = publish_pipeline._PublishExecutionOptions(live=False, allow_dirty=True) runner = object() - publish._package_publishable_crates( + publish_pipeline._package_publishable_crates( plan, preparation, options=options, runner=runner ) - publish._publish_crates(plan, preparation, runner=runner, options=options) + publish_pipeline._publish_crates(plan, preparation, runner=runner, options=options) assert len(calls) == 2 - assert calls[0]["action"] is publish._package_crate - assert calls[1]["action"] is publish._publish_crate + assert calls[0]["action"] is publish_pipeline._package_crate + assert calls[1]["action"] is publish_pipeline._publish_crate assert calls[0]["runner"] is runner assert calls[1]["runner"] is runner diff --git a/tests/unit/publish/test_run_preflight.py b/tests/unit/publish/test_run_preflight.py index 54241526..e1f2b57c 100644 --- a/tests/unit/publish/test_run_preflight.py +++ b/tests/unit/publish/test_run_preflight.py @@ -8,7 +8,7 @@ import pytest -from lading.commands import publish, publish_preflight +from lading.commands import publish, publish_pipeline, publish_preflight from .conftest import ( ORIGINAL_PREFLIGHT, @@ -77,7 +77,7 @@ def fake_invoke( calls.append((tuple(command), cwd)) return 0, "", "" - monkeypatch.setattr(publish, "_invoke", fake_invoke) + monkeypatch.setattr(publish_pipeline, "_invoke", fake_invoke) publish.run( root, @@ -267,7 +267,7 @@ def skip_git_invoke( raise AssertionError(message) return 0, "", "" - monkeypatch.setattr(publish, "_invoke", skip_git_invoke) + monkeypatch.setattr(publish_pipeline, "_invoke", skip_git_invoke) message = publish.run(root, configuration, workspace) @@ -297,7 +297,7 @@ def dirty_invoke( return 0, " M Cargo.toml\n", "" return 0, "", "" - monkeypatch.setattr(publish, "_invoke", dirty_invoke) + monkeypatch.setattr(publish_pipeline, "_invoke", dirty_invoke) with pytest.raises(publish.PublishPreflightError) as excinfo: publish.run( @@ -346,7 +346,7 @@ def failing_invoke( return 1, "", expected_message return 0, "", "" - monkeypatch.setattr(publish, "_invoke", failing_invoke) + monkeypatch.setattr(publish_pipeline, "_invoke", failing_invoke) with pytest.raises(publish.PublishPreflightError) as excinfo: publish.run(root, configuration, workspace) diff --git a/tests/unit/publish/test_run_preflight_properties.py b/tests/unit/publish/test_run_preflight_properties.py new file mode 100644 index 00000000..f4bc6f6d --- /dev/null +++ b/tests/unit/publish/test_run_preflight_properties.py @@ -0,0 +1,55 @@ +"""Property tests for publish preflight failure transitions.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from lading.commands import ( + publish, + publish_pipeline, + publish_preflight, + publish_staging, +) +from lading.commands.publish_errors import PublishPreflightError + +from .conftest import make_config, make_crate, make_workspace + +_FAILURE_DETAIL = st.text( + alphabet="abcdefghijklmnopqrstuvwxyz0123456789 ", + min_size=1, + max_size=24, +) + + +@given(detail=_FAILURE_DETAIL) +@settings(max_examples=20, suppress_health_check=[HealthCheck.function_scoped_fixture]) +def test_run_never_stages_or_dispatches_after_preflight_failure( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, detail: str +) -> None: + """Any preflight error prevents the staging and publication phases.""" + root = tmp_path / "workspace" + workspace = make_workspace(root, make_crate(root, "alpha")) + configuration = make_config() + reached_phases: list[str] = [] + + def fail_preflight(*_args: object, **_kwargs: object) -> None: + raise PublishPreflightError(detail) + + def record_staging(*_args: object, **_kwargs: object) -> None: + reached_phases.append("staging") + + def record_dispatch(*_args: object, **_kwargs: object) -> None: + reached_phases.append("dispatch") + + monkeypatch.setattr(publish_preflight, "_run_preflight_checks", fail_preflight) + monkeypatch.setattr(publish_staging, "prepare_workspace", record_staging) + monkeypatch.setattr(publish_pipeline, "_dispatch_publication", record_dispatch) + + with pytest.raises(PublishPreflightError, match=detail): + publish.run(root, configuration, workspace) + + assert reached_phases == [] diff --git a/tests/unit/publish/test_run_publish_ordering.py b/tests/unit/publish/test_run_publish_ordering.py index bcc04120..75efcdb9 100644 --- a/tests/unit/publish/test_run_publish_ordering.py +++ b/tests/unit/publish/test_run_publish_ordering.py @@ -52,6 +52,7 @@ def test_run_logs_when_unpublished_workspace_dependency_override_is_enabled( ) -> None: """``run`` downgrades in-plan index misses when the override is enabled.""" caplog.set_level(logging.INFO, logger="lading.commands.publish") + caplog.set_level(logging.INFO, logger="lading.commands.publish_staging") root = tmp_path / "workspace" workspace = make_workspace(root, *make_dependency_chain(root)) configuration = make_config() @@ -118,6 +119,7 @@ def test_run_keeps_dry_run_publication_batched( runs in the correct staging directory. """ caplog.set_level(logging.INFO, logger="lading.commands.publish") + caplog.set_level(logging.INFO, logger="lading.commands.publish_staging") root = tmp_path / "workspace" workspace = make_workspace(root, *make_n_crate_chain(root, crate_count)) configuration = make_config() diff --git a/tests/unit/publish/test_run_workspace_config.py b/tests/unit/publish/test_run_workspace_config.py index 86d9a01b..eaa0cf58 100644 --- a/tests/unit/publish/test_run_workspace_config.py +++ b/tests/unit/publish/test_run_workspace_config.py @@ -9,7 +9,7 @@ import pytest from lading import config as config_module -from lading.commands import publish +from lading.commands import publish, publish_staging from lading.workspace import WorkspaceGraph, WorkspaceModelError from .conftest import make_config, make_crate, make_workspace @@ -35,10 +35,10 @@ def fake_load(root: Path) -> WorkspaceGraph: monkeypatch.setattr("lading.workspace.load_workspace", fake_load) monkeypatch.setattr( - publish, + publish_staging, "prepare_workspace", - lambda *_args, **_kwargs: publish.PublishPreparation( - staging_root=resolved, copied_readmes=() + lambda *_args, **_kwargs: publish_staging.PublishPreparation( + staging_root=resolved ), ) output = publish.run(workspace, configuration) diff --git a/tests/unit/publish/test_snapshot_messages.py b/tests/unit/publish/test_snapshot_messages.py index 4c46bcab..2bff858d 100644 --- a/tests/unit/publish/test_snapshot_messages.py +++ b/tests/unit/publish/test_snapshot_messages.py @@ -27,7 +27,7 @@ import pytest -from lading.commands import publish +from lading.commands import publish, publish_pipeline, publish_plan, publish_staging from lading.commands.cargo_output_adapter import ( CargoIndexLookupFailure, CargoSubprocessResult, @@ -78,7 +78,7 @@ def _missing_dependency_name(stderr: str) -> str | None: class _InPlanSnapshotCase(typ.NamedTuple): """Variable parts for in-plan fatal-path snapshot tests.""" - plan_transform: cabc.Callable[[publish.PublishPlan], publish.PublishPlan] + plan_transform: cabc.Callable[[publish_plan.PublishPlan], publish_plan.PublishPlan] stderr_transform: cabc.Callable[[str], str] @@ -127,7 +127,7 @@ def test_run_rejects_allow_unpublished_with_live( def _handle_index_missing_version_message( - plan: publish.PublishPlan, + plan: publish_plan.PublishPlan, *, stderr: str, allow_unpublished_workspace_deps: bool, @@ -145,10 +145,10 @@ def _handle_index_missing_version_message( ) with pytest.raises(publish.PublishPreflightError) as excinfo: - publish._handle_index_missing_version( + publish_pipeline._handle_index_missing_version( failure, plan=plan, - options=publish._PublishExecutionOptions( + options=publish_pipeline._PublishExecutionOptions( live=False, allow_dirty=True, allow_unpublished_workspace_deps=allow_unpublished_workspace_deps, @@ -183,7 +183,9 @@ def _snapshot_message(message: str) -> str: ], ) def test_index_missing_version_message_snapshot( - publish_plan_and_prep: tuple[publish.PublishPlan, publish.PublishPreparation, Path], + publish_plan_and_prep: tuple[ + publish_plan.PublishPlan, publish_staging.PublishPreparation, Path + ], caplog: pytest.LogCaptureFixture, snapshot: SnapshotAssertion, case: _IndexMissingCase, @@ -203,7 +205,9 @@ def test_index_missing_version_message_snapshot( def test_index_missing_in_plan_downgrade_snapshot( - publish_plan_and_prep: tuple[publish.PublishPlan, publish.PublishPreparation, Path], + publish_plan_and_prep: tuple[ + publish_plan.PublishPlan, publish_staging.PublishPreparation, Path + ], caplog: pytest.LogCaptureFixture, snapshot: SnapshotAssertion, ) -> None: @@ -220,10 +224,10 @@ def test_index_missing_in_plan_downgrade_snapshot( ) # Must not raise - the success/downgrade path returns without raising. - publish._handle_index_missing_version( + publish_pipeline._handle_index_missing_version( failure, plan=plan, - options=publish._PublishExecutionOptions( + options=publish_pipeline._PublishExecutionOptions( live=False, allow_dirty=True, allow_unpublished_workspace_deps=True, @@ -288,7 +292,9 @@ def test_index_missing_out_of_plan_message_snapshot( ], ) def test_index_missing_in_plan_fatal_message_snapshot( - publish_plan_and_prep: tuple[publish.PublishPlan, publish.PublishPreparation, Path], + publish_plan_and_prep: tuple[ + publish_plan.PublishPlan, publish_staging.PublishPreparation, Path + ], caplog: pytest.LogCaptureFixture, snapshot: SnapshotAssertion, case: _InPlanSnapshotCase, @@ -313,26 +319,28 @@ def test_index_missing_in_plan_fatal_message_snapshot( "options", [ pytest.param( - publish._PublishExecutionOptions(live=False, allow_dirty=True), + publish_pipeline._PublishExecutionOptions(live=False, allow_dirty=True), id="dry_run", ), pytest.param( - publish._PublishExecutionOptions(live=True, allow_dirty=True), + publish_pipeline._PublishExecutionOptions(live=True, allow_dirty=True), id="live", ), ], ) def test_pipeline_info_log_snapshot( - publish_plan_and_prep: tuple[publish.PublishPlan, publish.PublishPreparation, Path], + publish_plan_and_prep: tuple[ + publish_plan.PublishPlan, publish_staging.PublishPreparation, Path + ], caplog: pytest.LogCaptureFixture, snapshot: SnapshotAssertion, - options: publish._PublishExecutionOptions, + options: publish_pipeline._PublishExecutionOptions, ) -> None: """Snapshot pipeline selector and progression logs for each mode.""" - caplog.set_level(logging.INFO, logger="lading.commands.publish") + caplog.set_level(logging.INFO, logger=publish_pipeline.LOGGER.name) plan, preparation, _staging_root = publish_plan_and_prep - publish._dispatch_publication( + publish_pipeline._dispatch_publication( plan, preparation, options=options, @@ -372,11 +380,11 @@ def test_already_published_warning_snapshot( ) beta = next(crate for crate in plan.publishable if crate.name == "beta") - publish._handle_publish_result( + publish_pipeline._handle_publish_result( beta, (101, "", stderr_marker), plan=plan, - options=publish._PublishExecutionOptions(live=False, allow_dirty=True), + options=publish_pipeline._PublishExecutionOptions(live=False, allow_dirty=True), ) assert _warning_records(caplog) == snapshot() diff --git a/tests/unit/test_bump_context_properties.py b/tests/unit/test_bump_context_properties.py index 17c70d09..f471bfea 100644 --- a/tests/unit/test_bump_context_properties.py +++ b/tests/unit/test_bump_context_properties.py @@ -2,7 +2,7 @@ These tests guard that the ``excluded`` and ``updated_crate_names`` sets are derived once in :func:`lading.commands.bump._initialize_bump_context` and that -:func:`~lading.commands.bump._apply_crate_manifest_update` consumes them, +:func:`~lading.commands.bump_pipeline._apply_crate_manifest_update` consumes them, rather than recomputing selection per crate. """ @@ -18,7 +18,7 @@ from hypothesis import HealthCheck, given, settings from tomlkit import parse as parse_toml -from lading.commands import bump +from lading.commands import bump, bump_pipeline from lading.workspace import WorkspaceCrate, WorkspaceDependency, WorkspaceGraph from tests.helpers.workspace_builders import _make_config @@ -270,12 +270,14 @@ def _assert_crate_manifest_update( depends_on_updated = any(target in expected.updated_names for target in crate_edges) expect_skipped = expected.excluded and not depends_on_updated - outcome = bump._apply_crate_manifest_update(crate, _TARGET_VERSION, context) + outcome = bump_pipeline._apply_crate_manifest_update( + crate, _TARGET_VERSION, context + ) expected_outcome = ( - bump._CrateManifestOutcome.SKIPPED + bump_pipeline._CrateManifestOutcome.SKIPPED if expect_skipped - else bump._CrateManifestOutcome.UPDATED + else bump_pipeline._CrateManifestOutcome.UPDATED ) assert outcome is expected_outcome, ( f"crate {crate.name!r}: expected outcome {expected_outcome}, got {outcome}" diff --git a/tests/unit/test_bump_manifest_writing.py b/tests/unit/test_bump_manifest_writing.py index 6406c24b..36ae2c2d 100644 --- a/tests/unit/test_bump_manifest_writing.py +++ b/tests/unit/test_bump_manifest_writing.py @@ -7,7 +7,7 @@ import pytest from tomlkit import parse as parse_toml -from lading.commands import bump, bump_toml +from lading.commands import bump, bump_manifests, bump_toml from tests.helpers.workspace_builders import _load_version @@ -15,7 +15,7 @@ def test_update_manifest_writes_when_changed(tmp_path: Path) -> None: """Applying a new version persists changes to disk.""" manifest_path = tmp_path / "Cargo.toml" manifest_path.write_text('[package]\nname = "demo"\nversion = "0.1.0"\n') - changed = bump._update_manifest( + changed = bump_manifests._update_manifest( manifest_path, (("package",),), "1.0.0", bump.BumpOptions() ) assert changed is True, "changing the version should report a change" @@ -30,7 +30,7 @@ def test_update_manifest_preserves_inline_comment(tmp_path: Path) -> None: manifest_path.write_text( '[package]\nversion = "0.1.0" # keep me\n', encoding="utf-8" ) - changed = bump._update_manifest( + changed = bump_manifests._update_manifest( manifest_path, (("package",),), "1.2.3", bump.BumpOptions() ) assert changed is True, "rewriting the version should report a change" @@ -47,7 +47,7 @@ def test_update_manifest_skips_when_unchanged(tmp_path: Path) -> None: manifest_path = tmp_path / "Cargo.toml" original = '[package]\nname = "demo"\nversion = "0.1.0"\n' manifest_path.write_text(original) - changed = bump._update_manifest( + changed = bump_manifests._update_manifest( manifest_path, (("package",),), "0.1.0", bump.BumpOptions() ) assert changed is False, "an already-current version should report no change" diff --git a/tests/unit/test_bump_result_formatting.py b/tests/unit/test_bump_result_formatting.py index 560867b6..81d71b6a 100644 --- a/tests/unit/test_bump_result_formatting.py +++ b/tests/unit/test_bump_result_formatting.py @@ -1,4 +1,4 @@ -"""Unit tests for ``lading.commands.bump._apply_crate_manifest_update``. +"""Unit tests for ``lading.commands.bump_pipeline._apply_crate_manifest_update``. Exercises single-crate manifest updates (exclusion handling and dependency rewrites) and hosts the workspace-construction fixtures those assertions share. @@ -13,7 +13,7 @@ import pytest from tomlkit import parse as parse_toml -from lading.commands import bump +from lading.commands import bump, bump_pipeline from lading.workspace import WorkspaceCrate, WorkspaceDependency, WorkspaceGraph from tests.helpers.workspace_builders import _make_config @@ -147,9 +147,9 @@ def test_update_crate_manifest(tmp_path: Path, params: UpdateCrateTestParams) -> ), ) - outcome = bump._apply_crate_manifest_update(crate, "1.2.3", context) + outcome = bump_pipeline._apply_crate_manifest_update(crate, "1.2.3", context) - assert outcome is bump._CrateManifestOutcome.UPDATED, ( + assert outcome is bump_pipeline._CrateManifestOutcome.UPDATED, ( f"expected manifest update for {params.test_id}" ) package_version, alpha_version = _parse_manifest_versions(crate.manifest_path) diff --git a/tests/unit/test_bump_selectors.py b/tests/unit/test_bump_selectors.py index be9f48db..6cbefe21 100644 --- a/tests/unit/test_bump_selectors.py +++ b/tests/unit/test_bump_selectors.py @@ -4,21 +4,21 @@ import pytest -from lading.commands import bump +from lading.commands import bump_manifests def test_determine_package_selectors_respects_exclusions() -> None: """Excluded crates produce no package selectors.""" - assert bump._determine_package_selectors("beta", {"beta"}) == (), ( + assert bump_manifests._determine_package_selectors("beta", {"beta"}) == (), ( "an excluded crate should yield no package selectors" ) def test_determine_package_selectors_includes_package_for_active_crates() -> None: """Active crates receive the package selector tuple.""" - assert bump._determine_package_selectors("beta", set()) == (("package",),), ( - "an active crate should yield the package selector" - ) + assert bump_manifests._determine_package_selectors("beta", set()) == ( + ("package",), + ), "an active crate should yield the package selector" @pytest.mark.parametrize( @@ -42,7 +42,7 @@ def test_should_skip_crate_update_requires_selectors_or_dependencies( expected_skip: bool, ) -> None: """A crate is skipped only when it has neither selectors nor dependencies.""" - result = bump._should_skip_crate_update(selectors, dependency_sections) + result = bump_manifests._should_skip_crate_update(selectors, dependency_sections) assert result is expected_skip, ( "skip should be True only when selectors and dependency sections are both empty" ) diff --git a/tests/unit/test_dependency_sections.py b/tests/unit/test_dependency_sections.py index ea74a58f..0db86036 100644 --- a/tests/unit/test_dependency_sections.py +++ b/tests/unit/test_dependency_sections.py @@ -14,7 +14,7 @@ from hypothesis import given, settings from tomlkit import parse as parse_toml -from lading.commands import bump, bump_docs, bump_manifests, bump_toml +from lading.commands import bump_docs, bump_manifests, bump_toml if typ.TYPE_CHECKING: from syrupy.assertion import SnapshotAssertion @@ -31,7 +31,7 @@ def test_kind_mapping_agrees_with_canonical_sections() -> None: def test_workspace_dependency_sections_use_canonical_vocabulary() -> None: """The workspace manifest update targets exactly the canonical sections.""" - sections = bump._workspace_dependency_sections(["alpha", "beta"]) + sections = bump_manifests._workspace_dependency_sections(["alpha", "beta"]) assert tuple(sections) == bump_toml.DEPENDENCY_SECTIONS assert all(names == {"alpha", "beta"} for names in sections.values()) diff --git a/tests/unit/test_publish_formatting.py b/tests/unit/test_publish_formatting.py index 0b839c4f..f0dabc39 100644 --- a/tests/unit/test_publish_formatting.py +++ b/tests/unit/test_publish_formatting.py @@ -5,7 +5,7 @@ import collections.abc as cabc import typing as typ -from lading.commands import publish, publish_plan +from lading.commands import publish_plan, publish_staging from tests.unit.conftest import _CrateSpec if typ.TYPE_CHECKING: @@ -86,11 +86,9 @@ def test_format_preparation_summary_reports_bump_readme_handling( """Summary explains that README adoption is handled before publish.""" staging_root = tmp_path / "staging" staging_root.mkdir() - preparation = publish.PublishPreparation( - staging_root=staging_root, copied_readmes=() - ) + preparation = publish_staging.PublishPreparation(staging_root=staging_root) - lines = publish._format_preparation_summary(preparation) + lines = publish_staging._format_preparation_summary(preparation) assert lines == ( f"Staged workspace at: {staging_root}", diff --git a/tests/unit/test_publish_patch_strategy.py b/tests/unit/test_publish_patch_strategy.py index 4554c9c7..e4204a65 100644 --- a/tests/unit/test_publish_patch_strategy.py +++ b/tests/unit/test_publish_patch_strategy.py @@ -9,7 +9,7 @@ import pytest from tomlkit import parse as parse_toml -from lading.commands import publish +from lading.commands import publish, publish_plan if typ.TYPE_CHECKING: from pathlib import Path @@ -24,7 +24,7 @@ class _PatchStrategyTestSetup: """Parameters for patch strategy test setup.""" tmp_path: Path - make_plan_factory: cabc.Callable[[Path, tuple[str, ...]], publish.PublishPlan] + make_plan_factory: cabc.Callable[[Path, tuple[str, ...]], publish_plan.PublishPlan] patch_entries: str publishable_names: tuple[str, ...] strategy: str | bool @@ -33,17 +33,17 @@ class _PatchStrategyTestSetup: @pytest.fixture def make_plan_factory( make_crate: cabc.Callable[[Path, str, object | None], WorkspaceCrate], -) -> cabc.Callable[[Path, tuple[str, ...]], publish.PublishPlan]: +) -> cabc.Callable[[Path, tuple[str, ...]], publish_plan.PublishPlan]: """Return a factory for building publish plans rooted at ``workspace_root``.""" def _builder( workspace_root: Path, publishable_names: tuple[str, ...] - ) -> publish.PublishPlan: + ) -> publish_plan.PublishPlan: crates: list[WorkspaceCrate] = [] for name in publishable_names: crate = make_crate(workspace_root / "crates", name) crates.append(crate) - return publish.PublishPlan( + return publish_plan.PublishPlan( workspace_root=workspace_root, publishable=tuple(crates), skipped_manifest=(), @@ -84,7 +84,7 @@ def _apply_strategy_and_parse(setup: _PatchStrategyTestSetup) -> TOMLDocument: def test_strip_patches_all_removes_patch_section( tmp_path: Path, - make_plan_factory: cabc.Callable[[Path, tuple[str, ...]], publish.PublishPlan], + make_plan_factory: cabc.Callable[[Path, tuple[str, ...]], publish_plan.PublishPlan], ) -> None: """Strategy 'all' removes the entire [patch.crates-io] section.""" document = _apply_strategy_and_parse( @@ -101,7 +101,7 @@ def test_strip_patches_all_removes_patch_section( def test_strip_patches_per_crate_removes_publishable_only( tmp_path: Path, - make_plan_factory: cabc.Callable[[Path, tuple[str, ...]], publish.PublishPlan], + make_plan_factory: cabc.Callable[[Path, tuple[str, ...]], publish_plan.PublishPlan], ) -> None: """Strategy 'per-crate' removes only entries for publishable crates.""" document = _apply_strategy_and_parse( @@ -125,7 +125,7 @@ def test_strip_patches_per_crate_removes_publishable_only( def test_strip_patches_per_crate_removes_entire_table_when_empty( tmp_path: Path, - make_plan_factory: cabc.Callable[[Path, tuple[str, ...]], publish.PublishPlan], + make_plan_factory: cabc.Callable[[Path, tuple[str, ...]], publish_plan.PublishPlan], ) -> None: """Per-crate strategy cleans up empty patch tables after removals.""" document = _apply_strategy_and_parse( @@ -146,7 +146,7 @@ def test_strip_patches_per_crate_removes_entire_table_when_empty( def test_strip_patches_disabled_keeps_section( tmp_path: Path, - make_plan_factory: cabc.Callable[[Path, tuple[str, ...]], publish.PublishPlan], + make_plan_factory: cabc.Callable[[Path, tuple[str, ...]], publish_plan.PublishPlan], ) -> None: """Boolean false leaves the patch section untouched.""" document = _apply_strategy_and_parse( diff --git a/tests/unit/test_publish_planning.py b/tests/unit/test_publish_planning.py index 7acda65b..f6c11fa1 100644 --- a/tests/unit/test_publish_planning.py +++ b/tests/unit/test_publish_planning.py @@ -23,7 +23,7 @@ def _plan_with_crates( make_config: cabc.Callable[..., config_module.LadingConfig], crates: tuple[WorkspaceCrate, ...], **config_overrides: object, -) -> publish.PublishPlan: +) -> publish_plan.PublishPlan: """Plan publication for ``crates`` using ``tmp_path`` as the workspace root.""" root = tmp_path.resolve() workspace = make_workspace(root, *crates) diff --git a/tests/unit/test_publish_staging.py b/tests/unit/test_publish_staging.py index 5fec166a..d299d4c4 100644 --- a/tests/unit/test_publish_staging.py +++ b/tests/unit/test_publish_staging.py @@ -7,7 +7,7 @@ import pytest -from lading.commands import publish +from lading.commands import publish, publish_staging from tests.unit.conftest import ( PreparationFixtures, PrepareWorkspaceFixtures, @@ -23,7 +23,7 @@ def test_normalise_build_directory_defaults_to_tempdir(tmp_path: Path) -> None: workspace_root = tmp_path / "workspace" workspace_root.mkdir() - build_directory = publish._normalise_build_directory(workspace_root, None) + build_directory = publish_staging._normalise_build_directory(workspace_root, None) assert build_directory.exists() assert build_directory.is_absolute() @@ -38,7 +38,9 @@ def test_normalise_build_directory_resolves_relative_paths( workspace_root.mkdir() monkeypatch.chdir(tmp_path) - build_directory = publish._normalise_build_directory(workspace_root, "staging") + build_directory = publish_staging._normalise_build_directory( + workspace_root, "staging" + ) expected = (tmp_path / "staging").resolve() assert build_directory == expected @@ -54,12 +56,32 @@ def test_normalise_build_directory_rejects_workspace_descendants( build_directory = workspace_root / "target" - with pytest.raises(publish.PublishPreparationError) as excinfo: - publish._normalise_build_directory(workspace_root, build_directory) + with pytest.raises(publish_staging.PublishPreparationError) as excinfo: + publish_staging._normalise_build_directory(workspace_root, build_directory) assert "cannot reside within the workspace root" in str(excinfo.value) +def test_normalise_build_directory_wraps_creation_failure( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Build-directory creation failures use the staging error boundary.""" + workspace_root = tmp_path / "workspace" + workspace_root.mkdir() + + def fail_mkdir(*_args: object, **_kwargs: object) -> None: + message = "permission denied" + raise OSError(message) + + monkeypatch.setattr(publish_staging.Path, "mkdir", fail_mkdir) + + with pytest.raises(publish_staging.PublishPreparationError) as excinfo: + publish_staging._normalise_build_directory(workspace_root, tmp_path / "staging") + + assert "Cannot create publish build directory" in str(excinfo.value) + assert isinstance(excinfo.value.__cause__, OSError) + + def test_copy_workspace_tree_mirrors_workspace_contents(tmp_path: Path) -> None: """Workspace files are cloned into the staging directory.""" workspace_root = tmp_path / "workspace" @@ -74,7 +96,7 @@ def test_copy_workspace_tree_mirrors_workspace_contents(tmp_path: Path) -> None: build_directory = tmp_path / "staging" build_directory.mkdir() - staging_root = publish._copy_workspace_tree( + staging_root = publish_staging._copy_workspace_tree( workspace_root, build_directory, preserve_symlinks=True ) @@ -97,7 +119,7 @@ def test_copy_workspace_tree_replaces_existing_clone(tmp_path: Path) -> None: stale_file = existing_clone / "stale.txt" stale_file.write_text("stale", encoding="utf-8") - staging_root = publish._copy_workspace_tree( + staging_root = publish_staging._copy_workspace_tree( workspace_root, build_directory, preserve_symlinks=True ) @@ -111,14 +133,38 @@ def test_copy_workspace_tree_rejects_nested_clone(tmp_path: Path) -> None: workspace_root = tmp_path / "workspace" workspace_root.mkdir() - with pytest.raises(publish.PublishPreparationError) as excinfo: - publish._copy_workspace_tree( + with pytest.raises(publish_staging.PublishPreparationError) as excinfo: + publish_staging._copy_workspace_tree( workspace_root, workspace_root, preserve_symlinks=True ) assert "cannot be nested inside the workspace root" in str(excinfo.value) +def test_copy_workspace_tree_wraps_copy_failure( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Workspace-copy failures use the staging error boundary.""" + workspace_root = tmp_path / "workspace" + workspace_root.mkdir() + build_directory = tmp_path / "staging" + build_directory.mkdir() + + def fail_copytree(*_args: object, **_kwargs: object) -> None: + message = "disk full" + raise OSError(message) + + monkeypatch.setattr(publish_staging.shutil, "copytree", fail_copytree) + + with pytest.raises(publish_staging.PublishPreparationError) as excinfo: + publish_staging._copy_workspace_tree( + workspace_root, build_directory, preserve_symlinks=True + ) + + assert "Cannot copy workspace into staging directory" in str(excinfo.value) + assert isinstance(excinfo.value.__cause__, OSError) + + @pytest.mark.parametrize( "scenario", [ @@ -148,7 +194,7 @@ def test_copy_workspace_tree_symlink_handling( build_directory = tmp_path / "staging" build_directory.mkdir() - staging_root = publish._copy_workspace_tree( + staging_root = publish_staging._copy_workspace_tree( workspace_root, build_directory, preserve_symlinks=preserve_symlinks ) @@ -160,32 +206,6 @@ def test_copy_workspace_tree_symlink_handling( assert staged_link.read_text(encoding="utf-8") == "payload" -def test_prepare_workspace_does_not_stage_workspace_readme( - prepare_workspace_fixtures: PrepareWorkspaceFixtures, - preparation_fixtures: PreparationFixtures, -) -> None: - """Publish staging leaves workspace README adoption to bump.""" - fx = prepare_workspace_fixtures - pf = preparation_fixtures - workspace_root = fx.tmp_path / "workspace" - workspace_root.mkdir() - readme = workspace_root / "README.md" - readme.write_text("Workspace README", encoding="utf-8") - crate = pf.make_crate(workspace_root, "alpha", _CrateSpec(readme_workspace=True)) - workspace = pf.make_workspace(workspace_root, crate) - configuration = pf.make_config() - plan = publish.plan_publication(workspace, configuration) - preparation = publish.prepare_workspace(plan, workspace, options=fx.publish_options) - - staging_root = preparation.staging_root - assert staging_root.exists() - staged_readme = ( - staging_root / crate.root_path.relative_to(workspace_root) / "README.md" - ) - assert not staged_readme.exists() - assert preparation.copied_readmes == () - - def test_prepare_workspace_registers_cleanup( monkeypatch: pytest.MonkeyPatch, prepare_workspace_fixtures: PrepareWorkspaceFixtures, @@ -201,15 +221,18 @@ def test_prepare_workspace_registers_cleanup( plan = publish.plan_publication(workspace, pf.make_config()) build_directory = fx.publish_options.build_directory + build_directory.mkdir(parents=True) + marker = build_directory / "keep.txt" + marker.write_text("keep", encoding="utf-8") registered: list[cabc.Callable[[], None]] = [] def capture(callback: cabc.Callable[[], None]) -> None: registered.append(callback) - monkeypatch.setattr(publish.atexit, "register", capture) + monkeypatch.setattr(publish_staging.atexit, "register", capture) options = publish.PublishOptions(build_directory=build_directory, cleanup=True) - preparation = publish.prepare_workspace(plan, workspace, options=options) + preparation = publish_staging.prepare_workspace(plan, options=options) assert len(registered) == 1 cleanup = registered[0] @@ -218,7 +241,9 @@ def capture(callback: cabc.Callable[[], None]) -> None: assert build_directory.exists() cleanup() - assert not build_directory.exists() + assert build_directory.exists() + assert marker.read_text(encoding="utf-8") == "keep" + assert not preparation.staging_root.exists() @pytest.mark.parametrize( @@ -231,50 +256,35 @@ def capture(callback: cabc.Callable[[], None]) -> None: pytest.param(_CrateSpec(), id="no_readme_opt_in"), ], ) -def test_prepare_workspace_returns_empty_copied_readmes( +def test_prepare_workspace_copies_workspace_readme_without_adopting_it_for_crates( prepare_workspace_fixtures: PrepareWorkspaceFixtures, preparation_fixtures: PreparationFixtures, crate_spec: _CrateSpec, ) -> None: - """Staging reports no copied READMEs regardless of readme opt-in status.""" + """Staging copies the workspace README without creating crate READMEs.""" fx = prepare_workspace_fixtures pf = preparation_fixtures workspace_root = fx.tmp_path / "workspace" workspace_root.mkdir() + readme = workspace_root / "README.md" + readme.write_text("Workspace README", encoding="utf-8") crate = pf.make_crate(workspace_root, "alpha", crate_spec) workspace = pf.make_workspace(workspace_root, crate) configuration = pf.make_config() plan = publish.plan_publication(workspace, configuration) - preparation = publish.prepare_workspace(plan, workspace, options=fx.publish_options) + preparation = publish_staging.prepare_workspace(plan, options=fx.publish_options) assert preparation.staging_root.exists() - assert preparation.copied_readmes == () - - -def test_prepare_workspace_keeps_copied_readmes_empty_for_opted_in_crates( - prepare_workspace_fixtures: PrepareWorkspaceFixtures, - preparation_fixtures: PreparationFixtures, -) -> None: - """Readme opt-in does not produce publish-time copied paths.""" - fx = prepare_workspace_fixtures - pf = preparation_fixtures - workspace_root = fx.tmp_path / "workspace" - workspace_root.mkdir() - readme = workspace_root / "README.md" - readme.write_text("Workspace", encoding="utf-8") - crate_alpha = pf.make_crate( - workspace_root, "alpha", _CrateSpec(readme_workspace=True) + assert (preparation.staging_root / readme.name).read_text(encoding="utf-8") == ( + "Workspace README" ) - crate_beta = pf.make_crate( - workspace_root, "beta", _CrateSpec(readme_workspace=True) + staged_crate_readme = ( + preparation.staging_root + / crate.root_path.relative_to(workspace_root) + / "README.md" ) - workspace = pf.make_workspace(workspace_root, crate_alpha, crate_beta) - plan = publish.plan_publication(workspace, pf.make_config()) - - preparation = publish.prepare_workspace(plan, workspace, options=fx.publish_options) - - assert preparation.copied_readmes == () + assert not staged_crate_readme.exists() def test_prepare_workspace_does_not_register_cleanup_when_disabled( @@ -296,8 +306,8 @@ def test_prepare_workspace_does_not_register_cleanup_when_disabled( def capture(callback: cabc.Callable[[], None]) -> None: registered.append(callback) - monkeypatch.setattr(publish.atexit, "register", capture) + monkeypatch.setattr(publish_staging.atexit, "register", capture) - publish.prepare_workspace(plan, workspace, options=fx.publish_options) + publish_staging.prepare_workspace(plan, options=fx.publish_options) assert registered == [] diff --git a/tests/unit/test_publish_staging_properties.py b/tests/unit/test_publish_staging_properties.py new file mode 100644 index 00000000..6de8890d --- /dev/null +++ b/tests/unit/test_publish_staging_properties.py @@ -0,0 +1,57 @@ +"""Property tests for publish staging path safety.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from lading.commands import publish_staging + +_SAFE_PATH_COMPONENT = st.text( + alphabet="abcdefghijklmnopqrstuvwxyz0123456789", + min_size=1, + max_size=8, +) + + +@given(parts=st.lists(_SAFE_PATH_COMPONENT, min_size=1, max_size=3)) +@settings(max_examples=20, suppress_health_check=[HealthCheck.function_scoped_fixture]) +def test_normalise_build_directory_rejects_workspace_descendants( + tmp_path: Path, parts: list[str] +) -> None: + """Every generated workspace descendant is rejected as a build directory.""" + workspace_root = tmp_path / "workspace" + workspace_root.mkdir(exist_ok=True) + descendant = workspace_root.joinpath(*parts) + + with pytest.raises( + publish_staging.PublishPreparationError, + match="cannot reside within the workspace root", + ): + publish_staging._normalise_build_directory(workspace_root, descendant) + + +@given(name=_SAFE_PATH_COMPONENT) +@settings(max_examples=20, suppress_health_check=[HealthCheck.function_scoped_fixture]) +def test_normalise_build_directory_accepts_workspace_siblings( + tmp_path: Path, name: str +) -> None: + """Every generated sibling build directory remains outside the workspace.""" + workspace_root = tmp_path / "workspace" + workspace_root.mkdir(exist_ok=True) + sibling = tmp_path / "build" / name + + build_directory = publish_staging._normalise_build_directory( + workspace_root, sibling + ) + + assert build_directory == sibling.resolve(), ( + f"Expected build directory {build_directory} to resolve to {sibling.resolve()}" + ) + assert not build_directory.is_relative_to(workspace_root), ( + f"Expected build directory {build_directory} to be outside workspace root " + f"{workspace_root.resolve()}" + )