Skip to content

Commit 01d9322

Browse files
committed
ai(rules[AGENTS]) Port asyncio/doctest guidelines from libvcs
why: Prepare for asyncio development with consistent patterns what: - Add critical doctest rules (executable tests, no SKIP workarounds) - Add async doctest pattern with asyncio.run() - Add Asyncio Development section with subprocess patterns - Add async API conventions, testing patterns, and anti-patterns
1 parent f84b6f9 commit 01d9322

1 file changed

Lines changed: 152 additions & 0 deletions

File tree

AGENTS.md

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,45 @@ type
226226

227227
### Doctest Guidelines
228228

229+
**All functions and methods MUST have working doctests.** Doctests serve as both documentation and tests.
230+
231+
**CRITICAL RULES:**
232+
- Doctests MUST actually execute - never comment out `asyncio.run()` or similar calls
233+
- Doctests MUST NOT be converted to `.. code-block::` as a workaround (code-blocks don't run)
234+
- If you cannot create a working doctest, **STOP and ask for help**
235+
236+
**Available tools for doctests:**
237+
- `doctest_namespace` fixtures: `tmp_path`, `asyncio`
238+
- Ellipsis for variable output: `# doctest: +ELLIPSIS`
239+
- Update `conftest.py` to add new fixtures to `doctest_namespace`
240+
241+
**`# doctest: +SKIP` is NOT permitted** - it's just another workaround that doesn't test anything. Use the fixtures properly.
242+
243+
**Async doctest pattern:**
244+
```python
245+
>>> async def example():
246+
... result = await some_async_function()
247+
... return result
248+
>>> asyncio.run(example())
249+
'expected output'
250+
```
251+
252+
**Using fixtures in doctests:**
253+
```python
254+
>>> from pathlib import Path
255+
>>> doc_path = tmp_path / "example.rst" # tmp_path from doctest_namespace
256+
>>> doc_path.write_text(">>> 1 + 1\\n2")
257+
...
258+
```
259+
260+
**When output varies, use ellipsis:**
261+
```python
262+
>>> import doctest_docutils
263+
>>> doctest_docutils.__file__ # doctest: +ELLIPSIS
264+
'.../doctest_docutils.py'
265+
```
266+
267+
**Additional guidelines:**
229268
1. **Use narrative descriptions** for test sections rather than inline comments
230269
2. **Move complex examples** to dedicated test files at `tests/examples/<path_to_module>/test_<example>.py`
231270
3. **Keep doctests simple and focused** on demonstrating usage
@@ -291,6 +330,119 @@ When stuck in debugging loops:
291330
3. **Document the issue** comprehensively for a fresh approach
292331
4. **Format for portability** (using quadruple backticks)
293332

333+
## Asyncio Development
334+
335+
### Async Subprocess Patterns
336+
337+
**Always use `communicate()` for subprocess I/O:**
338+
```python
339+
proc = await asyncio.create_subprocess_shell(...)
340+
stdout, stderr = await proc.communicate() # Prevents deadlocks
341+
```
342+
343+
**Use `asyncio.timeout()` for timeouts:**
344+
```python
345+
async with asyncio.timeout(300):
346+
stdout, stderr = await proc.communicate()
347+
```
348+
349+
**Handle BrokenPipeError gracefully:**
350+
```python
351+
try:
352+
proc.stdin.write(data)
353+
await proc.stdin.drain()
354+
except BrokenPipeError:
355+
pass # Process already exited - expected behavior
356+
```
357+
358+
### Async API Conventions
359+
360+
- **Class naming**: Use `Async` prefix: `AsyncDocTestRunner`
361+
- **Callbacks**: Async APIs accept only async callbacks (no union types)
362+
- **Shared logic**: Extract argument-building to sync functions, share with async
363+
364+
```python
365+
# Shared argument building (sync)
366+
def build_test_args(verbose: bool = False) -> dict[str, t.Any]:
367+
args = {"verbose": verbose}
368+
return args
369+
370+
# Async method uses shared logic
371+
async def run_tests(self, verbose: bool = False) -> TestResults:
372+
args = build_test_args(verbose)
373+
return await self._run(**args)
374+
```
375+
376+
### Async Testing
377+
378+
**pytest configuration:**
379+
```toml
380+
[tool.pytest.ini_options]
381+
asyncio_mode = "strict"
382+
asyncio_default_fixture_loop_scope = "function"
383+
```
384+
385+
**Async fixture pattern:**
386+
```python
387+
@pytest_asyncio.fixture(loop_scope="function")
388+
async def async_doc_runner(tmp_path: Path) -> t.AsyncGenerator[AsyncDocTestRunner, None]:
389+
runner = AsyncDocTestRunner(path=tmp_path)
390+
yield runner
391+
```
392+
393+
**Parametrized async tests:**
394+
```python
395+
class DocTestFixture(t.NamedTuple):
396+
test_id: str
397+
doc_content: str
398+
expected: list[str]
399+
400+
DOC_FIXTURES = [
401+
DocTestFixture("basic", ">>> 1 + 1\n2", ["pass"]),
402+
DocTestFixture("failure", ">>> 1 + 1\n3", ["fail"]),
403+
]
404+
405+
@pytest.mark.parametrize(
406+
list(DocTestFixture._fields),
407+
DOC_FIXTURES,
408+
ids=[f.test_id for f in DOC_FIXTURES],
409+
)
410+
@pytest.mark.asyncio
411+
async def test_doctest(test_id: str, doc_content: str, expected: list) -> None:
412+
...
413+
```
414+
415+
### Async Anti-Patterns
416+
417+
**DON'T poll returncode:**
418+
```python
419+
# WRONG
420+
while proc.returncode is None:
421+
await asyncio.sleep(0.1)
422+
423+
# RIGHT
424+
await proc.wait()
425+
```
426+
427+
**DON'T mix blocking calls in async code:**
428+
```python
429+
# WRONG
430+
async def bad():
431+
subprocess.run(["python", "-m", "doctest", file]) # Blocks event loop!
432+
433+
# RIGHT
434+
async def good():
435+
proc = await asyncio.create_subprocess_shell(...)
436+
await proc.wait()
437+
```
438+
439+
**DON'T close the event loop in tests:**
440+
```python
441+
# WRONG - breaks pytest-asyncio cleanup
442+
loop = asyncio.get_running_loop()
443+
loop.close()
444+
```
445+
294446
## Sphinx/Docutils-Specific Considerations
295447

296448
### Directive Registration

0 commit comments

Comments
 (0)