From 2359e1ab3ccceda86a2c51a13c050610abbff722 Mon Sep 17 00:00:00 2001 From: Eric Wieser Date: Wed, 11 Mar 2026 01:35:42 +0000 Subject: [PATCH 1/3] fix: throw a TypeError if unexpected arguments are passed --- src/subprocess_tee/__init__.py | 89 ++++++++++++++++++++-------------- src/subprocess_tee/_types.py | 21 ++------ test/test_unit.py | 27 +++++++++++ tox.ini | 2 +- 4 files changed, 83 insertions(+), 56 deletions(-) diff --git a/src/subprocess_tee/__init__.py b/src/subprocess_tee/__init__.py index 719b104..7d8b34f 100644 --- a/src/subprocess_tee/__init__.py +++ b/src/subprocess_tee/__init__.py @@ -14,7 +14,7 @@ from importlib.metadata import PackageNotFoundError, version from pathlib import Path from shlex import join -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Sequence try: __version__ = version("subprocess-tee") @@ -25,12 +25,9 @@ _logger = logging.getLogger(__name__) if TYPE_CHECKING: - from subprocess_tee._types import SequenceNotStr - - CompletedProcess = subprocess.CompletedProcess[Any] from collections.abc import Callable -else: - CompletedProcess = subprocess.CompletedProcess + from subprocess_tee._types import StrOrBytesPath +CompletedProcess = subprocess.CompletedProcess STREAM_LIMIT = 2**23 # 8MB instead of default 64kb, override it if you need @@ -44,22 +41,34 @@ async def _read_stream(stream: StreamReader, callback: Callable[..., Any]) -> No break -async def _stream_subprocess( # noqa: C901 - args: str | tuple[str, ...], +async def _stream_subprocess( # noqa: C901, R0913, R0914 + args: StrOrBytesPath | Sequence[StrOrBytesPath], + *, + stdin=None, + tee=True, + quiet=False, + check=False, + executable=None, **kwargs: Any, -) -> CompletedProcess: +) -> subprocess.CompletedProcess[str]: platform_settings: dict[str, Any] = {} if platform.system() == "Windows": platform_settings["env"] = os.environ - # this part keeps behavior backwards compatible with subprocess.run - tee = kwargs.get("tee", True) - stdout = kwargs.get("stdout", sys.stdout) + # pop arguments so that we can ensure there are no unexpected arguments + stdout = kwargs.pop("stdout", sys.stdout) + stderr = kwargs.pop("stderr", sys.stderr) + for arg in ["cwd", "env"]: + if arg in kwargs: + platform_settings[arg] = kwargs.pop(arg) + if kwargs: + msg = f"Popen.__init__() got an unexpected keyword argument '{next(iter(kwargs.keys()))}'" + raise TypeError(msg) + del kwargs with Path(os.devnull).open("w", encoding="UTF-8") as devnull: if stdout == subprocess.DEVNULL or not tee: stdout = devnull - stderr = kwargs.get("stderr", sys.stderr) if stderr == subprocess.DEVNULL or not tee: stderr = devnull @@ -67,32 +76,31 @@ async def _stream_subprocess( # noqa: C901 # commands. # * SHELL is not always defined # * /bin/bash does not exit on alpine, /bin/sh seems bit more portable - if "executable" not in kwargs and isinstance(args, str) and " " in args: - platform_settings["executable"] = os.environ.get("SHELL", "/bin/sh") - - # pass kwargs we know to be supported - for arg in ["cwd", "env"]: - if arg in kwargs: - platform_settings[arg] = kwargs[arg] + if executable is None and isinstance(args, str) and " " in args: + executable = os.environ.get("SHELL", "/bin/sh") + if isinstance(args, os.PathLike): + args = os.fspath(args) # Some users are reporting that default (undocumented) limit 64k is too # low - if isinstance(args, str): + if isinstance(args, (str, bytes)): process = await asyncio.create_subprocess_shell( args, limit=STREAM_LIMIT, - stdin=kwargs.get("stdin", False), + stdin=stdin, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + executable=executable, **platform_settings, ) else: process = await asyncio.create_subprocess_exec( *args, limit=STREAM_LIMIT, - stdin=kwargs.get("stdin", False), + stdin=stdin, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + executable=executable, **platform_settings, ) out: list[str] = [] @@ -101,7 +109,7 @@ async def _stream_subprocess( # noqa: C901 def tee_func(line: bytes, sink: list[str], pipe: Any | None) -> None: line_str = line.decode("utf-8").rstrip() sink.append(line_str) - if not kwargs.get("quiet"): + if not quiet: if pipe and hasattr(pipe, "write"): print(line_str, file=pipe) else: @@ -126,7 +134,6 @@ def tee_func(line: bytes, sink: list[str], pipe: Any | None) -> None: # We need to be sure we keep the stdout/stderr output identical with # the ones produced by subprocess.run(), at least when in text mode. - check = kwargs.get("check", False) stdout = None if check else "" stderr = None if check else "" if out: @@ -134,7 +141,7 @@ def tee_func(line: bytes, sink: list[str], pipe: Any | None) -> None: if err: stderr = os.linesep.join(err) + os.linesep - return CompletedProcess( + return subprocess.CompletedProcess( args=args, returncode=await process.wait(), stdout=stdout, @@ -147,17 +154,19 @@ def tee_func(line: bytes, sink: list[str], pipe: Any | None) -> None: # pylint: disable=too-many-arguments # ruff: ignore=FBT001,ARG001 def run( - args: str | SequenceNotStr[str] | None = None, + args: StrOrBytesPath | Sequence[StrOrBytesPath] | None = None, bufsize: int = -1, input: bytes | str | None = None, # noqa: A002 *, - capture_output: bool = False, + capture_output: bool = True, timeout: int | None = None, check: bool = False, **kwargs: Any, -) -> CompletedProcess: +) -> subprocess.CompletedProcess[str]: """Drop-in replacement for subprocess.run that behaves like tee. + Not all arguments to subprocess.run are supported. + Extra arguments added by our version: echo: False - Prints command before executing it. quiet: False - Avoid printing output @@ -174,29 +183,35 @@ def run( msg = "Popen.__init__() missing 1 required positional argument: 'args'" raise TypeError(msg) - cmd = args if isinstance(args, str) else join(args) # bufsize=-1, executable=None, stdin=None, stdout=None, stderr=None, preexec_fn=None, close_fds=True, shell=False, cwd=None, env=None, universal_newlines=None, startupinfo=None, creationflags=0, restore_signals=True, start_new_session=False, pass_fds=(), *, group=None, extra_groups=None, user=None, umask=-1, encoding=None, errors=None, text=None, pipesize=-1, process_group=None if bufsize != -1: - msg = "Ignored bufsize argument as it is not supported yet by __package__" + msg = f"Ignored bufsize argument as it is not supported yet by {__package__}" + _logger.warning(msg) + if input is not None: + msg = f"Ignored input argument as it is not supported yet by {__package__}" + _logger.warning(msg) + if timeout is not None: + msg = f"Ignored timeout argument as it is not supported yet by {__package__}" + _logger.warning(msg) + if not capture_output: + msg = f"Ignored capture_output argument as it is not supported yet by {__package__}" _logger.warning(msg) kwargs["check"] = check - kwargs["input"] = input - kwargs["timeout"] = timeout - kwargs["capture_output"] = capture_output check = kwargs.get("check", False) - if kwargs.get("echo"): + if kwargs.pop("echo", False): + cmd = args if isinstance(args, (str, bytes, os.PathLike)) else join(str(s) for s in args) print(f"COMMAND: {cmd}") # noqa: T201 - result = asyncio.run(_stream_subprocess(cmd, **kwargs)) + result = asyncio.run(_stream_subprocess(args, **kwargs)) # we restore original args to mimic subprocess.run() result.args = args if check and result.returncode != 0: raise subprocess.CalledProcessError( result.returncode, - cmd, # pyright: ignore[xxx] + args, output=result.stdout, stderr=result.stderr, ) diff --git a/src/subprocess_tee/_types.py b/src/subprocess_tee/_types.py index 74a1231..a4e2603 100644 --- a/src/subprocess_tee/_types.py +++ b/src/subprocess_tee/_types.py @@ -2,24 +2,9 @@ # Source from https://github.com/python/typing/issues/256#issuecomment-1442633430 from collections.abc import Iterator, Sequence -from typing import Any, Protocol, SupportsIndex, TypeVar, overload +from typing import Any, Protocol, SupportsIndex, TypeVar, overload, Union +from os import PathLike _T_co = TypeVar("_T_co", covariant=True) - -class SequenceNotStr(Protocol[_T_co]): - """Lists of strings which are not strings themselves.""" - - @overload - def __getitem__(self, index: SupportsIndex, /) -> _T_co: ... - @overload - def __getitem__(self, index: slice, /) -> Sequence[_T_co]: ... - def __contains__(self, value: object, /) -> bool: ... - def __len__(self) -> int: ... - def __iter__(self) -> Iterator[_T_co]: ... - def index( # pylint: disable=C0116 - self, value: Any, start: int = 0, stop: int = ..., / - ) -> int: ... - def count(self, value: Any, /) -> int: ... # pylint: disable=C0116 - - def __reversed__(self) -> Iterator[_T_co]: ... +StrOrBytesPath = Union[str, bytes, PathLike[str], PathLike[bytes]] diff --git a/test/test_unit.py b/test/test_unit.py index 6e71597..3e87209 100644 --- a/test/test_unit.py +++ b/test/test_unit.py @@ -46,6 +46,22 @@ def test_run_list() -> None: assert result.stderr == old_result.stderr +def test_run_executable() -> None: + """Validate run call with a command made of list of strings and an executable.""" + cmd = ["not a real executable", "-c", "import sys; print(sys.argv[0])"] + old_result = subprocess.run( + cmd, + executable=Path(sys.executable), + text=True, + capture_output=True, + check=False, + ) + result = run(cmd, executable=Path(sys.executable)) + assert result.returncode == old_result.returncode + assert result.stdout == old_result.stdout + assert result.stderr == old_result.stderr + + def test_run_echo(capsys: pytest.CaptureFixture[str]) -> None: """Validate run call with echo dumps command.""" cmd = [sys.executable, "--version"] @@ -174,3 +190,14 @@ def test_run_exc_no_args() -> None: subprocess.run(check=False) # type: ignore[call-overload] with pytest.raises(TypeError, match=expected): subprocess_tee.run() + + +def test_run_exc_extra_args() -> None: + """Checks that call with unrecognized arguments fails the same way as subprocess.run().""" + expected = re.compile( + r".*__init__\(\) got an unexpected keyword argument 'i_am_not_a_real_argument'" + ) + with pytest.raises(TypeError, match=expected): + subprocess.run(["true"], i_am_not_a_real_argument=False, check=False) # type: ignore[call-overload] + with pytest.raises(TypeError, match=expected): + subprocess_tee.run(["true"], i_am_not_a_real_argument=False, nor_am_i=True) diff --git a/tox.ini b/tox.ini index 22dd7a5..27fc061 100644 --- a/tox.ini +++ b/tox.ini @@ -52,7 +52,7 @@ set_env = PIP_CONSTRAINT = {tox_root}/.config/constraints.txt PIP_DISABLE_PIP_VERSION_CHECK = 1 PRE_COMMIT_COLOR = always - PYTEST_REQPASS = 18 + PYTEST_REQPASS = 20 PYTHONDONTWRITEBYTECODE = 1 PYTHONUNBUFFERED = 1 UV_CONSTRAINT = {tox_root}/.config/constraints.txt From b6331927e7fa813560d4c5fef888d98d248c012b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 02:43:58 +0000 Subject: [PATCH 2/3] chore: auto fixes from pre-commit.com hooks --- src/subprocess_tee/__init__.py | 12 +++++++++--- src/subprocess_tee/_types.py | 3 +-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/subprocess_tee/__init__.py b/src/subprocess_tee/__init__.py index 7d8b34f..253100d 100644 --- a/src/subprocess_tee/__init__.py +++ b/src/subprocess_tee/__init__.py @@ -11,10 +11,11 @@ import subprocess # noqa: S404 import sys from asyncio import StreamReader +from collections.abc import Sequence from importlib.metadata import PackageNotFoundError, version from pathlib import Path from shlex import join -from typing import TYPE_CHECKING, Any, Sequence +from typing import TYPE_CHECKING, Any try: __version__ = version("subprocess-tee") @@ -26,6 +27,7 @@ if TYPE_CHECKING: from collections.abc import Callable + from subprocess_tee._types import StrOrBytesPath CompletedProcess = subprocess.CompletedProcess @@ -41,7 +43,7 @@ async def _read_stream(stream: StreamReader, callback: Callable[..., Any]) -> No break -async def _stream_subprocess( # noqa: C901, R0913, R0914 +async def _stream_subprocess( # noqa: C901 args: StrOrBytesPath | Sequence[StrOrBytesPath], *, stdin=None, @@ -201,7 +203,11 @@ def run( check = kwargs.get("check", False) if kwargs.pop("echo", False): - cmd = args if isinstance(args, (str, bytes, os.PathLike)) else join(str(s) for s in args) + cmd = ( + args + if isinstance(args, (str, bytes, os.PathLike)) + else join(str(s) for s in args) + ) print(f"COMMAND: {cmd}") # noqa: T201 result = asyncio.run(_stream_subprocess(args, **kwargs)) diff --git a/src/subprocess_tee/_types.py b/src/subprocess_tee/_types.py index a4e2603..a055d46 100644 --- a/src/subprocess_tee/_types.py +++ b/src/subprocess_tee/_types.py @@ -1,9 +1,8 @@ """Internally used types.""" # Source from https://github.com/python/typing/issues/256#issuecomment-1442633430 -from collections.abc import Iterator, Sequence -from typing import Any, Protocol, SupportsIndex, TypeVar, overload, Union from os import PathLike +from typing import TypeVar, Union _T_co = TypeVar("_T_co", covariant=True) From fcb53478acbd88f6a2f53211e0f331e62ff72ce8 Mon Sep 17 00:00:00 2001 From: Eric Wieser Date: Wed, 11 Mar 2026 03:05:32 +0000 Subject: [PATCH 3/3] fix --- .config/dictionary.txt | 1 + .pre-commit-config.yaml | 1 + src/subprocess_tee/__init__.py | 4 ++-- src/subprocess_tee/_types.py | 5 +---- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.config/dictionary.txt b/.config/dictionary.txt index 1e32e86..6392662 100644 --- a/.config/dictionary.txt +++ b/.config/dictionary.txt @@ -5,6 +5,7 @@ codespell commitlint devel dists +fspath instafail mkdocstrings nosetests diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ce31217..b2312db 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -80,6 +80,7 @@ repos: - id: mypy # empty args needed in order to match mypy cli behavior args: [] + files: ^(src|test)/ additional_dependencies: - pytest>=6.1.2 - enrich>=1.2.5 diff --git a/src/subprocess_tee/__init__.py b/src/subprocess_tee/__init__.py index 253100d..f192d92 100644 --- a/src/subprocess_tee/__init__.py +++ b/src/subprocess_tee/__init__.py @@ -11,7 +11,6 @@ import subprocess # noqa: S404 import sys from asyncio import StreamReader -from collections.abc import Sequence from importlib.metadata import PackageNotFoundError, version from pathlib import Path from shlex import join @@ -26,7 +25,7 @@ _logger = logging.getLogger(__name__) if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Sequence from subprocess_tee._types import StrOrBytesPath CompletedProcess = subprocess.CompletedProcess @@ -43,6 +42,7 @@ async def _read_stream(stream: StreamReader, callback: Callable[..., Any]) -> No break +# pylint: disable=too-many-arguments, too-many-locals async def _stream_subprocess( # noqa: C901 args: StrOrBytesPath | Sequence[StrOrBytesPath], *, diff --git a/src/subprocess_tee/_types.py b/src/subprocess_tee/_types.py index a055d46..70aed7c 100644 --- a/src/subprocess_tee/_types.py +++ b/src/subprocess_tee/_types.py @@ -1,9 +1,6 @@ """Internally used types.""" -# Source from https://github.com/python/typing/issues/256#issuecomment-1442633430 from os import PathLike -from typing import TypeVar, Union - -_T_co = TypeVar("_T_co", covariant=True) +from typing import Union StrOrBytesPath = Union[str, bytes, PathLike[str], PathLike[bytes]]