Repository: amoffat/sh
Pull request #751 (2025-02-24 07:05:00)

Release 2.2.2


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0eae0cf..78859dd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 # Changelog
 
+## 2.2.2 - 2/23/25
+
+- Bugfix where it was impossible to use a signal as an `ok_code` [#699](https://github.com/amoffat/sh/issues/699)
+
 ## 2.2.1 - 1/9/25
 
 - Bugfix where `async` and `return_cmd` does not raise exceptions [#746](https://github.com/amoffat/sh/pull/746)
diff --git a/README.rst b/README.rst
index 962e8dc..a5b73fb 100644
--- a/README.rst
+++ b/README.rst
@@ -63,6 +63,16 @@ To run a single test::
 
     $> make test='FunctionalTests.test_background' test_one
 
+Docs
+----
+
+To build the docs, make sure you've run ``poetry install`` to install the dev dependencies, then::
+
+    $> cd docs
+    $> make html
+
+This will generate the docs in ``docs/build/html``. You can open the ``index.html`` file in your browser to view the docs.
+
 Coverage
 --------
 
diff --git a/docs/source/sections/exit_codes.rst b/docs/source/sections/exit_codes.rst
index 1711902..edca84b 100644
--- a/docs/source/sections/exit_codes.rst
+++ b/docs/source/sections/exit_codes.rst
@@ -49,6 +49,11 @@ exception raised in this situation is :ref:`signal_exc`, which subclasses
     except sh.SignalException_SIGKILL:
         print("killed")
 
+This behavior could be blocked by appending the negative value of the signal to
+:ref:`ok_code`. All signals that raises :ref:`signal_exc` are ``[SIGABRT, 
+SIGBUS, SIGFPE, SIGILL, SIGINT, SIGKILL, SIGPIPE, SIGQUIT, SIGSEGV, SIGTERM, 
+SIGTERM]``.
+
 .. note::
 
     You can catch :ref:`signal_exc` by using either a number or a signal name.
diff --git a/docs/source/sections/special_arguments.rst b/docs/source/sections/special_arguments.rst
index 8f745f3..8cb3d0f 100644
--- a/docs/source/sections/special_arguments.rst
+++ b/docs/source/sections/special_arguments.rst
@@ -210,6 +210,20 @@ programs use exit codes other than 0 to indicate success.
     import sh
     sh.weird_program(_ok_code=[0,3,5])
 
+If the process is killed by a signal, a :ref:`signal_exc` is raised by
+default. This behavior could be blocked by appending a negative number to
+:ref:`ok_code` that represents the signal.
+
+.. code-block:: python
+
+    import sh
+    # the process won't raise SignalException if SIGINT, SIGKILL, or SIGTERM
+    # are sent to kill the process
+    p = sh.sleep(3, _bg=True, _ok_code=[0, -2, -9, -15])
+
+    # No exception will be raised here
+    p.kill()
+
 .. seealso:: :ref:`exit_codes`
 
 .. _new_session:
diff --git a/pyproject.toml b/pyproject.toml
index af03549..0e8bb2f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "sh"
-version = "2.2.1"
+version = "2.2.2"
 description = "Python subprocess replacement"
 authors = ["Andrew Moffat <arwmoffat@gmail.com>"]
 readme = "README.rst"
diff --git a/sh.py b/sh.py
index 8f8f3c6..d339b1e 100644
--- a/sh.py
+++ b/sh.py
@@ -2,8 +2,9 @@
 https://sh.readthedocs.io/en/latest/
 https://github.com/amoffat/sh
 """
+
 # ===============================================================================
-# Copyright (C) 2011-2023 by Andrew Moffat
+# Copyright (C) 2011-2025 by Andrew Moffat
 #
 # Permission is hereby granted, free of charge, to any person obtaining a copy
 # of this software and associated documentation files (the "Software"), to deal
@@ -24,10 +25,9 @@ https://github.com/amoffat/sh
 # THE SOFTWARE.
 # ===============================================================================
 import asyncio
+import platform
 from collections import deque
 from collections.abc import Mapping
-
-import platform
 from importlib import metadata
 
 try:
@@ -1730,7 +1730,10 @@ def construct_streamreader_callback(process, handler):
 def get_exc_exit_code_would_raise(exit_code, ok_codes, sigpipe_ok):
     exc = None
     success = exit_code in ok_codes
-    bad_sig = -exit_code in SIGNALS_THAT_SHOULD_THROW_EXCEPTION
+    signals_that_should_throw_exception = [
+        sig for sig in SIGNALS_THAT_SHOULD_THROW_EXCEPTION if -sig not in ok_codes
+    ]
+    bad_sig = -exit_code in signals_that_should_throw_exception
 
     # if this is a piped command, SIGPIPE must be ignored by us and not raise an
     # exception, since it's perfectly normal for the consumer of a process's


Test patch:
diff --git a/tests/sh_test.py b/tests/sh_test.py
index 0419806..64b7018 100644
--- a/tests/sh_test.py
+++ b/tests/sh_test.py
@@ -33,6 +33,20 @@ RAND_BYTES = os.urandom(10)
 tempdir = Path(tempfile.gettempdir()).resolve()
 IS_MACOS = platform.system() in ("AIX", "Darwin")
 
+SIGNALS_THAT_SHOULD_THROW_EXCEPTION = [
+    signal.SIGABRT,
+    signal.SIGBUS,
+    signal.SIGFPE,
+    signal.SIGILL,
+    signal.SIGINT,
+    signal.SIGKILL,
+    signal.SIGPIPE,
+    signal.SIGQUIT,
+    signal.SIGSEGV,
+    signal.SIGTERM,
+    signal.SIGSYS,
+]
+
 
 def hash(a: str):
     h = md5(a.encode("utf8") + RAND_BYTES)
@@ -87,6 +101,7 @@ baked_env = os.environ.copy()
 append_module_path(baked_env, sh)
 python = system_python.bake(_env=baked_env, _return_cmd=True)
 pythons = python.bake(_return_cmd=False)
+python_bg = system_python.bake(_env=baked_env, _bg=True)
 
 
 def requires_progs(*progs):
@@ -3137,6 +3152,61 @@ for line in sys.stdin:
             ErrorReturnCode_2, python, middleman_normal_pipe, consumer.name
         )
 
+    def test_bad_sig_raise_exception(self):
+        # test all bad signal are correctly raised
+        py = create_tmp_test(
+            """
+import time
+import sys
+
+time.sleep(2)
+sys.exit(1)
+"""
+        )
+        for sig in SIGNALS_THAT_SHOULD_THROW_EXCEPTION:
+            if sig == signal.SIGPIPE:
+                continue
+            sig_exception_name = f"SignalException_{sig}"
+            sig_exception = getattr(sh, sig_exception_name)
+            try:
+                p = python_bg(py.name)
+                time.sleep(0.5)
+                p.signal(sig)
+                p.wait()
+            except sig_exception:
+                pass
+            else:
+                self.fail(f"{sig_exception_name} not raised")
+
+    def test_ok_code_ignores_bad_sig_exception(self):
+        # Test if I have [-sig] in _ok_code, the exception won't be raised
+        py = create_tmp_test(
+            """
+import time
+import sys
+
+time.sleep(2)
+sys.exit(1)
+"""
+        )
+        for sig in SIGNALS_THAT_SHOULD_THROW_EXCEPTION:
+            if sig == signal.SIGPIPE:
+                continue
+            sig_exception_name = f"SignalException_{sig}"
+            sig_exception = getattr(sh, sig_exception_name)
+            python_bg_no_sig_exception = python_bg.bake(_ok_code=[-sig])
+            try:
+                p = python_bg_no_sig_exception(py.name)
+                time.sleep(0.5)
+                p.signal(sig)
+                p.wait()
+            except sig_exception:
+                self.fail(
+                    f"{sig_exception_name} should not be raised setting _ok_code."
+                )
+            else:
+                self.assertEqual(p.exit_code, -sig)
+
 
 class MockTests(BaseTests):
     def test_patch_command_cls(self):