Skip to content

Commit 2937ecf

Browse files
committed
fix: throw a TypeError if unexpected arguments are passed
1 parent 1e0fe45 commit 2937ecf

2 files changed

Lines changed: 71 additions & 29 deletions

File tree

src/subprocess_tee/__init__.py

Lines changed: 43 additions & 29 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,62 @@ 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,
4953
**kwargs: Any,
50-
) -> CompletedProcess:
54+
) -> subprocess.CompletedProcess[str]:
5155
platform_settings: dict[str, Any] = {}
5256
if platform.system() == "Windows":
5357
platform_settings["env"] = os.environ
5458

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

5970
with Path(os.devnull).open("w", encoding="UTF-8") as devnull:
6071
if stdout == subprocess.DEVNULL or not tee:
6172
stdout = devnull
62-
stderr = kwargs.get("stderr", sys.stderr)
6373
if stderr == subprocess.DEVNULL or not tee:
6474
stderr = devnull
6575

6676
# We need to tell subprocess which shell to use when running shell-like
6777
# commands.
6878
# * SHELL is not always defined
6979
# * /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]
80+
if executable is None and isinstance(args, str) and " " in args:
81+
executable = os.environ.get("SHELL", "/bin/sh")
7782

7883
# Some users are reporting that default (undocumented) limit 64k is too
7984
# low
8085
if isinstance(args, str):
8186
process = await asyncio.create_subprocess_shell(
8287
args,
8388
limit=STREAM_LIMIT,
84-
stdin=kwargs.get("stdin", False),
89+
stdin=stdin,
8590
stdout=asyncio.subprocess.PIPE,
8691
stderr=asyncio.subprocess.PIPE,
92+
executable=executable,
8793
**platform_settings,
8894
)
8995
else:
9096
process = await asyncio.create_subprocess_exec(
9197
*args,
9298
limit=STREAM_LIMIT,
93-
stdin=kwargs.get("stdin", False),
99+
stdin=stdin,
94100
stdout=asyncio.subprocess.PIPE,
95101
stderr=asyncio.subprocess.PIPE,
102+
executable=executable,
96103
**platform_settings,
97104
)
98105
out: list[str] = []
@@ -101,7 +108,7 @@ async def _stream_subprocess( # noqa: C901
101108
def tee_func(line: bytes, sink: list[str], pipe: Any | None) -> None:
102109
line_str = line.decode("utf-8").rstrip()
103110
sink.append(line_str)
104-
if not kwargs.get("quiet"):
111+
if not quiet:
105112
if pipe and hasattr(pipe, "write"):
106113
print(line_str, file=pipe)
107114
else:
@@ -126,15 +133,14 @@ def tee_func(line: bytes, sink: list[str], pipe: Any | None) -> None:
126133

127134
# We need to be sure we keep the stdout/stderr output identical with
128135
# the ones produced by subprocess.run(), at least when in text mode.
129-
check = kwargs.get("check", False)
130136
stdout = None if check else ""
131137
stderr = None if check else ""
132138
if out:
133139
stdout = os.linesep.join(out) + os.linesep
134140
if err:
135141
stderr = os.linesep.join(err) + os.linesep
136142

137-
return CompletedProcess(
143+
return subprocess.CompletedProcess(
138144
args=args,
139145
returncode=await process.wait(),
140146
stdout=stdout,
@@ -151,13 +157,15 @@ def run(
151157
bufsize: int = -1,
152158
input: bytes | str | None = None, # noqa: A002
153159
*,
154-
capture_output: bool = False,
160+
capture_output: bool = True,
155161
timeout: int | None = None,
156162
check: bool = False,
157163
**kwargs: Any,
158-
) -> CompletedProcess:
164+
) -> subprocess.CompletedProcess[str]:
159165
"""Drop-in replacement for subprocess.run that behaves like tee.
160166
167+
Not all arguments to subprocess.run are supported.
168+
161169
Extra arguments added by our version:
162170
echo: False - Prints command before executing it.
163171
quiet: False - Avoid printing output
@@ -177,16 +185,22 @@ def run(
177185
cmd = args if isinstance(args, str) else join(args)
178186
# 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
179187
if bufsize != -1:
180-
msg = "Ignored bufsize argument as it is not supported yet by __package__"
188+
msg = f"Ignored bufsize argument as it is not supported yet by {__package__}"
189+
_logger.warning(msg)
190+
if input is not None:
191+
msg = f"Ignored input argument as it is not supported yet by {__package__}"
192+
_logger.warning(msg)
193+
if timeout is not None:
194+
msg = f"Ignored timeout argument as it is not supported yet by {__package__}"
195+
_logger.warning(msg)
196+
if not capture_output:
197+
msg = f"Ignored capture_output argument as it is not supported yet by {__package__}"
181198
_logger.warning(msg)
182199
kwargs["check"] = check
183-
kwargs["input"] = input
184-
kwargs["timeout"] = timeout
185-
kwargs["capture_output"] = capture_output
186200

187201
check = kwargs.get("check", False)
188202

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

192206
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 with unrecognized 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)