Skip to content

Commit 7807878

Browse files
committed
AGENTS(docs[asyncio]): Add asyncio development guidelines
why: Document patterns and conventions for async support implementation what: - Architecture section showing _async/ subpackage structure - Subprocess patterns (communicate, timeout, BrokenPipeError) - API conventions (Async prefix, async-only callbacks, shared logic) - Testing patterns (strict mode, function-scoped loops, NamedTuple) - Anti-patterns to avoid (polling, blocking calls, loop closure)
1 parent 0f64398 commit 7807878

1 file changed

Lines changed: 133 additions & 0 deletions

File tree

AGENTS.md

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,139 @@ EOF
257257
)"
258258
```
259259

260+
## Asyncio Development
261+
262+
### Architecture
263+
264+
libvcs async support is organized in `_async/` subpackages:
265+
266+
```
267+
libvcs/
268+
├── _internal/
269+
│ ├── subprocess.py # Sync subprocess wrapper
270+
│ └── async_subprocess.py # Async subprocess wrapper
271+
├── cmd/
272+
│ ├── git.py # Git (sync)
273+
│ └── _async/git.py # AsyncGit
274+
├── sync/
275+
│ ├── git.py # GitSync (sync)
276+
│ └── _async/git.py # AsyncGitSync
277+
```
278+
279+
### Async Subprocess Patterns
280+
281+
**Always use `communicate()` for subprocess I/O:**
282+
```python
283+
proc = await asyncio.create_subprocess_shell(...)
284+
stdout, stderr = await proc.communicate() # Prevents deadlocks
285+
```
286+
287+
**Use `asyncio.timeout()` for timeouts:**
288+
```python
289+
async with asyncio.timeout(300):
290+
stdout, stderr = await proc.communicate()
291+
```
292+
293+
**Handle BrokenPipeError gracefully:**
294+
```python
295+
try:
296+
proc.stdin.write(data)
297+
await proc.stdin.drain()
298+
except BrokenPipeError:
299+
pass # Process already exited - expected behavior
300+
```
301+
302+
### Async API Conventions
303+
304+
- **Class naming**: Use `Async` prefix: `AsyncGit`, `AsyncGitSync`
305+
- **Callbacks**: Async APIs accept only async callbacks (no union types)
306+
- **Shared logic**: Extract argument-building to sync functions, share with async
307+
308+
```python
309+
# Shared argument building (sync)
310+
def build_clone_args(url: str, depth: int | None = None) -> list[str]:
311+
args = ["clone", url]
312+
if depth:
313+
args.extend(["--depth", str(depth)])
314+
return args
315+
316+
# Async method uses shared logic
317+
async def clone(self, url: str, depth: int | None = None) -> str:
318+
args = build_clone_args(url, depth)
319+
return await self.run(args)
320+
```
321+
322+
### Async Testing
323+
324+
**pytest configuration:**
325+
```toml
326+
[tool.pytest.ini_options]
327+
asyncio_mode = "strict"
328+
asyncio_default_fixture_loop_scope = "function"
329+
```
330+
331+
**Async fixture pattern:**
332+
```python
333+
@pytest_asyncio.fixture(loop_scope="function")
334+
async def async_git_repo(tmp_path: Path) -> t.AsyncGenerator[AsyncGitSync, None]:
335+
repo = AsyncGitSync(url="...", path=tmp_path / "repo")
336+
await repo.obtain()
337+
yield repo
338+
```
339+
340+
**Parametrized async tests:**
341+
```python
342+
class CloneFixture(t.NamedTuple):
343+
test_id: str
344+
clone_kwargs: dict[str, t.Any]
345+
expected: list[str]
346+
347+
CLONE_FIXTURES = [
348+
CloneFixture("basic", {}, [".git"]),
349+
CloneFixture("shallow", {"depth": 1}, [".git"]),
350+
]
351+
352+
@pytest.mark.parametrize(
353+
list(CloneFixture._fields),
354+
CLONE_FIXTURES,
355+
ids=[f.test_id for f in CLONE_FIXTURES],
356+
)
357+
@pytest.mark.asyncio
358+
async def test_clone(test_id: str, clone_kwargs: dict, expected: list) -> None:
359+
...
360+
```
361+
362+
### Async Anti-Patterns
363+
364+
**DON'T poll returncode:**
365+
```python
366+
# WRONG
367+
while proc.returncode is None:
368+
await asyncio.sleep(0.1)
369+
370+
# RIGHT
371+
await proc.wait()
372+
```
373+
374+
**DON'T mix blocking calls in async code:**
375+
```python
376+
# WRONG
377+
async def bad():
378+
subprocess.run(["git", "clone", url]) # Blocks event loop!
379+
380+
# RIGHT
381+
async def good():
382+
proc = await asyncio.create_subprocess_shell(...)
383+
await proc.wait()
384+
```
385+
386+
**DON'T close the event loop in tests:**
387+
```python
388+
# WRONG - breaks pytest-asyncio cleanup
389+
loop = asyncio.get_running_loop()
390+
loop.close()
391+
```
392+
260393
## Debugging Tips
261394

262395
When stuck in debugging loops:

0 commit comments

Comments
 (0)