Skip to content
Merged
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
33 changes: 33 additions & 0 deletions docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,13 @@ execution. When `command_runner` is `None`, bump falls back to the default
subprocess runner. Tests pass a runner explicitly so lockfile commands can be
observed without invoking real Cargo processes.

Bump-time crate-set derivation is centralized in the bump context: the
`excluded` and `updated_crate_names` sets are computed exactly once in
`bump._initialize_bump_context` and threaded to downstream helpers such as
`_update_crate_manifest`. Helpers must consume the context sets rather than
re-deriving them per crate, which would make manifest processing quadratic in
workspace size.

Option defaulting is the command layer's responsibility, not the CLI adapter's.
`cli.bump` forwards `rebuild_lockfiles` as the raw `bool | None` it received;
the only resolution against `configuration.bump.rebuild_lockfiles` happens in
Expand Down Expand Up @@ -709,6 +716,32 @@ a formatted message that includes all four values. Using a single function for
message construction keeps the error format consistent across the packaging and
publish phases and makes snapshot testing straightforward.

### Shared TOML coercion (`lading.toml_coerce`)

`lading.toml_coerce` is the canonical home for the TOML scalar, sequence, and
mapping coercion helpers shared by `lading.config` and
`lading.workspace.models`. Each helper takes an `error` keyword naming the
`LadingError` subclass to raise, and both consumers bind their domain error
type once with `functools.partial` (`ConfigurationError` in `config`,
`WorkspaceModelError` in `models`); neither module re-declares a coercer. The
canonical error-message shape is
`{field} must be {expected}; received {type(value).__name__}.` and is pinned by
property and snapshot tests in `tests/unit/test_toml_coerce.py`.

### Module size extractions (issue 108)

