Code style
This guide describes the conventions used across the OpenSecDash codebase. It applies to human contributors and AI coding agents alike. The single most important rule:
Write code that reads like the surrounding code. Match its naming, comment density, error handling, and structure. When this guide and a file's local style disagree, prefer consistency within the file and mention it in the PR.
There is intentionally no auto-formatter configured. The enforced gates are behavior and types, not formatting:
cd backend
uv run pytest -q
uv run pyright ../backend/app ../backend/tests ../pluginsBoth must pass with zero errors before a PR is opened. If you did not run them, say so in the PR description.
Python
- Python 3.13+, fully type-hinted. Public functions carry parameter and return annotations. Pyright is the type checker; new
# type: ignorecomments need a reason on the same line. - Naming:
snake_casefor functions/variables,PascalCasefor classes,UPPER_CASEfor module-level constants,_leading_underscorefor module-private helpers. - Imports: standard library, third-party, then
app.*— as absolute imports. Plugins import fromapp.*and use relative imports for their own submodules (from .services import decisions). Lazy imports inside functions are used only to break genuine import cycles or keep optional dependencies out of module import. - Constants over magic numbers: tunables live at module top with a comment explaining the chosen value (see
MAX_LINES_PER_TICKorCROWDSEC_BAN_DEDUPE_WINDOWfor the expected style). - Logging: one
logger = logging.getLogger(__name__)per module; lazy%sformatting (logger.info("Synced %d decisions", count)), never f-strings in log calls. Never log secrets — reuse the redaction helpers inapp/core/logging.py.
Comments and docstrings
Comments in this codebase explain why, not what. A comment states a constraint the code cannot express: why a value was chosen, which failure mode a branch prevents, what breaks if the line is removed. Examples of the expected style are everywhere — e.g. the threading notes in app/plugins/manager.py or the dedupe rationale in plugins/crowdsec/services/dedupe.py.
- Do not narrate code (
# increment counter), do not leave TODOs without an issue, and do not write comments addressed to a reviewer ("this change fixes..."). - Public functions and non-obvious modules get a short docstring; one-line summaries are fine.
- Code, comments, docstrings, and commit messages are written in English.
Error handling
- Never use bare
except:. Catch the narrowest exception that makes sense; log withlogger.exception(...)where the stack trace matters. - Failure isolation is a core pattern: one broken item must not abort the batch, and one broken plugin must not take down the app. Background loops, discovery, per-event ingestion, and per-plugin hooks all wrap the unit of work in
try/exceptand continue (seePluginManager.discover()and the savepoint-per-event in_run_datasource_tick). - User-facing validation raises
ValueErrorwith a clear, stable message — several are asserted verbatim in tests. Never change an existing error string casually.
Async and blocking work
The app runs a single event loop for every visitor. No blocking I/O on the event loop:
- Route handlers that write to the database or do real work are either plain
def(FastAPI runs them in a worker thread) orasync defthat pushes the blocking part intoasyncio.to_thread(...). - Background loops run their tick bodies via
asyncio.to_threadand sleep between runs. - Long batches commit periodically (see
EVENTS_COMMIT_EVERY) so the SQLite write lock is not held for the whole batch.
Database
- SQLAlchemy ORM only. No SQL string interpolation; the rare
text()usage is limited to controlled schema statements inapp/database/. - JSON columns require reassignment. Plain
JSONcolumns do not track in-place mutation:obj.data = {**obj.data, "key": value}— neverobj.data["key"] = value. - Schema changes go through an Alembic migration in
backend/migrations/versions/(copy the style of the most recent migration). Settings keys (plugin.<id>.<key>,ui.<page>.<name>) and existing schema are upgrade-contracts: never rename them. - Queries against
eventsmust stay on indexed columns and bounded time windows — the reference installation is a multi-hundred-MB SQLite database. If a new query pattern needs an index, add it in the same PR. - Secrets are stored encrypted transparently via
app/core/secrets.py; always read settings throughget_setting_value()and friends.
Architecture rules
- The core never imports from
plugins/. Plugins import fromapp.*. Where the core needs plugin behavior, it goes through the plugin manager, registries, or the hooks inapp/plugins/base.py. See ADR-044 anddocs/adr/in general — ADRs are binding. - Plugins are packages:
plugins/<name>/__init__.py+plugin.py, optionalroutes.py,templates/,locales.py,services/. Plugin API version is2. - Plugin hooks return data, not markup or code: widget/view/rule contributions are validated descriptors rendered by core templates.
- Layering:
app/core(no web/plugin imports) →app/services(domain logic) →app/web(shared web helpers) →app/api(routes) →plugins/.
Templates and frontend
- Server-rendered Jinja2 with Tailwind utility classes; htmx and Alpine.js for the small interactive parts. No new frontend frameworks or vendor dependencies without prior discussion.
- Autoescaping stays on. Never use
|safeon data that is not a compile-time constant. JS hooks usedata-*attributes, not inline event handlers. - Redirects only target internal paths: validate with
startswith("/") and not startswith("//")or reuse_safe_local_redirect_target. - Every user-visible string goes through
t("...")with locale entries in bothbackend/app/locales/en.pyandde.py(or the plugin'slocales.py). A PR that adds UI text in only one language is incomplete.
Tests
- Pytest, plain functions, in-memory SQLite via the
db_sessionfixture inbackend/tests/conftest.py. Plugin modules are loaded throughimport_plugin_module(...)from the same conftest — nosys.pathtricks. - Test names describe behavior:
test_ban_unban_reban_within_window_keeps_both_bans_as_distinct_events. - Test behavior, not implementation: assert stored rows, rendered values, raised messages. Monkeypatch at module boundaries (network, subprocess, plugin manager).
- Bug fixes come with a regression test that fails before the fix.
Commits and pull requests
- Commit subjects are lowercase with a prefix:
feat:,fix:,change:,enhance:,docs:— e.g.fix: reject action types without an owning plugin. - One focused change per PR; no drive-by refactoring or reformatting of untouched code.
- User-facing changes update the website docs (
website/guide/) in the same PR; architectural changes check the relevant ADR (docs/adr/). - The PR description states which checks were run (pytest, pyright, manual smoke) and their results.
For AI coding agents
Everything above applies. Additionally, follow the behavior rules in .agents/AGENTS.md — they govern how agents work in this repository (questions before changes, ADR compliance, minimal diffs, honest test reporting).