Skip to content

Commit f6d2a0f

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 b08957a commit f6d2a0f

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
@@ -225,6 +225,45 @@ type
225225

226226
### Doctest Guidelines
227227

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

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

295447
### Directive Registration

0 commit comments

Comments
 (0)