diff --git a/CHANGELOG.md b/CHANGELOG.md index 9593441..96c05a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ Every change a user would notice, newest first ([Keep a Changelog](https://keepa follow [Semantic Versioning](https://semver.org/); before 1.0, a minor version may change what a command does. ## [Unreleased] +### Added + +- `code-node -i` picks the file to start from in the terminal: move through the folders with the arrow keys, or type + to search every file by its path, then pick the function. Files an entry point is in have a ★, and a wide terminal + shows a file's functions or a function's code beside the list. Tab switches to the entry points found. + +### Changed + +- Where the command asks which entry point to start from, it opens the same picker instead of a numbered list, and + when it finds none in a terminal it lets you pick a file instead of stopping. `--list`, `--plugins` and what it + prints are drawn with rich, which is now a dependency. + ## [0.7.0] - 2026-10-21 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index e93bd51..16474cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,7 @@ Slash commands in `.claude/commands/`: `/check` (lint, tests, docs, package), `/ | `src/code_node/platforms/` | **Pure, no I/O.** `Platform` (plain Python: the base a plugin overrides), the registry (`register_platform`, `get_platform`, the `code_node.platforms` entry point group), `aws_lambda.py` (`LambdaPlatform`: handlers, the event, its findings and wording, IAM drift against a `Deployment`), and `web.py` (`WebPlatform`: FastAPI, Flask and Django apps and URL confs, a route's parameters and `flask.request` as the request, blocking calls in async routes) | | `src/code_node/libraries/` | **Pure, no I/O.** `Library` (a library's clients: `make`, `attribute`, `item`, `call`), `Handle` (what a name holds), `Use` (what a call does), `Call` (the call as a plugin sees it), the registry (`code_node.libraries`), and `redis.py`, `sql.py`, `mongo.py`, `llm.py` | | `src/code_node/deploys/` | **Pure, no I/O.** `Deploy` (a kind of deploy file: `deployments(files, code)` → `Deployment`s, `grant`), the registry (`code_node.deploys`), YAML with CloudFormation tags, IAM policy helpers, and `sam.py`, `serverless.py` | +| `src/code_node/browse.py`, `terminal.py` | The picker (`-i`, and wherever the command asks): `browse` draws `_Picker` full screen with rich `Live`; `terminal.py` has the rich consoles and `keys()`, one key at a time (termios, or msvcrt on Windows) | | `src/code_node/pages.py` | `PageOptions`, `make_page` / `make_mermaid`: read the folder, map, draw (what the command and the server share) | | `src/code_node/history.py` | What git says about each function shown (`read_history`: `git blame` once per file, onto `CodeFunction.history`). Read-only git, with no file system monitor or text conversion, and none at all when the folder's own config names a filter command | | `src/code_node/serve.py` | `--serve`: `LivePage` (the latest page, a version, a Condition), a polling watcher thread, `ThreadingHTTPServer` with `/` and `/events` (Server-Sent Events) | diff --git a/README.md b/README.md index 8293e05..108cf78 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ cd your/repo && code-node ```bash code-node # find entry points and pick one +code-node -i # walk the folder (arrow keys, or type to search) and pick a file code-node --list # just list them code-node src/app/main.py:run # start from a function code-node src/app/store.py:Store.save # ...or a method @@ -78,6 +79,7 @@ string is flagged. | --- | --- | | `path` | Folder to read, or a file to start from (`main.py`, `main.py:function`) | | `-e`, `--entry` | `module.function`, `module.Class.method`, `file.py[:function]`, or part of a name | +| `-i`, `--interactive` | Pick the file in the terminal: arrow keys through the folders, or type to search every file | | `-l`, `--list` | List entry points and stop | | `--plugins` | List platforms, libraries and deploy files and stop | | `--all` | Draw every function (default: 14 functions, 5 calls deep) | diff --git a/docs/usage.md b/docs/usage.md index b5d8c55..023981f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -27,6 +27,7 @@ in your browser. | You type | It starts at | | --- | --- | | `code-node` | Wherever you pick from what it finds | +| `code-node -i` | The file you pick by walking the folder | | `code-node app/main.py` | That file's `main()` | | `code-node app/main.py:run` | `run()` in that file | | `code-node app/store.py:Store.save` | A method | @@ -34,6 +35,29 @@ in your browser. | `code-node -e save` | Anything with "save" in its name | | `code-node --list` | Nowhere: it lists the entry points it found | +## Pick a file in the terminal { #interactive } + +```bash +code-node -i +``` + +This opens a picker in the terminal. It lists the folder's Python files and the folders that hold them, with a ★ on +each file an entry point is in. A terminal at least 110 columns wide also shows what's under the cursor: a file's +functions, or a function's code. + +| Key | Does | +| --- | --- | +| ↑ ↓, PgUp PgDn, Home End | Move | +| → or Enter | Open a folder; pick a file, then the function in it to start from | +| ← or Backspace | Go up a folder, or back from a file's functions | +| Any letters | Search every file by path: `ordhan` finds `orders/handler.py` | +| Tab | Switch between the files and the entry points found | +| Esc | Clear the search, go back, or quit | + +!!! tip "Worth knowing" + Plain `code-node` opens the same picker on the entry points it found when there are several, and on the files + when it found none. Press Tab there to see the files. + ??? more "What it finds on its own" In this order: @@ -103,6 +127,7 @@ to see its commit. The gear in the panel's head turns that off. It's read with ` | --- | --- | | `path` | A folder, or a file to start from | | `-e`, `--entry` | Where to start | +| `-i`, `--interactive` | Pick the file in the terminal | | `-l`, `--list` | List the starts | | `--plugins` | List the plugins | | `--serve`, `--port` | Live page (8765, or the next free port) | diff --git a/pyproject.toml b/pyproject.toml index 2690d61..8cc025f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,11 @@ classifiers = [ ] # botocore names AWS operations and their IAM actions from its own service models (no AWS call is ever made). PyYAML # reads deploy files (template.yaml, serverless.yml). -dependencies = ["botocore>=1.35.72", "pyyaml>=6.0"] +dependencies = [ + "botocore>=1.35.72", + "pyyaml>=6.0", + "rich>=13.7", +] # What working on code-node needs. None of it ships in the wheel. `uv sync` installs every group into .venv at the # versions in uv.lock, and Dependabot proposes upgrades to both. diff --git a/src/code_node/browse.py b/src/code_node/browse.py new file mode 100644 index 0000000..da5b8b5 --- /dev/null +++ b/src/code_node/browse.py @@ -0,0 +1,524 @@ +""" +The interactive picker, drawn with rich: walk the folder with the arrow keys, type to search every file in it, and pick +the function to start from. `code-node -i` opens it on the files; wherever else the command asks which entry point, +it opens on the ones found, with the files a Tab away. Nothing in it runs the code: it reads the trees `Project` has. +""" + +from __future__ import annotations + +import ast +from dataclasses import dataclass, field + +from rich import box +from rich.console import Group, RenderableType +from rich.live import Live +from rich.panel import Panel +from rich.rule import Rule +from rich.syntax import Syntax +from rich.table import Table +from rich.text import Text + +from .project import EntryPoint, Project, entries_in_file, entry_points +from .terminal import console, keys + +FILES, ENTRIES, FUNCTIONS = "files", "entries", "functions" # what the picker lists: a folder, the entry points found, +# or the functions to start from in one file (or that match what was typed with -e) + +_RESULTS = 300 # search results listed at most + +_CHROME = 8 # lines that aren't the list: the frame, the tabs, where it is, the search, two rules and the keys + +_PREVIEW_WIDTH = 110 # a terminal this wide shows what's under the cursor beside the list + +_QUIT = EntryPoint("", "", "", 0) # what pressing Esc or Ctrl-C picks: nothing + +_HIT = "bold yellow underline" # the characters of a name that match what's typed + +_FUNCTION = (ast.FunctionDef, ast.AsyncFunctionDef) + + +@dataclass +class _Row: + """ + One line of the list: a folder, a file or a function to start from. + """ + + kind: str # 'up', 'folder', 'file' or 'entry' + key: str # the folder or file, from the project's root; an entry point's name + label: Text + note: Text = field(default_factory=Text) + entry: EntryPoint | None = None + + +def _match(query: str, text: str) -> tuple[int, list[int]] | None: + """ + How well text matches what's typed, lower being better, and which of its characters do: the file's own name + first, then anywhere in its path, then its letters in order ('ordhan' finds orders/handler.py). None when it + doesn't match at all. + """ + needle, hay = query.lower().replace(" ", ""), text.lower() + if not needle: + return 0, [] + name = hay.rstrip("/").rfind("/") + 1 + found = hay.find(needle, name) + if found >= 0: + return (0 if found == name else 1), list(range(found, found + len(needle))) + found = hay.find(needle) + if found >= 0: + return 2, list(range(found, found + len(needle))) + at, spots = 0, [] + for char in needle: + at = hay.find(char, at) + if at < 0: + return None + spots.append(at) + at += 1 + return 3, spots + + +def _lit(text: str, spots: list[int], style: str = "") -> Text: + """ + The text, with the characters at spots lit. + """ + out = Text(text, style=style) + for spot in spots: + out.stylize(_HIT, spot, spot + 1) + return out + + +class _Tree: + """ + The folders that hold the project's Python files: each one's folders and files, and how many files are under it. + """ + + def __init__(self, files: list[str]) -> None: + self.folders: dict[str, list[str]] = {"": []} + self.files: dict[str, list[str]] = {"": []} + self.counts: dict[str, int] = {"": 0} + for rel in sorted(files): + folder = "" + self.counts[""] += 1 + for part in rel.split("/")[:-1]: + child = f"{folder}/{part}" if folder else part + if child not in self.folders: + self.folders[child], self.files[child], self.counts[child] = [], [], 0 + self.folders[folder].append(child) + self.counts[child] += 1 + folder = child + self.files[folder].append(rel) + for children in self.folders.values(): + children.sort(key=str.lower) + for names in self.files.values(): + names.sort(key=lambda rel: rel.lower()) + + +def _parent(folder: str) -> str: + return folder.rsplit("/", 1)[0] if "/" in folder else "" + + +def _name(path: str) -> str: + return path.rstrip("/").rsplit("/", 1)[-1] + + +class _Picker: + """ + What the picker shows and where its cursor is; `press` moves it, and gives back the entry point picked. + """ + + def __init__( + self, + project: Project, + found: list[EntryPoint], + *, + mode: str, + folder: str = "", + file: str = "", + choices: list[EntryPoint] | None = None, + title: str = "", + ) -> None: + self.project = project + self.found = found + self.starts = {entry.name for entry in found} + self.by_file: dict[str, list[EntryPoint]] = {} + for entry in found: + self.by_file.setdefault(entry.file, []).append(entry) + self.tree = _Tree(list(project.files)) + self.under = { # entry points found in each folder, however deep + folder: sum(len(entries) for rel, entries in self.by_file.items() if rel.startswith(f"{folder}/")) + for folder in self.tree.folders + } + self.mode, self.file, self.title = mode, file, title + self.folder = folder if folder in self.tree.folders else "" + self.choices = choices or [] + self.query = "" + self.cursor = self.top = 0 + self.room = 10 # rows the list has room for, as last drawn + self.said = "" # a line for the key bar, until the next key + + # what's listed + + def rows(self) -> list[_Row]: + if self.mode == ENTRIES: + return self._entry_rows(self.found, where=True) + if self.mode == FUNCTIONS: + return self._entry_rows(self.choices, where=not self.file) + return self._search_rows() if self.query else self._folder_rows() + + def _folder_rows(self) -> list[_Row]: + rows = [] + if self.folder: + rows.append(_Row("up", _parent(self.folder), Text("..", style="bold blue"), Text("up a folder", "dim"))) + for child in self.tree.folders[self.folder]: + rows.append(_Row("folder", child, Text(f"{_name(child)}/", style="bold blue"), self._folder_note(child))) + for rel in self.tree.files[self.folder]: + rows.append( + _Row("file", rel, Text(_name(rel), style="bold" if rel in self.by_file else ""), self._note(rel)) + ) + return rows + + def _search_rows(self) -> list[_Row]: + scored = [] + for folder in self.tree.folders: + matched = _match(self.query, f"{folder}/") if folder else None + if matched is not None: + scored.append((matched[0], 1, len(folder), folder, "folder", matched[1])) + for rel in self.project.files: + matched = _match(self.query, rel) + if matched is not None: + scored.append((matched[0], 0 if rel in self.by_file else 1, len(rel), rel, "file", matched[1])) + rows = [] + for _, _, _, path, kind, spots in sorted(scored)[:_RESULTS]: + if kind == "folder": + rows.append(_Row(kind, path, _lit(f"{path}/", spots, "bold blue"), self._folder_note(path))) + else: + rows.append( + _Row(kind, path, _lit(path, spots, "bold" if path in self.by_file else ""), self._note(path)) + ) + return rows + + def _entry_rows(self, entries: list[EntryPoint], *, where: bool) -> list[_Row]: + scored = [] + for order, entry in enumerate(entries): + matched = _match(self.query, entry.name) or ( + (4, []) if _match(self.query, f"{entry.file} {entry.why}") else None + ) + if matched is not None: + scored.append((matched[0] if self.query else 0, order, entry, matched[1])) + rows = [] + for _, _, entry, spots in sorted(scored, key=lambda item: item[:2]): + name = _lit(entry.name, spots) + module = self.project.modules.get(entry.file, "") + if module and entry.name.startswith(f"{module}."): + name.stylize("dim", 0, len(module) + 1) + star = Text("★ " if entry.name in self.starts else " ", style="yellow") + place = f"{entry.file}:{entry.line}" if where else f":{entry.line}" + note = Text.assemble((entry.why, "dim"), " ", (place if entry.line else entry.file, "cyan")) + rows.append(_Row("entry", entry.name, Text.assemble(star, name), note, entry)) + return rows + + def _note(self, rel: str) -> Text: + entries = self.by_file.get(rel, []) + if not entries: + return Text() + said = entries[0].why if len(entries) == 1 else f"{len(entries)} entry points" + return Text.assemble(("★ ", "yellow"), (said, "yellow")) + + def _folder_note(self, folder: str) -> Text: + count = self.tree.counts[folder] + note = Text(f"{count} file{'s' if count != 1 else ''}", style="dim") + if self.under[folder]: + note.append(f" ★ {self.under[folder]}", style="yellow") + return note + + # keys + + def press(self, key: str) -> EntryPoint | None: + """ + Does what the key does: the entry point picked, _QUIT, or None to keep going. + """ + self.said = "" + rows = self.rows() + row = rows[min(self.cursor, len(rows) - 1)] if rows else None + last = max(len(rows) - 1, 0) + if key == "ctrl-c": + return _QUIT + if key == "up": + self.cursor = self.cursor - 1 if self.cursor > 0 else last + elif key == "down": + self.cursor = self.cursor + 1 if self.cursor < last else 0 + elif key == "pageup": + self.cursor = max(self.cursor - self.room, 0) + elif key == "pagedown": + self.cursor = min(self.cursor + self.room, last) + elif key == "home": + self.cursor = 0 + elif key == "end": + self.cursor = last + elif key in ("enter", "right"): + return self._open(row) if row is not None else None + elif key == "left": + self._back() + elif key == "backspace": + if self.query: + self._search(self.query[:-1]) + else: + self._back() + elif key == "esc": + if self.query: + self._search("") + elif self.mode == FUNCTIONS: + self._back() + else: + return _QUIT + elif key in ("tab", "shift-tab"): + self._switch(FILES if self.mode == ENTRIES else ENTRIES) + elif len(key) == 1 and key.isprintable(): + self._search(self.query + key) + return None + + def _search(self, query: str) -> None: + self.query, self.cursor, self.top = query, 0, 0 + + def _open(self, row: _Row) -> EntryPoint | None: + if row.kind == "entry": + return row.entry + if row.kind in ("up", "folder"): + self._go(row.key, self.folder if row.kind == "up" else "") + return None + choices = entries_in_file(self.project, row.key) + if not choices: + self.said = f"{_name(row.key)} has no functions to start from" + return None + if len(choices) == 1: + return choices[0] + self.mode, self.file, self.choices, self.title = FUNCTIONS, row.key, choices, "" + self._search("") + return None + + def _back(self) -> None: + if self.mode == FUNCTIONS: + self.mode = FILES + self._go(_parent(self.file) if self.file else self.folder, self.file) + elif self.mode == FILES and not self.query and self.folder: + self._go(_parent(self.folder), self.folder) + + def _go(self, folder: str, select: str = "") -> None: + """ + Lists a folder, the cursor on select (the folder just left, going up) when it's there. + """ + self.folder = folder + self._search("") + rows = self.rows() + first = 1 if folder and len(rows) > 1 else 0 # past '..' + self.cursor = next((i for i, row in enumerate(rows) if select and row.key == select), first) + + def _switch(self, mode: str) -> None: + self.mode = mode + self._search("") + + # drawing + + def draw(self, width: int, height: int) -> RenderableType: + rows = self.rows() + self.room = max(height - _CHROME, 3) + self.cursor = min(self.cursor, max(len(rows) - 1, 0)) + if self.cursor < self.top: + self.top = self.cursor + if self.cursor >= self.top + self.room: + self.top = self.cursor - self.room + 1 + self.top = max(0, min(self.top, max(len(rows) - self.room, 0))) + beside = width >= _PREVIEW_WIDTH + listed = self._list(rows, width * 3 // 5 if beside else width) + if beside: + row = rows[self.cursor] if rows else None + beside = Table.grid(expand=True, padding=(0, 1)) + beside.add_column(ratio=3) + beside.add_column(ratio=2) + beside.add_row(listed, self._preview(row, self.room)) + listed = beside + parts = [self._tabs(), self._where(), self._search_line(len(rows)), Rule(style="dim"), listed] + parts += [Rule(style="dim"), self._keys()] + return Panel( + Group(*parts), + title=Text(" code-node ", style="bold"), + title_align="left", + subtitle=Text(f" {self.project.root} ", style="dim"), + subtitle_align="right", + box=box.ROUNDED, + border_style="cyan", + height=height, + ) + + def _list(self, rows: list[_Row], width: int) -> RenderableType: + table = Table.grid(expand=True, padding=(0, 1)) + table.add_column(width=1, no_wrap=True) + table.add_column(ratio=1, no_wrap=True, overflow="ellipsis") + table.add_column(justify="right", no_wrap=True, overflow="ellipsis", max_width=max(width // 2, 20)) + shown = rows[self.top : self.top + self.room] + for at, row in enumerate(shown, self.top): + here = at == self.cursor + table.add_row( + Text("›" if here else " ", style="bold cyan"), row.label, row.note, style="reverse" if here else "" + ) + if not rows: + table.add_row("", Text(self._nothing(), style="dim italic"), "") + for _ in range(self.room - max(len(shown), 1)): + table.add_row("", "", "") + return table + + def _nothing(self) -> str: + if self.query: + return f"Nothing matches {self.query!r}: Backspace or Esc to clear it" + if self.mode == ENTRIES: + return "No entry points found here: press Tab to pick a file" + return "Nothing here" + + def _tabs(self) -> Text: + tabs = Text() + for mode, label in ((FILES, "Files"), (ENTRIES, f"Entry points {len(self.found)}")): + on = self.mode == mode or (mode == FILES and self.mode == FUNCTIONS) + tabs.append(f" {label} ", style="bold reverse cyan" if on else "dim") + tabs.append(" ") + return tabs + + def _where(self) -> Text: + where = Text(self.project.root.name, style="bold") + if self.mode == ENTRIES: + return where.append(" where the code starts, best first", style="dim") + if self.mode == FUNCTIONS and not self.file: + return where.append(f" {self.title}", style="dim") + path = self.file if self.mode == FUNCTIONS else self.folder + for part in path.split("/") if path else []: + where.append(" / ", style="dim").append(part, style="bold blue" if not part.endswith(".py") else "bold") + if self.mode == FUNCTIONS: + where.append(" pick a function to start from", style="dim") + return where + + def _search_line(self, count: int) -> Table: + line = Table.grid(expand=True) + line.add_column(ratio=1, no_wrap=True) + line.add_column(justify="right", no_wrap=True) + typed = Text("Search ", style="bold cyan") + if self.query: + typed.append(self.query).append("▏", style="blink cyan") + else: + hint = "type to search every file" if self.mode == FILES else "type to filter" + typed.append("▏", style="blink cyan").append(hint, style="dim") + place = f"{self.cursor + 1} of {count}" if count else "0" + line.add_row(typed, Text(place, style="dim")) + return line + + def _keys(self) -> Text: + if self.said: + return Text(self.said, style="yellow") + pairs = { + FILES: [("↑↓", "move"), ("→ Enter", "open"), ("←", "up"), ("Tab", "entry points"), ("Esc", "quit")], + ENTRIES: [("↑↓", "move"), ("Enter", "start here"), ("Tab", "files"), ("Esc", "quit")], + FUNCTIONS: [("↑↓", "move"), ("Enter", "start here"), ("← Esc", "back"), ("Tab", "entry points")], + }[self.mode] + bar = Text() + for key, does in pairs: + bar.append(key, style="bold").append(f" {does} ", style="dim") + return bar + + def _preview(self, row: _Row | None, height: int) -> RenderableType: + title, body = "", Text("Pick a folder, a file or a function", style="dim") + if row is not None and row.kind == "entry" and row.entry is not None: + title, body = self._code(row.entry, height - 2) + elif row is not None and row.kind == "file": + title, body = row.key, self._outline(row.key) + elif row is not None and row.kind in ("folder", "up"): + title, body = (row.key or self.project.root.name) + "/", self._inside(row.key) + return Panel( + body, + title=Text(title, style="bold"), + title_align="left", + height=height, + border_style="dim", + box=box.ROUNDED, + ) + + def _code(self, entry: EntryPoint, lines: int) -> tuple[str, RenderableType]: + source = self.project.files.get(entry.file) + if source is None: + return entry.name, Text(entry.why, style="dim") + start = max(entry.line, 1) + code = "\n".join(source.splitlines()[start - 1 : start - 1 + lines]) + syntax = Syntax( + code, "python", line_numbers=True, start_line=start, theme="ansi_dark", background_color="default" + ) + return f"{entry.file}:{start}", syntax + + def _outline(self, rel: str) -> RenderableType: + tree = self.project.trees.get(rel) + if tree is None: + return Text("This file doesn't parse, so it can't be read", style="yellow") + module = self.project.modules.get(rel, "") + table = Table.grid(padding=(0, 1)) + table.add_column(width=1) + table.add_column(no_wrap=True, overflow="ellipsis") + table.add_column(justify="right", style="dim") + table.add_row("", Text(module, style="dim"), "") + for node in tree.body: + if isinstance(node, _FUNCTION): + self._outline_row(table, f"{module}.{node.name}", f"{node.name}()", node.lineno) + elif isinstance(node, ast.ClassDef): + table.add_row("", Text(f"class {node.name}", style="bold magenta"), str(node.lineno)) + for method in (m for m in node.body if isinstance(m, _FUNCTION)): + self._outline_row(table, f"{module}.{node.name}.{method.name}", f" {method.name}()", method.lineno) + if table.row_count == 1: + table.add_row("", Text("No functions: code-node starts from one", style="dim"), "") + return table + + def _outline_row(self, table: Table, name: str, label: str, line: int) -> None: + start = name in self.starts + table.add_row(Text("★" if start else "", style="yellow"), Text(label, style="bold" if start else ""), str(line)) + + def _inside(self, folder: str) -> RenderableType: + count = self.tree.counts[folder] + lines = [Text(f"{count} Python file{'s' if count != 1 else ''}", style="dim")] + starts = [e for e in self.found if not folder or e.file.startswith(f"{folder}/")] + if starts: + lines.append(Text(f"{len(starts)} entry point{'s' if len(starts) != 1 else ''}:", style="dim")) + for entry in starts: + line = Text.assemble(("★ ", "yellow"), entry.name, (f" {entry.why}", "dim")) + line.no_wrap, line.overflow = True, "ellipsis" + lines.append(line) + return Group(*lines) + + def run(self) -> EntryPoint | None: + """ + Draws the picker full screen until something is picked (or Esc / Ctrl-C picks nothing). + """ + with keys() as read, Live(console=console, screen=True, auto_refresh=False, transient=True) as live: + while True: + live.update(self.draw(*console.size), refresh=True) + try: + picked = self.press(read()) + except KeyboardInterrupt: + picked = _QUIT + if picked is not None: + return None if picked is _QUIT else picked + + +def browse( + project: Project, + *, + mode: str = FILES, + found: list[EntryPoint] | None = None, + folder: str = "", + file: str = "", + choices: list[EntryPoint] | None = None, + title: str = "", +) -> EntryPoint | None: + """ + The entry point picked in the picker, or None when nothing was. It opens on mode: FILES in folder, ENTRIES (the + entry points found), or FUNCTIONS (the choices, in file or under title). + """ + if found is None: + with console.status("Looking for entry points…"): + found = entry_points(project) + picked = _Picker(project, found, mode=mode, folder=folder, file=file, choices=choices, title=title).run() + if picked is None: + console.print("Nothing picked.", style="dim") + return picked diff --git a/src/code_node/cli.py b/src/code_node/cli.py index fb0141e..cc7f47a 100644 --- a/src/code_node/cli.py +++ b/src/code_node/cli.py @@ -3,6 +3,7 @@ as a graph of who calls whom and what each path reaches, and the steps of each call. code-node # this folder: picks an entry point (asks when there are several) + code-node -i # walk the folder with the arrow keys, or type to search, and pick a file code-node src/app/main.py # start from a file: its main(), or pick one of its functions code-node src/app/main.py:run # ...or name the function (or Class.method) in it code-node path/to/repo -e pkg.cli.main @@ -20,10 +21,14 @@ import re import shutil import subprocess -import sys import webbrowser from pathlib import Path +from rich import box +from rich.table import Table +from rich.text import Text + +from .browse import ENTRIES, FILES, FUNCTIONS, browse from .deploys import deploys from .libraries import libraries from .pages import GRAPH_DEPTH, GRAPH_FUNCTIONS, PageOptions, make_mermaid, make_page @@ -39,6 +44,7 @@ project_root, read_project, ) +from .terminal import can_ask, console, errors _CACHE = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "code-node" @@ -59,6 +65,12 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace: help="where to start: module.function, module.Class.method, " "path/to/file.py[:function], or part of a function's name", ) + parser.add_argument( + "-i", + "--interactive", + action="store_true", + help="pick the file to start from in the terminal: arrow keys through the folders, or type to search", + ) parser.add_argument("-l", "--list", action="store_true", help="list the entry points found, and stop") parser.add_argument( "--plugins", @@ -108,25 +120,33 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace: return parser.parse_args(argv) -def _ask(candidates: list[EntryPoint]) -> EntryPoint: +def _ask( + project: Project, candidates: list[EntryPoint], *, mode: str = FUNCTIONS, file: str = "", title: str = "" +) -> EntryPoint | None: """ - The entry point the user picks from a numbered list (the first, when nobody's there to ask). + The entry point the user picks in the picker (the first, when there's one or nobody's there to ask), or None + when they quit it. """ - if len(candidates) == 1 or not sys.stdin.isatty(): + if len(candidates) == 1 or not can_ask(): return candidates[0] - _print_entries(candidates) - while True: - answer = input(f"Start from which one? [1-{len(candidates)}, Enter for 1] ").strip() - if not answer: - return candidates[0] - if answer.isdigit() and 1 <= int(answer) <= len(candidates): - return candidates[int(answer) - 1] + if mode == ENTRIES: + return browse(project, mode=ENTRIES, found=candidates) + return browse(project, mode=FUNCTIONS, choices=candidates, file=file, title=title) + + +def _fail(message: str) -> None: + errors.print(Text(message, style="red"), soft_wrap=True) def _print_entries(candidates: list[EntryPoint]) -> None: - width = max(len(c.name) for c in candidates) + table = Table(box=box.SIMPLE_HEAD, header_style="bold", pad_edge=False) + table.add_column("#", justify="right", style="dim") + table.add_column("Entry point", style="bold", no_wrap=True) + table.add_column("Why", style="dim", overflow="fold") + table.add_column("Where", style="cyan", no_wrap=True) for i, entry in enumerate(candidates, 1): - print(f" {i:>2}. {entry.name:<{width}} {entry.why} ({entry.file}:{entry.line})") + table.add_row(str(i), entry.name, entry.why, f"{entry.file}:{entry.line}") + console.print(table) def _pick_entry(project: Project, given: str | None) -> EntryPoint | None: @@ -146,23 +166,24 @@ def _pick_entry(project: Project, given: str | None) -> EntryPoint | None: return EntryPoint(given, "given", "", 0) matches = functions_matching(project, given) if not matches: - print(f"No function in {project.root} matches {given!r}.", file=sys.stderr) + _fail(f"No function in {project.root} matches {given!r}.") return None - return _ask(matches[:30]) + return _ask(project, matches, title=f"functions matching {given!r}") found = entry_points(project) + if not found and can_ask(): # nothing to offer: let them walk the folder for a file instead + return browse(project, mode=FILES, found=[]) if not found: kinds = [ "console script", "main()", *(p.entry_kind.split(":")[0].split(" (")[0].removeprefix("a ") for p in platforms() if p.entry_kind), ] - print( + _fail( f"Found no entry point in {project.root}: no {', '.join(kinds[:-1])}, or {kinds[-1]}. Name one with -e " - "module.function.", - file=sys.stderr, + "module.function, or pick a file with -i." ) return None - return _ask(found[:30]) + return _ask(project, found, mode=ENTRIES) def _print_plugins() -> None: @@ -184,10 +205,13 @@ def _print_plugins() -> None: ), ] for title, rows in kinds: - print(title) - width = max((len(name) for name, _, _ in rows), default=0) + table = Table(title=title, title_justify="left", title_style="bold", box=box.SIMPLE_HEAD, pad_edge=False) + table.add_column("Name", style="bold cyan", no_wrap=True) + table.add_column("What", no_wrap=True) + table.add_column("Reads", style="dim", overflow="fold") for name, label, what in rows: - print(f" {name:<{width}} {label}" + (f" ({what})" if what else "")) + table.add_row(name, label, what) + console.print(table) def _is_wsl() -> bool: @@ -240,17 +264,16 @@ def _from_file(project: Project, file: Path, function: str) -> EntryPoint | None """ rel = file.relative_to(project.root).as_posix() if rel not in project.files: - print(f"{rel} is skipped (a virtual environment, cache or build folder).", file=sys.stderr) + _fail(f"{rel} is skipped (a virtual environment, cache or build folder).") return None found = entries_in_file(project, rel, function) if not found: - print( + _fail( f"{rel} has no functions to start from: code-node follows a function (a script's top-level code " - "isn't one yet).", - file=sys.stderr, + "isn't one yet)." ) return None - return _ask(found[:30]) + return _ask(project, found, file=rel) def _root_for(path: Path, file: Path | None) -> Path: @@ -277,18 +300,30 @@ def main(argv: list[str] | None = None) -> int: in_file = _split_file(args.path) or (_split_file(args.entry) if args.entry else None) root = _root_for(Path(args.path).expanduser(), in_file[0] if in_file else None) if not root.is_dir(): - print(f"{root} isn't a folder or a .py file.", file=sys.stderr) + _fail(f"{root} isn't a folder or a .py file.") return 2 project = read_project(root) if not project.files: - print(f"No Python files under {project.root}.", file=sys.stderr) + _fail(f"No Python files under {project.root}.") return 1 if args.list: found = entry_points(project) - print(f"{len(found)} entry point{'s' if len(found) != 1 else ''} in {project.root}:") - _print_entries(found or [EntryPoint("(none: name one with -e module.function)", "", "-", 0)]) + console.print( + Text.assemble((f"{len(found)} entry point{'s' if len(found) != 1 else ''}", "bold"), f" in {project.root}"), + soft_wrap=True, + ) + _print_entries( + found or [EntryPoint("(none: name one with -e module.function, or pick a file with -i)", "", "-", 0)] + ) return 0 - entry = _from_file(project, *in_file) if in_file else _pick_entry(project, args.entry) + if args.interactive: + if not can_ask(): + _fail("-i picks a file in the terminal, so it needs one: name the file or function instead.") + return 2 + start = in_file[0].relative_to(project.root).parent.as_posix() if in_file else "" + entry = browse(project, mode=FILES, folder=start) + else: + entry = _from_file(project, *in_file) if in_file else _pick_entry(project, args.entry) if entry is None: return 1 options = PageOptions( @@ -311,13 +346,17 @@ def main(argv: list[str] | None = None) -> int: out.parent.mkdir(parents=True, exist_ok=True) out.write_text(page, "utf-8") services = list(dict.fromkeys(call.service for call in cmap.library_calls)) - print( - f"{entry.name}: {len(cmap.functions)} functions, {len(cmap.aws)} AWS calls" - + (f", {len(cmap.library_calls)} through libraries ({', '.join(services)})" if services else "") - + (f" ({cmap.note})" if cmap.note else "") - + (f"\nDeployed as {cmap.function.name} in {cmap.function.source}" if cmap.function is not None else "") - + f"\nSaved {out}" + said = Text.assemble( + ("✓ ", "green"), + (entry.name, "bold"), + f": {len(cmap.functions)} functions, {len(cmap.aws)} AWS calls", + f", {len(cmap.library_calls)} through libraries ({', '.join(services)})" if services else "", + (f" ({cmap.note})" if cmap.note else "", "dim"), ) + if cmap.function is not None: + said.append(f"\n Deployed as {cmap.function.name} in {cmap.function.source}", style="dim") + said.append("\n Saved ", style="dim").append(str(out), style="cyan") + console.print(said, soft_wrap=True) if not args.no_open: _open(str(out)) return 0 diff --git a/src/code_node/serve.py b/src/code_node/serve.py index 00a9b6a..af880e2 100644 --- a/src/code_node/serve.py +++ b/src/code_node/serve.py @@ -18,10 +18,13 @@ from pathlib import Path from typing import Callable +from rich.text import Text + from .deploys import deploy_file_names from .pages import PageOptions, make_page from .project import _SKIPPED_FOLDERS, EntryPoint from .report.style import FAVICON_LINK, FAVICON_SVG +from .terminal import console, errors DEFAULT_PORT = 8765 # without --port, the first free port from here on is used, so several folders can be served @@ -113,7 +116,7 @@ class LivePage: root: Path entry: EntryPoint options: PageOptions - log: Callable[[str], None] = field(default=lambda line: print(line, flush=True)) + log: Callable[[str], None] = field(default=lambda line: console.print(Text(line, style="dim"), soft_wrap=True)) page: str = "" version: int = 0 changed: threading.Condition = field(default_factory=threading.Condition) @@ -256,21 +259,34 @@ def serve( try: server = listen(_handler(live), port) except OSError as exc: - print( - f"Can't listen on port {port if port is not None else DEFAULT_PORT} ({exc.strerror}): pick another with " - "--port." + errors.print( + Text( + f"Can't listen on port {port if port is not None else DEFAULT_PORT} ({exc.strerror}): pick another " + "with --port.", + style="red", + ), + soft_wrap=True, ) return 1 url = f"http://localhost:{server.server_address[1]}/" watcher = threading.Thread(target=live.watch, name="code-node-watch", daemon=True) watcher.start() - print(f"Serving {entry.name} at {url} (watching {root}; Ctrl-C to stop)", flush=True) + console.print( + Text.assemble( + ("Serving ", "green"), + (entry.name, "bold"), + " at ", + (url, "bold cyan underline"), + (f" (watching {root}; Ctrl-C to stop)", "dim"), + ), + soft_wrap=True, + ) if open_page is not None: open_page(url) try: server.serve_forever(poll_interval=0.25) except KeyboardInterrupt: - print("\nStopped.") + console.print("\nStopped.", style="dim") finally: live.stop() server.server_close() diff --git a/src/code_node/terminal.py b/src/code_node/terminal.py new file mode 100644 index 0000000..e9449cd --- /dev/null +++ b/src/code_node/terminal.py @@ -0,0 +1,118 @@ +""" +The terminal: rich consoles for what the command says, and keys read one at a time for the file browser. +""" + +from __future__ import annotations + +import os +import sys +from collections.abc import Callable, Generator +from contextlib import contextmanager + +from rich.console import Console + +console = Console(highlight=False) +errors = Console(stderr=True, highlight=False) + +# what a key's escape sequence (after ESC) means, on Linux and macOS terminals +_ESCAPES = { + "[A": "up", + "[B": "down", + "[C": "right", + "[D": "left", + "OA": "up", + "OB": "down", + "OC": "right", + "OD": "left", + "[H": "home", + "[F": "end", + "OH": "home", + "OF": "end", + "[1~": "home", + "[4~": "end", + "[7~": "home", + "[8~": "end", + "[5~": "pageup", + "[6~": "pagedown", + "[3~": "delete", + "[Z": "shift-tab", +} + +# what the second half of a Windows console's two-part key means +_WINDOWS_KEYS = { + "H": "up", + "P": "down", + "M": "right", + "K": "left", + "G": "home", + "O": "end", + "I": "pageup", + "Q": "pagedown", + "S": "delete", +} + +# the plain keys, by the character the terminal sends +_PLAIN = { + "\r": "enter", + "\n": "enter", + "\t": "tab", + "\x7f": "backspace", + "\x08": "backspace", + "\x1b": "esc", + "\x03": "ctrl-c", +} + + +def can_ask() -> bool: + """ + Whether someone is at a terminal to ask: stdin and stdout are both one. + """ + return sys.stdin.isatty() and sys.stdout.isatty() + + +@contextmanager +def keys() -> Generator[Callable[[], str], None, None]: + """ + A function that waits for the next key and names it: 'up', 'enter', 'esc', 'ctrl-c', or the character typed. + The terminal stops echoing what's typed until the block ends (Ctrl-C still raises KeyboardInterrupt). + """ + if os.name == "nt": + yield _windows_key + return + import termios + import tty + + fd = sys.stdin.fileno() + saved = termios.tcgetattr(fd) + try: + tty.setcbreak(fd) # not raw: rich's newlines still go back to the line's start + yield lambda: _unix_key(fd) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, saved) + + +def _unix_key(fd: int) -> str: + import select + + first = os.read(fd, 1) + if first == b"\x1b": + sequence = "" + while select.select([fd], [], [], 0.03)[0]: # the rest of an escape sequence comes at once; Esc alone doesn't + sequence += os.read(fd, 1).decode("ascii", "replace") + if sequence[-1].isalpha() or sequence[-1] == "~": + break + return _ESCAPES.get(sequence, "esc" if not sequence else "") + if first[0] >= 0xC0: # the first byte of a character UTF-8 spends several on + more = 3 if first[0] >= 0xF0 else 2 if first[0] >= 0xE0 else 1 + first += os.read(fd, more) + text = first.decode("utf-8", "replace") + return _PLAIN.get(text, text) + + +def _windows_key() -> str: + import msvcrt + + first = msvcrt.getwch() + if first in ("\x00", "\xe0"): + return _WINDOWS_KEYS.get(msvcrt.getwch(), "") + return _PLAIN.get(first, first) diff --git a/tests/test_code_node.py b/tests/test_code_node.py index 3dfb477..a804928 100644 --- a/tests/test_code_node.py +++ b/tests/test_code_node.py @@ -4,6 +4,7 @@ from __future__ import annotations +import io import os import re from pathlib import Path @@ -246,6 +247,49 @@ def test_starting_from_a_file(tmp_path: Path, capsys) -> None: assert "tool.store.Store.save" in capsys.readouterr().out +def test_the_picker_walks_folders_and_searches_files(tmp_path: Path) -> None: + from rich.console import Console + + from code_node.browse import FILES, _match, _Picker + + project = read_project(write(tmp_path / "repo", PACKAGE)) + picker = _Picker(project, entry_points(project), mode=FILES) + + def keys(*pressed: str): + picked = None + for key in pressed: + picked = picker.press(key) + return picked + + def listed() -> list[str]: + return [row.key for row in picker.rows()] + + assert listed() == ["src", "tests"] and keys("enter") is None and picker.folder == "src" + assert listed() == ["", "src/tool"] and picker.cursor == 1 # past '..' + assert keys("right") is None and listed()[1:] == ["src/tool/__init__.py", "src/tool/cli.py", "src/tool/store.py"] + assert "★" in str(picker.rows()[2].note) # cli.py holds the console script + assert ( + keys("left") is None and picker.folder == "src" and listed()[picker.cursor] == "src/tool" + ) # back where it was + keys("s", "t", "o") # typing searches every file + assert listed()[0] == "src/tool/store.py" and keys("enter") is None and picker.mode == "functions" + assert [row.key for row in picker.rows()] == ["tool.store.Store.save", "tool.store.Store._write"] + assert keys("down", "enter").name == "tool.store.Store._write" + assert keys("esc") is None and picker.mode == FILES and listed()[picker.cursor] == "src/tool/store.py" + keys("backspace", "backspace", "tab") + assert picker.mode == "entries" and keys("enter").name == "tool.cli.main" + assert keys("tab", "c", "l", "i", "enter").name == "tool.cli.main" # a file with one function picks it + assert keys("esc", "esc").name == "" # Esc clears what's typed, then quits + assert _match("hdlr", "orders/handler.py") == (3, [7, 10, 11, 13]) and _match("zz", "a.py") is None + assert _match("orders", "orders/orders.py")[0] == 0 and _match("ders", "orders/x.py")[0] == 2 + screen = Console(width=120, height=30, file=io.StringIO()) + with screen.capture() as seen: + screen.print(picker.draw(120, 30)) + assert all(text in seen.get() for text in ("Search", "Entry points 1", "src/", "★ tool.cli.main", "3 Python files")) + assert "repo /" not in seen.get() + assert main([str(tmp_path / "repo"), "-i", "--no-open"]) == 2 # no terminal to pick in + + def test_cli_commands_are_followed_from_the_app(tmp_path: Path) -> None: source = ( "import typer\n\napp = typer.Typer()\n\n\ndef helper():\n pass\n\n\n@app.command()\n" @@ -1148,7 +1192,7 @@ def test_serverless_yml_names_the_entry_point(tmp_path: Path) -> None: def test_the_command_lists_its_plugins(capsys) -> None: assert main(["--plugins"]) == 0 out = capsys.readouterr().out - assert "lambda AWS Lambda" in out and "redis Redis (redis)" in out and "sam AWS SAM" in out + assert all(re.search(row, out) for row in (r"lambda +AWS Lambda", r"redis +Redis +redis", r"sam +AWS SAM")) def _matches_snapshot(example: str) -> None: diff --git a/uv.lock b/uv.lock index 36a26df..13d151e 100644 --- a/uv.lock +++ b/uv.lock @@ -310,6 +310,7 @@ source = { editable = "." } dependencies = [ { name = "botocore" }, { name = "pyyaml" }, + { name = "rich" }, ] [package.dev-dependencies] @@ -337,6 +338,7 @@ package = [ requires-dist = [ { name = "botocore", specifier = ">=1.35.72" }, { name = "pyyaml", specifier = ">=6.0" }, + { name = "rich", specifier = ">=13.7" }, ] [package.metadata.requires-dev]