Repository: amoffat/sh
Pull request #745 (2025-01-09 17:46:01)

Release 2.2.0


Patch:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 888ced5..74b7272 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -55,7 +55,7 @@ jobs:
     strategy:
       matrix:
         os: [ubuntu-latest]
-        python-version: ["3.8", "3.9", "3.10", "3.11"]
+        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
         use-select: [0, 1]
         lang: [C, en_US.UTF-8]
 
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dda831b..f705b55 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
 # Changelog
 
+## 2.2.0 - 1/9/25
+
+- `return_cmd` with `await` now works correctly [#743](https://github.com/amoffat/sh/issues/743)
+- Formal support for Python 3.12
+
 ## 2.1.0 - 10/8/24
 
 - Add contrib command `sh.contrib.bash` [#736](https://github.com/amoffat/sh/pull/736)
diff --git a/README.rst b/README.rst
index 873f71f..962e8dc 100644
--- a/README.rst
+++ b/README.rst
@@ -21,7 +21,7 @@
 
 |
 
-sh is a full-fledged subprocess replacement for Python 3.8 - 3.11, and PyPy
+sh is a full-fledged subprocess replacement for Python 3.8 - 3.12, and PyPy
 that allows you to call *any* program as if it were a function:
 
 .. code:: python
diff --git a/pyproject.toml b/pyproject.toml
index 9b8b034..6621570 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,12 +1,12 @@
 [tool.poetry]
 name = "sh"
-version = "2.1.0"
+version = "2.2.0"
 description = "Python subprocess replacement"
 authors = ["Andrew Moffat <arwmoffat@gmail.com>"]
 readme = "README.rst"
 maintainers = [
     "Andrew Moffat <arwmoffat@gmail.com>",
-    "Erik Cederstrand <erik@cederstrand.dk>"
+    "Erik Cederstrand <erik@cederstrand.dk>",
 ]
 homepage = "https://sh.readthedocs.io/"
 repository = "https://github.com/amoffat/sh"
diff --git a/sh.py b/sh.py
index 4a83a3a..b02d6e1 100644
--- a/sh.py
+++ b/sh.py
@@ -27,6 +27,19 @@ import asyncio
 from collections import deque
 from collections.abc import Mapping
 
+import platform
+from importlib import metadata
+
+try:
+    __version__ = metadata.version("sh")
+except metadata.PackageNotFoundError:  # pragma: no cover
+    __version__ = "unknown"
+
+if "windows" in platform.system().lower():  # pragma: no cover
+    raise ImportError(
+        f"sh {__version__} is currently only supported on Linux and macOS."
+    )
+
 import errno
 import fcntl
 import gc
@@ -35,7 +48,6 @@ import glob as glob_module
 import inspect
 import logging
 import os
-import platform
 import pty
 import pwd
 import re
@@ -55,7 +67,6 @@ import weakref
 from asyncio import Queue as AQueue
 from contextlib import contextmanager
 from functools import partial
-from importlib import metadata
 from io import BytesIO, StringIO, UnsupportedOperation
 from io import open as fdopen
 from locale import getpreferredencoding
@@ -64,17 +75,8 @@ from shlex import quote as shlex_quote
 from types import GeneratorType, ModuleType
 from typing import Any, Dict, Type, Union
 
-try:
-    __version__ = metadata.version("sh")
-except metadata.PackageNotFoundError:  # pragma: no cover
-    __version__ = "unknown"
 __project_url__ = "https://github.com/amoffat/sh"
 
-if "windows" in platform.system().lower():  # pragma: no cover
-    raise ImportError(
-        f"sh {__version__} is currently only supported on Linux and macOS."
-    )
-
 TEE_STDOUT = {True, "out", 1}
 TEE_STDERR = {"err", 2}
 
@@ -887,7 +889,10 @@ class RunningCommand:
     def __await__(self):
         async def wait_for_completion():
             await self.aio_output_complete.wait()
-            return str(self)
+            if self.call_args["return_cmd"]:
+                return self
+            else:
+                return str(self)
 
         return wait_for_completion().__await__()
 


Test patch:
diff --git a/tests/sh_test.py b/tests/sh_test.py
index d12c3dc..0fe0311 100644
--- a/tests/sh_test.py
+++ b/tests/sh_test.py
@@ -1707,7 +1707,6 @@ print("hello")
         )
 
         alternating = []
-        q = AQueue()
 
         async def producer(q):
             alternating.append(1)
@@ -1722,19 +1721,20 @@ print("hello")
             self.assertEqual(msg, "hello")
             alternating.append(2)
 
-        loop = asyncio.get_event_loop()
-        fut = asyncio.gather(producer(q), consumer(q))
-        loop.run_until_complete(fut)
+        async def main():
+            q = AQueue()
+            await asyncio.gather(producer(q), consumer(q))
+
+        asyncio.run(main())
         self.assertListEqual(alternating, [1, 2, 1, 2])
 
     def test_async_exc(self):
         py = create_tmp_test("""exit(34)""")
 
         async def producer():
-            await python(py.name, _async=True)
+            await python(py.name, _async=True, _return_cmd=False)
 
-        loop = asyncio.get_event_loop()
-        self.assertRaises(sh.ErrorReturnCode_34, loop.run_until_complete, producer())
+        self.assertRaises(sh.ErrorReturnCode_34, asyncio.run, producer())
 
     def test_async_iter(self):
         py = create_tmp_test(
@@ -1743,7 +1743,6 @@ for i in range(5):
     print(i)
 """
         )
-        q = AQueue()
 
         # this list will prove that our coroutines are yielding to eachother as each
         # line is produced
@@ -1763,9 +1762,11 @@ for i in range(5):
                     return
                 alternating.append(2)
 
-        loop = asyncio.get_event_loop()
-        res = asyncio.gather(producer(q), consumer(q))
-        loop.run_until_complete(res)
+        async def main():
+            q = AQueue()
+            await asyncio.gather(producer(q), consumer(q))
+
+        asyncio.run(main())
         self.assertListEqual(alternating, [1, 2, 1, 2, 1, 2, 1, 2, 1, 2])
 
     def test_async_iter_exc(self):
@@ -1783,8 +1784,23 @@ exit(34)
             async for line in python(py.name, _async=True):
                 lines.append(int(line.strip()))
 
-        loop = asyncio.get_event_loop()
-        self.assertRaises(sh.ErrorReturnCode_34, loop.run_until_complete, producer())
+        self.assertRaises(sh.ErrorReturnCode_34, asyncio.run, producer())
+
+    def test_async_return_cmd(self):
+        py = create_tmp_test(
+            """
+import sys
+sys.exit(0)
+"""
+        )
+
+        async def main():
+            result = await python(py.name, _async=True, _return_cmd=True)
+            self.assertIsInstance(result, sh.RunningCommand)
+            result_str = await python(py.name, _async=True, _return_cmd=False)
+            self.assertIsInstance(result_str, str)
+
+        asyncio.run(main())
 
     def test_handle_both_out_and_err(self):
         py = create_tmp_test(