Skip to content

Commit 2b8758d

Browse files
authored
Add types to run command (#149)
1 parent 914ec1a commit 2b8758d

File tree

7 files changed

+99
-14
lines changed

7 files changed

+99
-14
lines changed

.config/constraints.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ mkdocs==1.6.1 # via mkdocs-autorefs, mkdocs-git-revision-date-locali
4141
mkdocs-autorefs==1.2.0 # via mkdocstrings, mkdocstrings-python
4242
mkdocs-get-deps==0.2.0 # via mkdocs
4343
mkdocs-git-revision-date-localized-plugin==1.3.0 # via subprocess-tee (pyproject.toml)
44-
mkdocs-material==9.5.47 # via subprocess-tee (pyproject.toml)
44+
mkdocs-material==9.5.48 # via subprocess-tee (pyproject.toml)
4545
mkdocs-material-extensions==1.3.1 # via mkdocs-material, subprocess-tee (pyproject.toml)
4646
mkdocstrings==0.27.0 # via mkdocstrings-python, subprocess-tee (pyproject.toml)
4747
mkdocstrings-python==1.12.2 # via subprocess-tee (pyproject.toml)

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ repos:
1818
hooks:
1919
- id: prettier
2020
- repo: https://github.com/streetsidesoftware/cspell-cli
21-
rev: v8.16.0
21+
rev: v8.16.1
2222
hooks:
2323
- id: cspell
2424
# entry: codespell --relative
@@ -93,7 +93,7 @@ repos:
9393
- typing
9494
- typing-extensions
9595
- repo: https://github.com/jendrikseipp/vulture
96-
rev: v2.13
96+
rev: v2.14
9797
hooks:
9898
- id: vulture
9999
- # keep at bottom as these are slower

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,9 @@ disable = [
9797
# Disabled on purpose:
9898
"line-too-long", # covered by black
9999
"protected-access", # covered by ruff SLF001
100+
"redefined-builtin", # covered by ruff
100101
"too-many-branches", # covered by ruff C901
102+
"unused-argument", # covered vby ruff
101103
"wrong-import-order", # covered by ruff
102104
# TODO(ssbarnea): remove temporary skips adding during initial adoption:
103105
"duplicate-code",
@@ -195,6 +197,7 @@ exclude = [
195197
".tox",
196198
"build",
197199
"venv",
198-
"src/subprocess_tee/_version.py"
200+
"src/subprocess_tee/_version.py",
201+
"src/subprocess_tee/_types.py"
199202
]
200203
paths = ["src", "test"]

src/subprocess_tee/__init__.py

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
"""tee like run implementation."""
22

3+
# cspell: ignore popenargs preexec startupinfo creationflags pipesize
4+
35
from __future__ import annotations
46

57
import asyncio
8+
import logging
69
import os
710
import platform
811
import subprocess # noqa: S404
@@ -19,9 +22,12 @@
1922
__version__ = "0.1.dev1"
2023

2124
__all__ = ["CompletedProcess", "__version__", "run"]
25+
_logger = logging.getLogger(__name__)
2226

2327
if TYPE_CHECKING:
24-
CompletedProcess = subprocess.CompletedProcess[Any] # pylint: disable=E1136
28+
from subprocess_tee._types import SequenceNotStr
29+
30+
CompletedProcess = subprocess.CompletedProcess[Any]
2531
from collections.abc import Callable
2632
else:
2733
CompletedProcess = subprocess.CompletedProcess
@@ -39,7 +45,7 @@ async def _read_stream(stream: StreamReader, callback: Callable[..., Any]) -> No
3945

4046

4147
async def _stream_subprocess( # noqa: C901
42-
args: str | list[str],
48+
args: str | tuple[str, ...],
4349
**kwargs: Any,
4450
) -> CompletedProcess:
4551
platform_settings: dict[str, Any] = {}
@@ -136,7 +142,20 @@ def tee_func(line: bytes, sink: list[str], pipe: Any | None) -> None:
136142
)
137143

138144

139-
def run(args: str | list[str], **kwargs: Any) -> CompletedProcess:
145+
# signature is based on stdlib
146+
# subprocess.run()
147+
# pylint: disable=too-many-arguments
148+
# ruff: ignore=FBT001,ARG001
149+
def run(
150+
args: str | SequenceNotStr[str] | None = None,
151+
bufsize: int = -1,
152+
input: bytes | str | None = None, # noqa: A002
153+
*,
154+
capture_output: bool = False,
155+
timeout: int | None = None,
156+
check: bool = False,
157+
**kwargs: Any,
158+
) -> CompletedProcess:
140159
"""Drop-in replacement for subprocess.run that behaves like tee.
141160
142161
Extra arguments added by our version:
@@ -148,26 +167,36 @@ def run(args: str | list[str], **kwargs: Any) -> CompletedProcess:
148167
149168
Raises:
150169
CalledProcessError: ...
170+
TypeError: ...
151171
152172
"""
153-
# run was called with a list instead of a single item but asyncio
154-
# create_subprocess_shell requires command as a single string, so
155-
# we need to convert it to string
173+
if args is None:
174+
msg = "Popen.__init__() missing 1 required positional argument: 'args'"
175+
raise TypeError(msg)
176+
156177
cmd = args if isinstance(args, str) else join(args)
178+
# 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
179+
if bufsize != -1:
180+
msg = "Ignored bufsize argument as it is not supported yet by __package__"
181+
_logger.warning(msg)
182+
kwargs["check"] = check
183+
kwargs["input"] = input
184+
kwargs["timeout"] = timeout
185+
kwargs["capture_output"] = capture_output
157186

158187
check = kwargs.get("check", False)
159188

160189
if kwargs.get("echo", False):
161190
print(f"COMMAND: {cmd}") # noqa: T201
162191

163-
result = asyncio.run(_stream_subprocess(args, **kwargs))
192+
result = asyncio.run(_stream_subprocess(cmd, **kwargs))
164193
# we restore original args to mimic subprocess.run()
165194
result.args = args
166195

167196
if check and result.returncode != 0:
168197
raise subprocess.CalledProcessError(
169198
result.returncode,
170-
args,
199+
cmd, # pyright: ignore[xxx]
171200
output=result.stdout,
172201
stderr=result.stderr,
173202
)

src/subprocess_tee/_types.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Internally used types."""
2+
3+
# Source from https://github.com/python/typing/issues/256#issuecomment-1442633430
4+
from collections.abc import Iterator, Sequence
5+
from typing import Any, Protocol, SupportsIndex, TypeVar, overload
6+
7+
_T_co = TypeVar("_T_co", covariant=True)
8+
9+
10+
class SequenceNotStr(Protocol[_T_co]):
11+
"""Lists of strings which are not strings themselves."""
12+
13+
@overload
14+
def __getitem__(self, index: SupportsIndex, /) -> _T_co: ...
15+
@overload
16+
def __getitem__(self, index: slice, /) -> Sequence[_T_co]: ...
17+
def __contains__(self, value: object, /) -> bool: ...
18+
def __len__(self) -> int: ...
19+
def __iter__(self) -> Iterator[_T_co]: ...
20+
def index( # pylint: disable=C0116
21+
self, value: Any, start: int = 0, stop: int = ..., /
22+
) -> int: ...
23+
def count(self, value: Any, /) -> int: ... # pylint: disable=C0116
24+
25+
def __reversed__(self) -> Iterator[_T_co]: ...

test/test_unit.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
"""Unit tests."""
22

3+
import re
34
import subprocess
45
import sys
56
from pathlib import Path
67

78
import pytest
89

10+
import subprocess_tee
911
from subprocess_tee import run
1012

1113

@@ -140,8 +142,35 @@ def test_run_compat() -> None:
140142
assert ours.args == original.args
141143

142144

145+
def test_run_compat2() -> None:
146+
"""Assure compatibility with subprocess.run()."""
147+
cmd: tuple[str, int] = ("true", -1)
148+
ours = run(*cmd)
149+
original = subprocess.run(
150+
*cmd,
151+
capture_output=True,
152+
text=True,
153+
check=False,
154+
)
155+
assert ours.returncode == original.returncode
156+
assert ours.stdout == original.stdout
157+
assert ours.stderr == original.stderr
158+
assert ours.args == original.args
159+
160+
143161
def test_run_waits_for_completion(tmp_path: Path) -> None:
144162
"""run() should always wait for the process to complete."""
145163
tmpfile = tmp_path / "output.txt"
146164
run(f"sleep 0.1 && echo 42 > {tmpfile!s}")
147165
assert tmpfile.read_text() == "42\n"
166+
167+
168+
def test_run_exc_no_args() -> None:
169+
"""Checks that call without arguments fails the same way as subprocess.run()."""
170+
expected = re.compile(
171+
r".*__init__\(\) missing 1 required positional argument: 'args'"
172+
)
173+
with pytest.raises(TypeError, match=expected):
174+
subprocess.run(check=False) # type: ignore[call-overload]
175+
with pytest.raises(TypeError, match=expected):
176+
subprocess_tee.run()

tox.ini

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,7 @@ setenv =
4747
PIP_CONSTRAINT = {tox_root}/.config/constraints.txt
4848
devel,pkg,pre: PIP_CONSTRAINT = /dev/null
4949
PIP_DISABLE_VERSION_CHECK=1
50-
PYTEST_REQPASS=16
51-
py38: PYTEST_REQPASS=15
50+
PYTEST_REQPASS=18
5251
PYTHONDONTWRITEBYTECODE=1
5352
PYTHONUNBUFFERED=1
5453
commands =

0 commit comments

Comments
 (0)