Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,36 @@ invocations resolve to `False`, and explicit values are honoured. This keeps
the command dataclass free of adapter-only state while preserving the dry-run
default expected by operators.

### CLI context loading (`_run_with_context`)

```python
_run_with_context(
workspace_root: Path,
runner: Callable[[Path, LadingConfig, WorkspaceGraph, CommandRunner], str],
*,
command_runner: CommandRunner | None = None,
) -> str
```

`_run_with_context` is the single shared entry point that both the `bump` and
`publish` CLI commands call to run a command with configuration and workspace
data. It resolves configuration once: it first tries
`config.current_configuration()`, the already-installed context-var
configuration that `main()` sets up. If that raises
`config.ConfigurationNotLoadedError` — for example, when a command is invoked
programmatically via `cli.app` without a preloaded scope — it loads
configuration from disk with `config.load_configuration(workspace_root)` and
installs it under a fresh `config.use_configuration(...)` scope. When
configuration is already active, a `nullcontext()` stands in for that scope,
so the single `with` block is uniform: this was the issue #107 refactor that
removed the duplicated load-workspace-and-run block from the two branches.

Under that scope, alongside
`metadata_module.use_command_runner(active_runner)`, `_run_with_context` loads
the workspace graph via `load_workspace(workspace_root)` and calls the
caller-supplied `runner(workspace_root, configuration, workspace_model,
active_runner)`, returning its string result.

### Exception hierarchy (`lading.exceptions`)

`lading.exceptions.LadingError` is the package-level base class for domain
Expand Down Expand Up @@ -622,6 +652,22 @@ 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_plan.py` also owns section rendering for plan output.
`render_section(items, *, header, formatter=str, empty_message=None) ->
list[str]` is the single section renderer (issue #107): a query that returns
the formatted lines rather than mutating an accumulator. Non-empty items
render `[header, "- <formatter(item)>", ...]`; an empty sequence renders `[]`
(the section is omitted) unless `empty_message` is supplied, in which case it
renders `[empty_message]`. `append_section(lines, items, *, header,
formatter=str) -> None` is a thin, backwards-compatible command wrapper that
delegates to `render_section` and extends `lines` in place; it has no
`empty_message` parameter and is retained as the historical public helper,
though new code may prefer `render_section`. `format_plan(plan, *,
strip_patches) -> str` composes the `lading publish` plan summary by calling
`render_section` for the publishable crates (with `empty_message="Crates to
publish: none"`) and for each skipped-crate group, then joins the resulting
lines. All three helpers are public exports of `publish_plan`.

`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
Expand Down Expand Up @@ -913,6 +959,16 @@ values are pinned by a syrupy snapshot. See the
[cmd-mox usage guide](./cmd-mox-usage-guide.md#environment-variables) for the
operator-facing description of the variable and its failure modes.

`lading.utils.commands.LADING_CATALOGUE` is the staged cuprum programme
catalogue (cargo, git). It is intentionally not yet wired into the execution
path — `publish_execution._invoke` still delegates to the subprocess runner,
which spawns processes directly. It becomes live with the [Phase 5.2
publish-execution migration](./roadmap.md), which rewires
`publish_execution.py` command execution (the roadmap's
`_invoke_via_subprocess()` step) onto the catalogue's
`scoped(allowlist=…)` model. Treat it as a registration point, not as
active allowlist enforcement.

#### Subprocess invocation logging

`subprocess_runner` emits **exactly one** log record per external command
Expand Down
28 changes: 28 additions & 0 deletions docs/users-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,34 @@ crate fails, crates already uploaded to crates.io are not rolled back. Reruns
skip versions that are already present on crates.io and continue with the
remaining crates.

At the end of the run, `lading publish` prints a summary of the publish plan
it computed, listing the crates to publish and any crates skipped because
they are marked `publish = false`, excluded via `publish.exclude`, or named
in `publish.exclude` but absent from the workspace:

```plaintext
Publish plan for <workspace>
Strip patch strategy: all
Crates to publish (2):
- alpha @ 0.1.0
- beta @ 0.1.0
Skipped (publish = false):
- gamma
Skipped via publish.exclude:
- delta
Configured exclusions not found in workspace:
- missing
```

When no crates are publishable, the summary reports `Crates to publish: none`
instead of a list:

```plaintext
Publish plan for <workspace>
Strip patch strategy: per-crate
Crates to publish: none
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

`bump` adopts the workspace `README.md` for any member crate that sets
`readme.workspace = true`. The adopted README is written into the crate
directory and relative Markdown links are rewritten so they still resolve from
Expand Down
14 changes: 6 additions & 8 deletions lading/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
import os
import sys
import typing as typ
from contextlib import contextmanager
from contextlib import AbstractContextManager, contextmanager, nullcontext
from pathlib import Path

from cyclopts import App
Expand Down Expand Up @@ -303,17 +303,15 @@ def _run_with_context(
) -> str:
"""Execute ``runner`` with configuration and workspace data."""
active_runner = command_runner or _select_runner()
configuration_scope: AbstractContextManager[object] = nullcontext()
try:
configuration = config.current_configuration()
except config.ConfigurationNotLoadedError:
# Freshly loaded configuration must be installed for the duration of
# the command; an already-active configuration needs no new scope.
configuration = config.load_configuration(workspace_root)
with (
config.use_configuration(configuration),
metadata_module.use_command_runner(active_runner),
):
workspace_model = load_workspace(workspace_root)
return runner(workspace_root, configuration, workspace_model, active_runner)
with metadata_module.use_command_runner(active_runner):
configuration_scope = config.use_configuration(configuration)
with configuration_scope, metadata_module.use_command_runner(active_runner):
workspace_model = load_workspace(workspace_root)
return runner(workspace_root, configuration, workspace_model, active_runner)

