From a90f61c7c8caec668409ba8ed8bab7f0c5f8fe30 Mon Sep 17 00:00:00 2001 From: Lewis Cowles Date: Thu, 17 Oct 2024 22:19:05 +0100 Subject: [PATCH 1/4] chore: windows error before other imports --- sh.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/sh.py b/sh.py index 4a83a3ac..d52d8b60 100644 --- a/sh.py +++ b/sh.py @@ -27,6 +27,19 @@ 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 inspect import logging import os -import platform import pty import pwd import re @@ -55,7 +67,6 @@ 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 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} From 484399f482675d7a45c68adbedfadef338da2b31 Mon Sep 17 00:00:00 2001 From: Carl George Date: Sat, 7 Dec 2024 01:52:41 -0600 Subject: [PATCH 2/4] Avoid manual async loop management Python 3.14 removes the implicit creation of an event loop when running asyncio.get_event_loop(). Rather than manually managing the loop creation, switch the async tests to use the higher level asyncio.run() interface, as recommended by the standard library docs. https://docs.python.org/dev/whatsnew/3.14.html#id4 Resolves #741 --- tests/sh_test.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/sh_test.py b/tests/sh_test.py index d12c3dcf..8a87fd69 100644 --- a/tests/sh_test.py +++ b/tests/sh_test.py @@ -1707,7 +1707,6 @@ def test_async(self): ) alternating = [] - q = AQueue() async def producer(q): alternating.append(1) @@ -1722,9 +1721,11 @@ async def consumer(q): 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): @@ -1733,8 +1734,7 @@ def test_async_exc(self): async def producer(): await python(py.name, _async=True) - 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 @@ def test_async_iter(self): print(i) """ ) - q = AQueue() # this list will prove that our coroutines are yielding to eachother as each # line is produced @@ -1763,9 +1762,11 @@ async def consumer(q): 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,7 @@ async def producer(): 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_handle_both_out_and_err(self): py = create_tmp_test( From 3a9d8ce9fb88e4abbea5dbb7e84929e6f8968a63 Mon Sep 17 00:00:00 2001 From: Sam Hartman Date: Wed, 8 Jan 2025 15:32:27 -0700 Subject: [PATCH 3/4] await command with _return_cmd True returns RunningCommand If _return_cmd is True, have __await__ return the RunningCommand object rather than a string representation. Fixes #743 Add test for this behavior and adjust existing tests because the baked test python instance is created with _return_cmd True. --- sh.py | 5 ++++- tests/sh_test.py | 18 +++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/sh.py b/sh.py index d52d8b60..b02d6e16 100644 --- a/sh.py +++ b/sh.py @@ -889,7 +889,10 @@ def __next__(self): 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__() diff --git a/tests/sh_test.py b/tests/sh_test.py index 8a87fd69..0fe03111 100644 --- a/tests/sh_test.py +++ b/tests/sh_test.py @@ -1732,7 +1732,7 @@ 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) self.assertRaises(sh.ErrorReturnCode_34, asyncio.run, producer()) @@ -1786,6 +1786,22 @@ async def 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( """ From ca44695bc4a05aedc2cee17069fce41d7b20a36b Mon Sep 17 00:00:00 2001 From: Andrew Moffat Date: Thu, 9 Jan 2025 09:45:20 -0800 Subject: [PATCH 4/4] Release 2.2.0 metadata --- .github/workflows/main.yml | 2 +- CHANGELOG.md | 5 +++++ README.rst | 2 +- pyproject.toml | 4 ++-- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 888ced54..74b72729 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 dda831bd..f705b552 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 873f71f1..962e8dc8 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 9b8b0348..6621570b 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 "] readme = "README.rst" maintainers = [ "Andrew Moffat ", - "Erik Cederstrand " + "Erik Cederstrand ", ] homepage = "https://sh.readthedocs.io/" repository = "https://github.com/amoffat/sh"