Skip to content

Commit 3d5e491

Browse files
committed
docs: add AGENTS.md with build, lint, test, and style guidelines
1 parent d58fa3a commit 3d5e491

1 file changed

Lines changed: 209 additions & 0 deletions

File tree

AGENTS.md

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
# AGENTS.md — NotEnoughModPolling
2+
3+
IRC bot that automatically keeps mods in Not Enough Mods up-to-date.
4+
Python 3.14+, managed with `uv`, linted/formatted with `ruff`, tested with `pytest`.
5+
6+
## Build & Run
7+
8+
```bash
9+
uv sync --dev # Install all dependencies (including dev)
10+
uv run python irc_bot.py # Start the bot (requires config.yml)
11+
```
12+
13+
## Lint
14+
15+
```bash
16+
uv run ruff check . # Lint (pyflakes, pycodestyle, isort, pyupgrade, bugbear, simplify, ruff)
17+
uv run ruff check --fix . # Lint with auto-fix
18+
uv run ruff format . # Format code
19+
uv run ruff format --check . # Check formatting without modifying
20+
```
21+
22+
CI runs both `ruff check .` and `ruff format --check .` — both must pass.
23+
24+
## Test
25+
26+
```bash
27+
uv run pytest # Run all tests
28+
uv run pytest tests/test_ban_list.py # Run one test file
29+
uv run pytest tests/test_ban_list.py::TestBanListGroups # Run one test class
30+
uv run pytest tests/test_ban_list.py::TestBanListGroups::test_define_group # Run single test
31+
uv run pytest -x # Stop on first failure
32+
uv run pytest --tb=short -q # Short output (CI mode)
33+
```
34+
35+
All async tests run automatically via `pytest-asyncio` with `asyncio_mode = "auto"`.
36+
37+
## Project Structure
38+
39+
Flat layout (no `src/` directory). All primary modules live at the repo root:
40+
41+
```
42+
irc_bot.py # Entry point, IrcBot class
43+
irc_connection.py # TCP/IRC connection handling
44+
irc_logging.py # Logging configuration (file + console)
45+
config.py # YAML config loading
46+
command_router.py # Central command dispatch, plugin loading
47+
bot_events.py # Event system (timer, chat, join events)
48+
help_system.py # Help database
49+
user_auth.py # Auth/registration tracking
50+
ban_list.py # SQLite-backed ban system
51+
task_pool.py # Async task management
52+
plugins/ # Bot command plugins (dynamically loaded)
53+
irc_handlers/ # IRC protocol handlers (one per command/numeric)
54+
mod_polling/ # Mod polling engine, parsers, data files
55+
config/ # Supplementary YAML config files
56+
tests/ # All tests, with conftest.py for shared fixtures
57+
```
58+
59+
Plugins and IRC handlers are discovered at runtime via `os.listdir()` and loaded
60+
with `importlib.util.spec_from_file_location()`.
61+
62+
## Code Style
63+
64+
### Formatting
65+
66+
- **Line length**: 120 characters (configured in `pyproject.toml` under `[tool.ruff]`)
67+
- **Indentation**: 4 spaces for Python, 2 spaces for YAML (see `.editorconfig`)
68+
- **Final newline**: Always insert
69+
- **Trailing whitespace**: Always trim
70+
71+
### Imports
72+
73+
- **Absolute imports only** — never use relative imports (`from . import`)
74+
- **Ordering** (enforced by ruff/isort): stdlib → third-party → local, separated by blank lines
75+
- `plugins` and `irc_handlers` are configured as known first-party in isort
76+
- Use `import module` for broad usage; `from module import Name` for specific items
77+
- Standard library modules are typically imported whole (`import asyncio`, `import logging`)
78+
79+
```python
80+
import asyncio
81+
import logging
82+
83+
import aiohttp
84+
import yaml
85+
86+
from bot_events import MsgEvent, StandardEvent
87+
from command_router import Permission, command
88+
```
89+
90+
### Naming Conventions
91+
92+
| Element | Convention | Example |
93+
|----------------------|--------------------|--------------------------------------------|
94+
| Modules | `snake_case` | `irc_connection.py`, `bot_events.py` |
95+
| IRC handler modules | include numeric | `rpl_endofmotd_376.py`, `err_nicknameinuse_433.py` |
96+
| Classes | `PascalCase` | `IrcBot`, `ModPoller`, `CommandRouter` |
97+
| Functions/methods | `snake_case` | `fetch_page`, `check_mod`, `add_event` |
98+
| Private members | `_underscore` | `_parse_message`, `_handler_lock` |
99+
| Constants | `UPPER_SNAKE_CASE` | `PLUGIN_ID`, `MAX_POLL_FAILURES` |
100+
| Plugin IDs | `PLUGIN_ID = "x"` | Module-level constant in every plugin |
101+
| IRC handler IDs | `ID = "XXX"` | Module-level constant (`"PRIVMSG"`, `"376"`) |
102+
| New-style commands | `cmd_` prefix | `cmd_enable`, `cmd_disable`, `cmd_status` |
103+
| Command aliases | `alias_` prefix | `alias_start`, `alias_stop` |
104+
| Unused loop vars | `_prefix` | `_i`, `_k`, `_op` |
105+
106+
### Type Annotations
107+
108+
Type annotations are used **sparingly** — only on `NamedTuple` fields and occasional
109+
instance variable declarations. Function signatures do not carry type hints.
110+
111+
- Use modern union syntax: `X | Y` (not `Optional[X]` or `Union[X, Y]`)
112+
- Annotate `NamedTuple` fields and complex instance variables where it aids clarity
113+
114+
```python
115+
class PluginEntry(NamedTuple):
116+
module: object
117+
path: str
118+
setup: Callable | None
119+
teardown: Callable | None
120+
instance: object | None = None
121+
122+
self._host_locks: dict[str, asyncio.Lock] = {}
123+
```
124+
125+
### String Formatting
126+
127+
- **f-strings** for most string construction
128+
- **`.format()`** only for complex IRC messages with many color-code variables
129+
- **`%`-style** only inside `logging` calls (standard practice for lazy interpolation)
130+
131+
```python
132+
f"PRIVMSG {channel} :{msg}" # general use
133+
logger.info("Connected to %s", self.host) # logging only
134+
"{purple}{name}{end}".format(name=n, purple=P, end=E) # complex IRC messages
135+
```
136+
137+
### Error Handling
138+
139+
- **Custom exceptions** inherit from `Exception`, define `__init__` and `__str__`:
140+
`ConnectionDown`, `NEMPException`, `InvalidVersion`, `EventAlreadyExists`, etc.
141+
- `NEMPException` is a base class; `InvalidVersion` inherits from it
142+
- **Top-level loops** use broad `except Exception` with `logger.exception()`
143+
- **Specific catches** where meaningful: `KeyError`, `TimeoutError`, `asyncio.CancelledError`
144+
- Use `contextlib.suppress(ExcType)` instead of bare `try/except pass`
145+
- Validate inputs eagerly with `isinstance` checks, raising `TypeError`/`ValueError`
146+
147+
### Async Patterns
148+
149+
The codebase is fully async, built on `asyncio`:
150+
151+
- Entry point: `asyncio.run(async_main())` in `irc_bot.py`
152+
- Background tasks via `asyncio.create_task()` with done-callback cleanup
153+
- `asyncio.Lock` for serialized handler execution and per-host rate limiting
154+
- `asyncio.Queue` for inter-task communication (with `queue.shutdown()` for cleanup)
155+
- `asyncio.gather`, `asyncio.as_completed`, `asyncio.wait_for` for concurrency
156+
- `async for` with async generators (`IrcConnection.read_lines()`)
157+
- aiohttp sessions: explicit `User-Agent` header, `aiohttp.ClientTimeout`, `async with`
158+
159+
### Logging
160+
161+
- **Module-level** loggers: `logger = logging.getLogger("BanList")`
162+
- **Instance-level** loggers: `self._logger = logging.getLogger("IRCConnection")`
163+
- Hierarchical naming: `irc.ping`, `irc.rpl.353`, `irc.err.433`, `cmd.say`, `cmd.pycalc`
164+
- Always use `%`-style formatting in log calls (lazy interpolation)
165+
- Use `.exception()` for logging with tracebacks
166+
167+
### Plugin Architecture
168+
169+
Two plugin styles coexist:
170+
171+
**Old-style** (function-based): module-level `COMMANDS` dict + underscore-prefixed async functions:
172+
```python
173+
PLUGIN_ID = "say"
174+
async def _say(router, name, params, channel, userdata, rank, is_channel): ...
175+
COMMANDS = {"say": {"execute": _say, "permission": Permission.HIDDEN}}
176+
```
177+
178+
**New-style** (class-based): `Plugin` class with `@command`/`@subcommand` decorators:
179+
```python
180+
PLUGIN_ID = "nemp"
181+
class Plugin:
182+
async def setup(self, router, startup): ...
183+
async def teardown(self, router): ...
184+
@command("nemp", permission=Permission.VOICED, allow_private=True)
185+
async def nemp(self, router, name, params, channel, userdata, rank, is_channel): ...
186+
@subcommand("nemp", "enable", permission=Permission.OP)
187+
async def cmd_enable(self, router, ...): ...
188+
```
189+
190+
Prefer the **new-style** class-based pattern for new plugins.
191+
192+
## Testing Conventions
193+
194+
- Test files: `tests/test_<module>.py`
195+
- Group tests in `class TestXxx` with `test_xxx` methods
196+
- Use `pytest.raises(ExcType, match="pattern")` for exception testing
197+
- Use `tmp_path` fixture for file/database isolation
198+
- Mocking: `MagicMock` (sync), `AsyncMock` (async), `patch`/`patch.object`
199+
- HTTP mocking: `aioresponses` library for `aiohttp` requests
200+
- Plain `assert` statements (pytest-style, no `unittest` assertions)
201+
- Shared fixtures in `tests/conftest.py` (e.g., `mod_poller`, `ban_list`, event fixtures)
202+
203+
## Key Configuration
204+
205+
- `ruff` config: `pyproject.toml` — rules: F, E, W, I, UP, B, SIM, RUF
206+
- `pytest` config: `pyproject.toml``asyncio_mode = "auto"`, `testpaths = ["tests"]`
207+
- `.editorconfig`: charset utf-8, trim whitespace, final newlines
208+
- `.gitignore`: `config.yml`, `*.db`, `__pycache__/`, `BotLogs/`, `mod_polling/htdocs/`
209+
- CI: `.github/workflows/tests.yml` — runs lint + tests on push/PR to `master`

0 commit comments

Comments
 (0)