├── .gitignore ├── tox.ini ├── .flake8 ├── LICENSE ├── README.rst ├── test_pythonrc.py ├── pythonrc_pre38.py └── pythonrc.py /.gitignore: -------------------------------------------------------------------------------- 1 | .cache/ 2 | .tox/ 3 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py3.8,py3.9 3 | skipsdist = True 4 | 5 | [testenv] 6 | deps=pytest 7 | mock 8 | setenv=PYTHONDONTWRITEBYTECODE=1 9 | commands=pytest 10 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 100 3 | # E402: module level import not at top of file 4 | # E221: multiple spaces before operator 5 | # E251: unexpected spaces around keyword / parameter equals 6 | ignore = E402,E221,E251 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2021 Steven Fernandez 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 | 23 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============================= 2 | lonetwin's pimped-up pythonrc 3 | ============================= 4 | 5 | What is this ? 6 | ============== 7 | 8 | This is a python script intended to improve on the default Python interactive 9 | shell experience. 10 | 11 | Unlike ipython_, bpython_ or any of the many other options out there, this is 12 | not designed to be used as a separate interactive environment. The intent is to 13 | keep it as a single file and use it as any other rcfile. This script relies 14 | solely on the standard python library and will always remain that way. 15 | 16 | --------- 17 | 18 | .. note:: 19 | 20 | The `pythonrc.py` file here targets **python-3.8+**. If you wish to use/try 21 | this out with an earlier version of python please use the `pythonrc_pre38.py` 22 | file instead. Any new development will be done on this version but I'm 23 | happy to fix any reported bugs for either. 24 | 25 | --------- 26 | 27 | Demo 28 | ===== 29 | |demo| 30 | 31 | Usage 32 | ===== 33 | 34 | The `pythonrc` file will be executed when the Python interactive shell is 35 | started, if `$PYTHONSTARTUP` is in your environment and points to the file (see 36 | note about version above). 37 | 38 | You could also simply make the file executable and call it directly. 39 | 40 | Additionally, this file will in turn, execute a virtualenv specific rc file [#]_ 41 | if it exists, for the current session, enabling you to *pre-populate* sessions 42 | specific to virtual environments. 43 | 44 | Features 45 | ======== 46 | 47 | The file creates an InteractiveConsole_ instance and executes it. This instance 48 | provides: 49 | 50 | * execution history 51 | * colored prompts and pretty printing 52 | * auto-indentation 53 | * intelligent tab completion [#]_ 54 | 55 | - without preceding text four spaces 56 | - with preceding text 57 | 58 | + names in the current namespace 59 | + for objects, their attributes/methods 60 | + for strings with a `/`, pathname completion 61 | + module name completion in an import statement 62 | 63 | * edit the session or a file in your `$EDITOR` (the ``\e`` command) 64 | 65 | - with no arguments, opens your `$EDITOR` with the session hstory 66 | - with filename argument, opens the file in your `$EDITOR` 67 | - with object as an argument, opens the source code for the object in `$EDITOR` 68 | 69 | * list the source code for objects when available (the ``\l`` command) 70 | * temporary escape to `$SHELL` or ability to execute a shell command and 71 | capturing the output in to the `_` variable (the ``!`` command) 72 | * convenient printing of doc stings (the ``?`` command) and search for entries in 73 | online docs (the ``??`` command) 74 | * auto-execution of a virtual env specific (`.venv_rc.py`) file at startup 75 | 76 | If you have any other good ideas please feel free to submit pull requests or issues. 77 | There's an section below which shows you how to add new commands. 78 | 79 | 80 | Configuration 81 | ============= 82 | 83 | The code attempts to be easy to read and modify to suit personal preferences. 84 | You can change any of the `commands` or the options like the path to the history 85 | file, its size etc in the config dict at the top of the rc file. For instance, 86 | if you prefer to set the default edit command to `%edit` instead of the default 87 | ``\e``, you just have to change the entry in the config dict. 88 | 89 | Note that, the `init_readline()` method also reads your `.inputrc` file if it 90 | exists. This allows you to share the same `readline` behavior as all other tools 91 | that use readline. For instance, in my personal `~/.inputrc` I have the 92 | following:: 93 | 94 | # - when performing completion in the middle of a word, do not insert characters 95 | # from the completion that match characters after point in the word being 96 | # completed 97 | set skip-completed-text on 98 | 99 | # - displays possible completions using different colors according to file type. 100 | set colored-stats on 101 | 102 | # - show completed prefix in a different color 103 | set colored-completion-prefix on 104 | 105 | # - jump temporarily to matching open parenthesis 106 | set blink-matching-paren on 107 | 108 | set expand-tilde on 109 | set history-size -1 110 | set history-preserve-point on 111 | 112 | "\e[A": history-search-backward 113 | "\e[B": history-search-forward 114 | 115 | 116 | Adding new commands 117 | =================== 118 | 119 | It is relatively simple to add new commands to the `ImprovedConsole` class: 120 | 121 | 1. Add the string that would invoke your new command to the `config` dict. 122 | 2. Create a method in the `ImprovedConsole` class which receives a string 123 | argument and returns either a string that can be evaluated as a python 124 | expression or `None`. The method may do anything it fancies. 125 | 3. Add an entry mapping the command to the method in the `commands` dict. 126 | 127 | That's all ! 128 | 129 | The way commands work is, the text entered at the prompt is examined against the 130 | `commands_re` regular expression. This regular expression is simply the grouping 131 | of all valid commands, obtained from the keys of the `commands` dict. 132 | 133 | If a match is found the corresponding function from the `commands` dict is 134 | called with the rest of the text following the command provided as the argument 135 | to the function. 136 | 137 | You may choose to resolve this string argument to an object in the session 138 | namespace by using the helper function `lookup()`. 139 | 140 | Whatever text is returned by the function is then passed on for further 141 | evaluation by the python interpreter. 142 | 143 | Various helper functions exist like all the globally defined color functions 144 | (initialized by the `init_colors` method), the `_doc_to_usage` decorator, 145 | `_mktemp_buffer` and `_exec_from_file` whose intent ought to be hopefully 146 | obvious. 147 | 148 | Here's a complete example demonstrating the idea, by specifying a new command 149 | ``\s`` which prints the size of the specified object or of all objects in the 150 | current namespace. 151 | 152 | :: 153 | 154 | config = dict( 155 | ... 156 | SIZE_OF = '\s', 157 | ) 158 | ... 159 | 160 | class ImprovedConsole(...) 161 | ... 162 | 163 | def __init__(...): 164 | ... 165 | self.commands = { 166 | ... 167 | config['SIZE_OF']: self.print_sizeof, 168 | ... 169 | } 170 | ... 171 | 172 | 173 | @_doc_to_usage 174 | def print_sizeof(self, arg=''): 175 | """{SIZE_OF} 176 | 177 | Print the size of specified object or of all objects in current 178 | namespace 179 | """ 180 | if arg: 181 | obj = self.lookup(arg) 182 | if obj: 183 | return print(sys.getsizeof(obj)) 184 | else: 185 | return self.print_sizeof('-h') 186 | print({k: sys.getsizeof(v) for k, v in self.locals.items()}) 187 | 188 | 189 | A little history 190 | ================ 191 | 192 | Ever since around 2005_, I've been obsessed with tweaking my python interactive 193 | console to have it behave the way I prefer. Despite multiple attempts I've failed to 194 | embrace ipython on the command line because some of ipython's approach just 195 | don't *fit my head*. Additionally, ipython is a full environment and I just need 196 | some conveniences added to the default environment. This is why I started 197 | maintaining my own pythonrc. I started eventually sharing it as a gist_ back in 198 | 2014 and now about 38 revisions later, I think it might just make sense to set 199 | it up as a project so that I can accept pull requests, bug reports or 200 | suggestions in case somebody bothers to use it and contribute back. 201 | 202 | 203 | Known Issue 204 | =========== 205 | 206 | The console is *not* `__main__`. The issue was first reported by @deeenes in the 207 | gist_ I used to maintain. In essence, this code fails:: 208 | 209 | >>> import timeit 210 | >>> 211 | >>> def getExecutionTime(): 212 | ... t = timeit.Timer("sayHello()", "from __main__ import sayHello") 213 | ... return t.timeit(2) 214 | ... 215 | >>> def sayHello(): 216 | ... print("Hello") 217 | ... 218 | >>> print(getExecutionTime()) 219 | Traceback (most recent call last): 220 | File "", line 1, in 221 | File "", line 3, in getExecutionTime 222 | File "/usr/lib64/python2.7/timeit.py", line 202, in timeit 223 | timing = self.inner(it, self.timer) 224 | File "", line 3, in inner 225 | ImportError: cannot import name sayHello 226 | >>> 227 | 228 | There are two possible workarounds for this: 229 | 230 | * When within the console, if you have to reference local names via 231 | `__main__`, remember to do it via `__main__.pymp.locals` instead, something 232 | like (for the example above):: 233 | 234 | ... 235 | def getExecutionTime(): 236 | t = timeit.Timer("sayHello()", "from __main__ import pymp; sayHello = pymp.locals['sayHello']") 237 | ... 238 | 239 | * Or in the pythonrc file, change the initialization of `ImprovedConsole` to 240 | accept `locals()`. That is something like this:: 241 | 242 | pymp = ImprovedConsole(locals=locals()) 243 | 244 | Although the downside of this is, doing it will pollute your console 245 | namespace with everything in the pythonrc file. 246 | 247 | 248 | .. [#] Named `.venv_rc.py` by default, but like almost everything else, is configurable 249 | .. [#] Since python 3.4 the default interpreter also has tab completion enabled however it does not do pathname completion 250 | .. _ipython: https://ipython.org/ 251 | .. _bpython: https://bpython-interpreter.org/ 252 | .. _InteractiveConsole: https://docs.python.org/3.6/library/code.html#code.InteractiveConsole 253 | .. _2005: http://code.activestate.com/recipes/438813/ 254 | .. _gist: https://gist.github.com/lonetwin/5902720 255 | .. |demo| image:: https://asciinema.org/a/134711.png 256 | :target: https://asciinema.org/a/134711?speed=2 257 | -------------------------------------------------------------------------------- /test_pythonrc.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import sys 5 | import tempfile 6 | 7 | from unittest import TestCase, skipIf, skipUnless, main 8 | from unittest.mock import patch, Mock, mock_open 9 | from io import StringIO 10 | 11 | 12 | os.environ['SKIP_PYMP'] = "1" 13 | 14 | 15 | import pythonrc 16 | 17 | EDIT_CMD_TEST_LINES = """ 18 | 19 | x = 42 20 | class Foo(object): 21 | 22 | 23 | def first(self): 24 | pass 25 | 26 | 27 | def second(self): 28 | 29 | pass 30 | 31 | 32 | if x == 43: 33 | raise Exception() 34 | elif x == 42: 35 | x += 1 36 | else: 37 | raise Exception() 38 | 39 | f = Foo() 40 | 41 | 1 + '2' 42 | z = 123 43 | """ 44 | 45 | 46 | class TestImprovedConsole(TestCase): 47 | 48 | def setUp(self): 49 | _, pythonrc.config.HISTFILE = tempfile.mkstemp() 50 | self.pymp = pythonrc.ImprovedConsole() 51 | pythonrc.config.EDITOR = 'vi' 52 | pythonrc.config.EDIT_CMD = r'\e' 53 | pythonrc.config.LIST_CMD = r'\l' 54 | # py27 compatibility 55 | if not hasattr(self, 'assertRegex'): 56 | self.assertRegex = self.assertRegexpMatches 57 | 58 | def test_init(self): 59 | self.assertEqual(self.pymp.session_history, []) 60 | self.assertEqual(self.pymp.buffer, []) 61 | self.assertIn('red', dir(pythonrc)) 62 | 63 | def test_init_color_functions(self): 64 | self.assertEqual(pythonrc.red('spam'), '\033[1;31mspam\033[0m') 65 | self.assertEqual(pythonrc.green('spam', False), '\033[32mspam\033[0m') 66 | self.assertEqual(pythonrc.yellow('spam', False, True), 67 | '\001\033[33m\002spam\001\033[0m\002') 68 | 69 | @skipIf(sys.version_info[:2] == (3, 5), 70 | "mock.assert_called_once doesn't exist in 3.5") 71 | @patch('pythonrc.readline') 72 | def test_init_readline(self, mock_readline): 73 | pythonrc.ImprovedConsole() 74 | for method in [mock_readline.set_history_length, 75 | mock_readline.parse_and_bind, 76 | mock_readline.set_completer, 77 | mock_readline.set_pre_input_hook, 78 | mock_readline.read_init_file]: 79 | method.assert_called_once() 80 | 81 | @patch('pythonrc.readline') 82 | def test_libedit_readline(self, mock_readline): 83 | mock_readline.__doc__ = 'libedit' 84 | pythonrc.ImprovedConsole() 85 | mock_readline.parse_and_bind.assert_called_once_with('bind ^I rl_complete') 86 | 87 | def test_init_prompt(self): 88 | self.assertRegex( 89 | sys.ps1, ('\001\033' r'\[1;3[23]m\002>>> ' '\001\033' r'\[0m\002') 90 | ) 91 | self.assertEqual(sys.ps2, '\001\033[1;31m\002... \001\033[0m\002') 92 | 93 | with patch.dict(os.environ, 94 | {'SSH_CONNECTION': '1.1.1.1 10240 127.0.0.1 22'}): 95 | self.pymp.init_prompt() 96 | self.assertIn('[127.0.0.1]>>> ', sys.ps1) 97 | self.assertIn('[127.0.0.1]... ', sys.ps2) 98 | 99 | def test_init_pprint(self): 100 | self.assertEqual(sys.displayhook.__name__, 'pprint_callback') 101 | with patch('sys.stdout', new_callable=StringIO): 102 | sys.displayhook(42) 103 | sys.displayhook({'spam': 42}) 104 | self.assertEqual( 105 | sys.stdout.getvalue(), 106 | ("%s\n" "{%s42}\n") % (pythonrc.blue('42'), 107 | pythonrc.purple("'spam': ")) 108 | ) 109 | 110 | @skipUnless(sys.version_info.major >= 3 and sys.version_info.minor > 3, 111 | 'compact option does not exist for pprint in python < 3.3') 112 | def test_pprint_compact(self): 113 | with patch('sys.stdout', new_callable=StringIO): 114 | 115 | # - test compact pprint-ing with 80x25 terminal 116 | with patch.object(pythonrc.subprocess, 'check_output', 117 | return_value='25 80'): 118 | sys.displayhook(list(range(22))) 119 | self.assertIn('20, 21]', sys.stdout.getvalue()) 120 | sys.displayhook(list(range(23))) 121 | self.assertIn('21,\n 22]', sys.stdout.getvalue()) 122 | 123 | # - test compact pprint-ing with resized 100x25 terminal 124 | with patch.object(pythonrc.subprocess, 'check_output', 125 | return_value=('25 100')): 126 | sys.displayhook(list(range(23))) 127 | self.assertIn('21, 22]', sys.stdout.getvalue()) 128 | 129 | def test_completer(self): 130 | completer = self.pymp.completer.complete 131 | rl = pythonrc.readline 132 | 133 | # - no leading characters 134 | with patch.object(rl, 'get_line_buffer', return_value='\t'): 135 | self.assertEqual(completer('\t', 0), ' ') 136 | self.assertEqual(completer('', 1), None) 137 | 138 | # - keyword completion 139 | with patch.object(rl, 'get_line_buffer', return_value='cla\t'): 140 | self.assertEqual(completer('cla', 0), 'class ') 141 | 142 | # - import statement completion 143 | with patch.object(rl, 'get_line_buffer', return_value='import th'): 144 | self.assertIn(completer('th', 0), ('this', 'threading')) 145 | self.assertIn(completer('th', 1), ('this', 'threading')) 146 | 147 | # - from ... completion (module name) 148 | with patch.object(rl, 'get_line_buffer', return_value='from th'): 149 | self.assertIn(completer('th', 0), ('this', 'threading')) 150 | self.assertIn(completer('th', 1), ('this', 'threading')) 151 | 152 | # - from ... import completion (import keyword) 153 | with patch.object(rl, 'get_line_buffer', return_value='from os '): 154 | self.assertEqual(completer('', 0), 'import ') 155 | 156 | # - from ... import completion (submodule name) 157 | with patch.object(rl, 'get_line_buffer', return_value='from xlm.'): 158 | self.assertEqual(completer('xml.', 0), 'xml.dom') 159 | self.assertTrue(completer('xml.', 1).startswith('xml.dom.')) 160 | 161 | # - from ... import completion (submodule import - 0) 162 | with patch.object(rl, 'get_line_buffer', return_value='from xml import '): 163 | self.assertEqual(completer('', 0), 'dom') 164 | self.assertTrue(completer('', 1).startswith('dom.')) 165 | 166 | # - from ... import completion (submodule import - 1) 167 | with patch.object(rl, 'get_line_buffer', return_value='from xml.dom import x'): 168 | self.assertEqual(completer('x', 0), 'xmlbuilder') 169 | 170 | # - from ... import completion (module content) 171 | with patch.object(rl, 'get_line_buffer', return_value='from tempfile import '): 172 | self.assertEqual(completer('', 0), 'NamedTemporaryFile') 173 | 174 | # - pathname completion 175 | with patch.object(rl, 'get_line_buffer', return_value='./t'): 176 | self.assertEqual(completer('./te', 0), './test_pythonrc.py') 177 | 178 | mock_input_line = ['/', '/', '/', '/h', '/home/t', '/home/t', '/home/test/f'] 179 | mock_globs = [['/bin', '/home', '/sbin'], 180 | ['/home'], 181 | ['/home/test', '/home/steve'], 182 | ['/home/test'], 183 | ['/home/test/foo', '/home/test/bar/', '/home/test/baz']] 184 | mock_isdir = lambda path: not (path == '/home/test/foo') 185 | 186 | with patch.object(rl, 'get_line_buffer', side_effect=mock_input_line), \ 187 | patch.object(pythonrc.glob, 'iglob', side_effect=mock_globs), \ 188 | patch.object(pythonrc.os.path, 'isdir', side_effect=mock_isdir): 189 | self.assertEqual(completer('/', 0), '/bin/') 190 | self.assertEqual(completer('/', 1), '/home/') 191 | self.assertEqual(completer('/', 2), '/sbin/') 192 | self.assertEqual(completer('/h', 0), '/home/') 193 | self.assertEqual(completer('/home/', 0), '/home/test/') 194 | self.assertEqual(completer('/home/t', 0), '/home/test/') 195 | self.assertEqual(completer('/home/test/f', 0), '/home/test/foo') 196 | 197 | # - pathname completion, with expand user 198 | with patch.object(rl, 'get_line_buffer', return_value='~/'): 199 | completion = completer('~/', 0) 200 | self.assertTrue(completion.startswith(os.path.expanduser('~'))) 201 | 202 | def test_push(self): 203 | self.assertEqual(self.pymp._indent, '') 204 | self.pymp.push('class Foo:') 205 | self.assertEqual(self.pymp._indent, ' ') 206 | self.pymp.push(' def dummy():') 207 | self.assertEqual(self.pymp._indent, ' ') 208 | self.pymp.push(' pass') 209 | self.assertEqual(self.pymp._indent, ' ') 210 | self.pymp.push('') 211 | self.assertEqual(self.pymp._indent, '') 212 | 213 | @patch.object(pythonrc.InteractiveConsole, 'raw_input', 214 | return_value=r'\e code') 215 | def test_raw_input_edit_cmd(self, ignored): 216 | mocked_cmd = Mock() 217 | with patch.dict(self.pymp.commands, {r'\e': mocked_cmd}): 218 | self.pymp.raw_input('>>> ') 219 | mocked_cmd.assert_called_once_with('code') 220 | 221 | @patch.object(pythonrc.InteractiveConsole, 'raw_input', 222 | return_value=r'\l shutil') 223 | def test_raw_input_list_cmd0(self, ignored): 224 | mocked_cmd = Mock() 225 | with patch.dict(self.pymp.commands, {r'\l': mocked_cmd}): 226 | ret = self.pymp.raw_input('>>> ') 227 | mocked_cmd.assert_called_once_with('shutil') 228 | 229 | @patch.object(pythonrc.InteractiveConsole, 'raw_input', 230 | return_value=r'\l global') 231 | def test_raw_input_list_cmd1(self, ignored): 232 | mocked_cmd = Mock() 233 | with patch.dict(self.pymp.commands, {r'\l': mocked_cmd}): 234 | self.pymp.raw_input('>>> ') 235 | mocked_cmd.assert_called_once_with('global') 236 | 237 | def test_increase_indent(self): 238 | for count, char in enumerate(['if True:', '\t[', '{', '('], 1): 239 | self.pymp.push(char) 240 | self.assertEqual(self.pymp._indent, pythonrc.config.ONE_INDENT*count) 241 | 242 | def test_donot_crash_on_empty_continuation(self): 243 | self.pymp.push('if True:') 244 | self.assertEqual(self.pymp._indent, pythonrc.config.ONE_INDENT) 245 | self.pymp.push('') 246 | self.assertEqual(self.pymp._indent, pythonrc.config.ONE_INDENT) 247 | 248 | @patch.object(pythonrc.ImprovedConsole, 'lookup', 249 | return_value=pythonrc.ImprovedConsole) 250 | def test_edit_cmd0(self, *ignored): 251 | """Test edit object""" 252 | with patch.object(pythonrc.os, 'system') as mocked_system: 253 | self.pymp.process_edit_cmd('pythonrc.ImprovedConsole') 254 | self.assertRegex(mocked_system.call_args[0][0], 255 | r'vi \+\d+ .*pythonrc.py') 256 | 257 | @patch.object(pythonrc.ImprovedConsole, 'lookup', return_value=None) 258 | def test_edit_cmd1(self, *ignored): 259 | """Test edit file""" 260 | with patch.object(pythonrc.os, 'system') as mocked_system: 261 | self.pymp.process_edit_cmd('/path/to/file') 262 | self.assertRegex(mocked_system.call_args[0][0], 263 | r'vi /path/to/file') 264 | 265 | def test_edit_cmd2(self, *ignored): 266 | """Test edit session""" 267 | tempfl = StringIO() 268 | tempfl.name = "/tmp/dummy" 269 | 270 | with patch.object(pythonrc.os, 'system', return_value=0) as mocked_system, \ 271 | patch.object(pythonrc.os, 'unlink') as mocked_unlink, \ 272 | patch.object(self.pymp, '_exec_from_file') as mocked_exec, \ 273 | patch.object(pythonrc, 'open', return_value=tempfl), \ 274 | patch.object(pythonrc.ImprovedConsole, '_mktemp_buffer', 275 | return_value=tempfl.name): 276 | self.pymp.session_history = 'x = 42' 277 | self.pymp.process_edit_cmd('') 278 | mocked_system.assert_called_once_with(f'vi {tempfl.name}') 279 | mocked_unlink.assert_called_once_with(tempfl.name) 280 | mocked_exec.assert_called_once_with( 281 | tempfl, print_comments=pythonrc.config.POST_EDIT_PRINT_COMMENTS 282 | ) 283 | 284 | def test_edit_cmd3(self, *ignored): 285 | """Test edit previous session""" 286 | tempfl = StringIO() 287 | tempfl.name = "/tmp/dummy" 288 | with patch.object(pythonrc.os, 'system', return_value=0) as mocked_system, \ 289 | patch.object(pythonrc.os, 'unlink') as mocked_unlink, \ 290 | patch.object(self.pymp, '_exec_from_file') as mocked_exec, \ 291 | patch.object(pythonrc, 'open', return_value=tempfl), \ 292 | patch.object(pythonrc.ImprovedConsole, '_mktemp_buffer', 293 | return_value=tempfl.name): 294 | self.pymp.session_history = [] 295 | self.pymp.process_edit_cmd('') 296 | mocked_system.assert_called_once_with(f'vi {tempfl.name}') 297 | mocked_unlink.assert_called_once_with(tempfl.name) 298 | mocked_exec.assert_called_once_with(tempfl, print_comments=False) 299 | 300 | def test_sh_exec0(self): 301 | """Test sh exec with command and argument""" 302 | self.pymp.locals['path'] = "/dummy/location" 303 | with patch('pythonrc.subprocess.run') as mocked_run, \ 304 | patch.object(sys, 'stdout', new_callable=StringIO): 305 | self.pymp.process_sh_cmd('ls -l {path}') 306 | mocked_run.assert_called_once_with( 307 | ['ls', '-l', '/dummy/location'], 308 | capture_output=True, 309 | env=os.environ, 310 | text=True 311 | ) 312 | 313 | @patch.object(pythonrc.os, 'chdir') 314 | def test_sh_exec1(self, mocked_chdir): 315 | """Test sh exec with cd, user home and shell variable""" 316 | self.pymp.locals['path'] = "~/${RUNTIME}/location" 317 | with patch.dict(pythonrc.os.environ, {'RUNTIME': 'dummy', 318 | 'HOME': '/home/me/'}): 319 | self.pymp.process_sh_cmd('cd {path}') 320 | mocked_chdir.assert_called_once_with('/home/me/dummy/location') 321 | 322 | def test_exec_from_file(self): 323 | """Test exec from file with multiple newlines in code blocks""" 324 | pymp = pythonrc.ImprovedConsole() 325 | tempfl = StringIO(EDIT_CMD_TEST_LINES) 326 | with patch.object(sys, 'stderr', new_callable=StringIO): 327 | pymp._exec_from_file(tempfl) 328 | 329 | self.assertIn('Foo', pymp.locals) 330 | self.assertIn('first', pymp.locals['Foo'].__dict__) 331 | self.assertIn('second', pymp.locals['Foo'].__dict__) 332 | self.assertIn('x', pymp.locals) 333 | self.assertIn('f', pymp.locals) 334 | self.assertEqual(pymp.locals['x'], 43) 335 | self.assertNotIn('z', pymp.locals) 336 | 337 | with tempfile.NamedTemporaryFile(mode='w') as tempfl: 338 | pythonrc.readline.write_history_file(tempfl.name) 339 | expected = filter(None, EDIT_CMD_TEST_LINES.splitlines()[:-1]) 340 | recieved = filter(None, map(str.rstrip, open(tempfl.name))) 341 | self.assertEqual(list(expected), list(recieved)) 342 | 343 | def test_post_edit_print_comments0(self): 344 | """Test post edit print comments""" 345 | with patch.object(sys, 'stderr', new_callable=StringIO) as mock_stderr, \ 346 | patch.object(pythonrc.os, 'system', return_value=0): 347 | self.pymp.session_history = ['x = 42'] 348 | self.pymp.process_edit_cmd('') 349 | self.assertEqual( 350 | mock_stderr.getvalue(), 351 | 352 | pythonrc.grey('... # x = 42', bold=False) 353 | ) 354 | 355 | def test_post_edit_print_comments1(self): 356 | """Test post edit do not print comments""" 357 | with patch.object(sys, 'stderr', new_callable=StringIO) as mock_stderr, \ 358 | patch.object(pythonrc.os, 'system', return_value=0): 359 | tempfl = StringIO('# x = 42\ny = "foo"') 360 | self.pymp._exec_from_file(tempfl, print_comments=False) 361 | self.assertEqual(mock_stderr.getvalue(), pythonrc.cyan('... y = "foo"')) 362 | 363 | def test_lookup(self): 364 | self.pymp.locals['os'] = os 365 | self.assertIs(self.pymp.lookup('os'), os) 366 | self.assertIs(self.pymp.lookup('os.path'), os.path) 367 | self.assertIs(self.pymp.lookup('os.path.basename'), os.path.basename) 368 | 369 | self.assertIs(self.pymp.lookup('subprocess'), None) 370 | self.assertIs(self.pymp.lookup('subprocess.Popen'), None) 371 | 372 | def test_process_help_cmd(self): 373 | with patch('sys.stdout', new_callable=StringIO) as mock_stderr: 374 | self.pymp.process_help_cmd('abs') 375 | output = sys.stdout.getvalue() 376 | self.assertTrue(output.startswith("Help on built-in function abs")) 377 | 378 | with patch.object(sys, 'stdout', new_callable=StringIO) as mock_stderr: 379 | self.pymp.process_help_cmd('') 380 | self.assertEqual( 381 | pythonrc.cyan(self.pymp.__doc__.format(**pythonrc.config.__dict__)), 382 | mock_stderr.getvalue().strip() 383 | ) 384 | 385 | 386 | if __name__ == '__main__': 387 | main() 388 | -------------------------------------------------------------------------------- /pythonrc_pre38.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # The MIT License (MIT) 4 | # 5 | # Copyright (c) 2015-2021 Steven Fernandez 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in all 15 | # copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | # SOFTWARE. 24 | 25 | # Keep a copy of the initial namespace, we'll need it later 26 | CLEAN_NS = globals().copy() 27 | 28 | """pymp - lonetwin's pimped-up pythonrc 29 | 30 | This file will be executed when the Python interactive shell is started, if 31 | $PYTHONSTARTUP is in your environment and points to this file. You could 32 | also make this file executable and call it directly. 33 | 34 | This file creates an InteractiveConsole instance, which provides: 35 | * execution history 36 | * colored prompts and pretty printing 37 | * auto-indentation 38 | * intelligent tab completion:¹ 39 | * source code listing for objects 40 | * session history editing using your $EDITOR, as well as editing of 41 | source files for objects or regular files 42 | * temporary escape to $SHELL or ability to execute a shell command and 43 | capturing the result into the '_' variable 44 | * convenient printing of doc stings and search for entries in online docs 45 | * auto-execution of a virtual env specific (`.venv_rc.py`) file at startup 46 | 47 | If you have any other good ideas please feel free to submit issues/pull requests. 48 | 49 | ¹ Since python 3.4 the default interpreter also has tab completion 50 | enabled however it does not do pathname completion 51 | """ 52 | 53 | 54 | # Fix for Issue #5 55 | # - Exit if being called from within ipython 56 | try: 57 | import sys 58 | __IPYTHON__ and sys.exit(0) 59 | except NameError: 60 | pass 61 | 62 | import atexit 63 | import glob 64 | import importlib 65 | import inspect 66 | import keyword 67 | import os 68 | import pkgutil 69 | import pprint 70 | import re 71 | import readline 72 | import rlcompleter 73 | import shlex 74 | import signal 75 | import subprocess 76 | import webbrowser 77 | 78 | from code import InteractiveConsole 79 | from collections import namedtuple 80 | from functools import partial 81 | from tempfile import NamedTemporaryFile 82 | 83 | 84 | __version__ = "0.8.4" 85 | 86 | 87 | config = dict( 88 | HISTFILE = os.path.expanduser("~/.python_history"), 89 | HISTSIZE = -1, 90 | EDITOR = os.getenv('EDITOR', 'vi'), 91 | SHELL = os.getenv('SHELL', '/bin/bash'), 92 | EDIT_CMD = r'\e', 93 | SH_EXEC = '!', 94 | DOC_CMD = '?', 95 | DOC_URL = "https://docs.python.org/{sys.version_info.major}/search.html?q={term}", 96 | HELP_CMD = r'\h', 97 | LIST_CMD = r'\l', 98 | # - Should we auto-indent by default 99 | AUTO_INDENT = True, 100 | # - Run-time toggle for auto-indent command (eg: when pasting code) 101 | TOGGLE_AUTO_INDENT_CMD = r'\\', 102 | VENV_RC = os.getenv("VENV_RC", ".venv_rc.py"), 103 | # - option to pass to the editor to open a file at a specific 104 | # `line_no`. This is used when the EDIT_CMD is invoked with a python 105 | # object to open the source file for the object. 106 | LINE_NUM_OPT = "+{line_no}", 107 | # - should path completion expand ~ using os.path.expanduser() 108 | COMPLETION_EXPANDS_TILDE = True, 109 | # - when executing edited history, should we also print comments 110 | POST_EDIT_PRINT_COMMENTS = True, 111 | ) 112 | 113 | 114 | if sys.version_info < (3, 7): 115 | import imp 116 | def find_module(name): 117 | """Search for a module""" 118 | (_, pkg_path, _) = imp.find_module(name) 119 | return pkg_path 120 | else: 121 | def find_module(name): 122 | """Search for a module""" 123 | spec = importlib.util.find_spec(name) 124 | orig = spec.origin 125 | sloc = spec.submodule_search_locations 126 | if orig and not orig.endswith('/__init__.py'): 127 | return orig 128 | if isinstance(sloc, list): 129 | return sloc[0] 130 | elif sloc.submodule_search_locations: 131 | return sloc.submodule_search_locations[0] 132 | return orig 133 | 134 | 135 | class ImprovedConsole(InteractiveConsole, object): 136 | """ 137 | Welcome to lonetwin's pimped up python prompt 138 | 139 | You've got color, tab completion, auto-indentation, pretty-printing 140 | and more ! 141 | 142 | * A tab with preceding text will attempt auto-completion of 143 | keywords, names in the current namespace, attributes and methods. 144 | If the preceding text has a '/', filename completion will be 145 | attempted. Without preceding text four spaces will be inserted. 146 | 147 | * History will be saved in {HISTFILE} when you exit. 148 | 149 | * If you create a file named {VENV_RC} in the current directory, the 150 | contents will be executed in this session before the prompt is 151 | shown. 152 | 153 | * Typing out a defined name followed by a '{DOC_CMD}' will print out 154 | the object's __doc__ attribute if one exists. 155 | (eg: []? / str? / os.getcwd? ) 156 | 157 | * Typing '{DOC_CMD}{DOC_CMD}' after something will search for the 158 | term at {DOC_URL} 159 | (eg: try webbrowser.open??) 160 | 161 | * Open the your editor with current session history, source code of 162 | objects or arbitrary files, using the '{EDIT_CMD}' command. 163 | 164 | * List source code for objects using the '{LIST_CMD}' command. 165 | 166 | * Execute shell commands using the '{SH_EXEC}' command. 167 | 168 | Try ` -h` for any of the commands to learn more. 169 | 170 | The EDITOR, SHELL, command names and more can be changed in the 171 | config dict at the top of this file. Make this your own ! 172 | """ 173 | 174 | def __init__(self, tab=' ', *args, **kwargs): 175 | self.session_history = [] # This holds the last executed statements 176 | self.buffer = [] # This holds the statement to be executed 177 | self.tab = tab 178 | self._indent = '' 179 | super(ImprovedConsole, self).__init__(*args, **kwargs) 180 | self.init_color_functions() 181 | self.init_readline() 182 | self.init_prompt() 183 | self.init_pprint() 184 | # - dict mapping commands to their handler methods 185 | self.commands = { 186 | config['EDIT_CMD']: self.process_edit_cmd, 187 | config['LIST_CMD']: self.process_list_cmd, 188 | config['SH_EXEC']: self.process_sh_cmd, 189 | config['HELP_CMD']: self.process_help_cmd, 190 | config['TOGGLE_AUTO_INDENT_CMD']: self.toggle_auto_indent 191 | } 192 | # - regex to identify and extract commands and their arguments 193 | self.commands_re = re.compile( 194 | r'({})\s*([^(]*)'.format( 195 | '|'.join(re.escape(cmd) for cmd in self.commands) 196 | ) 197 | ) 198 | 199 | def _doc_to_usage(method): 200 | def inner(self, arg): 201 | arg = arg.strip() 202 | if arg.startswith(('-h', '--help')): 203 | return self.writeline(blue(method.__doc__.strip().format(**config))) 204 | return method(self, arg) 205 | return inner 206 | 207 | def init_color_functions(self): 208 | """Populates globals dict with some helper functions for colorizing text 209 | """ 210 | def colorize(color_code, text, bold=True, readline_workaround=False): 211 | reset = '\033[0m' 212 | color = '\033[{0}{1}m'.format('1;' if bold else '', color_code) 213 | # - reason for readline_workaround: http://bugs.python.org/issue20359 214 | if readline_workaround: 215 | color = '\001{color}\002'.format(color=color) 216 | reset = '\001{reset}\002'.format(reset=reset) 217 | return "{color}{text}{reset}".format(**vars()) 218 | 219 | g = globals() 220 | for code, color in enumerate(['red', 'green', 'yellow', 'blue', 'purple', 'cyan', 'grey'], 31): 221 | g[color] = partial(colorize, code) 222 | 223 | def init_readline(self): 224 | """Activates history and tab completion 225 | """ 226 | # - mainly borrowed from site.enablerlcompleter() from py3.4+ 227 | 228 | # Reading the initialization (config) file may not be enough to set a 229 | # completion key, so we set one first and then read the file. 230 | readline_doc = getattr(readline, '__doc__', '') 231 | if readline_doc is not None and 'libedit' in readline_doc: 232 | readline.parse_and_bind('bind ^I rl_complete') 233 | else: 234 | readline.parse_and_bind('tab: complete') 235 | 236 | try: 237 | readline.read_init_file() 238 | except OSError: 239 | # An OSError here could have many causes, but the most likely one 240 | # is that there's no .inputrc file (or .editrc file in the case of 241 | # Mac OS X + libedit) in the expected location. In that case, we 242 | # want to ignore the exception. 243 | pass 244 | 245 | if readline.get_current_history_length() == 0: 246 | # If no history was loaded, default to .python_history. 247 | # The guard is necessary to avoid doubling history size at 248 | # each interpreter exit when readline was already configured 249 | # see: http://bugs.python.org/issue5845#msg198636 250 | try: 251 | readline.read_history_file(config['HISTFILE']) 252 | except IOError: 253 | pass 254 | atexit.register(readline.write_history_file, 255 | config['HISTFILE']) 256 | readline.set_history_length(config['HISTSIZE']) 257 | 258 | # - replace default completer 259 | readline.set_completer(self.improved_rlcompleter()) 260 | 261 | # - enable auto-indenting 262 | if config['AUTO_INDENT']: 263 | readline.set_pre_input_hook(self.auto_indent_hook) 264 | 265 | # - remove '/' and '~' from delimiters to help with path completion 266 | completer_delims = readline.get_completer_delims() 267 | completer_delims = completer_delims.replace('/', '') 268 | if config.get('COMPLETION_EXPANDS_TILDE'): 269 | completer_delims = completer_delims.replace('~', '') 270 | readline.set_completer_delims(completer_delims) 271 | 272 | def init_prompt(self): 273 | """Activates color on the prompt based on python version. 274 | 275 | Also adds the hosts IP if running on a remote host over a 276 | ssh connection. 277 | """ 278 | prompt_color = green if sys.version_info.major == 2 else yellow 279 | sys.ps1 = prompt_color('>>> ', readline_workaround=True) 280 | sys.ps2 = red('... ', readline_workaround=True) 281 | # - if we are over a remote connection, modify the ps1 282 | if os.getenv('SSH_CONNECTION'): 283 | _, _, this_host, _ = os.getenv('SSH_CONNECTION').split() 284 | sys.ps1 = prompt_color('[{}]>>> '.format(this_host), readline_workaround=True) 285 | sys.ps2 = red('[{}]... '.format(this_host), readline_workaround=True) 286 | 287 | def init_pprint(self): 288 | """Activates pretty-printing of output values. 289 | """ 290 | keys_re = re.compile(r'([\'\("]+(.*?[\'\)"]: ))+?') 291 | color_dict = partial(keys_re.sub, lambda m: purple(m.group())) 292 | format_func = pprint.pformat 293 | if sys.version_info.major >= 3 and sys.version_info.minor > 3: 294 | format_func = partial(pprint.pformat, compact=True) 295 | 296 | def pprint_callback(value): 297 | if value is not None: 298 | try: 299 | rows, cols = os.get_teminal_size() 300 | except AttributeError: 301 | try: 302 | rows, cols = map(int, subprocess.check_output(['stty', 'size']).split()) 303 | except: 304 | cols = 80 305 | formatted = format_func(value, width=cols) 306 | print(color_dict(formatted) if issubclass(type(value), dict) else blue(formatted)) 307 | self.locals['_'] = value 308 | 309 | sys.displayhook = pprint_callback 310 | 311 | def improved_rlcompleter(self): 312 | """Enhances the default rlcompleter 313 | 314 | The function enhances the default rlcompleter by also doing 315 | pathname completion and module name completion for import 316 | statements. Additionally, it inserts a tab instead of attempting 317 | completion if there is no preceding text. 318 | """ 319 | completer = rlcompleter.Completer(namespace=self.locals) 320 | pkglist, modlist = [], [] 321 | for _, name, ispkg in pkgutil.iter_modules(): 322 | modlist.append(name) 323 | if ispkg: 324 | pkglist.append(name) 325 | pkglist, modlist = frozenset(pkglist), frozenset(modlist) 326 | 327 | def startswith_filter(text, names, striptext=None): 328 | if striptext: 329 | return [name.replace(striptext, '') for name in names if name.startswith(text)] 330 | return [name for name in names if name.startswith(text)] 331 | 332 | def get_pkg_matches(pkg): 333 | pkg_path = find_module(pkg) 334 | return (name for _, name, _ in pkgutil.walk_packages( 335 | [pkg_path], '{}.'.format(pkg), onerror=lambda _: None 336 | )) 337 | 338 | def get_path_matches(text): 339 | return [ 340 | item+os.path.sep if os.path.isdir(item) else item 341 | for item in glob.glob('{}*'.format(text)) 342 | ] 343 | 344 | def complete_wrapper(text, state): 345 | line = readline.get_line_buffer() 346 | if line == '' or line.isspace(): 347 | return None if state > 0 else self.tab 348 | if state == 0 and line.startswith(('from ', 'import ')): 349 | words = line.split() 350 | if len(words) <= 2: 351 | # import p / from p 352 | modname, _, _ = text.partition('.') 353 | completer.matches = startswith_filter( 354 | text, (get_pkg_matches(modname) if modname in pkglist else modlist) 355 | ) 356 | 357 | if len(words) >= 2 and words[0] == 'from' and 'import'.startswith(text): 358 | # from pkg.sub im 359 | completer.matches = ['import'] 360 | 361 | if len(words) >= 3 and words[2] == 'import': 362 | # from pkg.sub import na 363 | completer.matches = [] 364 | namespace = words[1] 365 | pkg, _, _ = namespace.partition('.') 366 | if pkg in pkglist: 367 | # from pkg.sub import na 368 | match_text = '.'.join((namespace, text)) 369 | completer.matches = startswith_filter( 370 | match_text, get_pkg_matches(pkg), '{}.'.format(namespace) 371 | ) 372 | if not completer.matches: 373 | # from module import na 374 | mod = importlib.import_module(namespace) 375 | completer.matches = [ 376 | name for name in startswith_filter( 377 | text, getattr(mod, '__all__', dir(mod)) 378 | ) if not name.startswith('_') 379 | ] 380 | else: 381 | match = completer.complete(text, state) 382 | if match is None and os.path.sep in text: 383 | completer.matches = get_path_matches( 384 | os.path.expanduser(text) 385 | if config.get('COMPLETION_EXPANDS_TILDE') else text 386 | ) 387 | try: 388 | match = completer.matches[state] 389 | if keyword.iskeyword(match): 390 | if match in ('else', 'finally', 'try'): 391 | return '{}:'.format(match) 392 | return '{} '.format(match) 393 | return match 394 | except IndexError: 395 | # - if we just completed a directory, switch to matching its contents 396 | matched = completer.matches[0] 397 | if matched.endswith(os.path.sep): 398 | completer.matches = get_path_matches(matched) 399 | return completer.matches[state] if completer.matches else None 400 | return None 401 | return complete_wrapper 402 | 403 | def auto_indent_hook(self): 404 | """Hook called by readline between printing the prompt and 405 | starting to read input. 406 | """ 407 | readline.insert_text(self._indent) 408 | readline.redisplay() 409 | 410 | @_doc_to_usage 411 | def toggle_auto_indent(self, _): 412 | """{TOGGLE_AUTO_INDENT_CMD} - Toggles the auto-indentation behavior 413 | """ 414 | hook = None if config['AUTO_INDENT'] else self.auto_indent_hook 415 | msg = '# Auto-Indent has been {}abled\n'.format('en' if hook else 'dis') 416 | config['AUTO_INDENT'] = bool(hook) 417 | 418 | if hook is None: 419 | msg += ('# End of blocks will be detected after 3 empty lines\n' 420 | '# Re-type {TOGGLE_AUTO_INDENT_CMD} on a line by itself to enable') 421 | 422 | readline.set_pre_input_hook(hook) 423 | print(grey(msg.format(**config), bold=False)) 424 | return '' 425 | 426 | def raw_input(self, prompt=''): 427 | """Read the input and delegate if necessary. 428 | """ 429 | line = super(ImprovedConsole, self).raw_input(prompt) 430 | empty_lines = 3 if line else 1 431 | while not config['AUTO_INDENT'] and empty_lines < 3: 432 | line = super(ImprovedConsole, self).raw_input(prompt) 433 | empty_lines += 1 if not line else 3 434 | return self._cmd_handler(line) 435 | 436 | def _cmd_handler(self, line): 437 | matches = self.commands_re.match(line) 438 | if matches: 439 | command, args = matches.groups() 440 | line = self.commands[command](args) 441 | elif line.endswith(config['DOC_CMD']): 442 | if line.endswith(config['DOC_CMD']*2): 443 | # search for line in online docs 444 | # - strip off the '??' and the possible tab-completed 445 | # '(' or '.' and replace inner '.' with '+' to create the 446 | # query search string 447 | line = line.rstrip(config['DOC_CMD'] + '.(').replace('.', '+') 448 | webbrowser.open(config['DOC_URL'].format(sys=sys, term=line)) 449 | line = '' 450 | else: 451 | line = line.rstrip(config['DOC_CMD'] + '.(') 452 | if not line: 453 | line = 'dir()' 454 | elif keyword.iskeyword(line): 455 | line = 'help("{}")'.format(line) 456 | else: 457 | line = 'print({}.__doc__)'.format(line) 458 | elif config['AUTO_INDENT'] and (line.startswith(self.tab) or self._indent): 459 | if line.strip(): 460 | # if non empty line with an indent, check if the indent 461 | # level has been changed 462 | leading_space = line[:line.index(line.lstrip()[0])] 463 | if self._indent != leading_space: 464 | # indent level changed, update self._indent 465 | self._indent = leading_space 466 | else: 467 | # - empty line, decrease indent 468 | self._indent = self._indent[:-len(self.tab)] 469 | line = self._indent 470 | elif line.startswith('%'): 471 | self.writeline('Y U NO LIKE ME?') 472 | return line 473 | return line or '' 474 | 475 | def push(self, line): 476 | """Wrapper around InteractiveConsole's push method for adding an 477 | indent on start of a block. 478 | """ 479 | more = super(ImprovedConsole, self).push(line) 480 | if more: 481 | if line.endswith((':', '[', '{', '(')): 482 | self._indent += self.tab 483 | else: 484 | self._indent = '' 485 | return more 486 | 487 | def write(self, data): 488 | """Write out data to stderr 489 | """ 490 | sys.stderr.write(data if data.startswith('\033[') else red(data)) 491 | 492 | def writeline(self, data): 493 | """Same as write but adds a newline to the end 494 | """ 495 | return self.write('{}\n'.format(data)) 496 | 497 | def resetbuffer(self): 498 | self._indent = '' 499 | previous = '' 500 | for line in self.buffer: 501 | # - replace multiple empty lines with one before writing to session history 502 | stripped = line.strip() 503 | if stripped or stripped != previous: 504 | self.session_history.append(line) 505 | previous = stripped 506 | return super(ImprovedConsole, self).resetbuffer() 507 | 508 | 509 | def _mktemp_buffer(self, lines): 510 | """Writes lines to a temp file and returns the filename. 511 | """ 512 | with NamedTemporaryFile(mode='w+', suffix='.py', delete=False) as tempbuf: 513 | tempbuf.write('\n'.join(lines)) 514 | return tempbuf.name 515 | 516 | def showtraceback(self, *args): 517 | """Wrapper around super(..).showtraceback() 518 | 519 | We do this to detect whether any subsequent statements after a 520 | traceback occurs should be skipped. This is relevant when 521 | executing multiple statements from an edited buffer. 522 | """ 523 | self._skip_subsequent = True 524 | return super(ImprovedConsole, self).showtraceback(*args) 525 | 526 | def _exec_from_file(self, filename, quiet=False, skip_history=False, 527 | print_comments=config['POST_EDIT_PRINT_COMMENTS']): 528 | self._skip_subsequent = False 529 | previous = '' 530 | for stmt in open(filename): 531 | # - skip over multiple empty lines 532 | stripped = stmt.strip() 533 | if stripped == previous == '': 534 | continue 535 | 536 | # - if line is a comment, print (if required) and move to 537 | # next line 538 | if stripped.startswith('#'): 539 | if print_comments and not quiet: 540 | self.write(grey("... {}".format(stmt), bold=False)) 541 | continue 542 | 543 | # - process line only if we haven't encountered an error yet 544 | if not self._skip_subsequent: 545 | line = stmt.strip('\n') 546 | if line and not line[0].isspace(): 547 | # - end of previous statement, submit buffer for 548 | # execution 549 | source = "\n".join(self.buffer) 550 | more = self.runsource(source, self.filename) 551 | if not more: 552 | self.resetbuffer() 553 | 554 | if not quiet: 555 | self.write(cyan("... {}".format(stmt), bold=(not self._skip_subsequent))) 556 | 557 | if self._skip_subsequent: 558 | self.session_history.append(stmt) 559 | else: 560 | self.buffer.append(line) 561 | if not skip_history: 562 | readline.add_history(line) 563 | previous = stripped 564 | self.push('') 565 | 566 | def lookup(self, name, namespace=None): 567 | """Lookup the (dotted) object specified with the string `name` 568 | in the specified namespace or in the current namespace if 569 | unspecified. 570 | """ 571 | name, _, components = name.partition('.') 572 | obj = getattr(namespace, name, namespace) if namespace else self.locals.get(name) 573 | return self.lookup(components, obj) if components else obj 574 | 575 | @_doc_to_usage 576 | def process_edit_cmd(self, arg=''): 577 | """{EDIT_CMD} [object|filename] 578 | 579 | Open {EDITOR} with session history, provided filename or 580 | object's source file. 581 | 582 | - without arguments, a temporary file containing session history is 583 | created and opened in {EDITOR}. On quitting the editor, all 584 | the non commented lines in the file are executed, if the 585 | editor exits with a 0 return code (eg: if editor is `vim`, and 586 | you exit using `:cq`, nothing from the buffer is executed and 587 | you are returned to the prompt). 588 | 589 | - with a filename argument, the file is opened in the editor. On 590 | close, you are returned bay to the interpreter. 591 | 592 | - with an object name argument, an attempt is made to lookup the 593 | source file of the object and it is opened if found. Else the 594 | argument is treated as a filename. 595 | """ 596 | line_num_opt = '' 597 | if arg: 598 | obj = self.lookup(arg) 599 | try: 600 | if obj: 601 | filename = inspect.getsourcefile(obj) 602 | _, line_no = inspect.getsourcelines(obj) 603 | line_num_opt = config['LINE_NUM_OPT'].format(line_no=line_no) 604 | else: 605 | filename = arg 606 | 607 | except (IOError, TypeError, NameError) as e: 608 | return self.writeline(e) 609 | else: 610 | # - make a list of all lines in history, commenting any non-blank lines. 611 | history = self.session_history or open(config['HISTFILE']) 612 | filename = self._mktemp_buffer( 613 | '# {}'.format(line) if line.strip() else '' 614 | for line in (line.strip('\n') for line in history) 615 | ) 616 | 617 | # - shell out to the editor 618 | rc = os.system('{} {} {}'.format(config['EDITOR'], line_num_opt, filename)) 619 | 620 | # - if arg was not provided (ie: we edited history), execute 621 | # un-commented lines in the current namespace 622 | if not arg: 623 | if rc == 0: 624 | # - if HISTFILE contents were edited (ie: EDIT_CMD in a 625 | # brand new session), don't print commented out lines 626 | print_comments = (False if history != self.session_history 627 | else config['POST_EDIT_PRINT_COMMENTS']) 628 | self._exec_from_file(filename, print_comments=print_comments) 629 | else: 630 | self.writeline('{EDITOR} exited with an error code.' 631 | ' Skipping execution.'.format(**config)) 632 | os.unlink(filename) 633 | 634 | @_doc_to_usage 635 | def process_sh_cmd(self, cmd): 636 | """{SH_EXEC} [cmd [args ...] | {{fmt string}}] 637 | 638 | Escape to {SHELL} or execute `cmd` in {SHELL} 639 | 640 | - without arguments, the current interpreter will be suspended 641 | and you will be dropped in a {SHELL} prompt. Use fg to return. 642 | 643 | - with arguments, the text will be executed in {SHELL} and the 644 | output/error will be displayed. Additionally '_' will contain 645 | a named tuple with the (, , ) 646 | for the execution of the command. 647 | 648 | You may pass strings from the global namespace to the command 649 | line using the `.format()` syntax. for example: 650 | 651 | >>> filename = '/does/not/exist' 652 | >>> !ls {{filename}} 653 | ls: cannot access /does/not/exist: No such file or directory 654 | >>> _ 655 | CmdExec(out='', err='ls: cannot access /does/not/exist: No such file or directory\n', rc=2) 656 | """ 657 | if cmd: 658 | try: 659 | cmd = cmd.format(**self.locals) 660 | cmd = shlex.split(cmd) 661 | if cmd[0] == 'cd': 662 | os.chdir(os.path.expanduser(os.path.expandvars(' '.join(cmd[1:]) or '${HOME}'))) 663 | else: 664 | cmd_exec = namedtuple('CmdExec', ['out', 'err', 'rc']) 665 | process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 666 | (out, err), rc = (process.communicate(), process.returncode) 667 | print (red(err.decode('utf-8')) if err else green(out.decode('utf-8'), bold=False)) 668 | self.locals['_'] = cmd_exec(out, err, rc) 669 | del cmd_exec 670 | except: 671 | self.showtraceback() 672 | else: 673 | if os.getenv('SSH_CONNECTION'): 674 | # I use the bash function similar to the one below in my 675 | # .bashrc to directly open a python prompt on remote 676 | # systems I log on to. 677 | # function rpython { ssh -t $1 -- "python" } 678 | # Unfortunately, suspending this ssh session, does not place me 679 | # in a shell, so I need to create one: 680 | os.system(config['SHELL']) 681 | else: 682 | os.kill(os.getpgrp(), signal.SIGSTOP) 683 | 684 | @_doc_to_usage 685 | def process_list_cmd(self, arg): 686 | """{LIST_CMD} - List source code for object, if possible. 687 | """ 688 | if not arg: 689 | return self.writeline('source list command requires an argument ' 690 | '(eg: {} foo)'.format(config['LIST_CMD'])) 691 | try: 692 | src_lines, offset = inspect.getsourcelines(self.lookup(arg)) 693 | except (IOError, TypeError, NameError) as e: 694 | self.writeline(e) 695 | else: 696 | for line_no, line in enumerate(src_lines, offset+1): 697 | self.write(cyan("{0:03d}: {1}".format(line_no, line))) 698 | 699 | def process_help_cmd(self, arg): 700 | if arg: 701 | if keyword.iskeyword(arg): 702 | self.push('help("{}")'.format(arg)) 703 | elif arg in self.commands: 704 | self.commands[arg]('-h') 705 | else: 706 | self.push('help({})'.format(arg)) 707 | else: 708 | print(cyan(self.__doc__).format(**config)) 709 | 710 | def interact(self): 711 | """A forgiving wrapper around InteractiveConsole.interact() 712 | """ 713 | venv_rc_done = cyan('(no venv rc found)') 714 | try: 715 | self._exec_from_file(config['VENV_RC'], quiet=True, skip_history=True) 716 | # - clear out session_history for venv_rc commands 717 | self.session_history = [] 718 | venv_rc_done = green('Successfully executed venv rc !') 719 | except IOError: 720 | pass 721 | 722 | banner = ("Welcome to the ImprovedConsole (version {version})\n" 723 | "Type in {HELP_CMD} for list of features.\n" 724 | "{venv_rc_done}").format( 725 | version=__version__, venv_rc_done=venv_rc_done, **config) 726 | 727 | retries = 2 728 | while retries: 729 | try: 730 | super(ImprovedConsole, self).interact(banner=banner) 731 | except SystemExit: 732 | # Fixes #2: exit when 'quit()' invoked 733 | break 734 | except: 735 | import traceback 736 | retries -= 1 737 | print(red("I'm sorry, ImprovedConsole could not handle that !\n" 738 | "Please report an error with this traceback, " 739 | "I would really appreciate that !")) 740 | traceback.print_exc() 741 | 742 | print(red("I shall try to restore the crashed session.\n" 743 | "If the crash occurs again, please exit the session")) 744 | banner = blue("Your crashed session has been restored") 745 | else: 746 | # exit with a Ctrl-D 747 | break 748 | 749 | # Exit the Python shell on exiting the InteractiveConsole 750 | sys.exit() 751 | 752 | 753 | if not os.getenv('SKIP_PYMP'): 754 | # - create our pimped out console and fire it up ! 755 | pymp = ImprovedConsole(locals=CLEAN_NS) 756 | pymp.interact() 757 | -------------------------------------------------------------------------------- /pythonrc.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # The MIT License (MIT) 4 | # 5 | # Copyright (c) 2015-2021 Steven Fernandez 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in all 15 | # copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | # SOFTWARE. 24 | 25 | # Keep a copy of the initial namespace, we'll need it later 26 | CLEAN_NS = globals().copy() 27 | 28 | """pymp - lonetwin's pimped-up pythonrc 29 | 30 | This file will be executed when the Python interactive shell is started, if 31 | $PYTHONSTARTUP is in your environment and points to this file. You could 32 | also make this file executable and call it directly. 33 | 34 | This file creates an InteractiveConsole instance, which provides: 35 | * execution history 36 | * colored prompts and pretty printing 37 | * auto-indentation 38 | * intelligent tab completion:¹ 39 | * source code listing for objects 40 | * session history editing using your $EDITOR, as well as editing of 41 | source files for objects or regular files 42 | * temporary escape to $SHELL or ability to execute a shell command and 43 | capturing the result into the '_' variable 44 | * convenient printing of doc stings and search for entries in online docs 45 | * auto-execution of a virtual env specific (`.venv_rc.py`) file at startup 46 | 47 | If you have any other good ideas please feel free to submit issues/pull requests. 48 | 49 | ¹ Since python 3.4 the default interpreter also has tab completion 50 | enabled however it does not do pathname completion 51 | """ 52 | 53 | 54 | # Fix for Issue #5 55 | # - Exit if being called from within ipython 56 | try: 57 | import sys 58 | 59 | __IPYTHON__ and sys.exit(0) # type: ignore 60 | except NameError: 61 | pass 62 | 63 | import ast 64 | import asyncio 65 | import atexit 66 | import concurrent 67 | import glob 68 | import importlib 69 | import inspect 70 | import keyword 71 | import os 72 | import pkgutil 73 | import pprint 74 | import re 75 | import readline 76 | import rlcompleter 77 | import shlex 78 | import signal 79 | import subprocess 80 | import threading 81 | import warnings 82 | import webbrowser 83 | from code import InteractiveConsole 84 | from functools import cached_property, lru_cache, partial 85 | from itertools import chain 86 | from operator import attrgetter 87 | from tempfile import NamedTemporaryFile 88 | from types import FunctionType, SimpleNamespace 89 | 90 | __version__ = "0.9.0" 91 | 92 | 93 | config = SimpleNamespace( 94 | ONE_INDENT=" ", # what should we use for indentation ? 95 | HISTFILE=os.path.expanduser("~/.python_history"), 96 | HISTSIZE=-1, 97 | EDITOR=os.getenv("EDITOR", "vi"), 98 | SHELL=os.getenv("SHELL", "/bin/bash"), 99 | EDIT_CMD=r"\e", 100 | SH_EXEC="!", 101 | DOC_CMD="?", 102 | DOC_URL="https://docs.python.org/{sys.version_info.major}/search.html?q={term}", 103 | HELP_CMD=r"\h", 104 | LIST_CMD=r"\l", 105 | AUTO_INDENT=True, # - Should we auto-indent by default 106 | VENV_RC=os.getenv("VENV_RC", ".venv_rc.py"), 107 | # - option to pass to the editor to open a file at a specific 108 | # `line_no`. This is used when the EDIT_CMD is invoked with a python 109 | # object to open the source file for the object. 110 | LINE_NUM_OPT="+{line_no}", 111 | # - Run-time toggle for auto-indent command (eg: when pasting code) 112 | TOGGLE_AUTO_INDENT_CMD=r"\\", 113 | # - should path completion expand ~ using os.path.expanduser() 114 | COMPLETION_EXPANDS_TILDE=True, 115 | # - when executing edited history, should we also print comments 116 | POST_EDIT_PRINT_COMMENTS=True, 117 | # - Attempt to auto-import top-level module names on NameError 118 | ENABLE_AUTO_IMPORTS=True, 119 | # - Start/Stop the asyncio loop in the interpreter (similar to `python -m asyncio`) 120 | TOGGLE_ASYNCIO_LOOP_CMD=r"\A", 121 | ) 122 | 123 | # Color functions. These get initialized in init_color_functions() later 124 | red = green = yellow = blue = purple = cyan = grey = str 125 | 126 | 127 | class ImprovedCompleter(rlcompleter.Completer): 128 | """A smarter rlcompleter.Completer""" 129 | 130 | def __init__(self, namespace=None): 131 | super().__init__(namespace) 132 | # - remove '/' and '~' from delimiters to help with path completion 133 | completer_delims = readline.get_completer_delims() 134 | completer_delims = completer_delims.replace("/", "") 135 | if config.COMPLETION_EXPANDS_TILDE: 136 | completer_delims = completer_delims.replace("~", "") 137 | readline.set_completer_delims(completer_delims) 138 | self.matches = [] 139 | 140 | @lru_cache(None) 141 | def pkg_contents(self, pkg): 142 | """Given a package name, return a list of it's sub-modules.""" 143 | spec = importlib.util.find_spec(pkg) 144 | locs = [spec.origin] if not spec.parent else spec.submodule_search_locations 145 | return [ 146 | pkg.name 147 | for pkg in pkgutil.walk_packages(locs, f"{pkg}.", onerror=lambda _: None) 148 | ] 149 | 150 | @cached_property 151 | def pkglist(self): 152 | return frozenset(item.name for item in pkgutil.iter_modules() if item.ispkg) 153 | 154 | @cached_property 155 | def modlist(self): 156 | modlist = chain( 157 | sys.builtin_module_names, map(attrgetter("name"), pkgutil.iter_modules()) 158 | ) 159 | return frozenset(name for name in modlist if not name.startswith("_")) 160 | 161 | def exceptions(self, exc_cls=Exception): 162 | exc_names = [exc.__name__ for exc in exc_cls.__subclasses__()] 163 | for sub_cls in exc_cls.__subclasses__(): 164 | exc_names.extend(self.exceptions(sub_cls)) 165 | return exc_names 166 | 167 | def startswith_filter(self, text, names, striptext=None): 168 | filtered = [name for name in names if name.startswith(text)] 169 | if striptext: 170 | return [name.replace(striptext, "") for name in filtered] 171 | return filtered 172 | 173 | def get_path_matches(self, text): 174 | return [ 175 | f"{item}{os.path.sep}" if os.path.isdir(item) else item 176 | for item in glob.iglob(f"{text}**") 177 | ] 178 | 179 | def get_import_matches(self, text, words): 180 | if any( 181 | [ 182 | (len(words) == 2 and not text), 183 | (len(words) == 3 and text and "import".startswith(text)), 184 | ] 185 | ): 186 | return ["import "] 187 | 188 | if len(words) <= 2: 189 | # import p / from p 190 | modname, _, _ = text.partition(".") 191 | if modname in self.pkglist: 192 | return self.startswith_filter(text, self.pkg_contents(modname)) 193 | return self.startswith_filter(text, self.modlist) 194 | 195 | if len(words) >= 3 and words[2] == "import": 196 | # from pkg.sub import na 197 | namespace = words[1] 198 | pkg, _, _ = namespace.partition(".") 199 | if pkg in self.pkglist: 200 | # from pkg.sub import na 201 | match_text = ".".join((namespace, text)) 202 | if matches := self.startswith_filter( 203 | match_text, self.pkg_contents(pkg), striptext=f"{namespace}." 204 | ): 205 | return matches 206 | 207 | # from module import na 208 | mod = importlib.import_module(namespace) 209 | return self.startswith_filter(text, getattr(mod, "__all__", dir(mod))) 210 | 211 | def complete(self, text, state, line=None): 212 | if not line: 213 | line = readline.get_line_buffer() 214 | 215 | if line == "" or line.isspace(): 216 | return None if state else config.ONE_INDENT 217 | 218 | words = line.split() 219 | if state == 0: 220 | # - this is the first completion is being attempted for 221 | # text, we need to populate self.matches, just like 222 | # super().complete() 223 | if line.startswith(("from ", "import ")): 224 | self.matches = self.get_import_matches(text, words) 225 | elif words[0] in ("raise", "except"): 226 | self.matches = self.startswith_filter( 227 | text.lstrip("("), self.exceptions() 228 | ) 229 | elif os.path.sep in text: 230 | self.matches = self.get_path_matches( 231 | os.path.expanduser(text) 232 | if config.COMPLETION_EXPANDS_TILDE 233 | else text 234 | ) 235 | elif "." in text: 236 | self.matches = self.attr_matches(text) 237 | else: 238 | self.matches = self.global_matches(text) 239 | 240 | if len(self.matches) == 1: 241 | match = self.matches[0] 242 | if keyword.iskeyword(match) and match in ("raise", "except"): 243 | self.matches.extend(self.exceptions()) 244 | 245 | if match and match.endswith(os.path.sep): 246 | self.matches.extend(self.get_path_matches(match)) 247 | 248 | try: 249 | return self.matches[state] 250 | except IndexError: 251 | return None 252 | 253 | 254 | def _doc_to_usage(method): 255 | def inner(self, arg): 256 | arg = arg.strip() 257 | if arg.startswith(("-h", "--help")): 258 | return self.writeline(blue(method.__doc__.strip())) 259 | return method(self, arg) 260 | 261 | return inner 262 | 263 | 264 | class ImprovedConsole(InteractiveConsole): 265 | """ 266 | Welcome to lonetwin's pimped up python prompt 267 | 268 | You've got color, tab completion, auto-indentation, pretty-printing 269 | and more ! 270 | 271 | * A tab with preceding text will attempt auto-completion of 272 | keywords, names in the current namespace, attributes and methods. 273 | If the preceding text has a '/', filename completion will be 274 | attempted. Without preceding text four spaces will be inserted. 275 | 276 | * History will be saved in {HISTFILE} when you exit. 277 | 278 | * If you create a file named {VENV_RC} in the current directory, the 279 | contents will be executed in this session before the prompt is 280 | shown. 281 | 282 | * Typing out a defined name followed by a '{DOC_CMD}' will print out 283 | the object's __doc__ attribute if one exists. 284 | (eg: []? / str? / os.getcwd? ) 285 | 286 | * Typing '{DOC_CMD}{DOC_CMD}' after something will search for the 287 | term at {DOC_URL} 288 | (eg: try webbrowser.open??) 289 | 290 | * Open the your editor with current session history, source code of 291 | objects or arbitrary files, using the '{EDIT_CMD}' command. 292 | 293 | * List source code for objects using the '{LIST_CMD}' command. 294 | 295 | * Execute shell commands using the '{SH_EXEC}' command. 296 | 297 | Try ` -h` for any of the commands to learn more. 298 | 299 | The EDITOR, SHELL, command names and more can be changed in the 300 | config declaration at the top of this file. Make this your own ! 301 | """ 302 | 303 | def __init__(self, *args, **kwargs): 304 | self.session_history = [] # This holds the last executed statements 305 | self.buffer = [] # This holds the statement to be executed 306 | self._indent = "" 307 | self.loop = None 308 | super(ImprovedConsole, self).__init__(*args, **kwargs) 309 | 310 | self.init_color_functions() 311 | self.init_readline() 312 | self.init_prompt() 313 | self.init_pprint() 314 | # - dict mapping commands to their handler methods 315 | self.commands = { 316 | config.EDIT_CMD: self.process_edit_cmd, 317 | config.LIST_CMD: self.process_list_cmd, 318 | config.SH_EXEC: self.process_sh_cmd, 319 | config.HELP_CMD: self.process_help_cmd, 320 | config.TOGGLE_AUTO_INDENT_CMD: self.toggle_auto_indent, 321 | config.TOGGLE_ASYNCIO_LOOP_CMD: self.toggle_asyncio, 322 | } 323 | # - regex to identify and extract commands and their arguments 324 | self.commands_re = re.compile( 325 | r"(?P{})\s*(?P[^(]*)".format( 326 | "|".join(re.escape(cmd) for cmd in self.commands) 327 | ) 328 | ) 329 | 330 | def init_color_functions(self): 331 | """Populates globals dict with some helper functions for colorizing text""" 332 | 333 | def colorize(color_code, text, bold=True, readline_workaround=False): 334 | reset = "\033[0m" 335 | color = "\033[{0}{1}m".format("1;" if bold else "", color_code) 336 | # - reason for readline_workaround: http://bugs.python.org/issue20359 337 | if readline_workaround: 338 | return f"\001{color}\002{text}\001{reset}\002" 339 | return f"{color}{text}{reset}" 340 | 341 | g = globals() 342 | for code, color in enumerate( 343 | ["red", "green", "yellow", "blue", "purple", "cyan", "grey"], 31 344 | ): 345 | g[color] = partial(colorize, code) 346 | 347 | def init_readline(self): 348 | """Activates history and tab completion""" 349 | # - 1. history stuff 350 | # - mainly borrowed from site.enablerlcompleter() from py3.4+, 351 | # we can't simply call site.enablerlcompleter() because its 352 | # implementation overwrites the history file for each python 353 | # session whereas we prefer appending history from every 354 | # (potentially concurrent) session. 355 | 356 | # Reading the initialization (config) file may not be enough to set a 357 | # completion key, so we set one first and then read the file. 358 | readline_doc = getattr(readline, "__doc__", "") 359 | if readline_doc is not None and "libedit" in readline_doc: 360 | readline.parse_and_bind("bind ^I rl_complete") 361 | else: 362 | readline.parse_and_bind("tab: complete") 363 | 364 | try: 365 | readline.read_init_file() 366 | except OSError: 367 | # An OSError here could have many causes, but the most likely one 368 | # is that there's no .inputrc file (or .editrc file in the case of 369 | # Mac OS X + libedit) in the expected location. In that case, we 370 | # want to ignore the exception. 371 | pass 372 | 373 | def append_history(len_at_start): 374 | current_len = readline.get_current_history_length() 375 | readline.append_history_file(current_len - len_at_start, config.HISTFILE) 376 | 377 | if readline.get_current_history_length() == 0: 378 | # If no history was loaded, default to .python_history. 379 | # The guard is necessary to avoid doubling history size at 380 | # each interpreter exit when readline was already configured 381 | # see: http://bugs.python.org/issue5845#msg198636 382 | try: 383 | readline.read_history_file(config.HISTFILE) 384 | except IOError: 385 | pass 386 | len_at_start = readline.get_current_history_length() 387 | atexit.register(append_history, len_at_start) 388 | 389 | readline.set_history_length(config.HISTSIZE) 390 | 391 | # - 2. enable auto-indenting 392 | if config.AUTO_INDENT: 393 | readline.set_pre_input_hook(self.auto_indent_hook) 394 | 395 | # - 3. completion 396 | # - replace default completer 397 | self.completer = ImprovedCompleter(self.locals) 398 | readline.set_completer(self.completer.complete) 399 | 400 | def init_prompt(self, nested=False): 401 | """Activates color on the prompt based on python version. 402 | 403 | Also adds the hosts IP if running on a remote host over a 404 | ssh connection. 405 | """ 406 | prompt_color = green if sys.version_info.major == 2 else yellow 407 | sys.ps1 = prompt_color(">=> " if nested else ">>> ", readline_workaround=True) 408 | sys.ps2 = red("... ", readline_workaround=True) 409 | # - if we are over a remote connection, modify the ps1 410 | if os.getenv("SSH_CONNECTION"): 411 | _, _, this_host, _ = os.getenv("SSH_CONNECTION").split() 412 | sys.ps1 = prompt_color(f"[{this_host}]>>> ", readline_workaround=True) 413 | sys.ps2 = red(f"[{this_host}]... ", readline_workaround=True) 414 | 415 | def init_pprint(self): 416 | """Activates pretty-printing of output values.""" 417 | keys_re = re.compile(r'([\'\("]+(.*?[\'\)"]: ))+?') 418 | color_dict = partial(keys_re.sub, lambda m: purple(m.group())) 419 | format_func = pprint.pformat 420 | if sys.version_info.major >= 3 and sys.version_info.minor > 3: 421 | format_func = partial(pprint.pformat, compact=True) 422 | 423 | def pprint_callback(value): 424 | if value is not None: 425 | try: 426 | rows, cols = os.get_teminal_size() 427 | except AttributeError: 428 | try: 429 | rows, cols = map( 430 | int, subprocess.check_output(["stty", "size"]).split() 431 | ) 432 | except Exception: 433 | cols = 80 434 | formatted = format_func(value, width=cols) 435 | print( 436 | color_dict(formatted) 437 | if issubclass(type(value), dict) 438 | else blue(formatted) 439 | ) 440 | self.locals["_"] = value 441 | 442 | sys.displayhook = pprint_callback 443 | 444 | def _init_nested_repl(self): 445 | self.loop = asyncio.new_event_loop() 446 | asyncio.set_event_loop(self.loop) 447 | self.compile.compiler.flags |= ast.PyCF_ALLOW_TOP_LEVEL_AWAIT 448 | self.locals["asyncio"] = asyncio 449 | self.locals["repl_future"] = None 450 | self.locals["repl_future_interrupted"] = False 451 | self.runcode = self.runcode_async 452 | 453 | def repl_thread(): 454 | try: 455 | self.init_prompt(nested=True) 456 | self.interact( 457 | banner=( 458 | "An asyncio loop has been started in the main thread.\n" 459 | "This nested interpreter is now running in a separate thread.\n" 460 | f"Use {config.TOGGLE_ASYNCIO_LOOP_CMD} to stop the asyncio loop " 461 | "and simply exit this nested interpreter to stop this thread\n" 462 | ), 463 | exitmsg="now exiting nested REPL...\n", 464 | ) 465 | finally: 466 | warnings.filterwarnings( 467 | "ignore", 468 | message=r"^coroutine .* was never awaited$", 469 | category=RuntimeWarning, 470 | ) 471 | if self.loop and self.loop.is_running(): 472 | self.loop.call_soon_threadsafe(self._stop_asyncio_loop) 473 | 474 | self.init_prompt() 475 | 476 | self.repl_thread = threading.Thread(target=repl_thread) 477 | self.repl_thread.start() 478 | 479 | def _start_asyncio_loop(self): 480 | self.locals["repl_future"] = None 481 | self.locals["repl_future_interrupted"] = False 482 | self.runcode = self.runcode_async 483 | 484 | while self.loop is not None: 485 | try: 486 | self.loop.run_forever() 487 | except KeyboardInterrupt: 488 | if ( 489 | repl_future := self.locals["repl_future"] 490 | ) and not repl_future.done(): 491 | repl_future.cancel() 492 | self.locals["repl_future_interrupted"] = True 493 | 494 | def _stop_asyncio_loop(self): 495 | self.loop.stop() 496 | del self.locals["repl_future"] 497 | del self.locals["repl_future_interrupted"] 498 | self.runcode = self.runcode_sync 499 | self.loop = None 500 | self.writeline( 501 | grey( 502 | "Stopped the asyncio loop. " 503 | f"Use {config.TOGGLE_ASYNCIO_LOOP_CMD} to restart it." 504 | ) 505 | ) 506 | 507 | @_doc_to_usage 508 | def toggle_asyncio(self, _): 509 | """{config.TOGGLE_ASYNCIO_LOOP_CMD} - Starts/stops the asyncio loop 510 | 511 | Configures the interpreter in a similar manner to `python -m asyncio` 512 | """ 513 | if self.loop is None: 514 | self._init_nested_repl() 515 | self._start_asyncio_loop() 516 | elif not self.loop.is_running(): 517 | self.writeline(grey("Restarting previously stopped asyncio loop")) 518 | self._start_asyncio_loop() 519 | else: 520 | if ( 521 | repl_future := self.locals.get("repl_future", None) 522 | ) and not repl_future.done(): 523 | repl_future.cancel() 524 | 525 | self.loop.call_soon_threadsafe(self._stop_asyncio_loop) 526 | 527 | def auto_indent_hook(self): 528 | """Hook called by readline between printing the prompt and 529 | starting to read input. 530 | """ 531 | readline.insert_text(self._indent) 532 | readline.redisplay() 533 | 534 | @_doc_to_usage 535 | def toggle_auto_indent(self, _): 536 | """{config.TOGGLE_AUTO_INDENT_CMD} - Toggles the auto-indentation behavior""" 537 | hook = None if config.AUTO_INDENT else self.auto_indent_hook 538 | msg = "# Auto-Indent has been {}abled\n".format("en" if hook else "dis") 539 | config.AUTO_INDENT = bool(hook) 540 | 541 | if hook is None: 542 | msg += ( 543 | "# End of blocks will be detected after 3 empty lines\n" 544 | f"# Re-type {config.TOGGLE_AUTO_INDENT_CMD} on a line by itself to enable" 545 | ) 546 | 547 | readline.set_pre_input_hook(hook) 548 | print(grey(msg, bold=False)) 549 | return "" 550 | 551 | def raw_input(self, prompt=""): 552 | """Read the input and delegate if necessary.""" 553 | line = super(ImprovedConsole, self).raw_input(prompt) 554 | empty_lines = 3 if line else 1 555 | while not config.AUTO_INDENT and empty_lines < 3: 556 | line = super(ImprovedConsole, self).raw_input(prompt) 557 | empty_lines += 1 if not line else 3 558 | return self._cmd_handler(line) 559 | 560 | def _cmd_handler(self, line): 561 | if matches := self.commands_re.match(line): 562 | command, args = matches.groups() 563 | line = self.commands[command](args) 564 | elif line.endswith(config.DOC_CMD): 565 | if line.endswith(config.DOC_CMD * 2): 566 | # search for line in online docs 567 | # - strip off the '??' and the possible tab-completed 568 | # '(' or '.' and replace inner '.' with '+' to create the 569 | # search query string 570 | line = line.rstrip(f"{config.DOC_CMD}.(").replace(".", "+") 571 | webbrowser.open(config.DOC_URL.format(sys=sys, term=line)) 572 | line = "" 573 | else: 574 | line = line.rstrip(f"{config.DOC_CMD}.(") 575 | if not line: 576 | line = "dir()" 577 | elif keyword.iskeyword(line): 578 | line = f'help("{line}")' 579 | else: 580 | line = f"print({line}.__doc__)" 581 | elif config.AUTO_INDENT and ( 582 | line.startswith(config.ONE_INDENT) or self._indent 583 | ): 584 | if line.strip(): 585 | # if non empty line with an indent, check if the indent 586 | # level has been changed 587 | leading_space = line[: line.index(line.lstrip()[0])] 588 | if self._indent != leading_space: 589 | # indent level changed, update self._indent 590 | self._indent = leading_space 591 | else: 592 | # - empty line, decrease indent 593 | self._indent = self._indent[: -len(config.ONE_INDENT)] 594 | line = self._indent 595 | elif line.startswith("%"): 596 | self.writeline("Y U NO LIKE ME?") 597 | return line 598 | return line or "" 599 | 600 | def push(self, line): 601 | """Wrapper around InteractiveConsole's push method for adding an 602 | indent on start of a block. 603 | """ 604 | if more := super(ImprovedConsole, self).push(line): 605 | if line.endswith((":", "[", "{", "(")): 606 | self._indent += config.ONE_INDENT 607 | else: 608 | self._indent = "" 609 | return more 610 | 611 | def runcode_async(self, code): 612 | future = concurrent.futures.Future() 613 | 614 | def callback(): 615 | self.locals["repl_future"] = None 616 | self.locals["repl_future_interrupted"] = False 617 | 618 | func = FunctionType(code, self.locals) 619 | try: 620 | coro = func() 621 | except SystemExit: 622 | raise 623 | except BaseException as ex: 624 | if isinstance(ex, KeyboardInterrupt): 625 | self.locals["repl_future_interrupted"] = True 626 | future.set_exception(ex) 627 | return 628 | 629 | if not inspect.iscoroutine(coro): 630 | future.set_result(coro) 631 | return 632 | 633 | try: 634 | self.locals["repl_future"] = self.loop.create_task(coro) 635 | asyncio.futures._chain_future(self.locals["repl_future"], future) 636 | except BaseException as exc: 637 | future.set_exception(exc) 638 | 639 | self.loop.call_soon_threadsafe(callback) 640 | 641 | try: 642 | return future.result() 643 | except SystemExit: 644 | raise 645 | except BaseException: 646 | if self.locals["repl_future_interrupted"]: 647 | self.write("\nKeyboardInterrupt\n") 648 | else: 649 | self.showtraceback() 650 | 651 | def runcode_sync(self, code): 652 | """Wrapper around super().runcode() to enable auto-importing""" 653 | 654 | if not config.ENABLE_AUTO_IMPORTS: 655 | return super().runcode(code) 656 | 657 | try: 658 | exec(code, self.locals) 659 | except NameError as err: 660 | if match := re.search(r"'(\w+)' is not defined", err.args[0]): 661 | name = match.group(1) 662 | if name in self.completer.modlist: 663 | mod = importlib.import_module(name) 664 | print(grey(f"# imported undefined module: {name}", bold=False)) 665 | self.locals[name] = mod 666 | return self.runcode(code) 667 | self.showtraceback() 668 | except SystemExit: 669 | raise 670 | except Exception: 671 | self.showtraceback() 672 | 673 | runcode = runcode_sync 674 | 675 | def write(self, data): 676 | """Write out data to stderr""" 677 | sys.stderr.write(data if data.startswith("\033[") else red(data)) 678 | 679 | def writeline(self, data): 680 | """Same as write but adds a newline to the end""" 681 | return self.write(f"{data}\n") 682 | 683 | def resetbuffer(self): 684 | self._indent = previous = "" 685 | for line in self.buffer: 686 | # - replace multiple empty lines with one before writing to session history 687 | stripped = line.strip() 688 | if stripped or stripped != previous: 689 | self.session_history.append(line) 690 | previous = stripped 691 | return super(ImprovedConsole, self).resetbuffer() 692 | 693 | def _mktemp_buffer(self, lines): 694 | """Writes lines to a temp file and returns the filename.""" 695 | with NamedTemporaryFile(mode="w+", suffix=".py", delete=False) as tempbuf: 696 | tempbuf.write("\n".join(lines)) 697 | return tempbuf.name 698 | 699 | def showtraceback(self, *args): 700 | """Wrapper around super(..).showtraceback() 701 | 702 | We do this to detect whether any subsequent statements after a 703 | traceback occurs should be skipped. This is relevant when 704 | executing multiple statements from an edited buffer. 705 | """ 706 | self._skip_subsequent = True 707 | return super(ImprovedConsole, self).showtraceback(*args) 708 | 709 | def _exec_from_file( 710 | self, 711 | open_fd, 712 | quiet=False, 713 | skip_history=False, 714 | print_comments=config.POST_EDIT_PRINT_COMMENTS, 715 | ): 716 | self._skip_subsequent = False 717 | previous = "" 718 | for stmt in open_fd: 719 | # - skip over multiple empty lines 720 | stripped = stmt.strip() 721 | if stripped == previous == "": 722 | continue 723 | 724 | # - if line is a comment, print (if required) and move to 725 | # next line 726 | if stripped.startswith("#"): 727 | if print_comments and not quiet: 728 | self.write(grey(f"... {stmt}", bold=False)) 729 | continue 730 | 731 | # - process line only if we haven't encountered an error yet 732 | if not self._skip_subsequent: 733 | line = stmt.strip("\n") 734 | if line and not line[0].isspace(): 735 | # - end of previous statement, submit buffer for 736 | # execution 737 | source = "\n".join(self.buffer) 738 | more = self.runsource(source, self.filename) 739 | if not more: 740 | self.resetbuffer() 741 | 742 | if not quiet: 743 | self.write(cyan(f"... {stmt}", bold=(not self._skip_subsequent))) 744 | 745 | if self._skip_subsequent: 746 | self.session_history.append(stmt) 747 | else: 748 | self.buffer.append(line) 749 | if not skip_history: 750 | readline.add_history(line) 751 | previous = stripped 752 | self.push("") 753 | 754 | def lookup(self, name, namespace=None): 755 | """Lookup the (dotted) object specified with the string `name` 756 | in the specified namespace or in the current namespace if 757 | unspecified. 758 | """ 759 | name, _, components = name.partition(".") 760 | obj = ( 761 | getattr(namespace, name, namespace) if namespace else self.locals.get(name) 762 | ) 763 | return self.lookup(components, obj) if components else obj 764 | 765 | @_doc_to_usage 766 | def process_edit_cmd(self, arg=""): 767 | """{config.EDIT_CMD} [object|filename] 768 | 769 | Open {config.EDITOR} with session history, provided filename or 770 | object's source file. 771 | 772 | - without arguments, a temporary file containing session history is 773 | created and opened in {config.EDITOR}. On quitting the editor, all 774 | the non commented lines in the file are executed, if the 775 | editor exits with a 0 return code (eg: if editor is `vim`, and 776 | you exit using `:cq`, nothing from the buffer is executed and 777 | you are returned to the prompt). 778 | 779 | - with a filename argument, the file is opened in the editor. On 780 | close, you are returned bay to the interpreter. 781 | 782 | - with an object name argument, an attempt is made to lookup the 783 | source file of the object and it is opened if found. Else the 784 | argument is treated as a filename. 785 | """ 786 | line_num_opt = "" 787 | if arg: 788 | try: 789 | if obj := self.lookup(arg): 790 | filename = inspect.getsourcefile(obj) 791 | _, line_no = inspect.getsourcelines(obj) 792 | line_num_opt = config.LINE_NUM_OPT.format(line_no=line_no) 793 | else: 794 | filename = arg 795 | except (IOError, TypeError, NameError) as e: 796 | return self.writeline(e) 797 | else: 798 | # - make a list of all lines in history, commenting any non-blank lines. 799 | if not (history := self.session_history): 800 | history = open(config.HISTFILE).readlines() 801 | filename = self._mktemp_buffer( 802 | f"# {line}" if line.strip() else "" 803 | for line in (line.strip("\n") for line in history) 804 | ) 805 | line_num_opt = config.LINE_NUM_OPT.format(line_no=len(history)) 806 | 807 | # - shell out to the editor 808 | rc = os.system(f"{config.EDITOR} {line_num_opt} {filename}") 809 | 810 | # - if arg was not provided (ie: we edited history), execute 811 | # un-commented lines in the current namespace 812 | if not arg: 813 | if rc == 0: 814 | # - if HISTFILE contents were edited (ie: EDIT_CMD in a 815 | # brand new session), don't print commented out lines 816 | print_comments = ( 817 | False 818 | if history != self.session_history 819 | else config.POST_EDIT_PRINT_COMMENTS 820 | ) 821 | with open(filename) as edits: 822 | self._exec_from_file(edits, print_comments=print_comments) 823 | else: 824 | self.writeline( 825 | f"{config.EDITOR} exited with an error code. Skipping execution." 826 | ) 827 | os.unlink(filename) 828 | 829 | @_doc_to_usage 830 | def process_sh_cmd(self, cmd): 831 | """{config.SH_EXEC} [cmd [args ...] | {{fmt string}}] 832 | 833 | Escape to {config.SHELL} or execute `cmd` in {config.SHELL} 834 | 835 | - without arguments, the current interpreter will be suspended 836 | and you will be dropped in a {config.SHELL} prompt. Use fg to return. 837 | 838 | - with arguments, the text will be executed in {config.SHELL} and the 839 | output/error will be displayed. Additionally '_' will contain 840 | a named tuple with the (, , ) 841 | for the execution of the command. 842 | 843 | You may pass strings from the global namespace to the command 844 | line using the `.format()` syntax. for example: 845 | 846 | >>> filename = '/does/not/exist' 847 | >>> !ls {{filename}} 848 | ls: cannot access /does/not/exist: No such file or directory 849 | >>> _ 850 | CompletedProcess(arg=['ls'], returncode=0, stdout=b'', stderr=b'ls: 851 | cannot access /does/not/exist: No such file or directory\n') 852 | """ 853 | if cmd: 854 | try: 855 | cmd = cmd.format(**self.locals) 856 | cmd = shlex.split(cmd) 857 | if cmd[0] == "cd": 858 | os.chdir( 859 | os.path.expanduser( 860 | os.path.expandvars(" ".join(cmd[1:]) or "${HOME}") 861 | ) 862 | ) 863 | else: 864 | completed = subprocess.run( 865 | cmd, capture_output=True, env=os.environ, text=True 866 | ) 867 | out, rc = completed.stdout, completed.returncode 868 | print(red(out) if rc else green(out, bold=False)) 869 | self.locals["_"] = completed 870 | except Exception: 871 | self.showtraceback() 872 | else: 873 | if os.getenv("SSH_CONNECTION"): 874 | # I use the bash function similar to the one below in my 875 | # .bashrc to directly open a python prompt on remote 876 | # systems I log on to. 877 | # function rpython { ssh -t $1 -- "python" } 878 | # Unfortunately, suspending this ssh session, does not place me 879 | # in a shell, so I need to create one: 880 | os.system(config.SHELL) 881 | else: 882 | os.kill(os.getpgrp(), signal.SIGSTOP) 883 | 884 | @_doc_to_usage 885 | def process_list_cmd(self, arg): 886 | """{config.LIST_CMD} - List source code for object, if possible.""" 887 | if not arg: 888 | return self.writeline( 889 | "source list command requires an " 890 | f"argument (eg: {config.LIST_CMD} foo)" 891 | ) 892 | try: 893 | src_lines, offset = inspect.getsourcelines(self.lookup(arg)) 894 | except (IOError, TypeError, NameError) as e: 895 | self.writeline(e) 896 | else: 897 | for line_no, line in enumerate(src_lines, offset + 1): 898 | self.write(cyan(f"{line_no:03d}: {line}")) 899 | 900 | def process_help_cmd(self, arg): 901 | if arg: 902 | if keyword.iskeyword(arg): 903 | self.push(f'help("{arg}")') 904 | elif arg in self.commands: 905 | self.commands[arg]("-h") 906 | else: 907 | self.push(f"help({arg})") 908 | else: 909 | print(cyan(self.__doc__).format(**config.__dict__)) 910 | 911 | def interact(self, banner=None, exitmsg=None): 912 | """A forgiving wrapper around InteractiveConsole.interact()""" 913 | venv_rc_done = cyan("(no venv rc found)") 914 | try: 915 | with open(config.VENV_RC) as venv_rc: 916 | self._exec_from_file(venv_rc, quiet=True, skip_history=True) 917 | # - clear out session_history for venv_rc commands 918 | self.session_history = [] 919 | venv_rc_done = green("Successfully executed venv rc !") 920 | except IOError: 921 | pass 922 | 923 | if banner is None: 924 | banner = ( 925 | f"Welcome to the ImprovedConsole (version {__version__})\n" 926 | f"Type in {config.HELP_CMD} for list of features.\n" 927 | f"{venv_rc_done}" 928 | ) 929 | 930 | retries = 2 931 | while retries: 932 | try: 933 | super(ImprovedConsole, self).interact(banner=banner, exitmsg=exitmsg) 934 | except SystemExit: 935 | # Fixes #2: exit when 'quit()' invoked 936 | break 937 | except Exception: 938 | import traceback 939 | 940 | retries -= 1 941 | print( 942 | red( 943 | "I'm sorry, ImprovedConsole could not handle that !\n" 944 | "Please report an error with this traceback, " 945 | "I would really appreciate that !" 946 | ) 947 | ) 948 | traceback.print_exc() 949 | 950 | print( 951 | red( 952 | "I shall try to restore the crashed session.\n" 953 | "If the crash occurs again, please exit the session" 954 | ) 955 | ) 956 | banner = blue("Your crashed session has been restored") 957 | else: 958 | # exit with a Ctrl-D 959 | break 960 | 961 | # Exit the Python shell on exiting the InteractiveConsole 962 | if threading.current_thread() == threading.main_thread(): 963 | sys.exit() 964 | 965 | 966 | if not os.getenv("SKIP_PYMP"): 967 | # - create our pimped out console and fire it up ! 968 | pymp = ImprovedConsole(locals=CLEAN_NS) 969 | CLEAN_NS["__pymp__"] = pymp 970 | pymp.interact() 971 | --------------------------------------------------------------------------------