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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py-posix-shell
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: A cross-platform POSIX-style shell written in Python.
5
5
  Author: GGN_2015
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "py-posix-shell"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  description = "A cross-platform POSIX-style shell written in Python."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  """A cross-platform POSIX-style shell."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.4.2"
@@ -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
- return shell.repl()
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 = None
706
+ popen_stdin = subprocess.PIPE
704
707
  elif not has_fileno(stdin):
705
708
  communicate_input = stdin.read()
706
- popen_stdin = None
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
- completed = subprocess.run(
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 completed.stdout:
741
- self._last_external_output = completed.stdout
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 completed.stdout:
745
- stdout_capture_target.write(completed.stdout)
746
- if stderr_capture_target is not None and completed.stderr:
747
- stderr_capture_target.write(completed.stderr)
748
- return completed.returncode
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