├── .gitignore ├── LICENSE ├── README.md ├── cli.py ├── example1.py ├── example2.py ├── linenoise.py ├── pyproject.toml └── util.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | #Ipython Notebook 62 | .ipynb_checkpoints 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Jason Harris 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # py_linenoise 2 | 3 | A port of linenoise to Python (because sometimes readline doesn't cut it...) 4 | 5 | See: http://github.com/antirez/linenoise 6 | 7 | ## Standard Features 8 | * Single line editing 9 | * Multiline editing 10 | * Input from files/pipes 11 | * Input from unsupported terminals 12 | * History 13 | * Completions 14 | * Hints 15 | 16 | ## Other Features 17 | * Line buffer initialization: Set an initial buffer string for editing. 18 | * Hot keys: Set a special hot key for exiting line editing. 19 | * Loop Functions: Call a function in a loop until an exit key is pressed. 20 | 21 | ## Examples 22 | 23 | ### example1.py 24 | 25 | Matches the example code in the C version of the linenoise library. 26 | 27 | ### example2.py 28 | 29 | Implements a heirarchical command line interface using cli.py and the linenoise library. 30 | 31 | ## Motiviation 32 | 33 | The GNU readline library is a standard package in Python, but sometimes it's hard to use: 34 | 35 | * How do you have a hot key exit the line editing so you can offer context sensitive help? 36 | * How do you call a polling function during line editing so you can check for other things? 37 | 38 | Having a simple, hackable, native Python implementation of line editing makes these things much easier. 39 | The linenoise library in C already offers a simple alternative. 40 | Porting these functions to Python makes it even easier to use on any system with a POSIX compatible terminal environment. 41 | -------------------------------------------------------------------------------- /cli.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | """ 3 | Command Line Interface 4 | 5 | Implements a CLI with: 6 | 7 | * hierarchical menus 8 | * command tab completion 9 | * command history 10 | * context sensitive help 11 | * command editing 12 | 13 | Notes: 14 | 15 | Menu Tuple Format: 16 | (name, submenu, description) - submenu 17 | (name, leaf) - leaf command with generic help 18 | (name, leaf, help) - leaf command with specific argument help 19 | 20 | Help Format: 21 | (parm, descr) 22 | 23 | Leaf Functions: 24 | 25 | def leaf_function(ui, args): 26 | ..... 27 | 28 | ui: the ui object passed by the application to cli() 29 | args: the argument list from the command line 30 | 31 | The general help for a leaf function is the docstring for that function. 32 | """ 33 | # ----------------------------------------------------------------------------- 34 | 35 | import linenoise 36 | import util 37 | 38 | # ----------------------------------------------------------------------------- 39 | # common help for cli leaf functions 40 | 41 | cr_help = (("", "perform the function"),) 42 | 43 | general_help = ( 44 | ("?", "display command help - Eg. ?, show ?, s?"), 45 | ("", "go backwards in command history"), 46 | ("", "go forwards in command history"), 47 | ("", "auto complete commands"), 48 | ("* note", "commands can be incomplete - Eg. sh = sho = show"), 49 | ) 50 | 51 | history_help = ( 52 | ("", "display all history"), 53 | ("", "recall history entry "), 54 | ) 55 | 56 | # ----------------------------------------------------------------------------- 57 | 58 | 59 | def split_index(s): 60 | """split a string on whitespace and return the substring indices""" 61 | # start and end with whitespace 62 | ws = True 63 | s += " " 64 | start = [] 65 | end = [] 66 | for i, c in enumerate(s): 67 | if not ws and c == " ": 68 | # non-whitespace to whitespace 69 | end.append(i) 70 | ws = True 71 | elif ws and c != " ": 72 | # whitespace to non-whitespace 73 | start.append(i) 74 | ws = False 75 | return zip(start, end) 76 | 77 | 78 | # ----------------------------------------------------------------------------- 79 | 80 | 81 | class cli: 82 | """command line interface""" 83 | 84 | def __init__(self, ui, history=None): 85 | self.ui = ui 86 | self.ln = linenoise.linenoise() 87 | self.ln.set_completion_callback(self.completion_callback) 88 | self.ln.set_hotkey("?") 89 | self.ln.history_load(history) 90 | self.poll = None 91 | self.root = None 92 | self.prompt = "> " 93 | self.running = True 94 | 95 | def set_root(self, root): 96 | """set the menu root""" 97 | self.root = root 98 | 99 | def set_prompt(self, prompt): 100 | """set the command prompt""" 101 | self.prompt = prompt 102 | 103 | def set_poll(self, poll): 104 | """set the external polling function""" 105 | self.poll = poll 106 | 107 | def display_error(self, msg, cmds, idx): 108 | """display a parse error string""" 109 | marker = [] 110 | for i, cmd in enumerate(cmds): 111 | l = len(cmd) 112 | if i == idx: 113 | marker.append("^" * l) 114 | else: 115 | marker.append(" " * l) 116 | s = "\n".join([msg, " ".join(cmds), " ".join(marker)]) 117 | self.ui.put("%s\n" % s) 118 | 119 | def display_function_help(self, help_info): 120 | """display function help""" 121 | s = [] 122 | for parm, descr in help_info: 123 | p_str = (parm, "")[parm is None] 124 | d_fmt = (": %s", " %s")[parm is None] 125 | d_str = (d_fmt % descr, "")[descr is None] 126 | s.append([" ", p_str, d_str]) 127 | self.ui.put("%s\n" % util.display_cols(s, [0, 16, 0])) 128 | 129 | def command_help(self, cmd, menu): 130 | """display help results for a command at a menu level""" 131 | s = [] 132 | for item in menu: 133 | name = item[0] 134 | if name.startswith(cmd): 135 | if isinstance(item[1], tuple): 136 | # submenu: the next string is the help 137 | descr = item[2] 138 | else: 139 | # command: docstring is the help 140 | descr = item[1].__doc__ 141 | s.append([" ", name, ": %s" % descr]) 142 | self.ui.put("%s\n" % util.display_cols(s, [0, 16, 0])) 143 | 144 | def function_help(self, item): 145 | """display help for a leaf function""" 146 | if len(item) > 2: 147 | help_info = item[2] 148 | else: 149 | help_info = cr_help 150 | self.display_function_help(help_info) 151 | 152 | def general_help(self): 153 | """display general help""" 154 | self.display_function_help(general_help) 155 | 156 | def display_history(self, args): 157 | """display the command history""" 158 | # get the history 159 | h = self.ln.history_list() 160 | n = len(h) 161 | if len(args) == 1: 162 | # retrieve a specific history entry 163 | idx = util.int_arg(self.ui, args[0], (0, n - 1), 10) 164 | if idx is None: 165 | return 166 | # Return the next line buffer. 167 | # Note: linenoise wants to add the line buffer as the zero-th history entry. 168 | # It can only do this if it's unique- and this isn't because it's a prior 169 | # history entry. Make it unique by adding a trailing whitespace. The other 170 | # entries have been stripped prior to being added to history. 171 | return h[n - idx - 1] + " " 172 | else: 173 | # display all history 174 | if n: 175 | s = ["%-3d: %s" % (n - i - 1, l) for (i, l) in enumerate(h)] 176 | self.ui.put("%s\n" % "\n".join(s)) 177 | else: 178 | self.ui.put("no history\n") 179 | return "" 180 | 181 | @staticmethod 182 | def completions(line, minlen, cmd, names): 183 | """return the list of line completions""" 184 | line += ("", " ")[cmd == "" and line != ""] 185 | lines = ["%s%s" % (line, x[len(cmd) :]) for x in names] 186 | # pad the lines to a minimum length, we don't want 187 | # the cursor to move about unecessarily 188 | return [l + " " * max(0, minlen - len(l)) for l in lines] 189 | 190 | def completion_callback(self, cmd_line): 191 | """return a tuple of line completions for the command line""" 192 | line = "" 193 | # split the command line into a list of command indices 194 | cmd_list = split_index(cmd_line) 195 | # trace each command through the menu tree 196 | menu = self.root 197 | for start, end in cmd_list: 198 | cmd = cmd_line[start:end] 199 | line = cmd_line[:end] 200 | # How many items does this token match at this level of the menu? 201 | matches = [x for x in menu if x[0].startswith(cmd)] 202 | if len(matches) == 0: 203 | # no matches, no completions 204 | return None 205 | elif len(matches) == 1: 206 | item = matches[0] 207 | if len(cmd) < len(item[0]): 208 | # it's an unambiguous single match, but we still complete it 209 | return self.completions( 210 | line, 211 | len(cmd_line), 212 | cmd, 213 | [ 214 | item[0], 215 | ], 216 | ) 217 | else: 218 | # we have the whole command - is this a submenu or leaf? 219 | if isinstance(item[1], tuple): 220 | # submenu: switch to the submenu and continue parsing 221 | menu = item[1] 222 | continue 223 | else: 224 | # leaf function: no completions to offer 225 | return None 226 | else: 227 | # Multiple matches at this level. Return the matches. 228 | return self.completions(line, len(cmd_line), cmd, [x[0] for x in matches]) 229 | # We've made it here without returning a completion list. 230 | # The prior set of tokens have all matched single submenu items. 231 | # The completions are all of the items at the current menu level. 232 | return self.completions(line, len(cmd_line), "", [x[0] for x in menu]) 233 | 234 | def parse_cmdline(self, line): 235 | """ 236 | parse and process the current command line 237 | return a string for the new command line. 238 | This is generally '' (empty), but may be non-empty 239 | if the user needs to edit a pre-entered command. 240 | """ 241 | # scan the command line into a list of tokens 242 | cmd_list = [x for x in line.split(" ") if x != ""] 243 | # if there are no commands, print a new empty prompt 244 | if len(cmd_list) == 0: 245 | return "" 246 | # trace each command through the menu tree 247 | menu = self.root 248 | for idx, cmd in enumerate(cmd_list): 249 | # A trailing '?' means the user wants help for this command 250 | if cmd[-1] == "?": 251 | # strip off the '?' 252 | cmd = cmd[:-1] 253 | self.command_help(cmd, menu) 254 | # strip off the '?' and recycle the command 255 | return line[:-1] 256 | # try to match the cmd with a unique menu item 257 | matches = [] 258 | for item in menu: 259 | if item[0] == cmd: 260 | # accept an exact match 261 | matches = [item] 262 | break 263 | if item[0].startswith(cmd): 264 | matches.append(item) 265 | if len(matches) == 0: 266 | # no matches - unknown command 267 | self.display_error("unknown command", cmd_list, idx) 268 | # add it to history in case the user wants to edit this junk 269 | self.ln.history_add(line.strip()) 270 | # go back to an empty prompt 271 | return "" 272 | if len(matches) == 1: 273 | # one match - submenu/leaf 274 | item = matches[0] 275 | if isinstance(item[1], tuple): 276 | # this is a submenu 277 | # switch to the submenu and continue parsing 278 | menu = item[1] 279 | continue 280 | else: 281 | # this is a leaf function - get the arguments 282 | args = cmd_list[idx:] 283 | del args[0] 284 | if len(args) != 0: 285 | if args[-1][-1] == "?": 286 | self.function_help(item) 287 | # strip off the '?', repeat the command 288 | return line[:-1] 289 | # call the leaf function 290 | rc = item[1](self.ui, args) 291 | # post leaf function actions 292 | if rc is not None: 293 | # currently only history retrieval returns not None 294 | # the return code is the next line buffer 295 | return rc 296 | else: 297 | # add the command to history 298 | self.ln.history_add(line.strip()) 299 | # return to an empty line 300 | return "" 301 | else: 302 | # multiple matches - ambiguous command 303 | self.display_error("ambiguous command", cmd_list, idx) 304 | return "" 305 | # reached the end of the command list with no errors and no leaf function. 306 | self.ui.put("additional input needed\n") 307 | return line 308 | 309 | def run(self): 310 | """get and process cli commands in a loop""" 311 | line = "" 312 | while self.running: 313 | line = self.ln.read(self.prompt, line) 314 | if line is not None: 315 | line = self.parse_cmdline(line) 316 | else: 317 | # exit: ctrl-C/ctrl-D 318 | self.running = False 319 | self.ln.history_save("history.txt") 320 | 321 | def exit(self): 322 | """exit the cli""" 323 | self.running = False 324 | 325 | 326 | # ----------------------------------------------------------------------------- 327 | -------------------------------------------------------------------------------- /example1.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | """ 4 | example1.py - basic demonstration of linenoise functions. 5 | 6 | See: https://github.com/deadsy/py_linenoise 7 | 8 | """ 9 | 10 | import sys 11 | import time 12 | import linenoise 13 | 14 | _KEY_HOTKEY = "?" 15 | 16 | 17 | def completion(s): 18 | """return a list of line completions""" 19 | if len(s) >= 1 and s[0] == "h": 20 | return ("hello", "hello there") 21 | return None 22 | 23 | 24 | def hints(s): 25 | """return the hints for this command""" 26 | if s == "hello": 27 | # string, color, bold 28 | return (" World", 35, False) 29 | return None 30 | 31 | 32 | loop_idx = 0 33 | _LOOPS = 10 34 | 35 | 36 | def loop(): 37 | """example loop function - return True on completion""" 38 | global loop_idx 39 | sys.stdout.write(f"loop index {loop_idx}/{_LOOPS}\r\n") 40 | time.sleep(0.5) 41 | loop_idx += 1 42 | return loop_idx > _LOOPS 43 | 44 | 45 | def main(): 46 | """entry point- demonstration of linenoise functions""" 47 | ln = linenoise.linenoise() 48 | 49 | # Parse options 50 | argc = len(sys.argv) 51 | idx = 0 52 | while argc > 1: 53 | argc -= 1 54 | idx += 1 55 | argv = sys.argv[idx] 56 | if argv == "--multiline": 57 | ln.set_multiline(True) 58 | sys.stdout.write("Multi-line mode enabled.\n") 59 | elif argv == "--keycodes": 60 | ln.print_keycodes() 61 | sys.exit(0) 62 | elif argv == "--loop": 63 | print("looping: press ctrl-d to exit") 64 | rc = ln.loop(loop) 65 | print(("early exit of loop", "loop completed")[rc]) 66 | sys.exit(0) 67 | else: 68 | sys.stderr.write(f"Usage: {sys.argv[0]} [--multiline] [--keycodes] [--loop]\n") 69 | sys.exit(1) 70 | 71 | # Set the completion callback. This will be called 72 | # every time the user uses the key. 73 | ln.set_completion_callback(completion) 74 | ln.set_hints_callback(hints) 75 | 76 | # Load history from file. The history file is a plain text file 77 | # where entries are separated by newlines. 78 | ln.history_load("history.txt") 79 | 80 | # Set a hotkey. A hotkey will cause the line editing to exit. The hotkey 81 | # will be appended to the returned line buffer but not displayed. 82 | ln.set_hotkey(_KEY_HOTKEY) 83 | 84 | # This is the main loop of a typical linenoise-based application. 85 | # The call to read() will block until the user types something 86 | # and presses enter or a hotkey. 87 | while True: 88 | line = ln.read("hello> ") 89 | if line is None: 90 | break 91 | if line.startswith("/"): 92 | if line.startswith("/historylen"): 93 | l = line.split(" ") 94 | if len(l) >= 2: 95 | n = int(l[1], 10) 96 | ln.history_set_maxlen(n) 97 | else: 98 | print("no history length") 99 | else: 100 | print(f"unrecognized command: {line}") 101 | elif len(line): 102 | print(f"echo: '{line}'") 103 | if line.endswith(_KEY_HOTKEY): 104 | line = line[:-1] 105 | ln.history_add(line) 106 | ln.history_save("history.txt") 107 | 108 | sys.exit(0) 109 | 110 | 111 | main() 112 | -------------------------------------------------------------------------------- /example2.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | # ----------------------------------------------------------------------------- 4 | 5 | import sys 6 | import cli 7 | import time 8 | 9 | # ----------------------------------------------------------------------------- 10 | # cli related leaf functions 11 | 12 | 13 | def cmd_help(ui, args): 14 | """general help""" 15 | cli.general_help() 16 | 17 | 18 | def cmd_history(ui, args): 19 | """command history""" 20 | return cli.display_history(args) 21 | 22 | 23 | def cmd_exit(ui, args): 24 | """exit application""" 25 | cli.exit() 26 | 27 | 28 | # ----------------------------------------------------------------------------- 29 | # application leaf functions 30 | 31 | loop_idx = 0 32 | _LOOPS = 10 33 | 34 | 35 | def loop(ui): 36 | """example loop function - return True on completion""" 37 | global loop_idx 38 | sys.stdout.write("loop index %d/%d\r\n" % (loop_idx, _LOOPS)) 39 | time.sleep(0.5) 40 | loop_idx += 1 41 | return loop_idx > _LOOPS 42 | 43 | 44 | def a0_func(ui, args): 45 | """a0 function description""" 46 | global cli, loop_idx 47 | ui.put("a0 function arguments %s\n" % str(args)) 48 | ui.put("Looping... Ctrl-D to exit\n") 49 | loop_idx = 0 50 | cli.ln.loop(lambda: loop(ui)) 51 | 52 | 53 | def a1_func(ui, args): 54 | """a1 function description""" 55 | ui.put("a1 function arguments %s\n" % str(args)) 56 | 57 | 58 | def a2_func(ui, args): 59 | """a2 function description""" 60 | ui.put("a2 function arguments %s\n" % str(args)) 61 | 62 | 63 | def b0_func(ui, args): 64 | """b0 function description""" 65 | ui.put("b0 function arguments %s\n" % str(args)) 66 | 67 | 68 | def b1_func(ui, args): 69 | """b1 function description""" 70 | ui.put("b1 function arguments %s\n" % str(args)) 71 | 72 | 73 | def c0_func(ui, args): 74 | """c0 function description""" 75 | ui.put("c0 function arguments %s\n" % str(args)) 76 | 77 | 78 | def c1_func(ui, args): 79 | """c1 function description""" 80 | ui.put("c1 function arguments %s\n" % str(args)) 81 | 82 | 83 | def c2_func(ui, args): 84 | """c2 function description""" 85 | ui.put("c2 function arguments %s\n" % str(args)) 86 | 87 | 88 | # ----------------------------------------------------------------------------- 89 | """ 90 | Menu Tree 91 | 92 | (name, submenu, description) - reference to submenu 93 | (name, leaf) - leaf command with generic help 94 | (name, leaf, help) - leaf command with specific argument help 95 | 96 | Note: The general help for a leaf function is the docstring for the leaf function. 97 | """ 98 | 99 | # example of function argument help (parm, descr) 100 | argument_help = ( 101 | ("arg0", "arg0 description"), 102 | ("arg1", "arg1 description"), 103 | ("arg2", "arg2 description"), 104 | ) 105 | 106 | # 'a' submenu items 107 | a_menu = ( 108 | ("a0", a0_func, argument_help), 109 | ("a1", a1_func, argument_help), 110 | ("a2", a2_func), 111 | ) 112 | 113 | # 'b' submenu items 114 | b_menu = ( 115 | ("b0", b0_func, argument_help), 116 | ("b1", b1_func), 117 | ) 118 | 119 | # 'c' submenu items 120 | c_menu = ( 121 | ("c0", c0_func, argument_help), 122 | ("c1", c1_func, argument_help), 123 | ("c2", c2_func), 124 | ) 125 | 126 | # root menu 127 | menu_root = ( 128 | ("amenu", a_menu, "menu a functions"), 129 | ("bmenu", b_menu, "menu b functions"), 130 | ("cmenu", c_menu, "menu c functions"), 131 | ("exit", cmd_exit), 132 | ("help", cmd_help), 133 | ("history", cmd_history, cli.history_help), 134 | ) 135 | 136 | # ----------------------------------------------------------------------------- 137 | 138 | 139 | class user_interface(object): 140 | """ 141 | Each leaf function is called with an instance of this object. 142 | The user can extend this class with application specific functions. 143 | """ 144 | 145 | def __init__(self): 146 | pass 147 | 148 | def put(self, s): 149 | """print a string to stdout""" 150 | sys.stdout.write(s) 151 | 152 | 153 | # ----------------------------------------------------------------------------- 154 | 155 | # setup the cli object 156 | cli = cli.cli(user_interface(), "history.txt") 157 | cli.set_root(menu_root) 158 | cli.set_prompt("cli> ") 159 | 160 | 161 | def main(): 162 | cli.run() 163 | sys.exit(0) 164 | 165 | 166 | main() 167 | 168 | # ----------------------------------------------------------------------------- 169 | -------------------------------------------------------------------------------- /linenoise.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | """ 3 | 4 | linenoise for python 5 | 6 | See: https://github.com/deadsy/py_linenoise 7 | 8 | Based on: http://github.com/antirez/linenoise 9 | 10 | """ 11 | # ----------------------------------------------------------------------------- 12 | 13 | import os 14 | import stat 15 | import sys 16 | import select 17 | import atexit 18 | import termios 19 | import struct 20 | import fcntl 21 | import string 22 | import logging 23 | 24 | # ----------------------------------------------------------------------------- 25 | # logging 26 | 27 | _debug_mode = False 28 | if _debug_mode: 29 | logging.basicConfig( 30 | filename="linenoise_debug.log", format="%(asctime)s %(message)s", datefmt="%Y%m%d %I:%M:%S", level=logging.DEBUG 31 | ) 32 | 33 | # ----------------------------------------------------------------------------- 34 | 35 | # Key Codes 36 | _KEY_NULL = chr(0) 37 | _KEY_CTRL_A = chr(1) 38 | _KEY_CTRL_B = chr(2) 39 | _KEY_CTRL_C = chr(3) 40 | _KEY_CTRL_D = chr(4) 41 | _KEY_CTRL_E = chr(5) 42 | _KEY_CTRL_F = chr(6) 43 | _KEY_CTRL_H = chr(8) 44 | _KEY_TAB = chr(9) 45 | _KEY_CTRL_K = chr(11) 46 | _KEY_CTRL_L = chr(12) 47 | _KEY_ENTER = chr(13) 48 | _KEY_CTRL_N = chr(14) 49 | _KEY_CTRL_P = chr(16) 50 | _KEY_CTRL_T = chr(20) 51 | _KEY_CTRL_U = chr(21) 52 | _KEY_CTRL_W = chr(23) 53 | _KEY_ESC = chr(27) 54 | _KEY_BS = chr(127) 55 | 56 | # ----------------------------------------------------------------------------- 57 | 58 | _STDIN = sys.stdin.fileno() 59 | _STDOUT = sys.stdout.fileno() 60 | _STDERR = sys.stderr.fileno() 61 | 62 | _CHAR_TIMEOUT = 0.02 # 20 ms 63 | 64 | 65 | def _getc(fd, timeout=-1): 66 | """ 67 | read a single character string from a file (with timeout) 68 | timeout = 0 : return immediately 69 | timeout < 0 : wait for the character (block) 70 | timeout > 0 : wait for timeout seconds 71 | """ 72 | # use select() for the timeout 73 | if timeout >= 0: 74 | (rd, _, _) = select.select((fd,), (), (), timeout) 75 | if len(rd) == 0: 76 | return _KEY_NULL 77 | # read the character 78 | c = os.read(fd, 1) 79 | if c == "": 80 | return _KEY_NULL 81 | return c.decode("utf8") 82 | 83 | 84 | def _puts(fd, s): 85 | return os.write(fd, s.encode("utf8")) 86 | 87 | 88 | def would_block(fd, timeout): 89 | """if fd is not readable within timeout seconds - return True""" 90 | (rd, _, _) = select.select((fd,), (), (), timeout) 91 | return len(rd) == 0 92 | 93 | 94 | # ----------------------------------------------------------------------------- 95 | 96 | # Use this value if we can't work out how many columns the terminal has. 97 | _DEFAULT_COLS = 80 98 | 99 | 100 | def get_cursor_position(ifd, ofd): 101 | """Get the horizontal cursor position""" 102 | # query the cursor location 103 | if _puts(ofd, "\x1b[6n") != 4: 104 | return -1 105 | # read the response: ESC [ rows ; cols R 106 | # rows/cols are decimal number strings 107 | buf = [] 108 | while len(buf) < 32: 109 | c = _getc(ifd, _CHAR_TIMEOUT) 110 | if c == _KEY_NULL: 111 | break 112 | buf.append(c) 113 | if buf[-1] == "R": 114 | break 115 | # parse it: esc [ number ; number R (at least 6 characters) 116 | if len(buf) < 6 or buf[0] != _KEY_ESC or buf[1] != "[" or buf[-1] != "R": 117 | return -1 118 | # should have 2 number fields 119 | x = "".join(buf[2:-1]).split(";") 120 | if len(x) != 2: 121 | return -1 122 | (_, cols) = x 123 | # return the cols 124 | return int(cols, 10) 125 | 126 | 127 | def get_columns(ifd, ofd): 128 | """Get the number of columns for the terminal. Assume _DEFAULT_COLS if it fails.""" 129 | cols = 0 130 | # try using the ioctl to get the number of cols 131 | try: 132 | t = fcntl.ioctl(_STDOUT, termios.TIOCGWINSZ, struct.pack("HHHH", 0, 0, 0, 0)) 133 | (_, cols, _, _) = struct.unpack("HHHH", t) 134 | except: 135 | pass 136 | if cols == 0: 137 | # the ioctl failed - try using the terminal itself 138 | start = get_cursor_position(ifd, ofd) 139 | if start < 0: 140 | return _DEFAULT_COLS 141 | # Go to right margin and get position 142 | if _puts(ofd, "\x1b[999C") != 6: 143 | return _DEFAULT_COLS 144 | cols = get_cursor_position(ifd, ofd) 145 | if cols < 0: 146 | return _DEFAULT_COLS 147 | # restore the position 148 | if cols > start: 149 | _puts(ofd, "\x1b[%dD" % (cols - start)) 150 | return cols 151 | 152 | 153 | # ----------------------------------------------------------------------------- 154 | 155 | 156 | def clear_screen(): 157 | """Clear the screen""" 158 | sys.stdout.write("\x1b[H\x1b[2J") 159 | sys.stdout.flush() 160 | 161 | 162 | def beep(): 163 | """Beep""" 164 | sys.stderr.write("\x07") 165 | sys.stderr.flush() 166 | 167 | 168 | # ----------------------------------------------------------------------------- 169 | 170 | 171 | def unsupported_term(): 172 | """return True if we know we don't support this terminal""" 173 | unsupported = ("dumb", "cons25", "emacs") 174 | term = os.environ.get("TERM", "") 175 | return term in unsupported 176 | 177 | 178 | # ----------------------------------------------------------------------------- 179 | 180 | 181 | class line_state: 182 | """line editing state""" 183 | 184 | def __init__(self, ifd, ofd, prompt, ts): 185 | self.ifd = ifd # stdin file descriptor 186 | self.ofd = ofd # stdout file descriptor 187 | self.prompt = prompt # prompt string 188 | self.ts = ts # terminal state 189 | self.history_idx = 0 # history index we are currently editing, 0 is the LAST entry 190 | self.buf = [] # line buffer 191 | self.cols = get_columns(ifd, ofd) # number of columns in terminal 192 | self.pos = 0 # current cursor position within line buffer 193 | self.oldpos = 0 # previous refresh cursor position (multiline) 194 | self.maxrows = 0 # maximum num of rows used so far (multiline) 195 | 196 | def refresh_show_hints(self): 197 | """show hints to the right of the cursor""" 198 | if self.ts.hints_callback is None: 199 | # no hints 200 | return [] 201 | if len(self.prompt) + len(self.buf) >= self.cols: 202 | # no space to display hints 203 | return [] 204 | # get the hint 205 | result = self.ts.hints_callback(str(self)) 206 | if result is None: 207 | # no hints 208 | return [] 209 | (hint, color, bold) = result 210 | if hint is None or len(hint) == 0: 211 | # no hints 212 | return [] 213 | # work out the hint length 214 | hlen = min(len(hint), self.cols - len(self.prompt) - len(self.buf)) 215 | seq = [] 216 | if bold and color < 0: 217 | color = 37 218 | if color >= 0 or bold: 219 | seq.append("\033[%d;%d;49m" % ((0, 1)[bold], color)) 220 | seq.append(hint[:hlen]) 221 | if color >= 0 or bold: 222 | seq.append("\033[0m") 223 | return seq 224 | 225 | def refresh_singleline(self): 226 | """single line refresh""" 227 | seq = [] 228 | plen = len(self.prompt) 229 | blen = len(self.buf) 230 | idx = 0 231 | pos = self.pos 232 | # scroll the characters to the left if we are at max columns 233 | while (plen + pos) >= self.cols: 234 | idx += 1 235 | blen -= 1 236 | pos -= 1 237 | while (plen + blen) > self.cols: 238 | blen -= 1 239 | # cursor to the left edge 240 | seq.append("\r") 241 | # write the prompt 242 | seq.append(self.prompt) 243 | # write the current buffer content 244 | seq.append("".join([self.buf[i] for i in range(idx, idx + blen)])) 245 | # Show hints (if any) 246 | seq.extend(self.refresh_show_hints()) 247 | # Erase to right 248 | seq.append("\x1b[0K") 249 | # Move cursor to original position 250 | seq.append("\r\x1b[%dC" % (plen + pos)) 251 | # write it out 252 | _puts(self.ofd, "".join(seq)) 253 | 254 | def refresh_multiline(self): 255 | """multiline refresh""" 256 | plen = len(self.prompt) 257 | old_rows = self.maxrows 258 | # cursor position relative to row 259 | rpos = (plen + self.oldpos + self.cols) // self.cols 260 | # rows used by current buf 261 | rows = (plen + len(self.buf) + self.cols - 1) // self.cols 262 | # Update maxrows if needed 263 | if rows > self.maxrows: 264 | self.maxrows = rows 265 | seq = [] 266 | # First step: clear all the lines used before. To do so start by going to the last row. 267 | if old_rows - rpos > 0: 268 | logging.debug("go down %d" % (old_rows - rpos)) 269 | seq.append("\x1b[%dB" % (old_rows - rpos)) 270 | # Now for every row clear it, go up. 271 | for j in range(old_rows - 1): 272 | logging.debug("clear+up") 273 | seq.append("\r\x1b[0K\x1b[1A") 274 | # Clear the top line. 275 | logging.debug("clear") 276 | seq.append("\r\x1b[0K") 277 | # Write the prompt and the current buffer content 278 | seq.append(self.prompt) 279 | seq.append(str(self)) 280 | # Show hints (if any) 281 | seq.extend(self.refresh_show_hints()) 282 | # If we are at the very end of the screen with our cursor, we need to 283 | # emit a newline and move the cursor to the first column. 284 | if self.pos and self.pos == len(self.buf) and (self.pos + plen) % self.cols == 0: 285 | logging.debug("") 286 | seq.append("\n\r") 287 | rows += 1 288 | if rows > self.maxrows: 289 | self.maxrows = rows 290 | # Move cursor to right position. 291 | rpos2 = (plen + self.pos + self.cols) // self.cols # current cursor relative row. 292 | logging.debug("rpos2 %d" % rpos2) 293 | # Go up till we reach the expected position. 294 | if rows - rpos2 > 0: 295 | logging.debug("go-up %d" % (rows - rpos2)) 296 | seq.append("\x1b[%dA" % (rows - rpos2)) 297 | # Set column 298 | col = (plen + self.pos) % self.cols 299 | logging.debug("set col %d" % (1 + col)) 300 | if col: 301 | seq.append("\r\x1b[%dC" % col) 302 | else: 303 | seq.append("\r") 304 | # save the cursor position 305 | logging.debug("\n") 306 | self.oldpos = self.pos 307 | # write it out 308 | _puts(self.ofd, "".join(seq)) 309 | 310 | def refresh_line(self): 311 | """refresh the edit line""" 312 | if self.ts.mlmode: 313 | self.refresh_multiline() 314 | else: 315 | self.refresh_singleline() 316 | 317 | def edit_delete(self): 318 | """delete the character at the current cursor position""" 319 | if len(self.buf) > 0 and self.pos < len(self.buf): 320 | self.buf.pop(self.pos) 321 | self.refresh_line() 322 | 323 | def edit_backspace(self): 324 | """delete the character to the left of the current cursor position""" 325 | if self.pos > 0 and len(self.buf) > 0: 326 | self.buf.pop(self.pos - 1) 327 | self.pos -= 1 328 | self.refresh_line() 329 | 330 | def edit_insert(self, c): 331 | """insert a character at the current cursor position""" 332 | self.buf.insert(self.pos, c) 333 | self.pos += 1 334 | self.refresh_line() 335 | 336 | def edit_swap(self): 337 | """swap current character with the previous character""" 338 | if self.pos > 0 and self.pos < len(self.buf): 339 | tmp = self.buf[self.pos - 1] 340 | self.buf[self.pos - 1] = self.buf[self.pos] 341 | self.buf[self.pos] = tmp 342 | if self.pos != len(self.buf) - 1: 343 | self.pos += 1 344 | self.refresh_line() 345 | 346 | def edit_set(self, s): 347 | """set the line buffer to a string""" 348 | if s is None: 349 | return 350 | self.buf = list(s) 351 | self.pos = len(self.buf) 352 | self.refresh_line() 353 | 354 | def edit_move_left(self): 355 | """Move cursor on the left""" 356 | if self.pos > 0: 357 | self.pos -= 1 358 | self.refresh_line() 359 | 360 | def edit_move_right(self): 361 | """Move cursor to the right""" 362 | if self.pos != len(self.buf): 363 | self.pos += 1 364 | self.refresh_line() 365 | 366 | def edit_move_home(self): 367 | """move to the start of the line buffer""" 368 | if self.pos: 369 | self.pos = 0 370 | self.refresh_line() 371 | 372 | def edit_move_end(self): 373 | """move to the end of the line buffer""" 374 | if self.pos != len(self.buf): 375 | self.pos = len(self.buf) 376 | self.refresh_line() 377 | 378 | def delete_line(self): 379 | """delete the line""" 380 | self.buf = [] 381 | self.pos = 0 382 | self.refresh_line() 383 | 384 | def delete_to_end(self): 385 | """delete from the current cursor postion to the end of the line""" 386 | self.buf = self.buf[: self.pos] 387 | self.refresh_line() 388 | 389 | def delete_prev_word(self): 390 | """delete the previous space delimited word""" 391 | old_pos = self.pos 392 | # remove spaces 393 | while self.pos > 0 and self.buf[self.pos - 1] == " ": 394 | self.pos -= 1 395 | # remove word 396 | while self.pos > 0 and self.buf[self.pos - 1] != " ": 397 | self.pos -= 1 398 | self.buf = self.buf[: self.pos] + self.buf[old_pos:] 399 | self.refresh_line() 400 | 401 | def complete_line(self): 402 | """show completions for the current line""" 403 | c = _KEY_NULL 404 | # get a list of line completions 405 | lc = self.ts.completion_callback(str(self)) 406 | if lc is None or len(lc) == 0: 407 | # no line completions 408 | beep() 409 | else: 410 | # navigate and display the line completions 411 | stop = False 412 | idx = 0 413 | while not stop: 414 | if idx < len(lc): 415 | # save the line buffer 416 | saved_buf = self.buf 417 | saved_pos = self.pos 418 | # show the completion 419 | self.buf = list(lc[idx]) 420 | self.pos = len(self.buf) 421 | self.refresh_line() 422 | # restore the line buffer 423 | self.buf = saved_buf 424 | self.pos = saved_pos 425 | else: 426 | # show the original buffer 427 | self.refresh_line() 428 | # navigate through the completions 429 | c = _getc(self.ifd) 430 | if c == _KEY_NULL: 431 | # error on read 432 | stop = True 433 | elif c == _KEY_TAB: 434 | # loop through the completions 435 | idx = (idx + 1) % (len(lc) + 1) 436 | if idx == len(lc): 437 | beep() 438 | elif c == _KEY_ESC: 439 | # could be an escape, could be an escape sequence 440 | if would_block(self.ifd, _CHAR_TIMEOUT): 441 | # nothing more to read, looks like a single escape 442 | # re-show the original buffer 443 | if idx < len(lc): 444 | self.refresh_line() 445 | # don't pass the escape key back 446 | c = _KEY_NULL 447 | else: 448 | # probably an escape sequence 449 | # update the buffer and return 450 | if idx < len(lc): 451 | self.buf = list(lc[idx]) 452 | self.pos = len(self.buf) 453 | stop = True 454 | else: 455 | # update the buffer and return 456 | if idx < len(lc): 457 | self.buf = list(lc[idx]) 458 | self.pos = len(self.buf) 459 | stop = True 460 | # return the last character read 461 | return c 462 | 463 | def __str__(self): 464 | """return a string for the line buffer""" 465 | return "".join(self.buf) 466 | 467 | 468 | # ----------------------------------------------------------------------------- 469 | 470 | # Indices within the termios array 471 | _C_IFLAG = 0 472 | _C_OFLAG = 1 473 | _C_CFLAG = 2 474 | _C_LFLAG = 3 475 | _C_CC = 6 476 | 477 | 478 | class linenoise: 479 | """terminal state""" 480 | 481 | def __init__(self): 482 | self.history = [] # list of history strings 483 | self.history_maxlen = 32 # maximum number of history entries (default) 484 | self.rawmode = False # are we in raw mode? 485 | self.mlmode = False # are we in multiline mode? 486 | self.atexit_flag = False # have we registered a cleanup upon exit function? 487 | self.orig_termios = None # saved termios attributes 488 | self.completion_callback = None # callback function for tab completion 489 | self.hints_callback = None # callback function for hints 490 | self.hotkey = None # character for hotkey 491 | 492 | def enable_rawmode(self, fd): 493 | """Enable raw mode""" 494 | if not os.isatty(fd): 495 | return -1 496 | # ensure cleanup upon exit/disaster 497 | if not self.atexit_flag: 498 | atexit.register(self.atexit) 499 | self.atexit_flag = True 500 | # modify the original mode 501 | self.orig_termios = termios.tcgetattr(fd) 502 | raw = termios.tcgetattr(fd) 503 | # input modes: no break, no CR to NL, no parity check, no strip char, no start/stop output control 504 | raw[_C_IFLAG] &= ~(termios.BRKINT | termios.ICRNL | termios.INPCK | termios.ISTRIP | termios.IXON) 505 | # output modes - disable post processing 506 | raw[_C_OFLAG] &= ~(termios.OPOST) 507 | # control modes - set 8 bit chars 508 | raw[_C_CFLAG] |= termios.CS8 509 | # local modes - echo off, canonical off, no extended functions, no signal chars (^Z,^C) 510 | raw[_C_LFLAG] &= ~(termios.ECHO | termios.ICANON | termios.IEXTEN | termios.ISIG) 511 | # control chars - set return condition: min number of bytes and timer. 512 | # We want read to return every single byte, without timeout. 513 | raw[_C_CC][termios.VMIN] = 1 514 | raw[_C_CC][termios.VTIME] = 0 515 | # put terminal in raw mode after flushing 516 | termios.tcsetattr(fd, termios.TCSAFLUSH, raw) 517 | self.rawmode = True 518 | return 0 519 | 520 | def disable_rawmode(self, fd): 521 | """Disable raw mode""" 522 | if self.rawmode: 523 | termios.tcsetattr(fd, termios.TCSAFLUSH, self.orig_termios) 524 | self.rawmode = False 525 | 526 | def atexit(self): 527 | """Restore STDIN to the orignal mode""" 528 | sys.stdout.write("\r") 529 | sys.stdout.flush() 530 | self.disable_rawmode(_STDIN) 531 | 532 | def edit(self, ifd, ofd, prompt, s): 533 | """ 534 | edit a line in raw mode 535 | ifd = input fiel descriptor 536 | ofd = output file descriptor 537 | prompt = line prompt string 538 | s = initial line string 539 | """ 540 | # create the line state 541 | ls = line_state(ifd, ofd, prompt, self) 542 | # set and output the initial line 543 | ls.edit_set(s) 544 | # The latest history entry is always our current buffer 545 | self.history_add(str(ls)) 546 | while True: 547 | c = _getc(ifd) 548 | if c == _KEY_NULL: 549 | # error on read 550 | return str(ls) 551 | # Autocomplete when the callback is set. 552 | # It returns the character that should be handled next. 553 | if c == _KEY_TAB and self.completion_callback is not None: 554 | c = ls.complete_line() 555 | if c == _KEY_NULL: 556 | continue 557 | # handle the key code 558 | if c == _KEY_ENTER or c == self.hotkey: 559 | self.history.pop() 560 | if self.hints_callback: 561 | # Refresh the line without hints to leave the 562 | # line as the user typed it after the newline. 563 | hcb = self.hints_callback 564 | self.hints_callback = None 565 | ls.refresh_line() 566 | self.hints_callback = hcb 567 | return str(ls) + ("", self.hotkey)[c == self.hotkey] 568 | elif c == _KEY_BS: 569 | # backspace: remove the character to the left of the cursor 570 | ls.edit_backspace() 571 | elif c == _KEY_ESC: 572 | if would_block(ifd, _CHAR_TIMEOUT): 573 | # looks like a single escape- abandon the line 574 | self.history.pop() 575 | return "" 576 | # escape sequence 577 | s0 = _getc(ifd, _CHAR_TIMEOUT) 578 | s1 = _getc(ifd, _CHAR_TIMEOUT) 579 | if s0 == "[": 580 | # ESC [ sequence 581 | if s1 >= "0" and s1 <= "9": 582 | # Extended escape, read additional byte. 583 | s2 = _getc(ifd, _CHAR_TIMEOUT) 584 | if s2 == "~": 585 | if s1 == "3": 586 | # delete 587 | ls.edit_delete() 588 | else: 589 | if s1 == "A": 590 | # cursor up 591 | ls.edit_set(self.history_prev(ls)) 592 | elif s1 == "B": 593 | # cursor down 594 | ls.edit_set(self.history_next(ls)) 595 | elif s1 == "C": 596 | # cursor right 597 | ls.edit_move_right() 598 | elif s1 == "D": 599 | # cursor left 600 | ls.edit_move_left() 601 | elif s1 == "H": 602 | # cursor home 603 | ls.edit_move_home() 604 | elif s1 == "F": 605 | # cursor end 606 | ls.edit_move_end() 607 | elif s0 == "0": 608 | # ESC 0 sequence 609 | if s1 == "H": 610 | # cursor home 611 | ls.edit_move_home() 612 | elif s1 == "F": 613 | # cursor end 614 | ls.edit_move_end() 615 | else: 616 | pass 617 | elif c == _KEY_CTRL_A: 618 | # go to the start of the line 619 | ls.edit_move_home() 620 | elif c == _KEY_CTRL_B: 621 | # cursor left 622 | ls.edit_move_left() 623 | elif c == _KEY_CTRL_C: 624 | # return None == EOF 625 | return None 626 | elif c == _KEY_CTRL_D: 627 | # delete: remove the character to the right of the cursor. 628 | # If the line is empty act as an EOF. 629 | if len(ls.buf) != 0: 630 | ls.edit_delete() 631 | else: 632 | self.history.pop() 633 | return None 634 | elif c == _KEY_CTRL_E: 635 | # go to the end of the line 636 | ls.edit_move_end() 637 | elif c == _KEY_CTRL_F: 638 | # cursor right 639 | ls.edit_move_right() 640 | elif c == _KEY_CTRL_H: 641 | # backspace: remove the character to the left of the cursor 642 | ls.edit_backspace() 643 | elif c == _KEY_CTRL_K: 644 | # delete to the end of the line 645 | ls.delete_to_end() 646 | elif c == _KEY_CTRL_L: 647 | # clear screen 648 | clear_screen() 649 | ls.refresh_line() 650 | elif c == _KEY_CTRL_N: 651 | # next history item 652 | ls.edit_set(self.history_next(ls)) 653 | elif c == _KEY_CTRL_P: 654 | # previous history item 655 | ls.edit_set(self.history_prev(ls)) 656 | elif c == _KEY_CTRL_T: 657 | # swap current character with the previous 658 | ls.edit_swap() 659 | elif c == _KEY_CTRL_U: 660 | # delete the whole line 661 | ls.delete_line() 662 | elif c == _KEY_CTRL_W: 663 | # delete previous word 664 | ls.delete_prev_word() 665 | else: 666 | # insert the character into the line buffer 667 | ls.edit_insert(c) 668 | 669 | def read_raw(self, prompt, s): 670 | """read a line from stdin in raw mode""" 671 | if self.enable_rawmode(_STDIN) == -1: 672 | return None 673 | s = self.edit(_STDIN, _STDOUT, prompt, s) 674 | self.disable_rawmode(_STDIN) 675 | sys.stdout.write("\r\n") 676 | return s 677 | 678 | def read(self, prompt, s=""): 679 | """Read a line. Return None on EOF""" 680 | if not os.isatty(_STDIN): 681 | # Not a tty. Read from a file/pipe. 682 | s = sys.stdin.readline().strip("\n") 683 | return (s, None)[s == ""] 684 | if unsupported_term(): 685 | # Not a terminal we know about, so basic line reading. 686 | try: 687 | s = input(prompt) 688 | except EOFError: 689 | s = None 690 | return s 691 | return self.read_raw(prompt, s) 692 | 693 | def loop(self, fn, exit_key=_KEY_CTRL_D): 694 | """ 695 | Call the provided function in a loop. 696 | Exit when the function returns True or when the exit key is pressed. 697 | Returns True when the loop function completes, False for early exit. 698 | """ 699 | if self.enable_rawmode(_STDIN) == -1: 700 | return 701 | rc = None 702 | while True: 703 | if fn(): 704 | # the loop function has completed 705 | rc = True 706 | break 707 | if _getc(_STDIN, timeout=0.01) == exit_key: 708 | # the loop has been cancelled 709 | rc = False 710 | break 711 | self.disable_rawmode(_STDIN) 712 | return rc 713 | 714 | def print_keycodes(self): 715 | """Print scan codes on screen for debugging/development purposes""" 716 | print("Linenoise key codes debugging mode.") 717 | print("Press keys to see scan codes. Type 'quit' at any time to exit.") 718 | if self.enable_rawmode(_STDIN) != 0: 719 | return 720 | cmd = [""] * 4 721 | while True: 722 | # get a character 723 | c = _getc(_STDIN) 724 | if c == _KEY_NULL: 725 | continue 726 | # display the character 727 | if c in string.printable: 728 | m = {"\r": "\\r", "\n": "\\n", "\t": "\\t"} 729 | cstr = m.get(c, c) 730 | else: 731 | m = {_KEY_ESC: "ESC"} 732 | cstr = m.get(c, "?") 733 | sys.stdout.write("'%s' 0x%02x (%d)\r\n" % (cstr, ord(c), ord(c))) 734 | sys.stdout.flush() 735 | # check for quit 736 | cmd = cmd[1:] 737 | cmd.append(c) 738 | if "".join(cmd) == "quit": 739 | break 740 | # restore the original mode 741 | self.disable_rawmode(_STDIN) 742 | 743 | def set_completion_callback(self, fn): 744 | """set the completion callback function""" 745 | self.completion_callback = fn 746 | 747 | def set_hints_callback(self, fn): 748 | """set the hints callback function""" 749 | self.hints_callback = fn 750 | 751 | def set_multiline(self, mode): 752 | """set multiline mode""" 753 | self.mlmode = mode 754 | 755 | def set_hotkey(self, key): 756 | """ 757 | Set the hotkey. A hotkey will cause line editing to exit. 758 | The hotkey will be appended to the line buffer but not displayed. 759 | """ 760 | self.hotkey = key 761 | 762 | def history_set(self, idx, line): 763 | """set a history entry by index number""" 764 | self.history[len(self.history) - 1 - idx] = line 765 | 766 | def history_get(self, idx): 767 | """get a history entry by index number""" 768 | return self.history[len(self.history) - 1 - idx] 769 | 770 | def history_list(self): 771 | """return the full history list""" 772 | return self.history 773 | 774 | def history_next(self, ls): 775 | """return next history item""" 776 | if len(self.history) == 0: 777 | return None 778 | # update the current history entry with the line buffer 779 | self.history_set(ls.history_idx, str(ls)) 780 | ls.history_idx -= 1 781 | # next history item 782 | if ls.history_idx < 0: 783 | ls.history_idx = 0 784 | return self.history_get(ls.history_idx) 785 | 786 | def history_prev(self, ls): 787 | """return previous history item""" 788 | if len(self.history) == 0: 789 | return None 790 | # update the current history entry with the line buffer 791 | self.history_set(ls.history_idx, str(ls)) 792 | ls.history_idx += 1 793 | # previous history item 794 | if ls.history_idx >= len(self.history): 795 | ls.history_idx = len(self.history) - 1 796 | return self.history_get(ls.history_idx) 797 | 798 | def history_add(self, line): 799 | """Add a new entry to the history""" 800 | if self.history_maxlen == 0: 801 | return 802 | # don't re-add the last entry 803 | if len(self.history) != 0 and line == self.history[-1]: 804 | return 805 | # add the line to the history 806 | if len(self.history) == self.history_maxlen: 807 | # remove the first entry 808 | self.history.pop(0) 809 | self.history.append(line) 810 | 811 | def history_set_maxlen(self, n): 812 | """Set the maximum length for the history. Truncate the current history if needed.""" 813 | if n < 0: 814 | return 815 | self.history_maxlen = n 816 | current_length = len(self.history) 817 | if current_length > self.history_maxlen: 818 | # truncate and retain the latest history 819 | self.history = self.history[current_length - self.history_maxlen :] 820 | 821 | def history_save(self, fname): 822 | """Save the history to a file""" 823 | old_umask = os.umask(stat.S_IXUSR | stat.S_IRWXG | stat.S_IRWXO) 824 | f = open(fname, "w") 825 | os.umask(old_umask) 826 | os.chmod(fname, stat.S_IRUSR | stat.S_IWUSR) 827 | f.write("%s\n" % "\n".join(self.history)) 828 | f.close() 829 | 830 | def history_load(self, fname): 831 | """Load history from a file""" 832 | self.history = [] 833 | if fname and os.path.isfile(fname): 834 | f = open(fname, "r") 835 | x = f.readlines() 836 | f.close() 837 | self.history = [l.strip() for l in x] 838 | 839 | 840 | # ----------------------------------------------------------------------------- 841 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "py_linenoise" 7 | version = "1.0.0" 8 | description = "A port of linenoise to Python (because sometimes readline doesn't cut it...)" 9 | requires-python = ">=3.8" 10 | authors = [ 11 | { name = "Jason Harris", email = "sirmanlypowers@gmail.com" } 12 | ] 13 | license = { file = "LICENSE" } 14 | readme = "README.md" 15 | keywords = [ 16 | "linenoise", 17 | ] 18 | classifiers = [ 19 | "Environment :: Console", 20 | "License :: OSI Approved :: MIT License", 21 | "Programming Language :: Python :: 3", 22 | "Topic :: Software Development :: Libraries", 23 | "Topic :: Terminals", 24 | ] 25 | 26 | [tool.hatch.build] 27 | only-include = ["linenoise.py"] 28 | -------------------------------------------------------------------------------- /util.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | """ 3 | Utility Functions 4 | """ 5 | # ----------------------------------------------------------------------------- 6 | 7 | inv_arg = "invalid argument\n" 8 | 9 | 10 | def int_arg(ui, arg, limits, base): 11 | """convert a number string to an integer, or None""" 12 | try: 13 | val = int(arg, base) 14 | except ValueError: 15 | ui.put(inv_arg) 16 | return None 17 | if (val < limits[0]) or (val > limits[1]): 18 | ui.put(inv_arg) 19 | return None 20 | return val 21 | 22 | 23 | # ----------------------------------------------------------------------------- 24 | 25 | 26 | def display_cols(clist, csize=None): 27 | """ 28 | return a string for a list of columns 29 | each element in clist is [col0_str, col1_str, col2_str, ...] 30 | csize is a list of column width minimums 31 | """ 32 | if len(clist) == 0: 33 | return "" 34 | # how many columns? 35 | ncols = len(clist[0]) 36 | # make sure we have a well formed csize 37 | if csize is None: 38 | csize = [ 39 | 0, 40 | ] * ncols 41 | else: 42 | assert len(csize) == ncols 43 | # convert any "None" items to '' 44 | for l in clist: 45 | assert len(l) == ncols 46 | for i in range(ncols): 47 | if l[i] is None: 48 | l[i] = "" 49 | # additional column margin 50 | cmargin = 1 51 | # go through the strings and bump up csize widths if required 52 | for l in clist: 53 | for i in range(ncols): 54 | if csize[i] <= len(l[i]): 55 | csize[i] = len(l[i]) + cmargin 56 | # build the format string 57 | fmts = [] 58 | for n in csize: 59 | fmts.append("%%-%ds" % n) 60 | fmt = "".join(fmts) 61 | # generate the string 62 | s = [(fmt % tuple(l)) for l in clist] 63 | return "\n".join(s) 64 | 65 | 66 | # ----------------------------------------------------------------------------- 67 | --------------------------------------------------------------------------------