To keep source files within the 400-line guideline, the following
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.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`).
- `lading.cli_options` — Cyclopts argument declarations and version-argument
validation (imported by `cli`).

### In-process metrics (`lading.utils.metrics`)

`lading.utils.metrics` is a process-local metrics accumulator. Counters are
Expand Down
16 changes: 10 additions & 6 deletions docs/users-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,15 +86,19 @@ lading bump 1.2.3 --dry-run

After updating manifest versions, `lading bump` automatically refreshes any
git-tracked `Cargo.lock` files (excluding those under `target/`) that have an
adjacent `Cargo.toml`. Refreshed lockfiles are listed in the command output
with a `(lockfile)` suffix. In dry-run mode the lockfiles are listed but not
modified.
adjacent `Cargo.toml`. The result header counts each changed category, e.g.
"N manifest(s)", "N documentation file(s)", "N readme file(s)", "N
lockfile(s)", joined with Oxford-comma grammar. In the per-file body list,
manifest paths carry no suffix; the other categories carry a parenthetical
suffix: `(documentation)` for Markdown docs whose TOML code fences were
updated, `(readme)` for crate READMEs adopted from the workspace README, and
`(lockfile)` for regenerated `Cargo.lock` files. In dry-run mode, every file
is listed, but none are modified.

Where a member crate sets `readme.workspace = true`, `lading bump` also adopts
the workspace `README.md` into that crate's directory and rewrites relative
Markdown links so they still resolve from the crate directory. Adopted README
files are listed in the command output with a `(readme)` suffix. In dry-run
mode the files are listed but not written.
Markdown links so they still resolve from the crate directory. Such adopted
READMEs appear in the output with the `(readme)` suffix described above.

If `bump.documentation.globs` is configured, `lading` also searches those
Markdown files for TOML code fences and updates version values that refer to
Expand Down
84 changes: 12 additions & 72 deletions lading/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,73 +22,30 @@
import importlib
import logging
import os
import re
import sys
import typing as typ
from contextlib import contextmanager
from pathlib import Path

from cyclopts import App, Parameter
from cyclopts import App

from . import commands, config
from .cli_options import (
WORKSPACE_ROOT_ENV_VAR,
WORKSPACE_ROOT_REQUIRED_MESSAGE,
AllowUnpublishedWorkspaceDepsFlag,
DryRunFlag,
ForbidDirtyFlag,
LiveFlag,
RebuildLockfilesFlag,
VersionArgument,
WorkspaceRootOption,
)
from .runtime import CommandRunner, subprocess_runner
from .utils import metrics, normalise_workspace_root
from .workspace import WorkspaceGraph, WorkspaceModelError, load_workspace
from .workspace import metadata as metadata_module

WORKSPACE_ROOT_ENV_VAR = "LADING_WORKSPACE_ROOT"
WORKSPACE_ROOT_REQUIRED_MESSAGE = "--workspace-root requires a value"
_WORKSPACE_PARAMETER = Parameter(
name="workspace-root",
env_var=WORKSPACE_ROOT_ENV_VAR,
help="Path to the Rust workspace root.",
)
WorkspaceRootOption = typ.Annotated[Path, _WORKSPACE_PARAMETER]

_VERSION_PARAMETER = Parameter(
help="Target semantic version (e.g., 1.2.3) to set across workspace manifests.",
)
VersionArgument = typ.Annotated[str, _VERSION_PARAMETER]

_DRY_RUN_PARAMETER = Parameter(
name="dry-run",
help="Preview manifest changes without writing files.",
)
DryRunFlag = typ.Annotated[bool, _DRY_RUN_PARAMETER]

_REBUILD_LOCKFILES_PARAMETER = Parameter(
name="rebuild-lockfiles",
negative="no-rebuild-lockfiles",
help="Regenerate Cargo.lock files after manifest updates.",
)
RebuildLockfilesFlag = typ.Annotated[bool, _REBUILD_LOCKFILES_PARAMETER]

_LIVE_PARAMETER = Parameter(
name="live",
help="Run cargo publish without --dry-run; default behaviour is dry-run.",
)
LiveFlag = typ.Annotated[bool, _LIVE_PARAMETER]

_FORBID_DIRTY_PARAMETER = Parameter(
name="forbid-dirty",
help=("Require a clean working tree before running publish pre-flight checks."),
)
ForbidDirtyFlag = typ.Annotated[bool, _FORBID_DIRTY_PARAMETER]

_ALLOW_UNPUBLISHED_WORKSPACE_DEPS_PARAMETER = Parameter(
name="allow-unpublished-workspace-deps",
help=(
"Dry-run only: downgrade cargo package failures caused by a sibling "
"workspace crate version not yet on crates.io to a warning when the "
"missing crate is part of the planned publish set and appears earlier "
"in publish order. Defaults to enabled in dry-run mode. Cannot be "
"combined with --live."
),
)
AllowUnpublishedWorkspaceDepsFlag = typ.Annotated[
bool | None, _ALLOW_UNPUBLISHED_WORKSPACE_DEPS_PARAMETER
]

LOG_LEVEL_ENV_VAR = "LADING_LOG_LEVEL"
_DEFAULT_LOG_LEVEL = logging.INFO
_LOG_FORMAT = "%(levelname)s: %(message)s"
Expand Down Expand Up @@ -361,22 +318,6 @@ def _run_with_context(
return runner(workspace_root, configuration, workspace_model, active_runner)


_VERSION_PATTERN = re.compile(
r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$"
)


def _validate_version_argument(version: str) -> None:
"""Ensure ``version`` matches the semantic version pattern."""
if not _VERSION_PATTERN.fullmatch(version):
message = (
"Invalid version argument "
f"{version!r}. Expected semantic version in the form "
"<major>.<minor>.<patch> with optional pre-release/build segments."
)
raise SystemExit(message)


@app.command
def bump(
version: VersionArgument,
Expand All @@ -386,7 +327,6 @@ def bump(
rebuild_lockfiles: RebuildLockfilesFlag | None = None,
) -> str:
"""Update workspace manifests to ``version``."""
_validate_version_argument(version)
resolved = normalise_workspace_root(workspace_root)
return _run_with_context(
resolved,
Expand Down
100 changes: 100 additions & 0 deletions lading/cli_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Cyclopts argument declarations for the :mod:`lading` CLI.

Extracted from :mod:`lading.cli` (issue #108) so option declarations live
apart from dispatch logic. ``cli`` re-imports every public name, so external
access through ``lading.cli`` keeps working.
"""

from __future__ import annotations

import re
import typing as typ
from pathlib import Path

from cyclopts import Parameter

WORKSPACE_ROOT_ENV_VAR = "LADING_WORKSPACE_ROOT"
WORKSPACE_ROOT_REQUIRED_MESSAGE = "--workspace-root requires a value"
_WORKSPACE_PARAMETER = Parameter(
name="workspace-root",
env_var=WORKSPACE_ROOT_ENV_VAR,
help="Path to the Rust workspace root.",
)
WorkspaceRootOption = typ.Annotated[Path, _WORKSPACE_PARAMETER]

_VERSION_PATTERN = re.compile(
r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$"
)


def _validate_version_argument(_hint: object, version: str) -> None:
"""Reject a non-semantic-version ``version`` via cyclopts' validator hook."""
# Cyclopts calls validators as ``validator(type_hint, value)`` and converts a
# raised ValueError into a formatted ValidationError, so a bad version is
# reported through cyclopts' own error flow rather than a bare SystemExit.
if not _VERSION_PATTERN.fullmatch(version):
message = (
"Invalid version argument "
f"{version!r}. Expected semantic version in the form "
"<major>.<minor>.<patch> with optional pre-release/build segments."
)
raise ValueError(message)


_VERSION_PARAMETER = Parameter(
validator=_validate_version_argument,
help="Target semantic version (e.g., 1.2.3) to set across workspace manifests.",
)
VersionArgument = typ.Annotated[str, _VERSION_PARAMETER]

_DRY_RUN_PARAMETER = Parameter(
name="dry-run",
help="Preview manifest changes without writing files.",
)
DryRunFlag = typ.Annotated[bool, _DRY_RUN_PARAMETER]

_REBUILD_LOCKFILES_PARAMETER = Parameter(
name="rebuild-lockfiles",
negative="no-rebuild-lockfiles",
help="Regenerate Cargo.lock files after manifest updates.",
)
RebuildLockfilesFlag = typ.Annotated[bool, _REBUILD_LOCKFILES_PARAMETER]

_LIVE_PARAMETER = Parameter(
name="live",
help="Run cargo publish without --dry-run; default behaviour is dry-run.",
)
LiveFlag = typ.Annotated[bool, _LIVE_PARAMETER]

_FORBID_DIRTY_PARAMETER = Parameter(
name="forbid-dirty",
help=("Require a clean working tree before running publish pre-flight checks."),
)
ForbidDirtyFlag = typ.Annotated[bool, _FORBID_DIRTY_PARAMETER]

_ALLOW_UNPUBLISHED_WORKSPACE_DEPS_PARAMETER = Parameter(
name="allow-unpublished-workspace-deps",
help=(
"Dry-run only: downgrade cargo package failures caused by a sibling "
"workspace crate version not yet on crates.io to a warning when the "
"missing crate is part of the planned publish set and appears earlier "
"in publish order. Defaults to enabled in dry-run mode. Cannot be "
"combined with --live."
),
)
AllowUnpublishedWorkspaceDepsFlag = typ.Annotated[
bool | None, _ALLOW_UNPUBLISHED_WORKSPACE_DEPS_PARAMETER
]


__all__ = [
"WORKSPACE_ROOT_ENV_VAR",
"WORKSPACE_ROOT_REQUIRED_MESSAGE",
"AllowUnpublishedWorkspaceDepsFlag",
"DryRunFlag",
"ForbidDirtyFlag",
"LiveFlag",
"RebuildLockfilesFlag",
"VersionArgument",
"WorkspaceRootOption",
]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading
Loading