xmyshell 0.2.5__tar.gz → 0.2.7__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.
Files changed (25) hide show
  1. {xmyshell-0.2.5/xmyshell.egg-info → xmyshell-0.2.7}/PKG-INFO +3 -1
  2. {xmyshell-0.2.5 → xmyshell-0.2.7}/README.md +2 -0
  3. {xmyshell-0.2.5 → xmyshell-0.2.7}/pyproject.toml +1 -1
  4. {xmyshell-0.2.5 → xmyshell-0.2.7}/xmyshell/completer.py +57 -2
  5. {xmyshell-0.2.5 → xmyshell-0.2.7}/xmyshell/keybindings.py +125 -27
  6. {xmyshell-0.2.5 → xmyshell-0.2.7}/xmyshell/main.py +3 -1
  7. {xmyshell-0.2.5 → xmyshell-0.2.7}/xmyshell/meta.py +2 -0
  8. {xmyshell-0.2.5 → xmyshell-0.2.7/xmyshell.egg-info}/PKG-INFO +3 -1
  9. {xmyshell-0.2.5 → xmyshell-0.2.7}/LICENSE +0 -0
  10. {xmyshell-0.2.5 → xmyshell-0.2.7}/setup.cfg +0 -0
  11. {xmyshell-0.2.5 → xmyshell-0.2.7}/xmyshell/__init__.py +0 -0
  12. {xmyshell-0.2.5 → xmyshell-0.2.7}/xmyshell/__main__.py +0 -0
  13. {xmyshell-0.2.5 → xmyshell-0.2.7}/xmyshell/data/commands.json +0 -0
  14. {xmyshell-0.2.5 → xmyshell-0.2.7}/xmyshell/environment.py +0 -0
  15. {xmyshell-0.2.5 → xmyshell-0.2.7}/xmyshell/init.py +0 -0
  16. {xmyshell-0.2.5 → xmyshell-0.2.7}/xmyshell/kernel.py +0 -0
  17. {xmyshell-0.2.5 → xmyshell-0.2.7}/xmyshell/lexer.py +0 -0
  18. {xmyshell-0.2.5 → xmyshell-0.2.7}/xmyshell/themes.py +0 -0
  19. {xmyshell-0.2.5 → xmyshell-0.2.7}/xmyshell/update.py +0 -0
  20. {xmyshell-0.2.5 → xmyshell-0.2.7}/xmyshell/utils.py +0 -0
  21. {xmyshell-0.2.5 → xmyshell-0.2.7}/xmyshell.egg-info/SOURCES.txt +0 -0
  22. {xmyshell-0.2.5 → xmyshell-0.2.7}/xmyshell.egg-info/dependency_links.txt +0 -0
  23. {xmyshell-0.2.5 → xmyshell-0.2.7}/xmyshell.egg-info/entry_points.txt +0 -0
  24. {xmyshell-0.2.5 → xmyshell-0.2.7}/xmyshell.egg-info/requires.txt +0 -0
  25. {xmyshell-0.2.5 → xmyshell-0.2.7}/xmyshell.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xmyshell
3
- Version: 0.2.5
3
+ Version: 0.2.7
4
4
  Summary: XmyShell is a shell-like tool built on top of prompt_toolkit which provides a persistent Python runtime.
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
@@ -164,6 +164,8 @@ The path of config file.
164
164
  - `↑↓` -> Navigate through command history.
165
165
  - `→` -> Apply selected history entry to the prompt.
166
166
  - `Ctrl+R` -> Reverse incremental searching (history).
167
+ - `Ctrl+Z` -> Undo.
168
+ - `Ctrl+Y` -> Redo.
167
169
  - `Ctrl+D` -> Exit XmyShell.
168
170
  - Auto bracket/parenthesis/quote pairing
169
171
 
@@ -154,6 +154,8 @@ The path of config file.
154
154
  - `↑↓` -> Navigate through command history.
155
155
  - `→` -> Apply selected history entry to the prompt.
156
156
  - `Ctrl+R` -> Reverse incremental searching (history).
157
+ - `Ctrl+Z` -> Undo.
158
+ - `Ctrl+Y` -> Redo.
157
159
  - `Ctrl+D` -> Exit XmyShell.
158
160
  - Auto bracket/parenthesis/quote pairing
159
161
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xmyshell"
3
- version = "0.2.5"
3
+ version = "0.2.7"
4
4
  description = "XmyShell is a shell-like tool built on top of prompt_toolkit which provides a persistent Python runtime."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -1,3 +1,4 @@
