Lint and Type-Check a QGIS Plugin

Many plugin bugs never need a running QGIS to find: a misspelled variable in an error branch, an unused import of a module that does not exist on QGIS 4, a method that returns a tuple where the caller expects a layer, a signal connected to a function with the wrong number of arguments. A linter and a type checker find these in seconds, on every commit, without starting QGIS. They complement tests rather than replace them — tests prove behaviour, static checks prove the code is at least coherent — and they are much cheaper to run.

This recipe belongs to Testing & CI for Plugins. It configures ruff for linting and formatting, mypy with PyQGIS type stubs, deals with the qgis and Qt imports that tools cannot resolve on their own, adds pre-commit hooks, and runs everything in GitHub Actions.

Layers of checking for a pluginFormatting with ruff format keeps style consistent and is instant. Linting with ruff check finds undefined names, unused imports and suspicious patterns in under a second. Type checking with mypy and PyQGIS stubs finds wrong argument types and None handling in seconds. Tests with pytest and QGIS prove behaviour in a minute or more. Each layer catches different bugs; the cheap ones run first.Cheap checks first, tests lastformatruff formatstyleinstantlintruff checkundefined names< 1 stypesmypy + stubswrong typessecondstestspytest + QGISbehaviourminutes

Prerequisites

  • A plugin in a Git repository with a pyproject.toml (or willingness to add one).
  • Python 3.10+ for running the tools; they do not need QGIS installed, except for mypy, which benefits from stubs.

Configure ruff

Ruff is a fast linter and formatter that replaces flake8, isort and black. One section in pyproject.toml configures it for the plugin.

[tool.ruff]
line-length = 100
target-version = "py310"
extend-exclude = ["resources.py", "ui_*.py", "help/"]

[tool.ruff.lint]
select = ["E", "F", "W", "I", "B", "UP", "N"]
ignore = ["N802", "N803", "N815"]

[tool.ruff.lint.isort]
known-third-party = ["qgis"]
pip install ruff
ruff check .          # report problems
ruff check . --fix    # fix what can be fixed automatically
ruff format .         # format consistently

Breakdown: target-version matches the oldest Python your supported QGIS versions bundle. Generated files — compiled resources and UI modules — are excluded since nobody edits them. The selected rules cover pyflakes errors (F, the most valuable: undefined names and unused imports), style, import sorting, bugbear's likely-bug patterns and pyupgrade. The ignored N8xx rules would otherwise flag Qt-style camelCase method names like initGui and mousePressEvent, which a plugin must use.

Type-check with mypy and PyQGIS stubs

Type checking needs to know what QgsVectorLayer.getFeatures returns. Type stubs for PyQGIS and PyQt supply that without importing QGIS itself.

How stubs let mypy understand PyQGISThe plugin code imports qgis.core and calls layer.getFeatures. Without stubs, mypy sees qgis as an unknown module and treats everything from it as Any, checking nothing. With PyQGIS stubs installed, mypy knows getFeatures returns a QgsFeatureIterator and that mapLayer can return None, so it flags code that forgets the None case.Without stubs everything is Anyno stubsqgis → Anynothing checkedwith stubsmapLayer → QgsMapLayer | Nonemissing None check flagged

pip install mypy pyqgis-stubs pyqt5-stubs
[tool.mypy]
python_version = "3.10"
ignore_missing_imports = true
warn_unused_ignores = true
check_untyped_defs = true
exclude = ["resources\\.py$", "ui_.*\\.py$", "test/"]
from qgis.core import QgsProject, QgsVectorLayer

def asset_layer(layer_id: str) -> QgsVectorLayer | None:
    layer = QgsProject.instance().mapLayer(layer_id)
    if isinstance(layer, QgsVectorLayer):
        return layer
    return None

def feature_count(layer_id: str) -> int:
    layer = asset_layer(layer_id)
    return layer.featureCount()      # mypy: Item "None" has no attribute "featureCount"

Breakdown: The stubs package describes PyQGIS classes and their signatures for type checkers; PyQt stubs do the same for Qt. ignore_missing_imports stops mypy failing on modules without stubs, such as qgis.utils. check_untyped_defs checks bodies of functions without annotations too, which matters in plugins that were never annotated. The example shows the payoff: mypy notices that asset_layer can return None and the caller forgot to check — exactly the bug that appears when a user removes a layer. Stub package names and coverage vary; if one is unavailable for your QGIS version, mypy still checks your own code with QGIS treated as untyped.

Annotate gradually

A large untyped plugin will produce many mypy messages at once. Starting strict on new modules and relaxed on old ones keeps the signal useful.

[[tool.mypy.overrides]]
module = ["asset_sync.legacy.*"]
ignore_errors = true

[[tool.mypy.overrides]]
module = ["asset_sync.core.*"]
disallow_untyped_defs = true

Breakdown: Overrides apply settings per module. Legacy code is skipped for now, while new core modules must annotate every function. As modules are tidied, they move from the first list to the second. Annotating public functions first — the ones other modules call — gives the most checking for the least effort.

Add pre-commit hooks

Running checks locally before each commit catches problems before they reach CI. The pre-commit framework installs the hooks for everyone who clones the repository.

Where checks runOn git commit, pre-commit runs ruff format and ruff check on changed files in under a second and blocks the commit if they fail. On push, GitHub Actions runs ruff, mypy and the pytest suite against QGIS in a container, and a pull request cannot merge until they pass. Local hooks give fast feedback; CI is the authority.Fast locally, complete in CIgit commitpre-commit:ruff format, checkgit pushtriggers CIGitHub Actionsruff · mypypytest + QGIS

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.6.9
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format
pip install pre-commit
pre-commit install          # once per clone
pre-commit run --all-files  # check everything now

