pyrepl-hacks 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,224 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyrepl-hacks
3
+ Version: 0.1.0
4
+ Summary: Hacky extensions and helper functions for the new Python REPL.
5
+ Requires-Python: <3.15,>=3.13
6
+ Description-Content-Type: text/markdown
7
+
8
+ # pyrepl-hacks
9
+
10
+ Hacky extensions and helper functions for the new Python REPL.
11
+
12
+ ```python
13
+ import pyrepl_hacks as repl
14
+
15
+ repl.bind("Alt+M", "move-to-indentation") # Move to first non-space in current line
16
+ repl.bind("Shift+Tab", "dedent") # Dedent the whole input
17
+ repl.bind("Alt+Down", "move-line-down") # Swap current line with next in block
18
+ repl.bind("Alt+Up", "move-line-up") # Swap current line with previous in block
19
+ repl.bind("Shift+Home", "home") # Move to first character in the input
20
+ repl.bind("Shift+End", "end") # Move to last character in the input
21
+
22
+ # Make Ctrl+N insert a specific list of numbers
23
+ repl.bind_to_insert("Ctrl+N", "[2, 1, 3, 4, 7, 11, 18, 29]")
24
+
25
+
26
+ @repl.bind(r"Ctrl+X Ctrl+R", with_event=True)
27
+ def subprocess_run(reader, event_name, event):
28
+ """Ctrl+X followed by Ctrl+R will insert a subprocess.run command."""
29
+ reader.insert("import subprocess\n")
30
+ code = 'subprocess.run("", shell=True)'
31
+ reader.insert(code)
32
+ for _ in range(len(code) - code.index('""') - 1):
33
+ repl.commands.left(reader, event_name, event)
34
+ ```
35
+
36
+
37
+ ## ⚠️ WARNING: this could all break
38
+
39
+ This library relies on Python implementation details which may change in future Python versions.
40
+
41
+ This library uses the `_pyrepl` module (and optionally `_colorize`).
42
+ As the `_` prefix implies, these modules are not designed for public use.
43
+
44
+ That means that when you upgrade to a newer Python (for example Python 3.15) this code may break.
45
+ For that reason, the Python versions this package claims to work with are pinned to only known-to-be-working Python versions.
46
+
47
+
48
+ ## Installing
49
+
50
+ To install globally:
51
+
52
+ ```console
53
+ pip install pyrepl-hacks
54
+ ```
55
+
56
+ Then you can use it in [your `PYTHONSTARTUP` file][PYTHONSTARTUP]:
57
+
58
+ ```python
59
+ def _main():
60
+ try:
61
+ import pyrepl_hacks as repl
62
+ except ImportError:
63
+ pass # We must be on Python 3.12 or earlier
64
+ else:
65
+ repl.bind("Alt+M", "move-to-indentation")
66
+ repl.bind("Shift+Tab", "dedent")
67
+ repl.bind("Alt+Down", "move-line-down")
68
+ repl.bind("Alt+Up", "move-line-up")
69
+ repl.bind("Shift+Home", "home")
70
+ repl.bind("Shift+End", "end")
71
+
72
+ repl.bind_to_insert("Ctrl+N", "[2, 1, 3, 4, 7, 11, 18, 29]")
73
+
74
+ _main()
75
+ del _main # Don't polute the global namespace in our REPL
76
+ ```
77
+
78
+ Note that this will only modify the REPL in environments where `pyrepl-hacks` is installed.
79
+ So if you want it everywhere, you would need to install `pyrepl-hacks` system-wide *and* in every virtual environment.
80
+
81
+ If you just want to play with this tool, try this:
82
+
83
+ ```console
84
+ uvx --with pyrepl-hacks python
85
+ ```
86
+
87
+
88
+ ## Command Registering and Key Binding
89
+
90
+ This library includes features for easily registering and binding new REPL commands.
91
+
92
+ ### Binding to existing commands
93
+
94
+ You can bind a key to an existing command:
95
+
96
+ ```python
97
+ import pyrepl_hacks as repl
98
+
99
+ repl.bind("Shift+Home", "home")
100
+ ```
101
+
102
+ ### Inserting text with a binding
103
+
104
+ You can use the `bind_to_insert` helper to bind a key to insert specific text:
105
+
106
+ ```python
107
+ import pyrepl_hacks as repl
108
+
109
+ repl.bind_to_insert("Ctrl+P", "Python?!")
110
+ ```
111
+
112
+ ### Registering new commands
113
+
114
+ Need something fancy that doesn't exist yet?
115
+
116
+ You can register a new command:
117
+
118
+ ```python
119
+ import pyrepl_hacks as repl
120
+
121
+ @repl.register_command
122
+ def exit(reader):
123
+ """Exits Python immediately."""
124
+ import sys
125
+ sys.exit(0)
126
+ ```
127
+
128
+ The `register_command` decorator will turn the `under_score` separated name into a `kebab-case` name by default.
129
+
130
+ The `register_command` can optionally accept a command name and, if the command needs access to the event name and event object, a `with_event=True` argument can be provided:
131
+
132
+ ```python
133
+ import pyrepl_hacks as repl
134
+
135
+ @repl.register_command("delete-line", with_event=True)
136
+ def delete_whole_line(reader, event_name, event):
137
+ """Move to beginning of line and delete all text."""
138
+ reader.pos = reader.bol()
139
+ repl.commands.kill_line(reader, event_name, event)
140
+ ```
141
+
142
+ After commands have been registered, they can be used with the `bind` function to bind them to specific keys:
143
+
144
+ ```python
145
+ import pyrepl_hacks as repl
146
+
147
+ repl.bind("F4", "exit")
148
+ repl.bind("Ctrl+X Ctrl+D", "delete-line")
149
+ ```
150
+
151
+ ### Binding keys while registering
152
+
153
+ The `bind` function can also be used as a decorator to register a command and bind it to a specific key combination at the same time:
154
+
155
+ ```python
156
+ import pyrepl_hacks as repl
157
+
158
+ @repl.bind("F4")
159
+ def exit(reader):
160
+ """Exits Python immediately."""
161
+ import sys
162
+ sys.exit(0)
163
+ ```
164
+
165
+ Since there's not much point in making a new command *without* binding it, you'll usually want to use `bind` instead of `register_command`.
166
+
167
+ Just like `register_command`, `bind` decorator can also accept a `with_event=True` argument to pass the event name and event object into the command function.
168
+
169
+
170
+ ## Available Commands
171
+
172
+ Here are some of the interesting commands provided by Python (in `_pyrepl.commands`):
173
+
174
+ - `clear-screen`: Clear screen (`Ctrl+L`)
175
+ - `accept`: Run current code block (`Alt+Enter`)
176
+ - `beginning-of-line`: Move cursor to the first character of the current line (`Ctrl+A`)
177
+ - `end-of-line`: Move cursor to the last character of the current line (`Ctrl+E`)
178
+ - `home`: Move cursor the first character in the code block
179
+ - `end`: Move cursor the last character in the code block
180
+ - `kill-line`: Delete to end of line (`Ctrl+K`)
181
+ - `unix-line-discard`: Delete to beginning of line (`Ctrl+U`)
182
+ - `backward-word`: Move cursor back one word (`Ctrl+Left`)
183
+ - `forward-word`: Move cursor forward one word (`Ctrl+Right`)
184
+ - `backward-kill-word`: Delete to beginning of word (`Alt+Backspace`)
185
+ - `kill-word`: Delete to end of word (`Alt+D`)
186
+
187
+ This `pyrepl-hacks` project provides some additional commands as well:
188
+
189
+ - `move-to-indentation`: Move to first non-space in current line
190
+ - `dedent`: Dedent the whole code block
191
+ - `move-line-down`: Swap current line with next one in the block
192
+ - `move-line-up`: Swap current line with previous one in the block
193
+
194
+ These 4 additional commands have no key bindings by default.
195
+
196
+ I recommend binding these commands as well as the `home` and `end` commands (provided by `_pyrepl.commands`) which are also unbound by default:
197
+
198
+ ```python
199
+ repl.bind("Alt+M", "move-to-indentation") # Move to first non-space in current line
200
+ repl.bind("Shift+Tab", "dedent") # Dedent the whole input
201
+ repl.bind("Alt+Down", "move-line-down") # Swap current line with next in block
202
+ repl.bind("Alt+Up", "move-line-up") # Swap current line with previous in block
203
+ repl.bind("Shift+Home", "home") # Move to first character in the input
204
+ repl.bind("Shift+End", "end") # Move to last character in the input
205
+ ```
206
+
207
+ Note that these custom REPL commands and all existing commands provided by `_pyrepl.commands` include wrapper functions in the `commands` submodule.
208
+ These functions are named the same as their command name, except `-` must be replaced by `_`:
209
+
210
+ ```python
211
+ from repl.commands import move_to_indentation, clear_screen
212
+ ```
213
+
214
+
215
+ ## The Future is Obsolescence?
216
+
217
+ My hope is that this package will be obsolete one day.
218
+
219
+ I hope that Python will eventually include an official interface for creating new REPL commands and binding keys to commands.
220
+
221
+ I also hope that some (or all?) of the 4 new commands this module includes will eventually be included with Python by default.
222
+
223
+
224
+ [PYTHONSTARTUP]: https://nedbatchelder.com/blog/201001/running_code_at_python_startup.html
@@ -0,0 +1,217 @@
1
+ # pyrepl-hacks
2
+
3
+ Hacky extensions and helper functions for the new Python REPL.
4
+
5
+ ```python
6
+ import pyrepl_hacks as repl
7
+
8
+ repl.bind("Alt+M", "move-to-indentation") # Move to first non-space in current line
9
+ repl.bind("Shift+Tab", "dedent") # Dedent the whole input
10
+ repl.bind("Alt+Down", "move-line-down") # Swap current line with next in block
11
+ repl.bind("Alt+Up", "move-line-up") # Swap current line with previous in block
12
+ repl.bind("Shift+Home", "home") # Move to first character in the input
13
+ repl.bind("Shift+End", "end") # Move to last character in the input
14
+
15
+ # Make Ctrl+N insert a specific list of numbers
16
+ repl.bind_to_insert("Ctrl+N", "[2, 1, 3, 4, 7, 11, 18, 29]")
17
+
18
+
19
+ @repl.bind(r"Ctrl+X Ctrl+R", with_event=True)
20
+ def subprocess_run(reader, event_name, event):
21
+ """Ctrl+X followed by Ctrl+R will insert a subprocess.run command."""
22
+ reader.insert("import subprocess\n")
23
+ code = 'subprocess.run("", shell=True)'
24
+ reader.insert(code)
25
+ for _ in range(len(code) - code.index('""') - 1):
26
+ repl.commands.left(reader, event_name, event)
27
+ ```
28
+
29
+
30
+ ## ⚠️ WARNING: this could all break
31
+
32
+ This library relies on Python implementation details which may change in future Python versions.
33
+
34
+ This library uses the `_pyrepl` module (and optionally `_colorize`).
35
+ As the `_` prefix implies, these modules are not designed for public use.
36
+
37
+ That means that when you upgrade to a newer Python (for example Python 3.15) this code may break.
38
+ For that reason, the Python versions this package claims to work with are pinned to only known-to-be-working Python versions.
39
+
40
+
41
+ ## Installing
42
+
43
+ To install globally:
44
+
45
+ ```console
46
+ pip install pyrepl-hacks
47
+ ```
48
+
49
+ Then you can use it in [your `PYTHONSTARTUP` file][PYTHONSTARTUP]:
50
+
51
+ ```python
52
+ def _main():
53
+ try:
54
+ import pyrepl_hacks as repl
55
+ except ImportError:
56
+ pass # We must be on Python 3.12 or earlier
57
+ else:
58
+ repl.bind("Alt+M", "move-to-indentation")
59
+ repl.bind("Shift+Tab", "dedent")
60
+ repl.bind("Alt+Down", "move-line-down")
61
+ repl.bind("Alt+Up", "move-line-up")
62
+ repl.bind("Shift+Home", "home")
63
+ repl.bind("Shift+End", "end")
64
+
65
+ repl.bind_to_insert("Ctrl+N", "[2, 1, 3, 4, 7, 11, 18, 29]")
66
+
67
+ _main()
68
+ del _main # Don't polute the global namespace in our REPL
69
+ ```
70
+
71
+ Note that this will only modify the REPL in environments where `pyrepl-hacks` is installed.
72
+ So if you want it everywhere, you would need to install `pyrepl-hacks` system-wide *and* in every virtual environment.
73
+
74
+ If you just want to play with this tool, try this:
75
+
76
+ ```console
77
+ uvx --with pyrepl-hacks python
78
+ ```
79
+
80
+
81
+ ## Command Registering and Key Binding
82
+
83
+ This library includes features for easily registering and binding new REPL commands.
84
+
85
+ ### Binding to existing commands
86
+
87
+ You can bind a key to an existing command:
88
+
89
+ ```python
90
+ import pyrepl_hacks as repl
91
+
92
+ repl.bind("Shift+Home", "home")
93
+ ```
94
+
95
+ ### Inserting text with a binding
96
+
97
+ You can use the `bind_to_insert` helper to bind a key to insert specific text:
98
+
99
+ ```python
100
+ import pyrepl_hacks as repl
101
+
102
+ repl.bind_to_insert("Ctrl+P", "Python?!")
103
+ ```
104
+
105
+ ### Registering new commands
106
+
107
+ Need something fancy that doesn't exist yet?
108
+
109
+ You can register a new command:
110
+
111
+ ```python
112
+ import pyrepl_hacks as repl
113
+
114
+ @repl.register_command
115
+ def exit(reader):
116
+ """Exits Python immediately."""
117
+ import sys
118
+ sys.exit(0)
119
+ ```
120
+
121
+ The `register_command` decorator will turn the `under_score` separated name into a `kebab-case` name by default.
122
+
123
+ The `register_command` can optionally accept a command name and, if the command needs access to the event name and event object, a `with_event=True` argument can be provided:
124
+
125
+ ```python
126
+ import pyrepl_hacks as repl
127
+
128
+ @repl.register_command("delete-line", with_event=True)
129
+ def delete_whole_line(reader, event_name, event):
130
+ """Move to beginning of line and delete all text."""
131
+ reader.pos = reader.bol()
132
+ repl.commands.kill_line(reader, event_name, event)
133
+ ```
134
+
135
+ After commands have been registered, they can be used with the `bind` function to bind them to specific keys:
136
+
137
+ ```python
138
+ import pyrepl_hacks as repl
139
+
140
+ repl.bind("F4", "exit")
141
+ repl.bind("Ctrl+X Ctrl+D", "delete-line")
142
+ ```
143
+
144
+ ### Binding keys while registering
145
+
146
+ The `bind` function can also be used as a decorator to register a command and bind it to a specific key combination at the same time:
147
+
148
+ ```python
149
+ import pyrepl_hacks as repl
150
+
151
+ @repl.bind("F4")
152
+ def exit(reader):
153
+ """Exits Python immediately."""
154
+ import sys
155
+ sys.exit(0)
156
+ ```
157
+
158
+ Since there's not much point in making a new command *without* binding it, you'll usually want to use `bind` instead of `register_command`.
159
+
160
+ Just like `register_command`, `bind` decorator can also accept a `with_event=True` argument to pass the event name and event object into the command function.
161
+
162
+
163
+ ## Available Commands
164
+
165
+ Here are some of the interesting commands provided by Python (in `_pyrepl.commands`):
166
+
167
+ - `clear-screen`: Clear screen (`Ctrl+L`)
168
+ - `accept`: Run current code block (`Alt+Enter`)
169
+ - `beginning-of-line`: Move cursor to the first character of the current line (`Ctrl+A`)
170
+ - `end-of-line`: Move cursor to the last character of the current line (`Ctrl+E`)
171
+ - `home`: Move cursor the first character in the code block
172
+ - `end`: Move cursor the last character in the code block
173
+ - `kill-line`: Delete to end of line (`Ctrl+K`)
174
+ - `unix-line-discard`: Delete to beginning of line (`Ctrl+U`)
175
+ - `backward-word`: Move cursor back one word (`Ctrl+Left`)
176
+ - `forward-word`: Move cursor forward one word (`Ctrl+Right`)
177
+ - `backward-kill-word`: Delete to beginning of word (`Alt+Backspace`)
178
+ - `kill-word`: Delete to end of word (`Alt+D`)
179
+
180
+ This `pyrepl-hacks` project provides some additional commands as well:
181
+
182
+ - `move-to-indentation`: Move to first non-space in current line
183
+ - `dedent`: Dedent the whole code block
184
+ - `move-line-down`: Swap current line with next one in the block
185
+ - `move-line-up`: Swap current line with previous one in the block
186
+
187
+ These 4 additional commands have no key bindings by default.
188
+
189
+ I recommend binding these commands as well as the `home` and `end` commands (provided by `_pyrepl.commands`) which are also unbound by default:
190
+
191
+ ```python
192
+ repl.bind("Alt+M", "move-to-indentation") # Move to first non-space in current line
193
+ repl.bind("Shift+Tab", "dedent") # Dedent the whole input
194
+ repl.bind("Alt+Down", "move-line-down") # Swap current line with next in block
195
+ repl.bind("Alt+Up", "move-line-up") # Swap current line with previous in block
196
+ repl.bind("Shift+Home", "home") # Move to first character in the input
197
+ repl.bind("Shift+End", "end") # Move to last character in the input
198
+ ```
199
+
200
+ Note that these custom REPL commands and all existing commands provided by `_pyrepl.commands` include wrapper functions in the `commands` submodule.
201
+ These functions are named the same as their command name, except `-` must be replaced by `_`:
202
+
203
+ ```python
204
+ from repl.commands import move_to_indentation, clear_screen
205
+ ```
206
+
207
+
208
+ ## The Future is Obsolescence?
209
+
210
+ My hope is that this package will be obsolete one day.
211
+
212
+ I hope that Python will eventually include an official interface for creating new REPL commands and binding keys to commands.
213
+
214
+ I also hope that some (or all?) of the 4 new commands this module includes will eventually be included with Python by default.
215
+
216
+
217
+ [PYTHONSTARTUP]: https://nedbatchelder.com/blog/201001/running_code_at_python_startup.html
@@ -0,0 +1,7 @@
1
+ [project]
2
+ name = "pyrepl-hacks"
3
+ version = "0.1.0"
4
+ description = "Hacky extensions and helper functions for the new Python REPL."
5
+ readme = "README.md"
6
+ requires-python = ">=3.13,<3.15"
7
+ dependencies = []
@@ -0,0 +1,7 @@
1
+ from .bind_utils import bind, bind_to_insert
2
+ from .command_utils import register_command
3
+ from .theme_utils import update_theme
4
+ from . import commands
5
+
6
+
7
+ __all__ = ["bind", "bind_to_insert", "register_command", "update_theme"]
@@ -0,0 +1,52 @@
1
+ from _pyrepl.simple_interact import _get_reader
2
+ from collections.abc import Callable
3
+ import logging
4
+
5
+ from .command_utils import register_command
6
+ from .key_utils import slugify, to_keyspec
7
+
8
+
9
+ __all__ = ["bind", "bind_to_insert"]
10
+
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def _bind_decorator(keybinding: str, with_event: bool):
16
+ def decorator(command_function: Callable):
17
+ command = register_command(command_function, with_event=with_event)
18
+ return _bind_existing_command(keybinding, command.name)
19
+ return decorator
20
+
21
+
22
+ def _bind_existing_command(keybinding: str, command_name: str = None):
23
+ keyspec = to_keyspec(keybinding)
24
+ logger.debug("binding: %s for %s", keyspec, command_name)
25
+ reader = _get_reader()
26
+ reader.bind(keyspec, command_name)
27
+
28
+
29
+ def _bind_new_command(keybinding: str, command_name: str = None, command_function: Callable = None):
30
+ command = register_command(command_name)(command_function)
31
+ _bind_existing_command(keybinding, command_name)
32
+ return command
33
+
34
+
35
+ def bind(
36
+ keybinding: str,
37
+ command_name: str = None,
38
+ command_function: Callable = None,
39
+ *,
40
+ with_event = False,
41
+ ):
42
+ if command_function is not None:
43
+ return _bind_new_command(keybinding, command_name, command_function)
44
+ elif command_name is not None:
45
+ return _bind_existing_command(keybinding, command_name)
46
+ else:
47
+ return _bind_decorator(keybinding, with_event)
48
+
49
+
50
+ def bind_to_insert(keybinding: str, text: str):
51
+ def command_function(reader): reader.insert(text)
52
+ bind(keybinding, slugify(keybinding), command_function)
@@ -0,0 +1,38 @@
1
+ from _pyrepl.simple_interact import _get_reader
2
+ from _pyrepl.commands import Command
3
+ from collections.abc import Callable
4
+
5
+
6
+ __all__ = ["register_command", "custom_commands"]
7
+
8
+
9
+ def under_to_kebab(name):
10
+ """Convert under_score_case to kebab-case."""
11
+ return name.replace("_", "-")
12
+
13
+
14
+ def register_command(command_name: str = None, /, *, with_event: bool = False):
15
+ def decorator(command_function: Callable):
16
+ name = command_name or under_to_kebab(command_function.__name__)
17
+
18
+ def do(self):
19
+ if with_event:
20
+ return command_function(self.reader, self.event_name, self.event)
21
+ else:
22
+ return command_function(self.reader)
23
+ command_class = type(
24
+ name,
25
+ (Command,),
26
+ {"do": do},
27
+ )
28
+ reader = _get_reader()
29
+ reader.commands[name] = command_class
30
+ command_function.command_class = command_class
31
+ command_function.name = name
32
+ return command_function
33
+ if isinstance(command_name, Callable):
34
+ command_function = command_name
35
+ command_name = None
36
+ return decorator(command_function)
37
+ else:
38
+ return decorator
@@ -0,0 +1,112 @@
1
+ import re
2
+ import textwrap
3
+
4
+ from .command_utils import register_command
5
+
6
+
7
+ # _pyrepl.commands are also included later (see _add_pyrepl_commands)
8
+ __all__ = ["move_to_indentation", "dedent", "move_line_down", "move_line_up"]
9
+
10
+
11
+ @register_command
12
+ def move_to_indentation(reader):
13
+ """Move to the start of indentation for the current line."""
14
+ x, y = reader.pos2xy()
15
+ lines = reader.get_unicode().splitlines(keepends=True)
16
+ line = lines[y]
17
+ if match := re.search(r"^\s+", line):
18
+ index = match.end()
19
+ else:
20
+ index = 0
21
+ reader.pos = reader.bol() + index
22
+
23
+
24
+ @register_command
25
+ def dedent(reader):
26
+ """Dedent the current code block."""
27
+ x, y = reader.pos2xy()
28
+ original_text = reader.get_unicode()
29
+ dedented_text = textwrap.dedent(original_text)
30
+
31
+ # Dedent buffer and invalidate cache
32
+ reader.buffer[:] = list(dedented_text)
33
+ reader.last_refresh_cache.invalidated = True
34
+ reader.dirty = True
35
+
36
+ # Reposition cursor correctly
37
+ original_lines = original_text.splitlines()
38
+ dedented_lines = dedented_text.splitlines()
39
+ removed_characters = sum(
40
+ len(old) - len(new)
41
+ for old, new in zip(original_lines[:y+1], dedented_lines)
42
+ )
43
+ reader.pos -= removed_characters
44
+
45
+
46
+ @register_command
47
+ def move_line_down(reader):
48
+ """Move the current line down."""
49
+ x, y = reader.pos2xy()
50
+ lines = reader.get_unicode().splitlines(keepends=True)
51
+
52
+ # Can't move down if we're on the last line
53
+ if y >= len(lines) - 1:
54
+ return
55
+
56
+ # Swap current line with next line
57
+ lines[y], lines[y+1] = lines[y+1], lines[y]
58
+
59
+ if not lines[y].endswith("\n"):
60
+ lines[y] += "\n"
61
+
62
+ # Update buffer with swapped lines
63
+ reader.buffer[:] = list("".join(lines))
64
+ reader.last_refresh_cache.invalidated = True
65
+ reader.dirty = True
66
+
67
+ # Move cursor to same column in the moved line (one line up)
68
+ reader.pos += len(lines[y])
69
+
70
+
71
+ @register_command
72
+ def move_line_up(reader):
73
+ """Move the current line up."""
74
+ x, y = reader.pos2xy()
75
+ lines = reader.get_unicode().splitlines(keepends=True)
76
+
77
+ # Can't move up if we're on the first line
78
+ if y <= 0:
79
+ return
80
+
81
+ # Swap current line with previous line
82
+ lines[y-1], lines[y] = lines[y], lines[y-1]
83
+
84
+ # Update buffer with swapped lines
85
+ reader.buffer[:] = list("".join(lines))
86
+ reader.last_refresh_cache.invalidated = True
87
+ reader.dirty = True
88
+
89
+ # Move cursor to same column in the moved line (one line up)
90
+ reader.pos -= len(lines[y])
91
+
92
+
93
+ def _add_pyrepl_commands():
94
+ """Create simple command functions for all _pyrepl commands also."""
95
+ import _pyrepl.commands
96
+ from functools import wraps
97
+ for name, value in vars(_pyrepl.commands).items():
98
+ if (
99
+ isinstance(value, type)
100
+ and issubclass(value, _pyrepl.commands.Command)
101
+ and hasattr(value, "do")
102
+ ):
103
+ def wrapper(command_class):
104
+ @wraps(value, assigned=["__name__", "__doc__"], updated=[])
105
+ def command_function(reader, event_name, event):
106
+ return command_class(reader, event_name, event).do()
107
+ return command_function
108
+ globals()[name] = wrapper(value)
109
+ __all__.append(name)
110
+
111
+
112
+ _add_pyrepl_commands()
@@ -0,0 +1,54 @@
1
+ import re
2
+
3
+
4
+ bindings_to_specs = {
5
+ "ctrl": r"\C",
6
+ "alt": r"\M",
7
+ "pgup": r"\<page up>",
8
+ "pgdn": r"\<page down>",
9
+ }
10
+
11
+
12
+ # Cases that can't be handled by \C- or \M- notation
13
+ SPECIAL_CASES = {
14
+ "alt+up": r"\e[1;3A",
15
+ "alt+down": r"\e[1;3B",
16
+ "alt+right": r"\e[1;3C",
17
+ "alt+left": r"\e[1;3D",
18
+ "shift+tab": r"\e[Z",
19
+ "shift+up": r"\e[1;2A",
20
+ "shift+down": r"\e[1;2B",
21
+ "shift+right": r"\e[1;2C",
22
+ "shift+left": r"\e[1;2D",
23
+ "shift+home": r"\e[1;2H",
24
+ "shift+end": r"\e[1;2F",
25
+ "shift+pageup": r"\e[5;2~",
26
+ "shift+pagedown": r"\e[6;2~",
27
+ "shift+pgup": r"\e[5;2~",
28
+ "shift+pgdn": r"\e[6;2~",
29
+ "shift+insert": r"\e[2;2~",
30
+ "shift+delete": r"\e[3;2~",
31
+ # Add more as we discover them
32
+ }
33
+
34
+
35
+ def slugify(keybinding):
36
+ """Create unique slug for keybinding."""
37
+ return "_" + "".join(
38
+ c if c.isalnum() else "_"
39
+ for c in keybinding
40
+ )
41
+
42
+
43
+ def to_keyspec(keybinding: str):
44
+ r"""Convert human-readable bindings to specs (e.g. Ctrl+A to \C-a)."""
45
+ normalized = keybinding.lower().strip()
46
+ if normalized in SPECIAL_CASES:
47
+ return SPECIAL_CASES[normalized]
48
+ spec = ""
49
+ for section in normalized.split():
50
+ spec += "-".join([
51
+ bindings_to_specs.get(part, rf"\<{part}>") if len(part) != 1 else part
52
+ for part in section.split("+")
53
+ ])
54
+ return spec
@@ -0,0 +1,17 @@
1
+ def _convert_color(color):
2
+ """Convert strings like 'reset, intense blue' into valid color."""
3
+ subcolors = color.split(",")
4
+ return "".join(
5
+ getattr(ANSIColors, c.strip().replace(" ", "_").upper())
6
+ for c in subcolors
7
+ )
8
+
9
+
10
+ def update_theme(**kwargs):
11
+ from _colorize import set_theme, default_theme, Syntax, ANSIColors
12
+ items = {
13
+ name: _convert_color(color)
14
+ for name, color in kwargs.items()
15
+ }
16
+ new_theme = default_theme.copy_with(syntax=Syntax(**items))
17
+ set_theme(new_theme)
@@ -0,0 +1,224 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyrepl-hacks
3
+ Version: 0.1.0
4
+ Summary: Hacky extensions and helper functions for the new Python REPL.
5
+ Requires-Python: <3.15,>=3.13
6
+ Description-Content-Type: text/markdown
7
+
8
+ # pyrepl-hacks
9
+
10
+ Hacky extensions and helper functions for the new Python REPL.
11
+
12
+ ```python
13
+ import pyrepl_hacks as repl
14
+
15
+ repl.bind("Alt+M", "move-to-indentation") # Move to first non-space in current line
16
+ repl.bind("Shift+Tab", "dedent") # Dedent the whole input
17
+ repl.bind("Alt+Down", "move-line-down") # Swap current line with next in block
18
+ repl.bind("Alt+Up", "move-line-up") # Swap current line with previous in block
19
+ repl.bind("Shift+Home", "home") # Move to first character in the input
20
+ repl.bind("Shift+End", "end") # Move to last character in the input
21
+
22
+ # Make Ctrl+N insert a specific list of numbers
23
+ repl.bind_to_insert("Ctrl+N", "[2, 1, 3, 4, 7, 11, 18, 29]")
24
+
25
+
26
+ @repl.bind(r"Ctrl+X Ctrl+R", with_event=True)
27
+ def subprocess_run(reader, event_name, event):
28
+ """Ctrl+X followed by Ctrl+R will insert a subprocess.run command."""
29
+ reader.insert("import subprocess\n")
30
+ code = 'subprocess.run("", shell=True)'
31
+ reader.insert(code)
32
+ for _ in range(len(code) - code.index('""') - 1):
33
+ repl.commands.left(reader, event_name, event)
34
+ ```
35
+
36
+
37
+ ## ⚠️ WARNING: this could all break
38
+
39
+ This library relies on Python implementation details which may change in future Python versions.
40
+
41
+ This library uses the `_pyrepl` module (and optionally `_colorize`).
42
+ As the `_` prefix implies, these modules are not designed for public use.
43
+
44
+ That means that when you upgrade to a newer Python (for example Python 3.15) this code may break.
45
+ For that reason, the Python versions this package claims to work with are pinned to only known-to-be-working Python versions.
46
+
47
+
48
+ ## Installing
49
+
50
+ To install globally:
51
+
52
+ ```console
53
+ pip install pyrepl-hacks
54
+ ```
55
+
56
+ Then you can use it in [your `PYTHONSTARTUP` file][PYTHONSTARTUP]:
57
+
58
+ ```python
59
+ def _main():
60
+ try:
61
+ import pyrepl_hacks as repl
62
+ except ImportError:
63
+ pass # We must be on Python 3.12 or earlier
64
+ else:
65
+ repl.bind("Alt+M", "move-to-indentation")
66
+ repl.bind("Shift+Tab", "dedent")
67
+ repl.bind("Alt+Down", "move-line-down")
68
+ repl.bind("Alt+Up", "move-line-up")
69
+ repl.bind("Shift+Home", "home")
70
+ repl.bind("Shift+End", "end")
71
+
72
+ repl.bind_to_insert("Ctrl+N", "[2, 1, 3, 4, 7, 11, 18, 29]")
73
+
74
+ _main()
75
+ del _main # Don't polute the global namespace in our REPL
76
+ ```
77
+
78
+ Note that this will only modify the REPL in environments where `pyrepl-hacks` is installed.
79
+ So if you want it everywhere, you would need to install `pyrepl-hacks` system-wide *and* in every virtual environment.
80
+
81
+ If you just want to play with this tool, try this:
82
+
83
+ ```console
84
+ uvx --with pyrepl-hacks python
85
+ ```
86
+
87
+
88
+ ## Command Registering and Key Binding
89
+
90
+ This library includes features for easily registering and binding new REPL commands.
91
+
92
+ ### Binding to existing commands
93
+
94
+ You can bind a key to an existing command:
95
+
96
+ ```python
97
+ import pyrepl_hacks as repl
98
+
99
+ repl.bind("Shift+Home", "home")
100
+ ```
101
+
102
+ ### Inserting text with a binding
103
+
104
+ You can use the `bind_to_insert` helper to bind a key to insert specific text:
105
+
106
+ ```python
107
+ import pyrepl_hacks as repl
108
+
109
+ repl.bind_to_insert("Ctrl+P", "Python?!")
110
+ ```
111
+
112
+ ### Registering new commands
113
+
114
+ Need something fancy that doesn't exist yet?
115
+
116
+ You can register a new command:
117
+
118
+ ```python
119
+ import pyrepl_hacks as repl
120
+
121
+ @repl.register_command
122
+ def exit(reader):
123
+ """Exits Python immediately."""
124
+ import sys
125
+ sys.exit(0)
126
+ ```
127
+
128
+ The `register_command` decorator will turn the `under_score` separated name into a `kebab-case` name by default.
129
+
130
+ The `register_command` can optionally accept a command name and, if the command needs access to the event name and event object, a `with_event=True` argument can be provided:
131
+
132
+ ```python
133
+ import pyrepl_hacks as repl
134
+
135
+ @repl.register_command("delete-line", with_event=True)
136
+ def delete_whole_line(reader, event_name, event):
137
+ """Move to beginning of line and delete all text."""
138
+ reader.pos = reader.bol()
139
+ repl.commands.kill_line(reader, event_name, event)
140
+ ```
141
+
142
+ After commands have been registered, they can be used with the `bind` function to bind them to specific keys:
143
+
144
+ ```python
145
+ import pyrepl_hacks as repl
146
+
147
+ repl.bind("F4", "exit")
148
+ repl.bind("Ctrl+X Ctrl+D", "delete-line")
149
+ ```
150
+
151
+ ### Binding keys while registering
152
+
153
+ The `bind` function can also be used as a decorator to register a command and bind it to a specific key combination at the same time:
154
+
155
+ ```python
156
+ import pyrepl_hacks as repl
157
+
158
+ @repl.bind("F4")
159
+ def exit(reader):
160
+ """Exits Python immediately."""
161
+ import sys
162
+ sys.exit(0)
163
+ ```
164
+
165
+ Since there's not much point in making a new command *without* binding it, you'll usually want to use `bind` instead of `register_command`.
166
+
167
+ Just like `register_command`, `bind` decorator can also accept a `with_event=True` argument to pass the event name and event object into the command function.
168
+
169
+
170
+ ## Available Commands
171
+
172
+ Here are some of the interesting commands provided by Python (in `_pyrepl.commands`):
173
+
174
+ - `clear-screen`: Clear screen (`Ctrl+L`)
175
+ - `accept`: Run current code block (`Alt+Enter`)
176
+ - `beginning-of-line`: Move cursor to the first character of the current line (`Ctrl+A`)
177
+ - `end-of-line`: Move cursor to the last character of the current line (`Ctrl+E`)
178
+ - `home`: Move cursor the first character in the code block
179
+ - `end`: Move cursor the last character in the code block
180
+ - `kill-line`: Delete to end of line (`Ctrl+K`)
181
+ - `unix-line-discard`: Delete to beginning of line (`Ctrl+U`)
182
+ - `backward-word`: Move cursor back one word (`Ctrl+Left`)
183
+ - `forward-word`: Move cursor forward one word (`Ctrl+Right`)
184
+ - `backward-kill-word`: Delete to beginning of word (`Alt+Backspace`)
185
+ - `kill-word`: Delete to end of word (`Alt+D`)
186
+
187
+ This `pyrepl-hacks` project provides some additional commands as well:
188
+
189
+ - `move-to-indentation`: Move to first non-space in current line
190
+ - `dedent`: Dedent the whole code block
191
+ - `move-line-down`: Swap current line with next one in the block
192
+ - `move-line-up`: Swap current line with previous one in the block
193
+
194
+ These 4 additional commands have no key bindings by default.
195
+
196
+ I recommend binding these commands as well as the `home` and `end` commands (provided by `_pyrepl.commands`) which are also unbound by default:
197
+
198
+ ```python
199
+ repl.bind("Alt+M", "move-to-indentation") # Move to first non-space in current line
200
+ repl.bind("Shift+Tab", "dedent") # Dedent the whole input
201
+ repl.bind("Alt+Down", "move-line-down") # Swap current line with next in block
202
+ repl.bind("Alt+Up", "move-line-up") # Swap current line with previous in block
203
+ repl.bind("Shift+Home", "home") # Move to first character in the input
204
+ repl.bind("Shift+End", "end") # Move to last character in the input
205
+ ```
206
+
207
+ Note that these custom REPL commands and all existing commands provided by `_pyrepl.commands` include wrapper functions in the `commands` submodule.
208
+ These functions are named the same as their command name, except `-` must be replaced by `_`:
209
+
210
+ ```python
211
+ from repl.commands import move_to_indentation, clear_screen
212
+ ```
213
+
214
+
215
+ ## The Future is Obsolescence?
216
+
217
+ My hope is that this package will be obsolete one day.
218
+
219
+ I hope that Python will eventually include an official interface for creating new REPL commands and binding keys to commands.
220
+
221
+ I also hope that some (or all?) of the 4 new commands this module includes will eventually be included with Python by default.
222
+
223
+
224
+ [PYTHONSTARTUP]: https://nedbatchelder.com/blog/201001/running_code_at_python_startup.html
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ pyrepl_hacks/__init__.py
4
+ pyrepl_hacks/bind_utils.py
5
+ pyrepl_hacks/command_utils.py
6
+ pyrepl_hacks/commands.py
7
+ pyrepl_hacks/key_utils.py
8
+ pyrepl_hacks/theme_utils.py
9
+ pyrepl_hacks.egg-info/PKG-INFO
10
+ pyrepl_hacks.egg-info/SOURCES.txt
11
+ pyrepl_hacks.egg-info/dependency_links.txt
12
+ pyrepl_hacks.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ pyrepl_hacks
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+