Skip to content

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

import pytest


@pytest.fixture
def my_fixture():
    a = 1
    assert a == 3  # error[KST001]

Use instead:

import pytest


@pytest.fixture
def my_fixture():
    a = 1
    if a != 3:
        raise AssertionError(a)

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:

  1. Inline [[tool.konform.lint.structural-rules.rules]] in the config file.
  2. rules_file = "path" in [tool.konform.lint.structural-rules] (.toml or .yaml).
  3. konform_rules.toml next 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.

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.

Suppression

x = 1  # noqa: KST000
x = 1  # noqa: KST  (every KST* rule)