Skip to content

Commit d34844c

Browse files
committed
tests(_internal[async]): Add tests for async subprocess and run modules
what: - Add AsyncSubprocessCommand tests (run, timeout, concurrent) - Add async run() tests (callbacks, error handling) - Add AsyncProgressCallbackProtocol tests
1 parent 647f2a8 commit d34844c

2 files changed

Lines changed: 496 additions & 0 deletions

File tree

tests/_internal/test_async_run.py

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
"""Tests for libvcs._internal.async_run."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import datetime
7+
import typing as t
8+
from pathlib import Path
9+
10+
import pytest
11+
12+
from libvcs._internal.async_run import (
13+
AsyncProgressCallbackProtocol,
14+
async_run,
15+
wrap_sync_callback,
16+
)
17+
from libvcs.exc import CommandError, CommandTimeoutError
18+
19+
20+
class RunFixture(t.NamedTuple):
21+
"""Test fixture for async_run()."""
22+
23+
test_id: str
24+
args: list[str]
25+
kwargs: dict[str, t.Any]
26+
expected_output: str | None
27+
should_raise: bool
28+
29+
30+
RUN_FIXTURES = [
31+
RunFixture(
32+
test_id="echo_simple",
33+
args=["echo", "hello"],
34+
kwargs={},
35+
expected_output="hello",
36+
should_raise=False,
37+
),
38+
RunFixture(
39+
test_id="echo_multiline",
40+
args=["echo", "line1\nline2"],
41+
kwargs={},
42+
expected_output="line1\nline2",
43+
should_raise=False,
44+
),
45+
RunFixture(
46+
test_id="true_command",
47+
args=["true"],
48+
kwargs={},
49+
expected_output="",
50+
should_raise=False,
51+
),
52+
RunFixture(
53+
test_id="false_no_check",
54+
args=["false"],
55+
kwargs={"check_returncode": False},
56+
expected_output="",
57+
should_raise=False,
58+
),
59+
RunFixture(
60+
test_id="false_with_check",
61+
args=["false"],
62+
kwargs={"check_returncode": True},
63+
expected_output=None,
64+
should_raise=True,
65+
),
66+
]
67+
68+
69+
class TestAsyncRun:
70+
"""Tests for async_run function."""
71+
72+
@pytest.mark.parametrize(
73+
list(RunFixture._fields),
74+
RUN_FIXTURES,
75+
ids=[f.test_id for f in RUN_FIXTURES],
76+
)
77+
@pytest.mark.asyncio
78+
async def test_run(
79+
self,
80+
test_id: str,
81+
args: list[str],
82+
kwargs: dict[str, t.Any],
83+
expected_output: str | None,
84+
should_raise: bool,
85+
) -> None:
86+
"""Test async_run() with various commands."""
87+
if should_raise:
88+
with pytest.raises(CommandError):
89+
await async_run(args, **kwargs)
90+
else:
91+
output = await async_run(args, **kwargs)
92+
if expected_output is not None:
93+
assert output == expected_output
94+
95+
@pytest.mark.asyncio
96+
async def test_run_with_cwd(self, tmp_path: Path) -> None:
97+
"""Test async_run() uses specified working directory."""
98+
output = await async_run(["pwd"], cwd=tmp_path)
99+
assert output == str(tmp_path)
100+
101+
@pytest.mark.asyncio
102+
async def test_run_with_timeout(self) -> None:
103+
"""Test async_run() respects timeout."""
104+
with pytest.raises(CommandTimeoutError):
105+
await async_run(["sleep", "10"], timeout=0.1)
106+
107+
@pytest.mark.asyncio
108+
async def test_run_timeout_error_attributes(self) -> None:
109+
"""Test CommandTimeoutError has expected attributes."""
110+
with pytest.raises(CommandTimeoutError) as exc_info:
111+
await async_run(["sleep", "10"], timeout=0.1)
112+
113+
assert exc_info.value.returncode == -1
114+
assert "timed out" in exc_info.value.output
115+
116+
@pytest.mark.asyncio
117+
async def test_run_command_error_attributes(self) -> None:
118+
"""Test CommandError has expected attributes."""
119+
with pytest.raises(CommandError) as exc_info:
120+
await async_run(["false"], check_returncode=True)
121+
122+
assert exc_info.value.returncode == 1
123+
124+
@pytest.mark.asyncio
125+
async def test_run_with_callback(self) -> None:
126+
"""Test async_run() calls progress callback."""
127+
progress_output: list[str] = []
128+
timestamps: list[datetime.datetime] = []
129+
130+
async def callback(output: str, timestamp: datetime.datetime) -> None:
131+
progress_output.append(output)
132+
timestamps.append(timestamp)
133+
134+
# Use a command that writes to stderr
135+
await async_run(
136+
["sh", "-c", "echo stderr_line >&2"],
137+
callback=callback,
138+
check_returncode=True,
139+
)
140+
141+
# Should have received stderr output + final \r
142+
assert len(progress_output) >= 1
143+
assert any("stderr_line" in p for p in progress_output)
144+
# Final \r is sent
145+
assert progress_output[-1] == "\r"
146+
147+
@pytest.mark.asyncio
148+
async def test_run_callback_receives_timestamps(self) -> None:
149+
"""Test callback receives valid datetime timestamps."""
150+
timestamps: list[datetime.datetime] = []
151+
152+
async def callback(output: str, timestamp: datetime.datetime) -> None:
153+
timestamps.append(timestamp)
154+
155+
await async_run(
156+
["sh", "-c", "echo line >&2"],
157+
callback=callback,
158+
)
159+
160+
assert len(timestamps) >= 1
161+
for ts in timestamps:
162+
assert isinstance(ts, datetime.datetime)
163+
164+
@pytest.mark.asyncio
165+
async def test_run_stderr_on_error(self) -> None:
166+
"""Test stderr content is returned on command error."""
167+
output = await async_run(
168+
["sh", "-c", "echo error_msg >&2; exit 1"],
169+
check_returncode=False,
170+
)
171+
assert "error_msg" in output
172+
173+
@pytest.mark.asyncio
174+
async def test_run_concurrent(self) -> None:
175+
"""Test running multiple commands concurrently."""
176+
177+
async def run_echo(i: int) -> str:
178+
return await async_run(["echo", str(i)])
179+
180+
results = await asyncio.gather(*[run_echo(i) for i in range(5)])
181+
182+
assert len(results) == 5
183+
for i, result in enumerate(results):
184+
assert result == str(i)
185+
186+
187+
class TestWrapSyncCallback:
188+
"""Tests for wrap_sync_callback helper."""
189+
190+
@pytest.mark.asyncio
191+
async def test_wrap_sync_callback(self) -> None:
192+
"""Test wrap_sync_callback creates working async wrapper."""
193+
calls: list[tuple[str, datetime.datetime]] = []
194+
195+
def sync_cb(output: str, timestamp: datetime.datetime) -> None:
196+
calls.append((output, timestamp))
197+
198+
async_cb = wrap_sync_callback(sync_cb)
199+
200+
# Verify it's a valid async callback
201+
now = datetime.datetime.now()
202+
await async_cb("test", now)
203+
204+
assert len(calls) == 1
205+
assert calls[0] == ("test", now)
206+
207+
@pytest.mark.asyncio
208+
async def test_wrap_sync_callback_type(self) -> None:
209+
"""Test wrapped callback conforms to protocol."""
210+
211+
def sync_cb(output: str, timestamp: datetime.datetime) -> None:
212+
pass
213+
214+
async_cb = wrap_sync_callback(sync_cb)
215+
216+
# Type check: should be usable where AsyncProgressCallbackProtocol expected
217+
callback: AsyncProgressCallbackProtocol = async_cb
218+
await callback("test", datetime.datetime.now())
219+
220+
221+
class TestAsyncProgressCallbackProtocol:
222+
"""Tests for AsyncProgressCallbackProtocol."""
223+
224+
@pytest.mark.asyncio
225+
async def test_protocol_implementation(self) -> None:
226+
"""Test that a function can implement the protocol."""
227+
received: list[str] = []
228+
229+
async def my_callback(output: str, timestamp: datetime.datetime) -> None:
230+
received.append(output)
231+
232+
# Use as protocol type
233+
cb: AsyncProgressCallbackProtocol = my_callback
234+
await cb("hello", datetime.datetime.now())
235+
236+
assert received == ["hello"]
237+
238+
239+
class TestArgsToList:
240+
"""Tests for _args_to_list helper."""
241+
242+
@pytest.mark.asyncio
243+
async def test_string_arg(self) -> None:
244+
"""Test single string argument."""
245+
output = await async_run("echo")
246+
assert output == ""
247+
248+
@pytest.mark.asyncio
249+
async def test_path_arg(self, tmp_path: Path) -> None:
250+
"""Test Path argument."""
251+
test_script = tmp_path / "test.sh"
252+
test_script.write_text("#!/bin/sh\necho working")
253+
test_script.chmod(0o755)
254+
255+
output = await async_run(test_script)
256+
assert output == "working"
257+
258+
@pytest.mark.asyncio
259+
async def test_bytes_arg(self) -> None:
260+
"""Test bytes argument."""
261+
output = await async_run([b"echo", b"bytes_test"])
262+
assert output == "bytes_test"

0 commit comments

Comments
 (0)