Breakdown: Ruff runs on the changed files at each commit, fixing what it can and blocking the commit if problems remain. Mypy is left out of the commit hook because it is slower and needs the stubs installed; it runs in CI instead. Pin rev to a release so everyone runs the same version, and update it deliberately.

Run the checks in GitHub Actions

The same commands run in CI on every push and pull request, next to the test suite from running plugin tests in GitHub Actions.

# .github/workflows/lint.yml
name: lint
on: [push, pull_request]
jobs:
  static:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install ruff mypy pyqgis-stubs pyqt5-stubs
      - run: ruff check .
      - run: ruff format --check .
      - run: mypy asset_sync

Breakdown: Static checks need no QGIS container, so this job finishes in well under a minute and reports problems before the slower test job does. ruff format --check fails if any file is unformatted without changing it. Making the job a required check on the main branch means nothing merges with lint or type errors.

Rules that matter most in plugins

A few rule families catch plugin-specific mistakes particularly well.

import os                      # F401 unused import — dead code from an old feature

def on_clicked(self):
    if self.layer is None:
        self.iface.messageBar().pushWarning("Asset Sync", f"No layer: {layr}")   # F821 undefined name
    try:
        self.sync()
    except:                    # E722 bare except hides KeyboardInterrupt and real bugs
        pass

Breakdown: Undefined names (F821) in rarely run error branches are the classic plugin crash that only users see. Bare except (E722) and bugbear's B rules flag error handling that swallows the exception you needed to see. Unused imports (F401) of Qt modules that moved between PyQt5 and PyQt6 are an easy cleanup before QGIS 4 migration. Each of these takes seconds to fix once flagged and hours to diagnose from a user report.

Catch Qt 6 problems early

Moving a plugin to QGIS 4 means moving from PyQt5 to PyQt6, and many of the breaking changes are visible to static tools. A small custom check, run alongside ruff, flags the patterns that will fail on Qt 6 while the plugin still runs on QGIS 3.

# tools/check_qt6.py
import pathlib, re, sys

PATTERNS = {
    r"from PyQt5": "import through qgis.PyQt instead of PyQt5",
    r"\.exec_\(": "exec_() is removed in PyQt6; use exec()",
    r"Qt\.(AlignLeft|AlignRight|Checked|Unchecked)\b": "use scoped enums, e.g. Qt.AlignmentFlag.AlignLeft",
    r"QAction\b.*QtWidgets": "QAction moved to QtGui in Qt 6",
}

problems = 0
for path in pathlib.Path(".").rglob("*.py"):
    for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
        for pattern, advice in PATTERNS.items():
            if re.search(pattern, line):
                print(f"{path}:{n}: {advice}")
                problems += 1
sys.exit(1 if problems else 0)

Breakdown: Each pattern pairs a regular expression with advice. Direct PyQt5 imports are the most important: importing from qgis.PyQt lets the same code load against either Qt version. exec_() disappears in PyQt6, unscoped enum access changes, and QAction moves module. Scoped enums such as Qt.AlignmentFlag.AlignLeft already work with recent PyQt5, so fixing them now keeps the plugin working on both. Running the script in CI as another step turns QGIS 4 readiness into a checklist that shrinks with every commit rather than a big migration later.

Keep the checks trusted

Static checks only help if people believe their failures. A few habits keep the signal clean as the plugin grows.

Fix or explicitly silence every reported problem rather than letting warnings accumulate; a check that always shows forty warnings is a check nobody reads. When a rule is wrong for a specific line, silence it on that line with a comment naming the rule, such as # noqa: B008, so the exception is visible in review. Prefer changing configuration to scattering comments when a rule is wrong for the whole project. Upgrade ruff and the stubs on purpose, in their own commit, so new findings arrive in one reviewable batch instead of mixed into feature work. And keep the configuration in pyproject.toml, under version control, so the editor, the commit hook and CI all apply the same rules.

QGIS version compatibility

Ruff and mypy are independent of the QGIS version. Stubs should match the QGIS and Qt versions you target; when supporting QGIS 3.34 LTR, 3.40 LTR and QGIS 4 together, check against the oldest stubs for signatures and keep imports going through qgis.PyQt so both PyQt5 and PyQt6 resolve.

Troubleshooting

  • Ruff flags Qt method names. Ignore the N802/N803/N815 naming rules.
  • Mypy reports hundreds of errors at once. Start with overrides that skip legacy modules.
  • Everything from qgis is Any. Stubs are not installed in the environment mypy runs in.
  • Generated files fail checks. Exclude resources.py and compiled UI modules.

Conclusion

Add ruff for formatting and linting with Qt-friendly naming rules, add mypy with PyQGIS and PyQt stubs and adopt strictness module by module, run ruff in pre-commit hooks for instant feedback, and run all static checks in GitHub Actions as a required job beside the test suite.

Frequently Asked Questions

Do I still need tests if I type-check? Yes; types prove the code fits together, tests prove it does the right thing.

Can I use pylint instead of ruff? Yes, though it is much slower; ruff covers the most valuable rules.

Should the plugin ZIP include pyproject.toml? No; exclude development files when packaging the plugin.

Does the QGIS plugin repository run these checks? It runs its own security scans; your lint and type checks are for your own quality.