1
+ from collections.abc import Generator, Iterable
1
2
  import os
2
3
  import sys
3
4
  import re
@@ -6,7 +7,7 @@ import json
6
7
  import keyword
7
8
  import pkgutil
8
9
  from string import whitespace
9
- from prompt_toolkit.completion import Completer, Completion
10
+ from prompt_toolkit.completion import CompleteEvent, Completer, Completion
10
11
  from prompt_toolkit.document import Document
11
12
  from .environment import namespace, aliases
12
13
  from .utils import truncate
@@ -171,6 +172,44 @@ class ShellCompleter(Completer):
171
172
  )
172
173
 
173
174
  def _python_completion(self, text: str):
175
+
176
+ def _in_string(text: str) -> str | None:
177
+ i, n = 0, len(text)
178
+ quote = ''
179
+ content_start = 0
180
+
181
+ while i < n:
182
+ c = text[i]
183
+ if not quote:
184
+ if c in ('"', "'"):
185
+ if text[i:i+3] in ('"""', "'''"):
186
+ quote = text[i:i+3]
187
+ i += 3
188
+ else:
189
+ quote = c
190
+ i += 1
191
+ content_start = i
192
+ continue
193
+ i += 1
194
+ else:
195
+ if c == '\\':
196
+ i += 2
197
+ continue
198
+ if text.startswith(quote, i):
199
+ i += len(quote)
200
+ quote = ''
201
+ continue
202
+ i += 1
203
+
204
+ if not quote:
205
+ return None
206
+ return text[content_start:]
207
+
208
+ string = _in_string(text)
209
+ if string:
210
+ yield from self._path_completions(string)
211
+ return
212
+
174
213
  if text and text[-1].isspace():
175
214
  return
176
215
  last_token = text.split()[-1] if text.split() else ''
@@ -335,7 +374,7 @@ class ShellCompleter(Completer):
335
374
  display_meta="alias",
336
375
  )
337
376
 
338
- def get_completions(self, document: Document, complete_event):
377
+ def _get_completions(self, document: Document, complete_event) -> Generator[Completion, None, None]:
339
378
  text = document.text_before_cursor
340
379
  if "=>" in text: return
341
380
  segments = re.split(r'\|>|\|', text)
@@ -417,5 +456,21 @@ class ShellCompleter(Completer):
417
456
  yield from self._subcommand_completion(lstripped)
418
457
  yield from self._path_completions(lstripped)
419
458
 
459
+ def get_completions(self, document: Document, complete_event: CompleteEvent) -> list[Completion]:
460
+ completions: list[Completion] = list(self._get_completions(document, complete_event))
461
+ if len(completions) == 1:
462
+ # Trailing space bypasses prompt_toolkit's `completion_does_nothing`
463
+ # filter (which hides completions identical to the replaced text) and
464
+ # doubles as the shell argument separator. Invisible characters would
465
+ # also bypass the filter, but would corrupt command parsing.
466
+ completion = completions[0]
467
+ text_before_cursor = document.text_before_cursor
468
+ replaced_text = text_before_cursor[
469
+ len(text_before_cursor) + completion.start_position :
470
+ ]
471
+ if replaced_text == completion.text:
472
+ completion.text = completion.text + " "
473
+ return completions
474
+
420
475
  def completer_init() -> None:
421
476
  namespace["shell_completer"] = ShellCompleter()
@@ -1,22 +1,60 @@
1
+ import time
1
2
  from prompt_toolkit.buffer import Buffer
2
3
  from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
3
4
  from prompt_toolkit.selection import SelectionState
4
5
  from prompt_toolkit.keys import Keys
5
6
  from prompt_toolkit.key_binding.bindings.auto_suggest import load_auto_suggest_bindings
7
+ from dataclasses import dataclass
6
8
 
9
+ @dataclass
10
+ class BufferInfo:
11
+ text: str
12
+ cursor_pos: int
13
+
14
+ @staticmethod
15
+ def from_buffer(buffer: Buffer) -> "BufferInfo":
16
+ return BufferInfo(buffer.text, buffer.cursor_position)
17
+
18
+
19
+ MAX_HISTORY_ENTRY = 100
20
+ UNDO_MERGE_TIMEOUT = 0.5
7
21
  bindings = KeyBindings()
