Source code for ralph.install

"""Installation helpers for Ralph Workflow dev and stable builds.

Two builds are kept deliberately separate so they never shadow each other:

* **Dev build** — an editable install of the current checkout into the active
  environment (``install_dev_checkout``). Run it with ``uv run ralph``; it
  registers no global command, so it cannot collide with a stable ``ralph``.
* **Stable build** — a pinned release installed as an isolated global ``ralph``
  command via ``uv tool`` (``install_stable_release``).

See ``CONTRIBUTING.md`` (§"Dev build vs stable build") for the workflow.
"""

from __future__ import annotations

import argparse
import shutil
from pathlib import Path
from typing import TYPE_CHECKING, Protocol, cast

from ralph.executor.process import ProcessExecutionError, ProcessRunOptions, run_process

if TYPE_CHECKING:
    from collections.abc import Sequence

STABLE_PACKAGE_NAME = "ralph-workflow"
DEV_LAUNCHER_NAME = "rdev"


[docs] class RunCommand(Protocol): """Protocol for the subprocess runner passed to the install helpers.""" def __call__(self, command: Sequence[str], *, cwd: Path) -> None: ...
[docs] class LauncherWriter(Protocol): """Protocol for writing the dev launcher script to disk.""" def __call__(self, path: Path, content: str) -> None: ...
def _run_command(command: Sequence[str], *, cwd: Path) -> None: cmd = tuple(command) result = run_process(cmd[0], cmd[1:], options=ProcessRunOptions(cwd=cwd)) if not result.succeeded: raise ProcessExecutionError( cmd, f"Command failed with exit code {result.returncode}: {' '.join(cmd)}", )
[docs] def render_dev_launcher(package_dir: Path) -> str: """Return the contents of the ``rdev`` launcher for ``package_dir``. The launcher runs the dev build via ``uv run`` against the checkout while staying in the caller's working directory (the workspace ``ralph`` operates on); only the project environment is sourced from ``package_dir``. """ return ( "#!/usr/bin/env bash\n" "# Dev build of Ralph Workflow (generated by `make install`).\n" "# Runs the editable working tree via uv; counterpart of the stable\n" "# `ralph` installed with `uv tool install`.\n" f'exec uv run --project "{package_dir}" ralph "$@"\n' )
[docs] def write_dev_launcher(path: Path, content: str) -> None: """Write ``content`` to ``path`` and mark it executable.""" path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") path.chmod(0o755)
[docs] def install_dev_checkout( *, run: RunCommand = _run_command, uv_executable: str | None, cwd: Path, launcher_dir: Path, write_launcher: LauncherWriter = write_dev_launcher, ) -> None: """Set up the current checkout as a dev build via ``uv sync``. Syncs the project's own uv environment (``.venv``) so it holds the editable project plus the ``dev`` extra, then writes an ``rdev`` launcher into ``launcher_dir`` so the dev build has a stable command name. ``rdev`` runs the working tree with ``uv run`` and never shadows the stable ``ralph`` installed via :func:`install_stable_release`. """ if uv_executable is None: raise RuntimeError( "uv is required to set up the dev build. Install uv first: " "https://docs.astral.sh/uv/getting-started/installation/" ) run((uv_executable, "sync", "--extra", "dev"), cwd=cwd) write_launcher(launcher_dir / DEV_LAUNCHER_NAME, render_dev_launcher(cwd))
[docs] def install_stable_release( *, run: RunCommand = _run_command, uv_executable: str | None, cwd: Path, version: str | None = None, ) -> None: """Install a stable release as the isolated global ``ralph`` command. Uses ``uv tool install`` so the stable build lives in its own environment and stays independent of the working tree. Without ``version`` the latest published release is installed, and an already-installed older ``ralph`` is upgraded: ``--force`` re-runs the install even when the tool is already present (a plain ``uv tool install`` is a no-op in that case), and ``--upgrade`` (which implies ``--refresh``) re-resolves against PyPI so a newer release is picked up. Pass ``version`` to pin a specific release instead. """ if uv_executable is None: raise RuntimeError( "uv is required to install the stable build. Install uv first: " "https://docs.astral.sh/uv/getting-started/installation/" ) command = [uv_executable, "tool", "install", "--force"] if version is None: command.append("--upgrade") command.append(STABLE_PACKAGE_NAME) else: command.append(f"{STABLE_PACKAGE_NAME}=={version}") run(tuple(command), cwd=cwd)
def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="python -m ralph.install", description="Install Ralph Workflow as a dev build (default) or a pinned stable build.", ) parser.add_argument( "--stable", action="store_true", help="Install a pinned stable release as the global `ralph` command via uv tool.", ) parser.add_argument( "--version", default=None, help="Pin the stable release to this version (implies --stable).", ) return parser def _parse_args(argv: Sequence[str] | None) -> tuple[bool, str | None]: parsed = _build_parser().parse_args(argv) stable = cast("bool", parsed.stable) version = cast("str | None", parsed.version) return stable, version
[docs] def main(argv: Sequence[str] | None = None) -> int: """Install the dev build by default, or the stable build with ``--stable``.""" stable, version = _parse_args(argv) package_dir = Path(__file__).resolve().parents[1] if stable or version is not None: install_stable_release( run=_run_command, uv_executable=shutil.which("uv"), cwd=package_dir, version=version, ) else: install_dev_checkout( run=_run_command, uv_executable=shutil.which("uv"), cwd=package_dir, launcher_dir=Path.home() / ".local" / "bin", ) return 0
if __name__ == "__main__": raise SystemExit(main())