py-posix-shell 0.4.0__tar.gz → 0.4.2__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.
- {py_posix_shell-0.4.0 → py_posix_shell-0.4.2}/PKG-INFO +1 -1
- {py_posix_shell-0.4.0 → py_posix_shell-0.4.2}/pyproject.toml +1 -1
- {py_posix_shell-0.4.0 → py_posix_shell-0.4.2}/src/py_posix_shell/__init__.py +1 -1
- {py_posix_shell-0.4.0 → py_posix_shell-0.4.2}/src/py_posix_shell/cli.py +14 -2
- {py_posix_shell-0.4.0 → py_posix_shell-0.4.2}/src/py_posix_shell/shell.py +75 -11
- {py_posix_shell-0.4.0 → py_posix_shell-0.4.2}/tests/test_shell.py +55 -0
- {py_posix_shell-0.4.0 → py_posix_shell-0.4.2}/.gitignore +0 -0
- {py_posix_shell-0.4.0 → py_posix_shell-0.4.2}/LICENSE +0 -0
- {py_posix_shell-0.4.0 → py_posix_shell-0.4.2}/README.md +0 -0
- {py_posix_shell-0.4.0 → py_posix_shell-0.4.2}/src/py_posix_shell/__main__.py +0 -0
- {py_posix_shell-0.4.0 → py_posix_shell-0.4.2}/src/py_posix_shell/builtins.py +0 -0
- {py_posix_shell-0.4.0 → py_posix_shell-0.4.2}/src/py_posix_shell/errors.py +0 -0
- {py_posix_shell-0.4.0 → py_posix_shell-0.4.2}/src/py_posix_shell/expansion.py +0 -0
- {py_posix_shell-0.4.0 → py_posix_shell-0.4.2}/src/py_posix_shell/lexer.py +0 -0
- {py_posix_shell-0.4.0 → py_posix_shell-0.4.2}/src/py_posix_shell/parser.py +0 -0
- {py_posix_shell-0.4.0 → py_posix_shell-0.4.2}/src/py_posix_shell/posix_utils.py +0 -0
|
@@ -35,6 +35,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
35
35
|
return shell.execute(args.command)
|
|
36
36
|
except ShellExit as exc:
|
|
37
37
|
return exc.status
|
|
38
|
+
except KeyboardInterrupt:
|
|
39
|
+
print(file=sys.stderr)
|
|
40
|
+
return 130
|
|
38
41
|
|
|
39
42
|
if args.script:
|
|
40
43
|
try:
|
|
@@ -48,13 +51,22 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
48
51
|
return shell.execute(source)
|
|
49
52
|
except ShellExit as exc:
|
|
50
53
|
return exc.status
|
|
54
|
+
except KeyboardInterrupt:
|
|
55
|
+
print(file=sys.stderr)
|
|
56
|
+
return 130
|
|
51
57
|
|
|
52
58
|
interactive = args.interactive or sys.stdin.isatty()
|
|
53
59
|
shell = Shell(argv0="pysh", interactive=interactive)
|
|
54
60
|
if interactive:
|
|
55
|
-
|
|
61
|
+
try:
|
|
62
|
+
return shell.repl()
|
|
63
|
+
except KeyboardInterrupt:
|
|
64
|
+
print(file=sys.stderr)
|
|
65
|
+
return 130
|
|
56
66
|
try:
|
|
57
67
|
return shell.execute(sys.stdin.read())
|
|
58
68
|
except ShellExit as exc:
|
|
59
69
|
return exc.status
|
|
60
|
-
|
|
70
|
+
except KeyboardInterrupt:
|
|
71
|
+
print(file=sys.stderr)
|
|
72
|
+
return 130
|
|
@@ -6,6 +6,7 @@ import contextlib
|
|
|
6
6
|
import fnmatch
|
|
7
7
|
import io
|
|
8
8
|
import os
|
|
9
|
+
import signal
|
|
9
10
|
import shlex
|
|
10
11
|
import shutil
|
|
11
12
|
import subprocess
|
|
@@ -141,6 +142,8 @@ class Shell:
|
|
|
141
142
|
except ShellContinue:
|
|
142
143
|
self.stderr.write(f"{self.argv0}: continue: only meaningful in a loop\n")
|
|
143
144
|
status = 2
|
|
145
|
+
except KeyboardInterrupt:
|
|
146
|
+
status = 130
|
|
144
147
|
except ShellExit as exc:
|
|
145
148
|
self.last_status = exc.status
|
|
146
149
|
if self._execute_depth == 1:
|
|
@@ -700,10 +703,10 @@ class Shell:
|
|
|
700
703
|
communicate_input = input_text
|
|
701
704
|
|
|
702
705
|
if input_text is not None:
|
|
703
|
-
popen_stdin =
|
|
706
|
+
popen_stdin = subprocess.PIPE
|
|
704
707
|
elif not has_fileno(stdin):
|
|
705
708
|
communicate_input = stdin.read()
|
|
706
|
-
popen_stdin =
|
|
709
|
+
popen_stdin = subprocess.PIPE
|
|
707
710
|
|
|
708
711
|
stdout_capture_target: TextIO | None = None
|
|
709
712
|
if capture_stdout:
|
|
@@ -718,34 +721,39 @@ class Shell:
|
|
|
718
721
|
popen_stderr = subprocess.PIPE
|
|
719
722
|
|
|
720
723
|
try:
|
|
721
|
-
|
|
724
|
+
process = subprocess.Popen(
|
|
722
725
|
[executable, *argv[1:]],
|
|
723
|
-
input=communicate_input,
|
|
724
726
|
stdin=popen_stdin,
|
|
725
727
|
stdout=popen_stdout,
|
|
726
728
|
stderr=popen_stderr,
|
|
727
729
|
env=env,
|
|
728
730
|
text=True,
|
|
729
731
|
)
|
|
732
|
+
completed_stdout, completed_stderr = process.communicate(input=communicate_input)
|
|
730
733
|
except PermissionError:
|
|
731
734
|
(stderr or self.stderr).write(f"{argv[0]}: permission denied\n")
|
|
732
735
|
return 126
|
|
733
736
|
except FileNotFoundError:
|
|
734
737
|
(stderr or self.stderr).write(f"{argv[0]}: command not found\n")
|
|
735
738
|
return 127
|
|
739
|
+
except KeyboardInterrupt:
|
|
740
|
+
if "process" in locals():
|
|
741
|
+
stop_process_after_interrupt(process)
|
|
742
|
+
self._last_external_output = ""
|
|
743
|
+
return 130
|
|
736
744
|
except OSError as exc:
|
|
737
745
|
(stderr or self.stderr).write(f"{argv[0]}: {exc}\n")
|
|
738
746
|
return 126
|
|
739
747
|
|
|
740
|
-
if capture_stdout and
|
|
741
|
-
self._last_external_output =
|
|
748
|
+
if capture_stdout and completed_stdout:
|
|
749
|
+
self._last_external_output = completed_stdout
|
|
742
750
|
else:
|
|
743
751
|
self._last_external_output = ""
|
|
744
|
-
if stdout_capture_target is not None and
|
|
745
|
-
stdout_capture_target.write(
|
|
746
|
-
if stderr_capture_target is not None and
|
|
747
|
-
stderr_capture_target.write(
|
|
748
|
-
return
|
|
752
|
+
if stdout_capture_target is not None and completed_stdout:
|
|
753
|
+
stdout_capture_target.write(completed_stdout)
|
|
754
|
+
if stderr_capture_target is not None and completed_stderr:
|
|
755
|
+
stderr_capture_target.write(completed_stderr)
|
|
756
|
+
return normalize_process_status(process.returncode)
|
|
749
757
|
|
|
750
758
|
def run_preexpanded(self, argv: list[str], *, stdin: TextIO, stdout: TextIO, stderr: TextIO) -> int:
|
|
751
759
|
if not argv:
|
|
@@ -836,6 +844,12 @@ class Shell:
|
|
|
836
844
|
except EOFError:
|
|
837
845
|
self.stdout.write("\n")
|
|
838
846
|
return status
|
|
847
|
+
except KeyboardInterrupt:
|
|
848
|
+
self.stdout.write("\n")
|
|
849
|
+
buffer = ""
|
|
850
|
+
status = 130
|
|
851
|
+
self.last_status = status
|
|
852
|
+
continue
|
|
839
853
|
candidate = buffer + line + "\n"
|
|
840
854
|
try:
|
|
841
855
|
parse(candidate)
|
|
@@ -847,10 +861,20 @@ class Shell:
|
|
|
847
861
|
buffer = ""
|
|
848
862
|
status = 2
|
|
849
863
|
continue
|
|
864
|
+
except KeyboardInterrupt:
|
|
865
|
+
self.stdout.write("\n")
|
|
866
|
+
buffer = ""
|
|
867
|
+
status = 130
|
|
868
|
+
self.last_status = status
|
|
869
|
+
continue
|
|
850
870
|
try:
|
|
851
871
|
status = self.execute(candidate)
|
|
852
872
|
except ShellExit as exc:
|
|
853
873
|
return exc.status
|
|
874
|
+
except KeyboardInterrupt:
|
|
875
|
+
self.stdout.write("\n")
|
|
876
|
+
status = 130
|
|
877
|
+
self.last_status = status
|
|
854
878
|
buffer = ""
|
|
855
879
|
|
|
856
880
|
def get_parameter(self, name: str, *, strict: bool = False) -> str:
|
|
@@ -947,6 +971,46 @@ def has_fileno(stream: TextIO) -> bool:
|
|
|
947
971
|
return True
|
|
948
972
|
|
|
949
973
|
|
|
974
|
+
def stop_process_after_interrupt(process: subprocess.Popen[str]) -> None:
|
|
975
|
+
try:
|
|
976
|
+
process.wait(timeout=0.2)
|
|
977
|
+
return
|
|
978
|
+
except subprocess.TimeoutExpired:
|
|
979
|
+
pass
|
|
980
|
+
except OSError:
|
|
981
|
+
return
|
|
982
|
+
|
|
983
|
+
try:
|
|
984
|
+
if os.name == "nt":
|
|
985
|
+
process.terminate()
|
|
986
|
+
else:
|
|
987
|
+
process.send_signal(signal.SIGINT)
|
|
988
|
+
except OSError:
|
|
989
|
+
return
|
|
990
|
+
|
|
991
|
+
try:
|
|
992
|
+
process.wait(timeout=1)
|
|
993
|
+
except subprocess.TimeoutExpired:
|
|
994
|
+
try:
|
|
995
|
+
process.kill()
|
|
996
|
+
except OSError:
|
|
997
|
+
return
|
|
998
|
+
try:
|
|
999
|
+
process.wait(timeout=1)
|
|
1000
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1001
|
+
return
|
|
1002
|
+
except OSError:
|
|
1003
|
+
return
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def normalize_process_status(returncode: int) -> int:
|
|
1007
|
+
if os.name == "nt" and returncode in {0xC000013A, -1073741510}:
|
|
1008
|
+
return 130
|
|
1009
|
+
if returncode < 0:
|
|
1010
|
+
return 128 + abs(returncode)
|
|
1011
|
+
return returncode
|
|
1012
|
+
|
|
1013
|
+
|
|
950
1014
|
def invert_status(status: int) -> int:
|
|
951
1015
|
return 0 if status != 0 else 1
|
|
952
1016
|
|
|
@@ -4,6 +4,7 @@ import io
|
|
|
4
4
|
import os
|
|
5
5
|
import sys
|
|
6
6
|
|
|
7
|
+
import py_posix_shell.shell as shell_module
|
|
7
8
|
from py_posix_shell.lexer import dump_tokens, lex
|
|
8
9
|
from py_posix_shell.errors import ShellExit
|
|
9
10
|
from py_posix_shell.shell import Shell
|
|
@@ -339,6 +340,60 @@ def test_command_and_type_report_internal_utility_when_path_is_empty():
|
|
|
339
340
|
assert stderr == ""
|
|
340
341
|
|
|
341
342
|
|
|
343
|
+
def test_external_keyboard_interrupt_returns_130(monkeypatch):
|
|
344
|
+
shell = Shell(stdin=io.StringIO(), stdout=io.StringIO(), stderr=io.StringIO())
|
|
345
|
+
shell.resolve_command = lambda _name, _env: "fake-more" # type: ignore[method-assign]
|
|
346
|
+
events: list[str] = []
|
|
347
|
+
|
|
348
|
+
class FakeProcess:
|
|
349
|
+
returncode = None
|
|
350
|
+
|
|
351
|
+
def communicate(self, input=None):
|
|
352
|
+
events.append(f"communicate:{input!r}")
|
|
353
|
+
raise KeyboardInterrupt
|
|
354
|
+
|
|
355
|
+
def wait(self, timeout=None):
|
|
356
|
+
events.append(f"wait:{timeout}")
|
|
357
|
+
if timeout == 0.2:
|
|
358
|
+
raise shell_module.subprocess.TimeoutExpired("fake-more", timeout)
|
|
359
|
+
self.returncode = -2
|
|
360
|
+
return self.returncode
|
|
361
|
+
|
|
362
|
+
def terminate(self):
|
|
363
|
+
events.append("terminate")
|
|
364
|
+
|
|
365
|
+
def send_signal(self, sig):
|
|
366
|
+
events.append(f"signal:{sig}")
|
|
367
|
+
|
|
368
|
+
def kill(self):
|
|
369
|
+
events.append("kill")
|
|
370
|
+
|
|
371
|
+
monkeypatch.setattr(shell_module.subprocess, "Popen", lambda *args, **kwargs: FakeProcess())
|
|
372
|
+
|
|
373
|
+
assert shell.run_external(["more"]) == 130
|
|
374
|
+
assert "communicate:''" in events
|
|
375
|
+
assert "terminate" in events or any(event.startswith("signal:") for event in events)
|
|
376
|
+
assert shell.stderr.getvalue() == ""
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def test_repl_keyboard_interrupt_returns_to_prompt(monkeypatch):
|
|
380
|
+
stdout = io.StringIO()
|
|
381
|
+
shell = Shell(stdout=stdout, stderr=io.StringIO(), interactive=True)
|
|
382
|
+
events = iter([KeyboardInterrupt, "echo after:$?", EOFError])
|
|
383
|
+
|
|
384
|
+
def fake_input(_prompt):
|
|
385
|
+
event = next(events)
|
|
386
|
+
if isinstance(event, type) and issubclass(event, BaseException):
|
|
387
|
+
raise event
|
|
388
|
+
return event
|
|
389
|
+
|
|
390
|
+
monkeypatch.setattr("builtins.input", fake_input)
|
|
391
|
+
|
|
392
|
+
assert shell.repl() == 0
|
|
393
|
+
assert shell.last_status == 0
|
|
394
|
+
assert stdout.getvalue() == "\nafter:130\n\n"
|
|
395
|
+
|
|
396
|
+
|
|
342
397
|
def test_internal_extended_text_utilities_without_path(tmp_path):
|
|
343
398
|
data = tmp_path / "data.txt"
|
|
344
399
|
csv = tmp_path / "data.csv"
|
|
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
|