8
- _auto_editing = False
22
+ _auto_editing: bool = False
23
+ _previous_text: str = ""
24
+ _last_info: BufferInfo = BufferInfo("", 0)
25
+ _bracket_stack: list[str] = []
26
+ _undo_list: list[BufferInfo] = []
27
+ _redo_list: list[BufferInfo] = []
28
+ _last_edit_time: float = 0
29
+ _last_edit_kind: str | None = None # "insert" / "delete" / "replace"
30
+
31
+
32
+ def on_text_changed(buffer: Buffer) -> None:
33
+ global _last_info
34
+ if not _auto_editing:
35
+ _redo_list.clear()
36
+ if not _should_merge(buffer):
37
+ _undo_list.append(_last_info)
38
+ _last_info = BufferInfo.from_buffer(buffer)
39
+
40
+
41
+ def clean_buffer() -> None:
42
+ global _auto_editing, _previous_text, _last_info, _last_edit_time, _last_edit_kind
43
+ _auto_editing = False
44
+ _previous_text = ""
45
+ _last_info = BufferInfo("", 0)
46
+ _bracket_stack.clear()
47
+ _undo_list.clear()
48
+ _redo_list.clear()
49
+ _last_edit_time = 0
50
+ _last_edit_kind = None
9
51
 
10
- def buffer_get_stack(buffer) -> list[str]:
11
- if not hasattr(buffer, '_bracket_stack'):
12
- buffer._bracket_stack = []
13
- return buffer._bracket_stack
14
52
 
15
- def is_cursor_before_right(buffer, char) -> bool:
53
+ def is_cursor_before_right(buffer: Buffer, char) -> bool:
16
54
  pos = buffer.cursor_position
17
55
  return pos < len(buffer.text) and buffer.text[pos] == char
18
56
 
19
- def should_completion(buffer) -> bool:
57
+ def should_completion(buffer: Buffer) -> bool:
20
58
  pos = buffer.cursor_position
21
59
  return pos >= len(buffer.text) or buffer.text[pos].isspace() or buffer.text[pos] in ")]}>\"\'"
22
60
 
@@ -40,8 +78,9 @@ def _(event: KeyPressEvent) -> None:
40
78
  _auto_editing = True
41
79
  buffer.insert_text('()')
42
80
  buffer.cursor_left()
43
- buffer_get_stack(buffer).append('(')
81
+ _bracket_stack.append('(')
44
82
  _auto_editing = False
83
+ on_text_changed(buffer)
45
84
  else:
46
85
  buffer.insert_text('(')
47
86
 
@@ -65,8 +104,9 @@ def _(event: KeyPressEvent) -> None:
65
104
  _auto_editing = True
66
105
  buffer.insert_text('[]')
67
106
  buffer.cursor_left()
68
- buffer_get_stack(buffer).append('[')
107
+ _bracket_stack.append('[')
69
108
  _auto_editing = False
109
+ on_text_changed(buffer)
70
110
  else:
71
111
  buffer.insert_text('[')
72
112
 
@@ -90,8 +130,9 @@ def _(event: KeyPressEvent) -> None:
90
130
  _auto_editing = True
91
131
  buffer.insert_text('{}')
92
132
  buffer.cursor_left()
93
- buffer_get_stack(buffer).append('{')
133
+ _bracket_stack.append('{')
94
134
  _auto_editing = False
135
+ on_text_changed(buffer)
95
136
  else:
96
137
  buffer.insert_text('{')
97
138
 
@@ -99,7 +140,7 @@ def _(event: KeyPressEvent) -> None:
99
140
  def _(event: KeyPressEvent) -> None:
100
141
  global _auto_editing
101
142
  buffer = event.current_buffer
102
- stack = buffer_get_stack(buffer)
143
+ stack = _bracket_stack
103
144
  if stack and stack[-1] == '(' and is_cursor_before_right(buffer, ')'):
104
145
  _auto_editing = True
105
146
  buffer.cursor_right()
@@ -112,7 +153,7 @@ def _(event: KeyPressEvent) -> None:
112
153
  def _(event: KeyPressEvent) -> None:
113
154
  global _auto_editing
114
155
  buffer = event.current_buffer
115
- stack = buffer_get_stack(buffer)
156
+ stack = _bracket_stack
116
157
  if stack and stack[-1] == '[' and is_cursor_before_right(buffer, ']'):
117
158
  _auto_editing = True
118
159
  buffer.cursor_right()
@@ -125,7 +166,7 @@ def _(event: KeyPressEvent) -> None:
125
166
  def _(event: KeyPressEvent) -> None:
