Skip to content

Commit 292f647

Browse files
committed
AGENTS(docs[asyncio]): Add asyncio development guidelines
why: Document async patterns and conventions for contributors. what: - Add async architecture overview (_async/ subpackages) - Add async subprocess patterns (communicate, timeout, BrokenPipeError) - Add async API conventions (naming, callbacks, shared logic) - Add async testing patterns with pytest-asyncio - Add async anti-patterns to avoid
1 parent b55a443 commit 292f647

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
@@ -295,6 +295,139 @@ EOF
295295
)"
296296
```
297297

298+
## Asyncio Development
299+
300+
### Architecture
301+
302+
libvcs async support is organized in `_async/` subpackages:
303+
304+
```
305+
libvcs/
306+
├── _internal/
307+
│ ├── subprocess.py # Sync subprocess wrapper
308+
│ └── async_subprocess.py # Async subprocess wrapper
309+
├── cmd/
310+
│ ├── git.py # Git (sync)
311+
│ └── _async/git.py # AsyncGit
312+
├── sync/
313+
│ ├── git.py # GitSync (sync)
314+
│ └── _async/git.py # AsyncGitSync
315+
```
316+
317+
### Async Subprocess Patterns
318+
319+
**Always use `communicate()` for subprocess I/O:**
320+
```python
321+
proc = await asyncio.create_subprocess_shell(...)
322+
stdout, stderr = await proc.communicate() # Prevents deadlocks
323+
```
324+
325+
**Use `asyncio.timeout()` for timeouts:**
326+
```python
327+
async with asyncio.timeout(300):
328+
stdout, stderr = await proc.communicate()
329+
```
330+
331+
**Handle BrokenPipeError gracefully:**
332+
```python
333+
try:
334+
proc.stdin.write(data)
335+
await proc.stdin.drain()
336+
except BrokenPipeError:
337+
pass # Process already exited - expected behavior
338+
```
339+
340+
### Async API Conventions
341+
342+
- **Class naming**: Use `Async` prefix: `AsyncGit`, `AsyncGitSync`
343+
- **Callbacks**: Async APIs accept only async callbacks (no union types)
344+
- **Shared logic**: Extract argument-building to sync functions, share with async
345+
346+
```python
347+
# Shared argument building (sync)
348+
def build_clone_args(url: str, depth: int | None = None) -> list[str]:
349+
args = ["clone", url]
350+
if depth:
351+
args.extend(["--depth", str(depth)])
352+
return args
353+
354+
# Async method uses shared logic
355+
async def clone(self, url: str, depth: int | None = None) -> str:
356+
args = build_clone_args(url, depth)
357+
return await self.run(args)
358+
```
359+
360+
### Async Testing
361+
362+
**pytest configuration:**
363+
```toml
364+
[tool.pytest.ini_options]
365+
asyncio_mode = "strict"
366+
asyncio_default_fixture_loop_scope = "function"
367+
```
368+
369+
**Async fixture pattern:**
370+
```python
371+
@pytest_asyncio.fixture(loop_scope="function")
372+
async def async_git_repo(tmp_path: Path) -> t.AsyncGenerator[AsyncGitSync, None]:
373+
repo = AsyncGitSync(url="...", path=tmp_path / "repo")
374+
await repo.obtain()
375+
yield repo
376+
```
377+
378+
**Parametrized async tests:**
379+
```python
380+
class CloneFixture(t.NamedTuple):
381+
test_id: str
382+
clone_kwargs: dict[str, t.Any]
383+
expected: list[str]
384+
385+
CLONE_FIXTURES = [
386+
CloneFixture("basic", {}, [".git"]),
387+
CloneFixture("shallow", {"depth": 1}, [".git"]),
388+
]
389+
390+
@pytest.mark.parametrize(
391+
list(CloneFixture._fields),
392+
CLONE_FIXTURES,
393+
ids=[f.test_id for f in CLONE_FIXTURES],
394+
)
395+
@pytest.mark.asyncio
396+
async def test_clone(test_id: str, clone_kwargs: dict, expected: list) -> None:
397+
...
398+
```
399+
400+
### Async Anti-Patterns
401+
402+
**DON'T poll returncode:**
403+
```python
404+
# WRONG
405+
while proc.returncode is None:
406+
await asyncio.sleep(0.1)
407+
408+
# RIGHT
409+
await proc.wait()
410+
```
411+
412+
**DON'T mix blocking calls in async code:**
413+
```python
414+
# WRONG
415+
async def bad():
416+
subprocess.run(["git", "clone", url]) # Blocks event loop!
417+
418+
# RIGHT
419+
async def good():
420+
proc = await asyncio.create_subprocess_shell(...)
421+
await proc.wait()
422+
```
423+
424+
**DON'T close the event loop in tests:**
425+
```python
426+
# WRONG - breaks pytest-asyncio cleanup
427+
loop = asyncio.get_running_loop()
428+
loop.close()
429+
```
430+
298431
## Debugging Tips
299432

300433
When stuck in debugging loops:

0 commit comments

Comments
 (0)