Skip to content
Closed
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
45 changes: 45 additions & 0 deletions .github/workflows/test-multi-os.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Multi-OS tests

on:
push:
branches: [multi-os-revamp, 'cursor/**']
pull_request:
branches: [multi-os-revamp, master]
workflow_dispatch:

jobs:
unit-tests:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ['3.12']
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install package and test deps
run: pip install -e ".[dev]"
- name: Run unit tests
run: pytest -v

tty-smoke:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install package
run: pip install -e .
- name: Install Windows PTY helper
if: runner.os == 'Windows'
run: pip install pywinpty
- name: Real TTY smoke test
run: python scripts/tty_smoke_test.py
66 changes: 51 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@ A simple python class for creating Read Evaluate Print Line (REPL) interfaces.

## Requirements

This module requires Python 3.7 or higher.
This module requires Python 3.8 or higher.

Additionally this library makes use of the [GNU readline Interface](https://docs.python.org/3/library/readline.html), so it will only work on Unix based systems.
EasyREPL is a pure-Python line editor with no dependency on GNU readline. It works
on Linux, macOS, and Windows (Windows uses [colorama](https://pypi.org/project/colorama/)
to enable ANSI escape-sequence processing on the console).

## Installation

```bash
pip install easyrepl
```

## Usage

This module exposes the `REPL` class which can be used to quickly create a REPL interface. REPL will read in a line of user input via a custom input function that allows you to edit the text by moving the cursor with the arrow keys, as well as view the history of previous inputs.
This module exposes the `REPL` class which can be used to quickly create a REPL interface. `REPL` reads a line of user input via a built-in line editor that allows you to move the cursor with the arrow keys, navigate previous inputs, and edit multi-line input.

```python
from easyrepl import REPL
Expand All @@ -29,25 +37,28 @@ world
>>>
```

The input allows common terminal shortcuts like:
- **Ctrl-D**: exit REPL
The input supports the following key bindings:
- **Ctrl-D**: exit REPL (on an empty buffer); otherwise forward-delete
- **Ctrl-L**: clear screen
- **Ctrl-R**: search history
- **Ctrl-R**: reverse-i-search through history
- **Left/Right Arrow**: move cursor left/right
- **Up/Down Arrow**: previous/next history
- **Ctrl-A**: move cursor to beginning of line
- **Ctrl-E**: move cursor to end of line
- **Alt-B**: move cursor backward one word
- **Alt-F**: move cursor forward one word
- **Up/Down Arrow**: move up/down within a multi-line input; at the top/bottom edge, navigates previous/next history entry
- **Home / Ctrl-A**: move cursor to beginning of line
- **End / Ctrl-E**: move cursor to end of line
- **Alt-B / Ctrl-Left**: move cursor backward one word
- **Alt-F / Ctrl-Right**: move cursor forward one word
- **Ctrl-K**: delete from cursor to end of line
- **Ctrl-U**: delete from cursor to beginning of line
- **Ctrl-W**: delete from cursor to beginning of word
- **Alt-D**: delete from cursor to end of word
- **Ctrl-C**: no operation
- etc.
- **Ctrl-C**: abort current input (or quit, if `ctrl_c_quit=True`)


Additionally, multi-line input can be achieved by starting a line with triple quotes (`"""` or `'''`), and ending the final line with a matching triple quote. Triple quotes in the middle of a line will have no effect.
## Multi-line input

Multi-line input can be entered by starting a line with triple quotes (`"""` or `'''`),
and ending the final line with a matching triple quote. Triple quotes in the middle
of a line have no effect.


```
Expand All @@ -64,4 +75,29 @@ at the end of a line
>>>
```

Note that a single newline will be stripped from the beginning and end of the input if present.
A single newline is stripped from the beginning and end of the result if present.

Within a multi-line block, the Up and Down arrow keys move the cursor between
lines of the buffer. When the cursor is on the first row, Up navigates to the
previous history entry; when it's on the last row, Down navigates to the next
history entry. Pressing Enter inserts a newline unless the cursor is at the end
of the buffer and the block is closed (the line then gets submitted).

## API

```python
REPL(*, prompt='>>> ', continuation_prompt='... ', history=None,
dedup_history=True, ctrl_c_quit=False)
```

- `prompt`: prompt rendered before the first line of each input.
- `continuation_prompt`: prompt rendered on every line of a multi-line input after the first.
- `history`: path to a file used to load and persist history; `None` keeps history in-memory only.
- `dedup_history`: when True, appending an entry removes any earlier identical entries.
- `ctrl_c_quit`: when True, Ctrl-C re-raises `KeyboardInterrupt` to terminate the REPL.

```python
readl(*, prompt='', ctrl_c_quit=True, **kwargs) -> str
```

Read a single line using the REPL editor.
4 changes: 3 additions & 1 deletion easyrepl/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
from .repl import REPL, readl
from .repl import REPL, readl

__all__ = ['REPL', 'readl']
201 changes: 201 additions & 0 deletions easyrepl/buffer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
from dataclasses import dataclass, field
from typing import List


def _is_word_char(c: str) -> bool:
return c.isalnum() or c == '_'


@dataclass
class Buffer:
"""A multi-line text buffer with a cursor.

`lines` always has at least one entry. `row`/`col` index into it. `desired_col`
tracks the column the cursor "wants" to be in across vertical movement, so that
moving up from a short line into a long line preserves the original column.
"""

lines: List[str] = field(default_factory=lambda: [""])
row: int = 0
col: int = 0
desired_col: int = 0

@property
def text(self) -> str:
return '\n'.join(self.lines)

@classmethod
def from_text(cls, text: str) -> 'Buffer':
lines = text.split('\n') if text else [""]
buf = cls(lines=lines)
buf.move_end_of_buffer()
return buf

def replace_with(self, other: 'Buffer') -> None:
self.lines = list(other.lines)
self.row = other.row
self.col = other.col
self.desired_col = other.desired_col

def insert_char(self, c: str) -> None:
line = self.lines[self.row]
self.lines[self.row] = line[:self.col] + c + line[self.col:]
self.col += len(c)
self.desired_col = self.col

def insert_newline(self) -> None:
line = self.lines[self.row]
before = line[:self.col]
after = line[self.col:]
self.lines[self.row] = before
self.lines.insert(self.row + 1, after)
self.row += 1
self.col = 0
self.desired_col = 0

def backspace(self) -> None:
if self.col > 0:
line = self.lines[self.row]
self.lines[self.row] = line[:self.col - 1] + line[self.col:]
self.col -= 1
elif self.row > 0:
prev = self.lines[self.row - 1]
cur = self.lines[self.row]
self.col = len(prev)
self.lines[self.row - 1] = prev + cur
del self.lines[self.row]
self.row -= 1
self.desired_col = self.col

def delete(self) -> None:
line = self.lines[self.row]
if self.col < len(line):
self.lines[self.row] = line[:self.col] + line[self.col + 1:]
elif self.row < len(self.lines) - 1:
self.lines[self.row] = line + self.lines[self.row + 1]
del self.lines[self.row + 1]
self.desired_col = self.col

def move_left(self) -> bool:
if self.col > 0:
self.col -= 1
elif self.row > 0:
self.row -= 1
self.col = len(self.lines[self.row])
else:
return False
self.desired_col = self.col
return True

def move_right(self) -> bool:
line = self.lines[self.row]
if self.col < len(line):
self.col += 1
elif self.row < len(self.lines) - 1:
self.row += 1
self.col = 0
else:
return False
self.desired_col = self.col
return True

def move_up(self) -> bool:
if self.row > 0:
self.row -= 1
self.col = min(self.desired_col, len(self.lines[self.row]))
return True
return False

def move_down(self) -> bool:
if self.row < len(self.lines) - 1:
self.row += 1
self.col = min(self.desired_col, len(self.lines[self.row]))
return True
return False

def move_bol(self) -> None:
self.col = 0
self.desired_col = 0

def move_eol(self) -> None:
self.col = len(self.lines[self.row])
self.desired_col = self.col

def move_end_of_buffer(self) -> None:
self.row = len(self.lines) - 1
self.col = len(self.lines[self.row])
self.desired_col = self.col

def is_at_end_of_buffer(self) -> bool:
return self.row == len(self.lines) - 1 and self.col == len(self.lines[-1])

def _word_left_col(self) -> int:
line = self.lines[self.row]
i = self.col
while i > 0 and not _is_word_char(line[i - 1]):
i -= 1
while i > 0 and _is_word_char(line[i - 1]):
i -= 1
return i

def _word_right_col(self) -> int:
line = self.lines[self.row]
i = self.col
while i < len(line) and not _is_word_char(line[i]):
i += 1
while i < len(line) and _is_word_char(line[i]):
i += 1
return i

def move_word_left(self) -> None:
if self.col == 0 and self.row > 0:
self.row -= 1
self.col = len(self.lines[self.row])
else:
self.col = self._word_left_col()
self.desired_col = self.col

def move_word_right(self) -> None:
line = self.lines[self.row]
if self.col == len(line) and self.row < len(self.lines) - 1:
self.row += 1
self.col = 0
else:
self.col = self._word_right_col()
self.desired_col = self.col

def delete_word_left(self) -> None:
if self.col == 0:
if self.row > 0:
self.backspace()
return
new_col = self._word_left_col()
line = self.lines[self.row]
self.lines[self.row] = line[:new_col] + line[self.col:]
self.col = new_col
self.desired_col = self.col

def delete_word_right(self) -> None:
line = self.lines[self.row]
if self.col == len(line):
if self.row < len(self.lines) - 1:
self.delete()
return
new_col = self._word_right_col()
self.lines[self.row] = line[:self.col] + line[new_col:]
self.desired_col = self.col

def kill_to_eol(self) -> None:
line = self.lines[self.row]
if self.col < len(line):
self.lines[self.row] = line[:self.col]
elif self.row < len(self.lines) - 1:
self.lines[self.row] = line + self.lines[self.row + 1]
del self.lines[self.row + 1]
self.desired_col = self.col

def kill_to_bol(self) -> None:
line = self.lines[self.row]
self.lines[self.row] = line[self.col:]
self.col = 0
self.desired_col = 0
Loading
Loading