126
167
  global _auto_editing
127
168
  buffer = event.current_buffer
128
- stack = buffer_get_stack(buffer)
169
+ stack = _bracket_stack
129
170
  if stack and stack[-1] == '{' and is_cursor_before_right(buffer, '}'):
130
171
  _auto_editing = True
131
172
  buffer.cursor_right()
@@ -150,7 +191,7 @@ def _(event: KeyPressEvent) -> None:
150
191
  )
151
192
  buffer.selection_state.shift_mode = True
152
193
  return
153
- stack = buffer_get_stack(buffer)
194
+ stack = _bracket_stack
154
195
  if stack and stack[-1] == '"' and is_cursor_before_right(buffer, '"'):
155
196
  _auto_editing = True
156
197
  buffer.cursor_right()
@@ -171,6 +212,7 @@ def _(event: KeyPressEvent) -> None:
171
212
  buffer.cursor_left()
172
213
  stack.append('"')
173
214
  _auto_editing = False
215
+ on_text_changed(buffer)
174
216
  else:
175
217
  buffer.insert_text('"')
176
218
 
@@ -190,7 +232,7 @@ def _(event: KeyPressEvent) -> None:
190
232
  )
191
233
  buffer.selection_state.shift_mode = True
192
234
  return
193
- stack = buffer_get_stack(buffer)
235
+ stack = _bracket_stack
194
236
  if stack and stack[-1] == "'" and is_cursor_before_right(buffer, "'"):
195
237
  _auto_editing = True
196
238
  buffer.cursor_right()
@@ -203,6 +245,7 @@ def _(event: KeyPressEvent) -> None:
203
245
  buffer.cursor_left()
204
246
  stack.append("'")
205
247
  _auto_editing = False
248
+ on_text_changed(buffer)
206
249
  else:
207
250
  buffer.insert_text("'")
208
251
 
@@ -216,12 +259,11 @@ def _(event: KeyPressEvent) -> None:
216
259
  if buffer.selection_state:
217
260
  start = min(buffer.cursor_position, buffer.selection_state.original_cursor_position)
218
261
  end = max(buffer.cursor_position, buffer.selection_state.original_cursor_position)
219
- _auto_editing = True
220
262
  buffer.text = buffer.text[:start] + buffer.text[end:]
221
263
  buffer.cursor_position = start
222
264
  buffer.selection_state = None
223
- buffer_get_stack(buffer).clear()
224
- _auto_editing = False
265
+ _bracket_stack.clear()
266
+ buffer.start_completion()
225
267
  return
226
268
 
227
269
  if pos > 0 and pos < len(text):
@@ -232,7 +274,7 @@ def _(event: KeyPressEvent) -> None:
232
274
  (left == '{' and right == '}') or \
233
275
  (left == '"' and right == '"') or \
234
276
  (left == "'" and right == "'"):
235
- stack = buffer_get_stack(buffer)
277
+ stack = _bracket_stack
236
278
  _auto_editing = True
237
279
  if stack and stack[-1] == left:
238
280
  stack.pop()
@@ -242,20 +284,76 @@ def _(event: KeyPressEvent) -> None:
242
284
  return
243
285
 
244
286
  buffer.delete_before_cursor()
287
+ buffer.start_completion()
245
288
 
246
289
  def on_cursor_position_changed(buffer: Buffer) -> None:
247
- global _auto_editing
290
+ global _auto_editing, _last_info, _previous_text
291
+
248
292
  if _auto_editing:
249
- buffer._previous_text = buffer.text # type: ignore
293
+ _previous_text = buffer.text
250
294
  return
251
295
 
252
- if not hasattr(buffer, '_previous_text'):
253
- buffer._previous_text = buffer.text # type: ignore
254
- return
296
+ _last_info = BufferInfo.from_buffer(buffer)
255
297
 
256
- if buffer.text != buffer._previous_text: # type: ignore
257
- buffer._previous_text = buffer.text # type: ignore
298
+ if buffer.text != _previous_text:
299
+ _previous_text = buffer.text
258
300
  return
259
301
 
260
302
  # Any cursor movement without editing will clear the stack
261
- buffer_get_stack(buffer).clear()
303
+ _bracket_stack.clear()
304
+
305
+
306
+ def _reset_undo_merge() -> None:
307
+ global _last_edit_time, _last_edit_kind
308
+ _last_edit_time = 0.0
309
+ _last_edit_kind = None
310
+
311
+
312
+ def _should_merge(buffer: Buffer) -> bool:
313
+ global _last_edit_time, _last_edit_kind
314
+
315
+ old_text = _last_info.text
316
+ new_text = buffer.text
317
+ if len(new_text) > len(old_text):
318
+ kind = "insert"
319
+ elif len(new_text) < len(old_text):
320
+ kind = "delete"
321
+ else:
322
+ kind = "replace"
323
+
324
+ now = time.monotonic()
325
+ same_kind = (kind == _last_edit_kind)
326
+ in_time = (now - _last_edit_time) <= UNDO_MERGE_TIMEOUT
327
+
328
+ _last_edit_time = now
329
+ _last_edit_kind = kind
330
+
331
+ return same_kind and in_time
332
+
333
+ @bindings.add(Keys.ControlZ)
334
+ def _(event: KeyPressEvent) -> None:
335
+ global _auto_editing, _last_info
336
+ _auto_editing = True
337
+ buffer = event.current_buffer
338
+ if _undo_list:
339
+ _redo_list.append(BufferInfo.from_buffer(buffer))
340
+ info = _undo_list.pop()
341
+ buffer.text = info.text
342
+ buffer._set_cursor_position(info.cursor_pos)
343
+ _auto_editing = False
344
+ _last_info = BufferInfo.from_buffer(buffer)
345
+ _reset_undo_merge()
346
+
347
+ @bindings.add(Keys.ControlY)
348
+ def _(event: KeyPressEvent) -> None:
349
+ global _auto_editing, _last_info
350
+ _auto_editing = True
351
+ buffer = event.current_buffer
352
+ if _redo_list:
353
+ _undo_list.append(BufferInfo.from_buffer(buffer))
354
+ info = _redo_list.pop()
355
+ buffer.text = info.text
356
+ buffer._set_cursor_position(info.cursor_pos)
357
+ _auto_editing = False
358
+ _last_info = BufferInfo.from_buffer(buffer)
359
+ _reset_undo_merge()
@@ -8,7 +8,7 @@ from prompt_toolkit.history import FileHistory
8
8
  from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
9
9
  from .meta import WELCOME_MESSAGE
10
10
  from .environment import namespace
11
- from .keybindings import bindings, on_cursor_position_changed
11
+ from .keybindings import bindings, on_cursor_position_changed, on_text_changed, clean_buffer
12
12
  from .init import xmyshell_init
13
13
  from .kernel import pyeval, xmyshell
14
14
  from .lexer import XmyShellLexer
@@ -29,6 +29,7 @@ def xmyshell_main() -> None:
29
29
  lexer=XmyShellLexer(),
30
30
  )
31
31
  session.default_buffer.on_cursor_position_changed += on_cursor_position_changed
32
+ session.default_buffer.on_text_changed += on_text_changed
32
33
 
33
34
  try:
34
35
  while True:
@@ -43,6 +44,7 @@ def xmyshell_main() -> None:
43
44
  start_time = time_ns()
44
45
  namespace["exit_code"] = xmyshell(cmd_line)
45
46
  namespace["exec_duration"] = time_ns() - start_time
47
+ clean_buffer()
46
48
  except KeyboardInterrupt:
47
49
  print("Ctrl-C")
48
50
  except EOFError:
@@ -64,4 +64,6 @@ Keyboard Shortcuts:
64
64
  ↑↓ - Navigate through command history.
65
65
  → - Apply selected history entry to the prompt.
66
66
  Ctrl+R - Reverse incremental searching (history).
67
+ Ctrl+Z - Undo.
68
+ Ctrl+Y - Redo.
67
69
  Ctrl+D - Exit XmyShell."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xmyshell
3
- Version: 0.2.5
3
+ Version: 0.2.7
4
4
  Summary: XmyShell is a shell-like tool built on top of prompt_toolkit which provides a persistent Python runtime.
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
@@ -164,6 +164,8 @@ The path of config file.
164
164
  - `↑↓` -> Navigate through command history.
165
165
  - `→` -> Apply selected history entry to the prompt.
166
166
  - `Ctrl+R` -> Reverse incremental searching (history).
167
+ - `Ctrl+Z` -> Undo.
168
+ - `Ctrl+Y` -> Redo.
167
169
  - `Ctrl+D` -> Exit XmyShell.
168
170
  - Auto bracket/parenthesis/quote pairing
169
171
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes