"""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())