structural-rules (KST*)¶
Structural rules
This rule is enabled by default.
What it does¶
Matches code structure (the syntax tree) against rules you define, so a rule keeps working whatever the formatting, comments or import aliases. KPT matches text; KST matches nodes. Each rule has its own id (starting with KST) and is addressable in select, ignore, per-file-ignores and # noqa. A rule can carry a fix that rewrites or deletes each match.
Why is this bad?¶
Some conventions are about structure, which a regex cannot express reliably: no assert inside pytest fixtures, no call to X from within Y, no function with a given decorator. A structural rule states that directly.
Example¶
Use instead:
Defining rules¶
[[tool.konform.lint.structural-rules.rules]]
id = "KST001"
message = "Do not use assert inside a pytest fixture."
help = "Raise an explicit exception instead."
level = "error"
files = ["tests/**"]
match = { kind = "assert", inside = { decorated_with = "pytest.fixture" } }
Rules are loaded from the first available source:
- Inline
[[tool.konform.lint.structural-rules.rules]]in the config file. rules_file = "path"in[tool.konform.lint.structural-rules](.tomlor.yaml).konform_rules.tomlnext to the config file (use[[rules]]tables).
An invalid rule is a hard error: konform check prints every problem (error: <source>: rule 'KST002': <why>) and exits 2 without linting, so a typo cannot produce a false green in CI. The language server instead shows one warning and keeps running the valid rules. Violations are reported at the matched node: the name for functions and classes, the first line for other blocks.
Matchers¶
A match is a table whose conditions must all hold:
| Key | Meaning |
|---|---|
kind |
node kind, or a list (any of): function class lambda assert assign import return raise yield try with for while if call await name attribute |
name |
regex searched in the node's identifier (anchor it with ^…$) |
qualname |
import-resolved dotted name of a name / attribute node |
callee |
import-resolved dotted name of a call's function |
decorated_with |
import-resolved decorator name(s) on a function or class |
inside |
some ancestor matches |
has |
some descendant matches |
not |
the node does not match |
all / any |
lists of matchers; all / at least one must match |
Writing rules¶
konform ast FILE prints the tree exactly as KST sees it. Each line is a node you can match with kind, with its position and the values the conditions compare against (name, import-resolved qualname / callee, decorators):
$ konform ast tests/conftest.py
import 1:1
function 5:5 name=my_fixture decorators=[pytest.fixture]
assert 8:5
Testing rules¶
Add valid / invalid snippets to a rule and run konform rule --test. Each valid snippet must produce no violation of that rule, each invalid one at least one (the rule's files glob is ignored for snippets). Exit code 1 if a snippet fails, 2 if a rule is invalid; handy in CI next to konform check.
[[rules]]
id = "KST001"
message = "No `assert` in pytest fixtures"
match = { kind = "assert", inside = { kind = "function", decorated_with = "pytest.fixture" } }
[rules.test]
valid = ["import pytest\n@pytest.fixture\ndef f():\n return 1\n"]
invalid = ["import pytest\n@pytest.fixture\ndef f():\n assert 1\n"]
Fixes¶
Add a fix table to make a rule fixable. replace is a template for the matched node; delete = true removes the matched statement (an expression on its own line, like breakpoint(), counts), leaving pass when the block would be empty. Fixes are unsafe (applied only with --unsafe-fixes) unless the rule sets safe = true, since konform can't know what a hand-written rewrite does.
[[rules]]
id = "KST002"
message = "Use the project logger instead of print()"
match = { callee = "print" }
fix = { replace = "logger.info({args})", safe = true }
[rules.test]
fixed = [{ input = "print(x)\n", output = "logger.info(x)\n" }]
Placeholders: {text} (the node's source), {name} (its identifier, as matched by name), {args} (a call's arguments without parentheses), {callee} (a call's function), {receiver} (the object in obj.attr or obj.method()). Write {{ / }} for literal braces. A match lacking a placeholder ({args} on a name) is reported but not fixed, and so is a statement sharing its line with another (a = 1; b = 2). When matches overlap, the outermost is fixed first and the rest on the next pass. fixed self-tests run the fix until it settles.
Editor support¶
konform rule --schema > konform-rules.schema.json writes a JSON Schema of the rules file (the kind list is generated from the vocabulary). Reference it for completion and validation, e.g. with taplo put #:schema ./konform-rules.schema.json at the top of konform_rules.toml.
Stopping the search¶
inside and has accept stop_by, a matcher that ends the search: the node matching it is still tried first, nothing beyond it is. To ignore helper functions nested in a fixture:
match = { kind = "assert", inside = { decorated_with = "pytest.fixture", stop_by = { kind = "function" } } }
Name resolution¶
Names resolve through the file's imports: @fixture after from pytest import fixture, @pt.fixture after import pytest as pt and @pytest.fixture(scope="session") all count as pytest.fixture. Simple alias assignments (fx = pytest.fixture) resolve the same way; they are ignored for any name that is also assigned something else in the file, and a call result (fx = pytest.fixture(scope="session")) is a value, not an alias. Resolution is file-wide and ignores local rebinding.
Options¶
Configured in [tool.konform.lint.structural-rules].
| Option | Type | Default | Description |
|---|---|---|---|
level |
"warning" \| "error" |
"warning" |
Default severity for rules that don't set their own. |
rules_file |
str |
unset | Load rules from this file instead of auto-discovery. |
rules |
array of tables |
[] |
Inline rules. |