Skip to content
← All articles

S101 and Per-File Rule Scoping

assert is correct in tests and wrong in library code, because python -O deletes it. The worked example of scoping a lint rule to where it applies instead of switching it off — the habit that keeps a lint config trusted.

You turn on ruff’s S ruleset (flake8-bandit). It immediately reports S101 Use of assert detected on all 1,900 asserts in your test suite.

There are three things you can do, and only one of them is right.

Why the rule exists

assert is not a validation mechanism. It is a debugging mechanism that the interpreter is allowed to delete:

def withdraw(account: Account, cents: int) -> None:
    assert cents > 0, "amount must be positive"
    account.balance -= cents

Run that module under python -O and the compiler emits no code for the assert at all. Not a faster check — no check. withdraw(account, -5000) now credits the account, in production, on the deployment where someone set PYTHONOPTIMIZE=1 in a Dockerfile to shave startup time.

So in library and application code the rule is simply correct, and the fix is to raise:

def withdraw(account: Account, cents: int) -> None:
    if cents <= 0:
        raise ValueError(f"amount must be positive, got {cents}")
    account.balance -= cents

This also gives you a better failure. AssertionError: amount must be positive tells a caller nothing about what to catch; ValueError with the offending value is something a handler can act on.

The exception people reach for — “but this assert is checking an internal invariant, not user input” — is legitimate and narrow. assert isinstance(node, ast.Compare) after you have already filtered for that type is a statement to the reader and to the type checker, not a validation. It is fine, and it is fine precisely because deleting it changes nothing. If deleting the assert would change behaviour, it was never an assert.

Why tests are different

In a test file, assert is the mechanism. pytest rewrites assert statements at import time to produce the detailed introspection you get on failure:

E   assert 'hello world' == 'hello-world'
E     - hello-world
E     ?      ^
E     + hello world
E     ?      ^

That output comes from pytest’s assertion rewriting, and it is the reason assert x == y beats self.assertEqual(x, y). Nobody runs a test suite under -O; if they did, the tests would not merely be unchecked, they would be silently unable to fail, which is a state no CI system tolerates for long.

So S101 is right for src/ and wrong for tests/. The question is how to express that.

The three options

Option one: turn the rule off.

[tool.ruff.lint]
ignore = ["S101"]

This is what most projects do, and it silently removes the check from the 1,900 places it was noise and from the twelve places in src/ where it would have caught a real validation bug. You have paid the full cost of adopting the ruleset and kept none of the benefit.

Option two: # noqa: S101 on every line. 1,900 comments, added by a script, reviewed by nobody. It also breaks the moment someone enables ruff’s RUF100 (unused noqa) or the rule code changes.

Option three: scope the rule to where it applies.

[tool.ruff.lint]
select = ["E", "F", "B", "S", "UP", "SIM", "RUF"]

[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101"]

Two lines. S101 remains active in src/, where the deletion-under--O argument holds, and is off in tests/, where it does not. Nothing is annotated, nothing is globally weakened, and a reader of pyproject.toml can see both the rule and its exception in the same file.

💡What other rules genuinely belong in per-file-ignores, and what is the test for whether a rule belongs there rather than in the global ignore list? click to reveal

The test is: does the rule’s justification stop applying in this directory? If yes, scope it. If the rule is simply annoying everywhere, that is a different conversation and it belongs in the global ignore with a comment.

A handful pass that test cleanly.

In tests/, alongside S101: PLR2004 (magic value in comparison) — assert result == 42 is the point of a test, and naming every literal EXPECTED_TOTAL = 42 makes tests less readable, not more. ARG001/ARG002 (unused argument) — a test requests a fixture for its side effect (def test_x(caplog: LogCaptureFixture) -> None) and never touches the name. SLF001 (private member access) is defensible in a narrow set of tests that legitimately inspect internals, though see the argument against testing privates.

In __init__.py: F401 (imported but unused) — a package __init__ re-exporting its public API is exactly an unused import, by design. The better modern answer is an explicit __all__ or from x import y as y, but the ignore is honest.

In conftest.py and migration files: INP001 (implicit namespace package), because those files intentionally are not part of a package.

In generated code and scripts/: T201 (print found) — a CLI script’s job is to print.

What fails the test: E501 (line too long), ANN (missing annotations), TRY rules. If a line is too long in tests/ it is too long everywhere; if you want a different limit, change the limit globally rather than pretending the rule is directory-specific. Scoping a rule you simply disagree with disguises a policy decision as a technical one, and the next person cannot tell the difference between “this rule does not apply here” and “we lost an argument about this rule in 2024”.

The habit, which is bigger than the rule

This is a small example of a general discipline, and it is the difference between a lint config people trust and one people route around.

A lint config accumulates two kinds of entry. Scoped exceptions — this rule does not apply to this directory, for this stated reason — are load-bearing and stay true. Global disables — this rule was noisy once, in one file, in 2024 — are entropy: nobody knows why they are there, nobody dares remove them, and each one silently reduces what the tool checks for the whole repo.

Three practices keep the first kind and prevent the second.

Prefer the narrowest scope that works. Per-file-ignores over global ignore. A # noqa: XYZ with a reason on the one genuinely exceptional line, over a per-file ignore for the whole file. The narrower the scope, the more likely it is still correct in a year.

Always give a reason. TOML comments cost nothing:

[tool.ruff.lint.per-file-ignores]
# pytest's assert rewriting is the assertion mechanism; -O is never used here.
"tests/**/*.py" = ["S101", "PLR2004", "ARG001"]
# Public re-exports.
"src/mypackage/__init__.py" = ["F401"]

Turn on RUF100. It flags # noqa directives that no longer suppress anything, so the annotations get deleted when the underlying problem is fixed instead of outliving it by three years.

The payoff is not tidiness. It is that when the config says a rule is on, it is genuinely on — so a new S101 in src/ is a real finding rather than one more line in a report nobody reads.

Tests That Earn Their Keep · step 19 of 19

That's the end of this track. Review it or pick another.

← Back to Tests That Earn Their Keep