okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

Aly Raffauffix(python): enforce ruff and ty checks4ad84fd

main
8.4 KiB235 linesraw
1"""Source ranges and diffs as syntax-highlighted images, for reviewing code by
2its shape.
3
4Rendering is albedo-render (native/render), run as a supervised `run` job
5like the files plugin's searches; its pages attach to the running cell.
6"""
7
8from __future__ import annotations
9
10import os
11import shlex
12import tempfile
13from collections.abc import Callable
14from pathlib import Path
15
16from albedo_api import PythonApi, Text
17from albedo_plugins.files import Search, _missing, _run, _which
18
19# Built by native/render/install.sh next to the kernel's own files.
20RENDERER = Path(__file__).resolve().parents[2] / "bin" / "albedo-render"
21ROWS = 80  # display rows per image: legible after providers scale a page down
22IMAGES = 4  # the kernel's per-cell image limit
23
24
25def setup(api: PythonApi) -> dict[str, object]:
26    attach = api.attach_image
27
28    def view_code(
29        path: str,
30        start_line: int = 1,
31        end_line: int | None = None,
32        *,
33        columns: int = 79,
34    ) -> Search[Text]:
35        """See lines start_line through end_line (default: as far as fits) as
36        syntax-highlighted images, returned to you with this cell's result.
37        Lines wrap at `columns`. At most 4 images of 80 display rows each; a
38        wrapped line takes several rows, so wrap-heavy code fits fewer source
39        lines per image. The text names the lines each image shows and the
40        call that continues. Await it: `await view_code(path, 40, 120)`."""
41        _check_range(start_line, end_line, columns)
42
43        async def run() -> Text:
44            renderer, target = _renderer(attach), _existing(path)
45            assert attach is not None
46            if target.is_dir():
47                raise IsADirectoryError(
48                    f"{path} is a directory; view_code shows one file"
49                )
50            return await _render(
51                renderer,
52                attach,
53                target,
54                path,
55                start_line,
56                end_line,
57                columns,
58                [],
59                lambda line: f"view_code({path!r}, {line})",
60            )
61
62        return Search("view_code", run, "images")
63
64    def view_diff(
65        path: str = ".", start_line: int = 1, *, staged: bool = False, columns: int = 80
66    ) -> Search[Text]:
67        """See your uncommitted changes under `path` (git diff; staged=True for
68        the index) as highlighted images: each hunk with its surrounding lines,
69        + for added and - for removed. `columns` is 80 so a 79-column line and
70        its mark fit. Files git does not track yet are not included; view them
71        with view_code. start_line counts lines of the diff, to continue where
72        a call stopped. Await it: `await view_diff()`."""
73        _check_range(start_line, None, columns)
74
75        def resume(line: int) -> str:
76            return f"view_diff({path!r}, {line}{', staged=True' if staged else ''})"
77
78        async def run() -> Text:
79            renderer, target = _renderer(attach), _existing(path)
80            assert attach is not None
81            where = target if target.is_dir() else target.parent
82            with tempfile.TemporaryDirectory(prefix="albedo-diff-") as scratch:
83                diff = Path(scratch) / "changes.diff"
84                git = ["git", "-C", str(where)]
85                changes = [
86                    *git,
87                    "diff",
88                    "--no-color",
89                    "--no-ext-diff",
90                    *(["--staged"] if staged else []),
91                    "--",
92                    str(target.resolve()),
93                ]
94                # Outside a repository git diff compares paths instead; ask first.
95                command = (
96                    " ".join(map(shlex.quote, [*git, "rev-parse", "--git-dir"]))
97                    + " > /dev/null && "
98                    + " ".join(map(shlex.quote, changes))
99                    + " > "
100                    + shlex.quote(str(diff))
101                )
102                status, output = await _run(command)
103                if status != 0:
104                    raise RuntimeError(
105                        f"git diff failed: {output.strip()[-400:] or f'exit {status}'}"
106                    )
107                if diff.stat().st_size == 0:
108                    return Text(
109                        f"no {'staged' if staged else 'uncommitted'} changes under {path}"
110                    )
111                label = f"diff of {path}" + (" (staged)" if staged else "")
112                return await _render(
113                    renderer,
114                    attach,
115                    diff,
116                    label,
117                    start_line,
118                    None,
119                    columns,
120                    ["--language", "diff", "--no-line-numbers"],
121                    resume,
122                )
123
124        return Search("view_diff", run, "images")
125
126    return {"view_code": view_code, "view_diff": view_diff}
127
128
129def _check_range(start_line: int, end_line: int | None, columns: int) -> None:
130    if start_line < 1 or (end_line is not None and end_line < start_line):
131        raise ValueError("start_line >= 1 and end_line >= start_line")
132    if not 20 <= columns <= 400:
133        raise ValueError("20 <= columns <= 400")
134
135
136def _renderer(attach: Callable[[bytes], str] | None) -> str:
137    """The renderer to run, checked before any work that needs it."""
138    if attach is None:
139        raise RuntimeError("this kernel cannot return images")
140    renderer = (
141        str(RENDERER) if os.access(RENDERER, os.X_OK) else _which("albedo-render")
142    )
143    if renderer is None:
144        raise RuntimeError(
145            "albedo-render is not built; run native/render/install.sh, "
146            "or use files.read for the text"
147        )
148    return renderer
149
150
151def _existing(path: str) -> Path:
152    target = Path(path).expanduser()
153    if not target.exists():
154        raise FileNotFoundError(_missing(target, path))
155    return target
156
157
158async def _render(
159    renderer: str,
160    attach: Callable[[bytes], str],
161    file: Path,
162    label: str,
163    start_line: int,
164    end_line: int | None,
165    columns: int,
166    options: list[str],
167    resume: Callable[[int], str],
168) -> Text:
169    """Render `file` and attach its pages to the cell; `resume` names the call
170    that continues from a line."""
171    with tempfile.TemporaryDirectory(prefix="albedo-view-") as out:
172        command = " ".join(
173            shlex.quote(part)
174            for part in [
175                renderer,
176                str(file.resolve()),
177                "--out",
178                out,
179                "--start",
180                str(start_line),
181                "--end",
182                str(end_line or 2**32),
183                "--columns",
184                str(columns),
185                "--max-rows",
186                str(ROWS),
187                "--max-images",
188                str(IMAGES),
189                *options,
190            ]
191        )
192        status, output = await _run(command)
193        if status != 0:
194            failure = [
195                line
196                for line in output.splitlines()
197                if line.startswith("albedo-render:")
198            ]
199            raise RuntimeError(
200                failure[-1].removeprefix("albedo-render: ")
201                if failure
202                else f"albedo-render did not finish: {output.strip()[-400:]}"
203            )
204        return _attach_pages(attach, label, output, resume)
205
206
207def _attach_pages(
208    attach: Callable[[bytes], str],
209    label: str,
210    report: str,
211    resume: Callable[[int], str],
212) -> Text:
213    """Attach each page the renderer wrote, in order, and say what was shown."""
214    language, shown, rest = "plain", [], None
215    for line in report.splitlines():
216        kind, _, fields = line.partition(" ")
217        if kind == "language":
218            language = fields
219        elif kind == "image":
220            file, first, last, size = fields.rsplit(" ", 3)
221            shown.append((file, int(first), int(last), size))
222        elif kind == "remaining":
223            rest = int(fields.split()[0])
224    notes = [f"{label} ({language})"]
225    for number, (file, first, last, size) in enumerate(shown, 1):
226        try:
227            _ = attach(Path(file).read_bytes())
228        except ValueError as full:
229            notes.append(f"image {number} not attached: {full}")
230            rest = first
231            break
232        notes.append(f"image {number}: lines {first}-{last}, {size}")
233    if rest is not None:
234        notes.append(f"not shown from line {rest}: {resume(rest)}")
235    return Text("\n".join(notes))