Expand Down
4 changes: 3 additions & 1 deletion lading/commands/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,9 @@ def _execute_live_publication_pipeline(
", ".join(completed) if completed else "none",
)
raise PublishPreflightError(str(exc)) from exc
except (PublishPreflightError, PublishError):
# PublishError subclasses PublishPreflightError, so the base class
# alone covers both pre-flight and publish failures here.
except PublishPreflightError:
LOGGER.exception(
"Live pipeline: aborted on crate %s — %d/%d crates completed (%s)",
crate.name,
Expand Down
102 changes: 70 additions & 32 deletions lading/commands/publish_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,19 +197,49 @@ def plan_publication(
)


def _format_crates_section(
lines: list[str],
crates: tuple[WorkspaceCrate, ...],
def render_section[T](
items: cabc.Sequence[T],
*,
header: str,
formatter: cabc.Callable[[T], str] = str,
empty_message: str | None = None,
) -> None:
"""Append publishable crate details to ``lines``."""
if crates:
lines.append(header)
lines.extend(f"- {crate.name} @ {crate.version}" for crate in crates)
elif empty_message is not None:
lines.append(empty_message)
) -> list[str]:
"""Return the rendered lines for one plan section.

This is the single section renderer for publish-plan output (issue #107):
a query returning the formatted lines rather than mutating an accumulator,
so callers compose sections with ``list.extend``.

Parameters
----------
items:
Entries rendered beneath ``header``. An empty sequence renders no
header and no bullets unless ``empty_message`` is supplied.
header:
Section heading emitted before the formatted entries.
formatter:
Callable mapping each item to its display string. Defaults to ``str``.
empty_message:
Rendered in place of the section when ``items`` is empty; omit it to
skip empty sections entirely.

Returns
-------
list[str]
The rendered lines for the section.

Examples
--------
>>> render_section(["alpha", "beta"], header="Members:")
['Members:', '- alpha', '- beta']
>>> render_section([], header="Members:")
[]
>>> render_section([], header="Members:", empty_message="Members: none")
['Members: none']
"""
if items:
return [header, *(f"- {formatter(item)}" for item in items)]
return [] if empty_message is None else [empty_message]


def append_section[T](
Expand All @@ -221,6 +251,10 @@ def append_section[T](
) -> None:
"""Append a formatted section to ``lines`` when ``items`` is non-empty.

Thin command wrapper over :func:`render_section` (issue #107), retained as
the historical public helper. New code may prefer ``render_section``, which
returns the lines as a query rather than mutating ``lines``.

Parameters
----------
lines:
Expand Down Expand Up @@ -248,9 +282,7 @@ def append_section[T](
>>> lines
['Header:', 'Members:', '- alpha', '- beta']
"""
if items:
lines.append(header)
lines.extend(f"- {formatter(item)}" for item in items)
lines.extend(render_section(items, header=header, formatter=formatter))


def format_plan(plan: PublishPlan, *, strip_patches: StripPatchesSetting) -> str:
Expand Down Expand Up @@ -288,28 +320,33 @@ def format_plan(plan: PublishPlan, *, strip_patches: StripPatchesSetting) -> str
f"Strip patch strategy: {strip_patches}",
]

_format_crates_section(
lines,
plan.publishable,
header=f"Crates to publish ({len(plan.publishable)}):",
empty_message="Crates to publish: none",
lines.extend(
render_section(
plan.publishable,
header=f"Crates to publish ({len(plan.publishable)}):",
formatter=lambda crate: f"{crate.name} @ {crate.version}",
empty_message="Crates to publish: none",
)
)
append_section(
lines,
plan.skipped_manifest,
header="Skipped (publish = false):",
formatter=lambda crate: crate.name,
lines.extend(
render_section(
plan.skipped_manifest,
header="Skipped (publish = false):",
formatter=lambda crate: crate.name,
)
)
append_section(
lines,
plan.skipped_configuration,
header="Skipped via publish.exclude:",
formatter=lambda crate: crate.name,
lines.extend(
render_section(
plan.skipped_configuration,
header="Skipped via publish.exclude:",
formatter=lambda crate: crate.name,
)
)
append_section(
lines,
plan.missing_configuration_exclusions,
header="Configured exclusions not found in workspace:",
lines.extend(
render_section(
plan.missing_configuration_exclusions,
header="Configured exclusions not found in workspace:",
)
)

return "\n".join(lines)
Expand All @@ -321,4 +358,5 @@ def format_plan(plan: PublishPlan, *, strip_patches: StripPatchesSetting) -> str
"append_section",
"format_plan",
"plan_publication",
"render_section",
]
8 changes: 8 additions & 0 deletions lading/utils/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@
Migration context: This is Step 5.1 of the Cuprum migration. Subsequent
steps will migrate existing plumbum and subprocess code to use this
catalogue via ``scoped(allowlist=LADING_CATALOGUE.allowlist)``.

The catalogue is staged but intentionally not yet wired into the execution
path: ``publish_execution._invoke`` still delegates to the subprocess runner,
which spawns processes directly. The wiring lands with the Phase 5.2
publish-execution migration in ``docs/roadmap.md`` -- the
``_invoke_via_subprocess()`` step that rewires ``publish_execution.py``
command execution onto ``scoped(allowlist=LADING_CATALOGUE.allowlist)``; do
not mistake this module for live enforcement in the meantime.
"""

from __future__ import annotations
Expand Down
23 changes: 23 additions & 0 deletions tests/unit/publish/__snapshots__/test_formatting_helpers.ambr
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# serializer version: 1
# name: test_format_plan_snapshot_with_publishable
'''
Publish plan for <workspace>
Strip patch strategy: all
Crates to publish (2):
- alpha @ 0.1.0
- beta @ 0.1.0
Skipped (publish = false):
- gamma
Skipped via publish.exclude:
- delta
Configured exclusions not found in workspace:
- missing
'''
# ---
# name: test_format_plan_snapshot_without_publishable
'''
Publish plan for <workspace>
Strip patch strategy: per-crate
Crates to publish: none
'''
# ---
Loading
Loading