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.
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.
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.
# .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/N815naming 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.pyand 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.