Skip to content

Instantly share code, notes, and snippets.

@LukeSavefrogs
Last active March 12, 2025 17:28
Show Gist options
  • Save LukeSavefrogs/1444e5ea62bfe8f8a9ea789922379460 to your computer and use it in GitHub Desktop.
Save LukeSavefrogs/1444e5ea62bfe8f8a9ea789922379460 to your computer and use it in GitHub Desktop.
[WIP] Jython 2.1 REPL
""" Alternative Jython interactive console with modern features.
Features:
- History (Up/Down arrow keys)
- Character movement (Left/Right arrow keys)
- Word movement (Ctrl+Left/Ctrl+Right arrow keys)
- Home/End support
- Backspace/DEL support
- Ctrl-C/Ctrl-D support
- Clear screen (Ctrl-L)
- Custom prompt (>>>)
- Debug mode showing key presses via environment variables (PYTHONDEBUG, PYTHONVERBOSE)
- History file save/load (set via environment variable PYTHON_HISTORY)
TODO: Add support for multiline commands
Original code:
https://github.com/python/cpython/blob/v2.1/Lib/code.py
"""
import os
import sys
import code
if sys.platform.startswith("java"):
import java.lang.System as System # pyright: ignore[reportMissingImports]
import java as _java # pyright: ignore[reportMissingImports]
# Set the prompt to ">>> " (like the standard Python REPL)
sys.ps1 = "\n>>> "
sys.ps2 = "... "
def _tty_set_raw_mode(shell="/bin/sh"):
""" Set the terminal to raw mode. """
execute_command([shell, "-c", "stty -echo </dev/tty && stty raw </dev/tty"])
def _tty_set_cooked_mode(shell="/bin/sh"):
""" Put the terminal back to cooked mode. """
execute_command([shell, "-c", "stty echo </dev/tty && stty sane </dev/tty"])
def execute_command(command):
# type: (list[str]) -> str
"""Execute a command and return the output.
Args:
command (list[str]): The command that will be executed.
Returns:
output (str): The output of the command.
Example:
>>> execute_command(["echo", "Hello World!"])
'Hello World!'
"""
output = []
try:
import subprocess # Used while testing on Windows
# Support Python 3.5 and up
if hasattr(subprocess, "run"):
return subprocess.run(
command,
capture_output=1==1,
text=1==1,
shell=1==1,
check=1==1,
).stdout
# Support Python 2.7
return subprocess.check_output(command).decode("utf-8")
except ImportError:
if not sys.platform.startswith("java"):
raise
builder = _java.lang.ProcessBuilder([str(word) for word in command])
builder.redirectErrorStream(1)
process = builder.start()
reader = _java.io.BufferedReader(
_java.io.InputStreamReader(process.getInputStream())
)
line = reader.readLine()
while line != None:
output.append(line)
line = reader.readLine()
reader, process, builder = None, None, None
return "\n".join(output)
def read_character(shell="/bin/sh"): # "/bin/sh"
# type: (str) -> int
"""Read a single character from the console"""
_tty_set_raw_mode(shell)
try:
try:
if sys.platform.startswith("java"):
return System.console().reader().read()
# For some reason, on Jython (tested on RHEL) `sys.stdin.read(1)` does not work as expected
return ord(sys.stdin.read(1))
except:
sys.stderr.write("Error while reading character from console\n")
raise
finally:
_tty_set_cooked_mode(shell)
class KeyboardCode:
def __init__(self, decimal=None, sequence=None, caret=None):
# type: (int|None, tuple[int, ...]|None, str|None) -> None
"""Initialize the KeyboardCode.
Args:
decimal (int): The decimal value of the byte representing the key.
sequence (tuple[int]): The sequence of bytes that represent the key.
caret (str): The caret representation of the key.
"""
self.sequence = tuple([]) # type: tuple[int, ...]
if sequence is not None:
self.sequence = sequence
elif decimal is not None:
self.sequence = tuple([decimal])
else:
raise ValueError("Either 'decimal' or 'sequence' must be set.")
self.caret = ""
if caret is not None:
self.caret = caret
def __repr__(self):
return "<KeyboardCode DEC=%s C=%s>" % (
'+'.join([str(byte) for byte in self.sequence]),
self.caret,
)
def __add__(self, other):
# type: (KeyboardCode|str) -> KeyboardCode
"""Add two KeyboardCodes together"""
if isinstance(other, KeyboardCode):
return KeyboardCode(
sequence=self.sequence + other.sequence,
caret=self.caret + other.caret,
)
elif type(other) in [type(""), type(u"")]:
return KeyboardCode(
sequence=self.sequence + tuple([ord(char) for char in str(other)]),
caret=self.caret + str(other),
)
raise ValueError("Unsupported type for addition: %s" % type(other))
# def __add__(self, other):
# # type: (KeyboardCode) -> str
# """Add two KeyboardCodes together"""
# # return KeyboardCode
class KeyboardKey:
def __init__(self, name, code):
# type: (str, KeyboardCode|list[KeyboardCode]|KeyboardKey|list[KeyboardKey]) -> None
"""Initialize the KeyboardKey"""
self.name = name
self.codes = [] # type: list[KeyboardCode]
if type(code) not in [type([]), type(())]:
code = [code]
for item in code:
if isinstance(item, KeyboardCode):
self.codes.append(item)
elif isinstance(item, KeyboardKey):
self.codes += item.codes
else:
raise ValueError("Unsupported type for code: %s" % type(item))
def __repr__(self):
return "<KeyboardKey: %s>" % self.name
def __add__(self, other):
# type: (KeyboardKey|str) -> KeyboardKey
"""Add two KeyboardKeys together"""
if isinstance(other, KeyboardKey):
return KeyboardKey(self.name + "+" + other.name, self.codes + other.codes)
elif type(other) in [type(""), type(u"")]:
if len(self.codes) != 1:
raise ValueError("Too many codes found (%d). Please add manually." % len(self.codes))
return KeyboardKey(
self.name + "+" + str(other),
self.codes[0] + str(other),
)
raise ValueError("Unsupported type for addition: %s" % type(other))
ESC = KeyboardKey("ESC", KeyboardCode(decimal=27, caret="^["))
class Keyboard:
""" Keyboard keys codes """
class Keys:
# https://www.gaijin.at/en/infos/ascii-ansi-character-table#asciicontrol
CTRL_C = KeyboardKey("CTRL_C", KeyboardCode(decimal=3, caret="^C"))
CTRL_D = KeyboardKey("CTRL_D", KeyboardCode(decimal=4, caret="^D"))
CTRL_L = KeyboardKey("CTRL_L", KeyboardCode(decimal=12, caret="^L"))
CTRL_R = KeyboardKey("CTRL_R", KeyboardCode(decimal=18, caret="^R"))
CTRL_S = KeyboardKey("CTRL_S", KeyboardCode(decimal=19, caret="^S"))
CTRL_X = KeyboardKey("CTRL_X", KeyboardCode(decimal=24, caret="^X"))
ENTER = KeyboardKey("ENTER", KeyboardCode(decimal=13, caret="^M"))
BACKSPACE = KeyboardKey("BACKSPACE", KeyboardCode(decimal=127, caret="^?"))
DEL = KeyboardKey("DEL", KeyboardCode(decimal=127, caret="^?"))
HOME = KeyboardKey(name="HOME", code=[
ESC + "[H",
ESC + "[1~",
])
END = KeyboardKey(name="END", code=[
ESC + "[F",
ESC + "[4~",
])
PAGE_UP = KeyboardKey(name="PAGE_UP", code=ESC + "[5~")
PAGE_DOWN = KeyboardKey(name="PAGE_DOWN", code=ESC + "[6~")
ARROW_UP = KeyboardKey(name="ARROW_UP", code=ESC + "[A")
ARROW_DOWN = KeyboardKey(name="ARROW_DOWN", code=ESC + "[B")
ARROW_RIGHT = KeyboardKey(name="ARROW_RIGHT", code=ESC + "[C")
ARROW_LEFT = KeyboardKey(name="ARROW_LEFT", code=ESC + "[D")
CTRL_ARROW_RIGHT = KeyboardKey("CTRL+ARROW_RIGHT", [
ESC + "OC",
ESC + "[1;5C",
])
CTRL_ARROW_LEFT = KeyboardKey("CTRL+ARROW_LEFT", [
ESC + "OD",
ESC + "[1;5D",
])
def read_keystroke():
# type: () -> KeyboardKey|int
"""Read a keystroke from the console and return the key pressed.
TODO: Make the list of keys dynamic (based on the terminal)
TODO: Make the character sequence dynamic (based on a predefined list)
"""
# Read the first byte of the keystroke
first_byte = read_character()
# https://www.gaijin.at/en/infos/ascii-ansi-character-table
if first_byte == 3:
return Keyboard.Keys.CTRL_C
elif first_byte == 4:
return Keyboard.Keys.CTRL_D
elif first_byte == 12:
return Keyboard.Keys.CTRL_L
elif first_byte == 18:
return Keyboard.Keys.CTRL_R
elif first_byte == 13:
return Keyboard.Keys.ENTER
elif first_byte == 127:
return Keyboard.Keys.BACKSPACE
# ----> Escape (start of escape sequence)
elif first_byte == 27:
second_byte = read_character()
# ----> [
if second_byte == 91:
third_byte = read_character()
# Arrow keys
# ----> A = Up
if third_byte == 65:
return Keyboard.Keys.ARROW_UP
# ----> B = Down
elif third_byte == 66:
return Keyboard.Keys.ARROW_DOWN
# ----> C = Right
elif third_byte == 67:
return Keyboard.Keys.ARROW_RIGHT
# ----> D = Left
elif third_byte == 68:
return Keyboard.Keys.ARROW_LEFT
# Implementation difference for some keys
# - HOME=^[[H END=^[[F (3 bytes)
# - HOME=^[[1~ END=^[[4~ (4 bytes)
elif third_byte == 72:
return Keyboard.Keys.HOME
elif third_byte == 70:
return Keyboard.Keys.END
# All of the following have 4 bytes (with DEC=126 / ASCII=~ as the last byte)
elif third_byte in [51, 49, 52, 53, 54]:
fourth_byte = read_character()
# Unexpected fourth byte (should be `~`)
if fourth_byte != 126:
raise ValueError("Error in escape sequence: ^[[%s%s (\\e[%s%s) [expected '~' as fourth byte, got '%s']" % (
third_byte,
fourth_byte,
chr(third_byte),
chr(fourth_byte),
chr(fourth_byte),
))
if third_byte == 49:
return Keyboard.Keys.HOME
elif third_byte == 51:
return Keyboard.Keys.DEL
elif third_byte == 52:
return Keyboard.Keys.END
elif third_byte == 53:
return Keyboard.Keys.PAGE_UP
elif third_byte == 54:
return Keyboard.Keys.PAGE_DOWN
else:
raise ValueError("Unknown escape sequence: ^[[%s%s (\\e[%s%s)" % (
third_byte,
fourth_byte,
chr(third_byte),
chr(fourth_byte),
))
else:
raise ValueError("Unknown escape sequence: ^[[%s (\\e[%s)" % (
third_byte,
chr(third_byte),
))
# ----> O
elif second_byte == 79:
third_byte = read_character()
# ----> CTRL+ARROW_RIGHT
if third_byte == 67:
return Keyboard.Keys.CTRL_ARROW_RIGHT
# ----> CTRL+ARROW_LEFT
elif third_byte == 68:
return Keyboard.Keys.CTRL_ARROW_LEFT
else:
raise ValueError("Unknown escape sequence: ^[O%s (\\e[O%s)" % (
third_byte,
chr(third_byte),
))
else:
raise ValueError("Unknown escape sequence: ^[%s (\\e[%s) [expected '[' as second byte, got '%s']" % (
second_byte,
chr(second_byte),
chr(second_byte),
))
else:
return first_byte
def is_word_character(character):
# type: (str) -> bool
"""Check if a character is a word character (alphanumeric)"""
return character.isalnum() and character not in ["(", ")", "[", "]"]
class ModernConsole(code.InteractiveConsole):
""" Emulate the modern REPL console with history. """
commands_history = [] # type: list[str]
def __init__(self, locals=None, filename="<console>", debug=0==1, history_file=None):
"""Initialize the console"""
self.commands_history = []
self.is_debug_mode = debug
self.current_command_rows = 0
self.history_file = history_file
self.load_history()
code.InteractiveConsole.__init__(self, locals, filename)
def load_history(self):
"""Load the history from a file"""
if not self.history_file or not os.path.exists(self.history_file):
return
if self.is_debug_mode:
sys.stderr.write("Loading history from file '%s'...\n" % self.history_file)
try:
self.commands_history = self._safe_file_read(self.history_file).splitlines()
except:
sys.stderr.write("Error while reading history from file '%s'\n" % self.history_file)
def save_history(self):
"""Save the history to a file"""
if not self.history_file:
return
if self.is_debug_mode:
sys.stderr.write("Saving history to file '%s'...\n" % self.history_file)
try:
self._safe_file_write(self.history_file, "\n".join(self.commands_history) + "\n")
except:
sys.stderr.write("Error while writing history to file '%s'\n" % self.history_file)
def _safe_file_read(self, filename):
# type: (str) -> str
"""Read the content from a file"""
try:
try:
file = open(filename, "r")
return file.read()
except:
raise
finally:
try:
file.close()
except:
pass
def _safe_file_write(self, filename, content):
# type: (str, str) -> None
"""Write the content to a file"""
try:
try:
file = open(filename, "w")
file.write(content)
except:
raise
finally:
try:
file.close()
except:
pass
def raw_input(self, prompt):
# type: (str) -> str
""" Write a prompt and read a line.
The returned line does not include the trailing newline.
When the user enters the EOF key sequence, EOFError is raised.
Args:
prompt (str): The prompt to display to the user.
Returns:
str: The line read from the user.
"""
line_buffer = []
# Write the prompt to the console (without newline)
self.write(prompt)
# Initialize history index (last command in history list)
current_history_index = len(self.commands_history)
current_cursor_position = 0
# ----> Read character by character
while 1:
keystroke = read_keystroke()
# ----> Any non-special character
if not isinstance(keystroke, KeyboardKey):
self.write(chr(keystroke)) # Write character to console
line_buffer.insert(current_cursor_position, chr(keystroke)) # Insert character at cursor position
current_cursor_position += 1
# Force re-render of the prompt (in case the cursor is in the middle of the line)
self.write("\r\x1B[K" + prompt.replace("\n", "") + ''.join(line_buffer))
if current_cursor_position < len(line_buffer):
# The cursor is moved to the left by the length of the line buffer
self.write("\x1B[%dD" % (len(line_buffer)-current_cursor_position))
# ----> Ctrl-C
elif keystroke == Keyboard.Keys.CTRL_C:
self._debug_key_press(keystroke.name, current_history_index, current_cursor_position)
raise KeyboardInterrupt
# ----> Ctrl-D
elif keystroke == Keyboard.Keys.CTRL_D:
self._debug_key_press(keystroke.name, current_history_index, current_cursor_position)
raise EOFError("Received EOF: %s" % sys.exc_info()[0])
# ----> Ctrl-L
elif keystroke == Keyboard.Keys.CTRL_L:
self.write("\x1B[2J\x1B[H") # Clear screen
self.write("\n")
self.write(prompt.replace("\n", "") + ''.join(line_buffer)) # Redraw prompt
self._debug_key_press(keystroke.name, current_history_index, current_cursor_position)
continue
# ----> Enter
elif keystroke == Keyboard.Keys.ENTER:
# ----> Add command to history (only if not empty)
if line_buffer and [x for x in line_buffer if x.strip() != ""]:
self.commands_history.append(''.join(line_buffer))
self._debug_key_press(keystroke.name, current_history_index, current_cursor_position)
# ----> Write newline to console (results in a new line)
self.write("\n")
break
# ----> Backspace (delete last character)
elif keystroke == Keyboard.Keys.BACKSPACE:
if current_cursor_position == 0:
continue
# Remove character from buffer and move cursor to the left
line_buffer.pop(current_cursor_position-1)
current_cursor_position -= 1
self.write("\r\x1B[K" + prompt.replace("\n", "") + ''.join(line_buffer))
if current_cursor_position < len(line_buffer):
# The cursor is moved to the left by the length of the line buffer
self.write("\x1B[%dD" % (len(line_buffer)-current_cursor_position))
self._debug_key_press(keystroke.name, current_history_index, current_cursor_position)
continue
# ----> A = Up (previous command)
elif keystroke == Keyboard.Keys.ARROW_UP:
# Prevent index out of range
if current_history_index-1 < 0: # -1 because we are going back in history
continue
current_history_index -= 1
self._debug_key_press(keystroke.name, current_history_index, current_cursor_position)
self.write("\r\x1B[K" + prompt.replace("\n", "") + self.commands_history[current_history_index])
line_buffer = list(self.commands_history[current_history_index]) # Convert string to list of characters
current_cursor_position = len(line_buffer)
continue
# ----> B = Down (next command)
elif keystroke == Keyboard.Keys.ARROW_DOWN:
# Prevent index out of range
if current_history_index+1 > len(self.commands_history): # +1 because we are going forward in history
continue
current_history_index += 1
if current_history_index == len(self.commands_history): # +1 because we are going forward in history
# Show clear prompt if no commands in history or after the end of history (like Bash does)
self.write("\r\x1B[K" + prompt.replace("\n", ""))
line_buffer = []
current_cursor_position = 0
else:
self.write("\r\x1B[K" + prompt.replace("\n", "") + self.commands_history[current_history_index])
line_buffer = list(self.commands_history[current_history_index]) # Convert string to list of characters
current_cursor_position = len(line_buffer)
self._debug_key_press(keystroke.name, current_history_index, current_cursor_position)
continue
# ----> C = Right (no action)
elif keystroke == Keyboard.Keys.ARROW_RIGHT:
# Move cursor to the right
if current_cursor_position < len(line_buffer):
self.write("\x1B[C")
current_cursor_position += 1
self._debug_key_press(keystroke.name, current_history_index, current_cursor_position)
continue
# ----> D = Left (no action)
elif keystroke == Keyboard.Keys.ARROW_LEFT:
# Move cursor to the left
if current_cursor_position > 0:
self.write("\x1B[D")
current_cursor_position -= 1
self._debug_key_press(keystroke.name, current_history_index, current_cursor_position)
continue
# ----> Ctrl+Right (move cursor to next word)
elif keystroke == Keyboard.Keys.CTRL_ARROW_RIGHT:
if current_cursor_position == len(line_buffer):
continue
# Move cursor to next word on the right (if current position is a space)
while current_cursor_position < len(line_buffer) and (line_buffer[current_cursor_position].isspace() or not is_word_character(line_buffer[current_cursor_position])):
self.write("\x1B[C")
current_cursor_position += 1
# Move cursor to the right by one word
while current_cursor_position < len(line_buffer) and not line_buffer[current_cursor_position].isspace() and is_word_character(line_buffer[current_cursor_position]):
self.write("\x1B[C")
current_cursor_position += 1
self._debug_key_press(keystroke.name, current_history_index, current_cursor_position)
continue
# ----> Ctrl+Left (move cursor to previous word)
elif keystroke == Keyboard.Keys.CTRL_ARROW_LEFT:
if current_cursor_position == 0:
continue
# Move cursor to next word on the left (if current position is a space)
while current_cursor_position > 0 and (line_buffer[current_cursor_position-1].isspace() or not is_word_character(line_buffer[current_cursor_position-1])):
self.write("\x1B[D")
current_cursor_position -= 1
# Move cursor to the left by one word
while current_cursor_position > 0 and not line_buffer[current_cursor_position-1].isspace() and is_word_character(line_buffer[current_cursor_position-1]):
self.write("\x1B[D")
current_cursor_position -= 1
self._debug_key_press(keystroke.name, current_history_index, current_cursor_position)
continue
# ----> 3 = Delete (delete character under cursor)
elif keystroke == Keyboard.Keys.DEL:
if current_cursor_position == len(line_buffer):
continue
# Remove character from buffer and move cursor to the left
line_buffer.pop(current_cursor_position)
self.write("\r\x1B[K" + prompt.replace("\n", "") + ''.join(line_buffer))
if current_cursor_position < len(line_buffer):
# The cursor is moved to the left by the length of the line buffer
self.write("\x1B[%dD" % (len(line_buffer)-current_cursor_position))
self._debug_key_press(keystroke.name, current_history_index, current_cursor_position)
continue
# ----> Home (go to start of line)
elif keystroke == Keyboard.Keys.HOME:
current_cursor_position = 0
self.write("\r\x1B[K" + prompt.replace("\n", "") + ''.join(line_buffer))
if len(line_buffer) > 0:
# The cursor is moved to the left by the length of the line buffer
self.write("\x1B[%dD" % len(line_buffer))
self._debug_key_press(keystroke.name, current_history_index, current_cursor_position)
continue
# ----> End (go to end of line)
elif keystroke == Keyboard.Keys.END:
current_cursor_position = len(line_buffer)
self.write("\r\x1B[K" + prompt.replace("\n", "") + ''.join(line_buffer))
self._debug_key_press(keystroke.name, current_history_index, current_cursor_position)
continue
else:
self._debug_key_press(keystroke, current_history_index, current_cursor_position)
continue
# raise ValueError("Detected non-implemented keystroke: %s" % keystroke)
self._debug_key_press(keystroke, current_history_index, current_cursor_position)
# ----> End of while loop
return ''.join(line_buffer)
def push(self, line):
"""Push a line to the interpreter"""
return code.InteractiveConsole.push(self, line)
def runsource(self, source, filename="<input>", symbol="single"):
"""Run the source code"""
self._write_debug(
"source=%(source)-12s | file=%(filename)-12s | symbol=%(symbol)-12s" % {
"source": (source
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
.replace("\v", "\\v")
.replace("\f", "\\f")
.replace("\b", "\\b")
.replace("\a", "\\a")
),
"filename": filename,
"symbol": symbol,
},
line_index=+1,
)
# TODO: Python 2.x compatibility
# - Allow multiline assignment with triple quotes (`string = """first line\nsecond line"""`)
# - Allow multiline assignment with backslash (`string = "test \\ntest2"`)
# - Allow multiline assignment with parenthesis (`string = (\n"test"\n)`)
# - Allow multiline assignment with brackets (`array = [\n]` , `dictionary = {\n}`)
needs_next_line = code.InteractiveConsole.runsource(self, source, filename, symbol)
if needs_next_line == 1:
self.current_command_rows += 1
else:
self.current_command_rows = 0
return needs_next_line
def write(self, data):
"""Write data to the console"""
sys.stdout.write(data)
def _write_debug(self, data, file=sys.stdout, flush=1==1, line_index=0):
"""Write debug data to the console"""
if not self.is_debug_mode:
return
# Save the current cursor position
file.write("\x1B[s")
# Move the cursor up one line and update debug data
file.write("\x1B[%dA\x1B[2K\r" % (self.current_command_rows + line_index + 1))
file.write(data)
# Restore the cursor position
file.write("\x1B[u")
if flush:
file.flush()
def _debug_key_press(self, key, history_index, position_x):
"""Debug the key pressed"""
current_command = ""
if len(self.commands_history) > 0 and history_index < len(self.commands_history):
current_command = self.commands_history[history_index]
self._write_debug("key=%(key_pressed)-12s | hist=%(history_current)03d/%(history_total)03d | posx=%(position_x)03d/%(position_max)03d" % {
"key_pressed": key,
"history_current": history_index,
"history_total": len(self.commands_history),
"position_x": position_x,
"position_max": len(current_command),
})
if __name__ == "__main__":
# Enable debug mode if any of the following environment variables are set
# - PYTHONDEBUG
# - PYTHONVERBOSE
debug_mode = (
os.environ.get("PYTHONDEBUG", "") != ""
or os.environ.get("PYTHONVERBOSE", "") != ""
)
history_file = os.environ.get("PYTHON_HISTORY", None)
# Start the REPL console
repl = ModernConsole(
debug=debug_mode,
history_file=history_file,
)
sys.stderr.write("Console started. Press CTRL+D to exit.\n")
try:
repl.interact(
banner="Python %s on %s\n(%s)\n" % (sys.version, sys.platform, "ModernConsole")
)
finally:
sys.stderr.write("Exiting console...\n")
_tty_set_cooked_mode()
# Save the history to a file when the console is closed
try:
repl.save_history()
except:
sys.stderr.write("Error while saving history: %s\n" % sys.exc_info()[1])
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment