xmyshell 0.2.11__tar.gz → 0.2.13__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 (28) hide show
  1. {xmyshell-0.2.11/xmyshell.egg-info → xmyshell-0.2.13}/PKG-INFO +7 -1
  2. {xmyshell-0.2.11 → xmyshell-0.2.13}/README.md +6 -0
  3. {xmyshell-0.2.11 → xmyshell-0.2.13}/pyproject.toml +1 -1
  4. {xmyshell-0.2.11 → xmyshell-0.2.13}/xmyshell/completer.py +47 -8
  5. {xmyshell-0.2.11 → xmyshell-0.2.13}/xmyshell/environment.py +3 -0
  6. {xmyshell-0.2.11 → xmyshell-0.2.13}/xmyshell/kernel.py +69 -46
  7. {xmyshell-0.2.11 → xmyshell-0.2.13}/xmyshell/keybindings.py +21 -10
  8. {xmyshell-0.2.11 → xmyshell-0.2.13}/xmyshell/lexer.py +41 -1
  9. {xmyshell-0.2.11 → xmyshell-0.2.13}/xmyshell/main.py +3 -2
  10. {xmyshell-0.2.11 → xmyshell-0.2.13}/xmyshell/meta.py +2 -0
  11. {xmyshell-0.2.11 → xmyshell-0.2.13}/xmyshell/stubs/__init__.pyi +2 -0
  12. {xmyshell-0.2.11 → xmyshell-0.2.13/xmyshell.egg-info}/PKG-INFO +7 -1
  13. {xmyshell-0.2.11 → xmyshell-0.2.13}/LICENSE +0 -0
  14. {xmyshell-0.2.11 → xmyshell-0.2.13}/setup.cfg +0 -0
  15. {xmyshell-0.2.11 → xmyshell-0.2.13}/xmyshell/__init__.py +0 -0
  16. {xmyshell-0.2.11 → xmyshell-0.2.13}/xmyshell/__main__.py +0 -0
  17. {xmyshell-0.2.11 → xmyshell-0.2.13}/xmyshell/data/commands.json +0 -0
  18. {xmyshell-0.2.11 → xmyshell-0.2.13}/xmyshell/init.py +0 -0
  19. {xmyshell-0.2.11 → xmyshell-0.2.13}/xmyshell/stubs/__init__.py +0 -0
  20. {xmyshell-0.2.11 → xmyshell-0.2.13}/xmyshell/stubs/py.typed +0 -0
  21. {xmyshell-0.2.11 → xmyshell-0.2.13}/xmyshell/themes.py +0 -0
  22. {xmyshell-0.2.11 → xmyshell-0.2.13}/xmyshell/update.py +0 -0
  23. {xmyshell-0.2.11 → xmyshell-0.2.13}/xmyshell/utils.py +0 -0
  24. {xmyshell-0.2.11 → xmyshell-0.2.13}/xmyshell.egg-info/SOURCES.txt +0 -0
  25. {xmyshell-0.2.11 → xmyshell-0.2.13}/xmyshell.egg-info/dependency_links.txt +0 -0
  26. {xmyshell-0.2.11 → xmyshell-0.2.13}/xmyshell.egg-info/entry_points.txt +0 -0
  27. {xmyshell-0.2.11 → xmyshell-0.2.13}/xmyshell.egg-info/requires.txt +0 -0
  28. {xmyshell-0.2.11 → xmyshell-0.2.13}/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.11
3
+ Version: 0.2.13
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
@@ -163,6 +163,12 @@ The exit code of the last command.
163
163
  - `exec_duration`
164
164
  The duration which last command took.
165
165
 
166
+ - `SHOW_WELCOME`
167
+ Toggle whether to show welcome message when staring.
168
+
169
+ - `SHELL_CMD`
170
+ Shell command line prefix, defaults to `["cmd", "/c"]` on Windows and `["bin/sh", "-c"]` on other OS.
171
+
166
172
  - `HOME_DIR`
167
173
  The home directory, similar to `~`.
168
174
 
@@ -153,6 +153,12 @@ The exit code of the last command.
153
153
  - `exec_duration`
154
154
  The duration which last command took.
155
155
 
156
+ - `SHOW_WELCOME`
157
+ Toggle whether to show welcome message when staring.
158
+
159
+ - `SHELL_CMD`
160
+ Shell command line prefix, defaults to `["cmd", "/c"]` on Windows and `["bin/sh", "-c"]` on other OS.
161
+
156
162
  - `HOME_DIR`
157
163
  The home directory, similar to `~`.
158
164
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xmyshell"
3
- version = "0.2.11"
3
+ version = "0.2.13"
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,5 +1,6 @@
1
1
  from collections.abc import Generator, Iterable
2
2
  import os
3
+ import shlex
3
4
  import sys
4
5
  import re
5
6
  import time
@@ -118,16 +119,51 @@ class ShellCompleter(Completer):
118
119
  others = sorted(c for c in commands if c not in COMMON and c not in BUILTINS)
119
120
  return builtins + common + others
120
121
 
121
- def _path_completions(self, text: str, max_results: int = 100):
122
+ def _path_completions(self, text: str, max_results: int = 100, in_string: bool=False):
122
123
  if text and text[-1] in whitespace: return
123
- last = text.split()[-1] if text.split() else ""
124
+
125
+ def last_token_raw(text: str) -> str:
126
+ quote = None
127
+ start = 0
128
+ i = 0
129
+ while i < len(text):
130
+ ch = text[i]
131
+ if quote:
132
+ if ch == quote:
133
+ quote = None
134
+ elif ch == "\\" and quote == '"':
135
+ i += 1
136
+ else:
137
+ if ch in ("'", '"'):
138
+ quote = ch
139
+ elif ch in " \t":
140
+ start = i + 1
141
+ elif ch == "\\":
142
+ i += 1
143
+ i += 1
144
+ return text[start:]
145
+
146
+ def start_pos(text: str, prefix: str) -> int:
147
+ original_len = len(text)
148
+ if text and text[-1] in ("'", '"') and not text.endswith(prefix):
149
+ text = text[:-1]
150
+ if not prefix or not text.endswith(prefix):
151
+ return -len(prefix)
152
+ start = len(text) - len(prefix)
153
+ if start > 0 and text[start - 1] in ("'", '"'):
154
+ start -= 1
155
+ return start - original_len
156
+
157
+ last = last_token_raw(text)
158
+ in_string = in_string or last.startswith('"') or last.startswith("'")
159
+ last = last.lstrip('"\'')
124
160
  if last == '~': return # don't remove this
125
161
  path = os.path.expanduser(last)
126
162
  dirname = os.path.dirname(path) or "."
127
- prefix = os.path.basename(path)
163
+ prefix = os.path.basename(path).strip('"\'')
128
164
 
129
165
  try:
130
- matches = []
166
+ matches: list[str] = []
131
167
  for item in os.listdir(dirname):
132
168
  if item.startswith(prefix):
133
169
  matches.append(item)
@@ -137,11 +173,14 @@ class ShellCompleter(Completer):
137
173
  for item in sorted(matches):
138
174
  full = os.path.join(dirname, item)
139
175
  is_dir = os.path.isdir(full)
140
- name = item + ("/" if is_dir else "")
176
+ if ' ' in item and not in_string:
177
+ name = f'"{item}"'
178
+ else:
179
+ name = item
141
180
  yield Completion(
142
181
  name,
143
- display=truncate(name, MAX_DISPLAY_LEN),
144
- start_position=-len(prefix),
182
+ display=truncate(item, MAX_DISPLAY_LEN),
183
+ start_position=start_pos(text, prefix),
145
184
  display_meta="directory" if is_dir else "file",
146
185
  )
147
186
  except OSError:
@@ -207,7 +246,7 @@ class ShellCompleter(Completer):
207
246
 
208
247
  string = _in_string(text)
209
248
  if string:
210
- yield from self._path_completions(string)
249
+ yield from self._path_completions(string, in_string=True)
211
250
  return
212
251
 
213
252
  if text and text[-1].isspace():
@@ -10,6 +10,7 @@ from .utils import XMYSHELL_MAGIC, getcwd, getlogin, HOME_DIR, xmyshell_cat
10
10
  namespace: dict[str, Any] = {}
11
11
  aliases: dict[str, str] = {}
12
12
  init_environ: dict[str, str] = dict(os.environ)
13
+ SHELL_CMD = ["cmd", "/c"] if os.name == "nt" else ["/bin/sh", "-c"]
13
14
 
14
15
 
15
16
  def xmyshell_alias(altname: str, target: str) -> None:
@@ -34,6 +35,8 @@ _init_namespace: dict[str, Any] = {
34
35
  "cat": xmyshell_cat,
35
36
  "alias": xmyshell_alias,
36
37
  "unalias": xmyshell_unalias,
38
+ "SHOW_WELCOME": True,
39
+ "SHELL_CMD": SHELL_CMD,
37
40
  "HOME_DIR": HOME_DIR,
38
41
  "PROFILE": f"{HOME_DIR}/.xmyshell/config.py",
39
42
  "exit_code": 0,
@@ -1,11 +1,13 @@
1
1
  from collections.abc import Generator, Sequence
2
+ from contextlib import contextmanager
2
3
  import os
3
4
  import sys
5
+ import re
4
6
  import runpy
5
7
  import subprocess
6
8
  import signal
7
9
  import shlex
8
- from .environment import namespace, aliases, xmyshell_alias, xmyshell_unalias
10
+ from .environment import namespace, aliases, xmyshell_alias, xmyshell_unalias, SHELL_CMD
9
11
  from .utils import pywarning, pyerror, getcwd
10
12
  from .meta import HELP_MESSAGE
11
13
 
@@ -14,8 +16,20 @@ def _reload() -> None:
14
16
  from .init import xmyshell_reload
15
17
  xmyshell_reload()
16
18
 
19
+ def _reset_sigint():
20
+ signal.signal(signal.SIGINT, signal.SIG_DFL)
17
21
 
18
- def _run(*args, **kwargs) -> subprocess.CompletedProcess:
22
+ _kwargs_preexec = {}
23
+ if os.name == "posix":
24
+ _kwargs_preexec["preexec_fn"] = _reset_sigint
25
+
26
+ @contextmanager
27
+ def _no_interrupt():
28
+ old = signal.signal(signal.SIGINT, signal.SIG_IGN)
29
+ yield
30
+ signal.signal(signal.SIGINT, old)
31
+
32
+ def _run(cmd_line: list[str] | str, **kwargs) -> subprocess.CompletedProcess:
19
33
  # Block SIGINT in the parent while the child runs, so that a Ctrl-C
20
34
  # delivered to the console/process group is handled solely by the child.
21
35
  #
@@ -26,17 +40,15 @@ def _run(*args, **kwargs) -> subprocess.CompletedProcess:
26
40
  # KeyboardInterrupt handler only fires for Ctrl-C at the prompt.
27
41
  #
28
42
  # Must run on the main thread: signal.signal() is not allowed elsewhere.
29
- kwargs_preexec = {}
30
- if os.name == "posix":
31
- def _reset_sigint():
32
- signal.signal(signal.SIGINT, signal.SIG_DFL)
33
- kwargs_preexec["preexec_fn"] = _reset_sigint
34
- old = signal.signal(signal.SIGINT, signal.SIG_IGN)
35
- try:
36
- return subprocess.run(*args, **kwargs_preexec, **kwargs)
37
- finally:
38
- signal.signal(signal.SIGINT, old)
39
-
43
+ with _no_interrupt():
44
+ if isinstance(cmd_line, list):
45
+ return subprocess.run(cmd_line, **_kwargs_preexec, **kwargs)
46
+ else:
47
+ return subprocess.run(
48
+ namespace.get("SHELL_CMD", SHELL_CMD) + [cmd_line],
49
+ **_kwargs_preexec,
50
+ **kwargs,
51
+ )
40
52
 
41
53
  def xmyshell_source(path: str) -> None:
42
54
  _updated = runpy.run_path(
@@ -250,29 +262,30 @@ def xmyshell(cmd_line: str) -> int:
250
262
  cmd_line, sh_cmd = cmd_line.split("|>", 1)
251
263
 
252
264
  if sh_cmd:
253
- proc = subprocess.Popen(
254
- sh_cmd,
255
- shell=True,
256
- env=os.environ,
257
- stdin=subprocess.PIPE,
258
- stdout=subprocess.PIPE,
259
- text=True,
260
- )
261
- old_stdout = sys.stdout
262
- sys.stdout = proc.stdin
263
- return_code = xmyshell_raw_command(cmd_line)
264
- if return_code is None:
265
- pyerror(f"'{cmd_line}' cannot be used in a pipeline with '|>'")
266
- return -1
267
- assert proc.stdin
268
- proc.stdin.close()
269
- sys.stdout = old_stdout
270
- if target_var:
271
- result, _ = proc.communicate()
272
- namespace[target_var] = result
273
- return proc.returncode
274
- else:
275
- return proc.wait()
265
+ with _no_interrupt():
266
+ proc = subprocess.Popen(
267
+ namespace.get("SHELL_CMD", SHELL_CMD) + [sh_cmd],
268
+ env=os.environ,
269
+ stdin=subprocess.PIPE,
270
+ stdout=subprocess.PIPE,
271
+ text=True,
272
+ **_kwargs_preexec,
273
+ )
274
+ old_stdout = sys.stdout
275
+ sys.stdout = proc.stdin
276
+ return_code = xmyshell_raw_command(cmd_line)
277
+ if return_code is None:
278
+ pyerror(f"'{cmd_line}' cannot be used in a pipeline with '|>'")
279
+ return -1
280
+ assert proc.stdin
281
+ proc.stdin.close()
282
+ sys.stdout = old_stdout
283
+ if target_var:
284
+ result, _ = proc.communicate()
285
+ namespace[target_var] = result
286
+ return proc.returncode
287
+ else:
288
+ return proc.wait()
276
289
 
277
290
  if target_var:
278
291
  from io import StringIO
@@ -310,21 +323,31 @@ def xmyshell(cmd_line: str) -> int:
310
323
  try:
311
324
  target_dir = os.path.expanduser(target_dir)
312
325
  # only works on Windows
313
- if os.name == "nt" and target_dir.startswith("/"):
314
- import re
315
-
326
+ if os.name == "nt":
316
327
  if target_dir.startswith("/"):
317
- m = re.match(r"^/([a-zA-Z])/", target_dir)
328
+ m = re.match(r"^/([a-zA-Z])(/.*)?$", target_dir)
318
329
  if m:
319
330
  drive = m.group(1).upper()
320
- target_dir = drive + ":/" + target_dir[3:]
321
- os.chdir(target_dir)
331
+ rest = m.group(2) or ""
332
+ target_dir = drive + ":" + rest
333
+ target_dir = target_dir.replace("\\", "/")
334
+ _splited = shlex.split(target_dir)
335
+ if len(_splited) > 1:
336
+ pyerror("too many arguments")
337
+ return -1
338
+ os.chdir(_splited[0])
322
339
  return 0
323
340
  except FileNotFoundError:
324
- pyerror(f"file not found: '{target_dir}'")
341
+ pyerror(f"file not found: {target_dir!r}")
325
342
  return -1
326
343
  except NotADirectoryError:
327
- pyerror(f"not a directory: '{target_dir}'")
344
+ pyerror(f"not a directory: {target_dir!r}")
345
+ return -1
346
+ except PermissionError:
347
+ pyerror(f"permission denied: {target_dir!r}")
348
+ return -1
349
+ except (OSError, ValueError):
350
+ pyerror(f"wrong path format: {target_dir!r}")
328
351
  return -1
329
352
 
330
353
  case "source":
@@ -342,10 +365,10 @@ def xmyshell(cmd_line: str) -> int:
342
365
  cmd_line = _fix_first_word(cmd_line)
343
366
  if target_var:
344
367
  result = _run(
345
- cmd_line, shell=True, env=os.environ, capture_output=True, text=True
368
+ cmd_line, env=os.environ, capture_output=True, text=True
346
369
  )
347
370
  namespace[target_var] = result.stdout
348
371
  else:
349
- result = _run(cmd_line, shell=True, env=os.environ)
372
+ result = _run(cmd_line, env=os.environ)
350
373
 
351
374
  return result.returncode
@@ -3,7 +3,6 @@ from prompt_toolkit.buffer import Buffer
3
3
  from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
4
4
  from prompt_toolkit.selection import SelectionState
5
5
  from prompt_toolkit.keys import Keys
6
- from prompt_toolkit.key_binding.bindings.auto_suggest import load_auto_suggest_bindings
7
6
  from dataclasses import dataclass
8
7
 
9
8
  @dataclass
@@ -200,15 +199,12 @@ def _(event: KeyPressEvent) -> None:
200
199
  stack.pop()
201
200
  _auto_editing = False
202
201
  return
203
- if should_completion(buffer) and (
204
- len(buffer.text) > 0
205
- and buffer.cursor_position > 0
206
- and (
207
- buffer.text[buffer.cursor_position - 1] in r"!@#$%^&*:;,./?|\+-=~()[]{}<>_"
208
- or buffer.text[buffer.cursor_position - 1].isspace()
209
- )
210
- or buffer.cursor_position == 0
211
- ):
202
+ pos = buffer.cursor_position
203
+ text = buffer.text
204
+ before_ok = pos == 0 or (pos > 0 and (text[pos - 1].isspace()
205
+ or text[pos - 1] in set(r"!@#$%^&*:;,./?|+-=~()[]{}<>_")))
206
+ not_on_quote = pos >= len(text) or text[pos] != '"'
207
+ if should_completion(buffer) and before_ok and not_on_quote:
212
208
  _auto_editing = True
213
209
  buffer.insert_text('""')
214
210
  buffer.cursor_left()
@@ -359,3 +355,18 @@ def _(event: KeyPressEvent) -> None:
359
355
  _auto_editing = False
360
356
  _last_info = BufferInfo.from_buffer(buffer)
361
357
  _reset_undo_merge()
358
+
359
+ @bindings.add(Keys.Tab)
360
+ def _(event: KeyPressEvent) -> None:
361
+ buffer = event.current_buffer
362
+ if buffer.complete_state:
363
+ if buffer.complete_state.current_completion is None:
364
+ buffer.complete_next()
365
+ if buffer.complete_state.current_completion:
366
+ # Trailing space bypasses prompt_toolkit's `completion_does_nothing`
367
+ # filter (which hides completions identical to the replaced text) and
368
+ # doubles as the shell argument separator. Invisible characters would
369
+ # also bypass the filter, but would corrupt command parsing.
370
+ text = buffer.complete_state.current_completion.text
371
+ buffer.complete_state.current_completion.text = text.rstrip()
372
+ buffer.apply_completion(buffer.complete_state.current_completion)
@@ -14,6 +14,46 @@ def _is_number_literal(s: str) -> bool:
14
14
  except (ValueError, SyntaxError):
15
15
  return False
16
16
 
17
+ def _shell_split_keep_sep(text: str) -> list[str]:
18
+ parts: list[str] = []
19
+ quote: str | None = None
20
+ i = 0
21
+ n = len(text)
22
+ seg_start = 0
23
+
24
+ def flush(end: int) -> None:
25
+ nonlocal seg_start
26
+ if end > seg_start:
27
+ parts.append(text[seg_start:end])
28
+ seg_start = end
29
+
30
+ while i < n:
31
+ ch = text[i]
32
+ if quote is None:
33
+ if ch in " \t":
34
+ flush(i)
35
+ j = i
36
+ while j < n and text[j] in " \t":
37
+ j += 1
38
+ parts.append(text[i:j])
39
+ seg_start = j
40
+ i = j
41
+ continue
42
+ elif ch == "\\" and i + 1 < n:
43
+ i += 2
44
+ continue
45
+ elif ch in ("'", '"'):
46
+ quote = ch
47
+ else:
48
+ if ch == quote:
49
+ quote = None
50
+ elif ch == "\\" and quote == '"' and i + 1 < n:
51
+ i += 2
52
+ continue
53
+ i += 1
54
+ flush(n)
55
+ return parts
56
+
17
57
  def _split_with_braces_and_pipes(line: str) -> list[str]:
18
58
  parts = []
19
59
  outer_buf = []
@@ -86,7 +126,7 @@ def _split_with_braces_and_pipes(line: str) -> list[str]:
86
126
  return parts
87
127
 
88
128
  def _lex_cmd(cmd: str, first_word: bool = True) -> list[tuple[str, str]]:
89
- parts: list[str] = re.split(r"(\s+)", cmd)
129
+ parts: list[str] = _shell_split_keep_sep(cmd)
90
130
  result: list[tuple[str, str]] = []
91
131
  for part in parts:
92
132
  if part == "":
@@ -17,8 +17,9 @@ from .lexer import XmyShellLexer
17
17
  def xmyshell_main() -> None:
18
18
  xmyshell_init()
19
19
 
20
- print(WELCOME_MESSAGE)
21
- print(f"Initialized with {(time_ns() - _start_time) // 1_000_000}ms.")
20
+ if namespace.get("SHOW_WELCOME"):
21
+ print(WELCOME_MESSAGE)
22
+ print(f"Initialized with {(time_ns() - _start_time) // 1_000_000}ms.")
22
23
 
23
24
  session = PromptSession(
24
25
  history=FileHistory(os.path.expanduser("~/.xmyshell/history.txt")),
@@ -59,6 +59,8 @@ Built-in variables:
59
59
  exec_duration - The duration which last command took.
60
60
  HOME_DIR - The home directory, similar to "~".
61
61
  PROFILE - The path of config file.
62
+ SHOW_WELCOME - Toggle whether to show welcome message.
63
+ SHELL_CMD - Shell command line prefix, e.g. ["cmd", "/c"]
62
64
 
63
65
  Keyboard Shortcuts:
64
66
  ↑↓ - Navigate through command history.
@@ -7,6 +7,8 @@ def basename(p: str) -> str: ...
7
7
  def cat(file_path: str) -> str: ...
8
8
  def alias(altname: str, target: str) -> None: ...
9
9
  def unalias(altname: str) -> None: ...
10
+ SHOW_WELCOME: bool
11
+ SHELL_CMD: list[str]
10
12
  HOME_DIR: str
11
13
  PROFILE: str
12
14
  exit_code: int
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xmyshell
3
- Version: 0.2.11
3
+ Version: 0.2.13
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
@@ -163,6 +163,12 @@ The exit code of the last command.
163
163
  - `exec_duration`
164
164
  The duration which last command took.
165
165
 
166
+ - `SHOW_WELCOME`
167
+ Toggle whether to show welcome message when staring.
168
+
169
+ - `SHELL_CMD`
170
+ Shell command line prefix, defaults to `["cmd", "/c"]` on Windows and `["bin/sh", "-c"]` on other OS.
171
+
166
172
  - `HOME_DIR`
167
173
  The home directory, similar to `~`.
168
174
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes