Skip to content

Commit 5b12274

Browse files
committed
Throw a TypeError if unexpected arguments are passed
1 parent 1e0fe45 commit 5b12274

2 files changed

Lines changed: 66 additions & 30 deletions

File tree

src/subprocess_tee/__init__.py

Lines changed: 38 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,10 @@
2525
_logger = logging.getLogger(__name__)
2626

2727
if TYPE_CHECKING:
28-
from subprocess_tee._types import SequenceNotStr
29-
30-
CompletedProcess = subprocess.CompletedProcess[Any]
3128
from collections.abc import Callable
32-
else:
33-
CompletedProcess = subprocess.CompletedProcess
29+
30+
from subprocess_tee._types import SequenceNotStr
31+
CompletedProcess = subprocess.CompletedProcess
3432

3533
STREAM_LIMIT = 2**23 # 8MB instead of default 64kb, override it if you need
3634

@@ -46,53 +44,66 @@ async def _read_stream(stream: StreamReader, callback: Callable[..., Any]) -> No
4644

4745
async def _stream_subprocess( # noqa: C901
4846
args: str | tuple[str, ...],
47+
*,
48+
stdin=None,
49+
tee=True,
50+
quiet=False,
51+
check=False,
52+
executable=None,
53+
capture_output=True,
4954
**kwargs: Any,
50-
) -> CompletedProcess:
55+
) -> subprocess.CompletedProcess[str]:
5156
platform_settings: dict[str, Any] = {}
5257
if platform.system() == "Windows":
5358
platform_settings["env"] = os.environ
5459

55-
# this part keeps behavior backwards compatible with subprocess.run
56-
tee = kwargs.get("tee", True)
57-
stdout = kwargs.get("stdout", sys.stdout)
60+
# pop arguments so that we can ensure there are no unexpected arguments
61+
stdout = kwargs.pop("stdout", sys.stdout)
62+
stderr = kwargs.pop("stderr", sys.stderr)
63+
for arg in ["cwd", "env"]:
64+
if arg in kwargs:
65+
platform_settings[arg] = kwargs.pop(arg)
66+
if kwargs:
67+
msg = f"Popen.__init__() got an unexpected keyword argument '{list(kwargs.keys())[0]}'"
68+
raise TypeError(msg)
69+
if not capture_output:
70+
msg = "Only capture_output=True is supported"
71+
raise NotImplementedError(msg)
72+
del kwargs
5873

5974
with Path(os.devnull).open("w", encoding="UTF-8") as devnull:
6075
if stdout == subprocess.DEVNULL or not tee:
6176
stdout = devnull
62-
stderr = kwargs.get("stderr", sys.stderr)
6377
if stderr == subprocess.DEVNULL or not tee:
6478
stderr = devnull
6579

6680
# We need to tell subprocess which shell to use when running shell-like
6781
# commands.
6882
# * SHELL is not always defined
6983
# * /bin/bash does not exit on alpine, /bin/sh seems bit more portable
70-
if "executable" not in kwargs and isinstance(args, str) and " " in args:
71-
platform_settings["executable"] = os.environ.get("SHELL", "/bin/sh")
72-
73-
# pass kwargs we know to be supported
74-
for arg in ["cwd", "env"]:
75-
if arg in kwargs:
76-
platform_settings[arg] = kwargs[arg]
84+
if executable is None and isinstance(args, str) and " " in args:
85+
executable = os.environ.get("SHELL", "/bin/sh")
7786

7887
# Some users are reporting that default (undocumented) limit 64k is too
7988
# low
8089
if isinstance(args, str):
8190
process = await asyncio.create_subprocess_shell(
8291
args,
8392
limit=STREAM_LIMIT,
84-
stdin=kwargs.get("stdin", False),
93+
stdin=stdin,
8594
stdout=asyncio.subprocess.PIPE,
8695
stderr=asyncio.subprocess.PIPE,
96+
executable=executable,
8797
**platform_settings,
8898
)
8999
else:
90100
process = await asyncio.create_subprocess_exec(
91101
*args,
92102
limit=STREAM_LIMIT,
93-
stdin=kwargs.get("stdin", False),
103+
stdin=stdin,
94104
stdout=asyncio.subprocess.PIPE,
95105
stderr=asyncio.subprocess.PIPE,
106+
executable=executable,
96107
**platform_settings,
97108
)
98109
out: list[str] = []
@@ -101,7 +112,7 @@ async def _stream_subprocess( # noqa: C901
101112
def tee_func(line: bytes, sink: list[str], pipe: Any | None) -> None:
102113
line_str = line.decode("utf-8").rstrip()
103114
sink.append(line_str)
104-
if not kwargs.get("quiet"):
115+
if not quiet:
105116
if pipe and hasattr(pipe, "write"):
106117
print(line_str, file=pipe)
107118
else:
@@ -126,15 +137,14 @@ def tee_func(line: bytes, sink: list[str], pipe: Any | None) -> None:
126137

127138
# We need to be sure we keep the stdout/stderr output identical with
128139
# the ones produced by subprocess.run(), at least when in text mode.
129-
check = kwargs.get("check", False)
130140
stdout = None if check else ""
131141
stderr = None if check else ""
132142
if out:
133143
stdout = os.linesep.join(out) + os.linesep
134144
if err:
135145
stderr = os.linesep.join(err) + os.linesep
136146

137-
return CompletedProcess(
147+
return subprocess.CompletedProcess(
138148
args=args,
139149
returncode=await process.wait(),
140150
stdout=stdout,
@@ -149,15 +159,15 @@ def tee_func(line: bytes, sink: list[str], pipe: Any | None) -> None:
149159
def run(
150160
args: str | SequenceNotStr[str] | None = None,
151161
bufsize: int = -1,
152-
input: bytes | str | None = None, # noqa: A002
153162
*,
154-
capture_output: bool = False,
155-
timeout: int | None = None,
163+
capture_output: bool = True,
156164
check: bool = False,
157165
**kwargs: Any,
158-
) -> CompletedProcess:
166+
) -> subprocess.CompletedProcess[str]:
159167
"""Drop-in replacement for subprocess.run that behaves like tee.
160168
169+
Not all arguments to subprocess.run are supported.
170+
161171
Extra arguments added by our version:
162172
echo: False - Prints command before executing it.
163173
quiet: False - Avoid printing output
@@ -177,16 +187,14 @@ def run(
177187
cmd = args if isinstance(args, str) else join(args)
178188
# 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
179189
if bufsize != -1:
180-
msg = "Ignored bufsize argument as it is not supported yet by __package__"
190+
msg = f"Ignored bufsize argument as it is not supported yet by {__package__}"
181191
_logger.warning(msg)
182192
kwargs["check"] = check
183-
kwargs["input"] = input
184-
kwargs["timeout"] = timeout
185193
kwargs["capture_output"] = capture_output
186194

187195
check = kwargs.get("check", False)
188196

189-
if kwargs.get("echo"):
197+
if kwargs.pop("echo", False):
190198
print(f"COMMAND: {cmd}") # noqa: T201
191199

192200
result = asyncio.run(_stream_subprocess(cmd, **kwargs))

test/test_unit.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,23 @@ def test_run_list() -> None:
4646
assert result.stderr == old_result.stderr
4747

4848

49+
def test_run_executable() -> None:
50+
"""Validate run call with a command made of list of strings and an executable."""
51+
cmd = ["not a real executable", "-c", "import sys; print(sys.argv[0])"]
52+
old_result = subprocess.run(
53+
cmd,
54+
executable=sys.executable,
55+
# shell=True,
56+
text=True,
57+
capture_output=True,
58+
check=False,
59+
)
60+
result = run(cmd)
61+
assert result.returncode == old_result.returncode
62+
assert result.stdout == old_result.stdout
63+
assert result.stderr == old_result.stderr
64+
65+
4966
def test_run_echo(capsys: pytest.CaptureFixture[str]) -> None:
5067
"""Validate run call with echo dumps command."""
5168
cmd = [sys.executable, "--version"]
@@ -174,3 +191,14 @@ def test_run_exc_no_args() -> None:
174191
subprocess.run(check=False) # type: ignore[call-overload]
175192
with pytest.raises(TypeError, match=expected):
176193
subprocess_tee.run()
194+
195+
196+
def test_run_exc_extra_args() -> None:
197+
"""Checks that call without arguments fails the same way as subprocess.run()."""
198+
expected = re.compile(
199+
r".*__init__\(\) got an unexpected keyword argument 'i_am_not_a_real_argument'"
200+
)
201+
with pytest.raises(TypeError, match=expected):
202+
subprocess.run(["true"], i_am_not_a_real_argument=False, check=False) # type: ignore[call-overload]
203+
with pytest.raises(TypeError, match=expected):
204+
subprocess_tee.run(["true"], i_am_not_a_real_argument=False, nor_am_i=True)

0 commit comments

Comments
 (0)