Qgis plugin
A battle-tested Claude/Agent Skill for authoring, debugging, packaging and publishing QGIS Python plugins.
npx -y skills add johnzastrow/qgis-plugin-skill --skill qgis-pluginAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- 14 days oldThe repository was created 14 days ago. New is not bad, but a brand new repository carrying a familiar-sounding name is the shape a typosquat arrives in, and there has been no time for anyone else to find a problem with it.
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Author, debug, package, and publish QGIS (3.x/4.x, PyQt5/PyQt6) Python plugins. Covers dock/UI patterns, the runtime crash pitfalls that segfault QGIS, Qt5-to-Qt6 migration, the plugins.qgis.org security gates (Bandit/secrets/flake8), a self-verifying build, local-deploy testing, the release flow, and Windows/OSGeo4W specifics. Use for ANY QGIS plugin work - creating, upgrading, debugging a crash in, packaging, or publishing - including anything touching metadata.txt, __init__.py/classFactory, a QGIS dock widget or QgsTask, the plugin zip, plugins.qgis.org uploads, or QGIS's own Python (python-qgis.bat / OSGeo4W). Triggers: "QGIS plugin", "plugins.qgis.org", "metadata.txt", "qgisMinimumVersion", "PyQGIS dock", "classFactory", "QgsMapLayer", "Install from ZIP", "pb_tool".
SKILL.md
14.2 KB, as published. Nobody here has run it
QGIS Plugin Development & Publishing
Battle-tested playbook distilled from shipping and upgrading real plugins. This file is the
operational index; deep detail lives in reference/, runnable tooling in scripts/, and
drop-in project files in templates/.
reference/publishing-playbook.md- the full publishing playbook (metadata, packaging, gates, Qt6, testing under QGIS Python, release). Read it for anything below in depth.reference/dock-crash-pitfalls.md- the runtime/UI crash pitfalls (READ THIS before touching a dock widget or anything that reacts to QGIS layer signals).scripts/build_plugin.sh- self-verifying zip builder, driven by a manifest.scripts/build_plugin.py- same build with the Python stdlib only, for Windows/Git Bash (nozipbinary) or when you would rather the manifest be parsed than executed.scripts/deploy_local.sh- build and install into the local QGIS profile for click-testing.templates/-plugin_build.conf(the build manifest),Makefile,release.yml,setup.cfg,ruff.toml. Copy into a plugin repo so every plugin releases identically.
Setup for a plugin repo: copy scripts/ and templates/plugin_build.conf to the repo root,
edit the manifest, then bash scripts/build_plugin.sh. Nothing else needs per-plugin editing.
Golden rules (the ones that actually bite)
- A crash (segfault) is not a Python exception.
try/exceptcannot catch a segfault. If QGIS closes, suspect C++ object lifetime: reading a layer/widget that is being torn down. Seereference/dock-crash-pitfalls.md. - Never read layers synchronously inside
layersAdded/layersRemovedhandlers. A project load fires a storm of these while layers are half-built/half-deleted. Debounce (QTimer.singleShot) so the refresh runs after the load settles. mapLayers()vslayerTreeRoot().layerOrder().layerOrder()gives panel order but comes back empty for some projects (e.g. a project stored in a GeoPackage), producing blank layer lists. UsemapLayers().values()for reliability, skipnot layer.isValid(), and only at stable times.blockSignals(True)around anyQListWidget.clear()+ rebuild. Clearing a list that has selected items emitsitemSelectionChangedmid-mutation; slots then read half-deleted items and crash.- Packaging drops subpackages silently.
make/pb_toolship an incomplete zip, producingModuleNotFoundErroron install. Usescripts/build_plugin.shwith an explicit manifest and a self-verify step. - QGIS 4 is PyQt6. Old plugins break on unscoped enums and
.exec_(). And withoutqgisMaximumVersion=4.99, QGIS 4 refuses to even load amin=3.40plugin. - State assumptions; test in a real QGIS. Static checks (compile/flake8) cannot catch enum breaks in un-exercised paths or C++ lifetime crashes. Deploy locally and click every path.
- Every control gets a helpful tooltip (
setToolTip) - buttons, radios, checkboxes, fields. Non-negotiable UX baseline. And persist every user-facing option in all the places that store state: the liveQgsSettings, the Save/Load config file (schema + read + write), and the export settings dict. An option added to only some of them silently fails to round-trip.
Runtime / dock stability (the crash class) - reference/dock-crash-pitfalls.md
The highest-value, hardest-won knowledge. Quick reference:
| Symptom | Cause | Fix |
|---|---|---|
| QGIS crashes on project load | Handler reads layers mid-teardown (esp. iterating mapLayers() while layers die) | Debounce layersAdded/Removed, refresh after load; skip not isValid() |
| QGIS crashes when clicking Refresh / on selection | list.clear() with a live selection fires itemSelectionChanged into a slot reading deleted items | blockSignals(True) around clear/rebuild, update dependent UI once after |
| Blank layer list for a gpkg-stored project | layerTreeRoot().layerOrder() returns empty | iterate mapLayers().values() |
| Crash during plugin reload / dock teardown | showEvent/handler runs on a half-destroyed widget | avoid showEvent-driven heavy work; guard with hasattr; prefer explicit refresh |
| Segfault iterating a categorized/graduated renderer | cat.symbol() returns a pointer owned by a temporary container that is then GC'd (use-after-free) | .clone() each symbol while the container is alive |
Wrong [Point]/[Line]/[Polygon] labels on QGIS 4 | geometryType() returns an enum (PyQt6), not an int | int(layer.geometryType()) or compare the enum |
| Labels ignore QGIS placement on QGIS 4 | A removed attribute read via getattr(obj, 'x', default) silently returns the default | Use the relocated API; audit every getattr(<qgis obj>, 'x', default) for a QGIS-4 rename that a getattr hides |
Local-deploy test loop:
bash scripts/deploy_local.sh # reads plugin_build.conf; -p PROFILE, -q QGIS3|QGIS4
# then: restart QGIS (or Plugin Reloader) - a full restart is the only sure reload
When a crash cannot be reproduced from code inspection, get the user to run the exact sequence with a pristine backup deployed. If the pristine build also crashes, the bug is pre-existing, not the change.
Testing - catch crashes before the user
The pure-Python unit suite usually mocks qgis, so it cannot see the C++/PyQt crashes above. Add a
QGIS-integration tier that runs the dock against real layers/renderers under QGIS's own Python
(headless, QT_QPA_PLATFORM=offscreen), with a fixture per (geometry x renderer) - especially a
categorized polygon (the classic dangling-symbol segfault). A crash kills the process (exit 139),
so "the call returned" is the assertion. Gate on an env var (<PLUGIN>_QGIS_TEST=1), not on
import qgis - the conftest mock makes the import succeed. Full recipe plus the QGIS-Python
environment in reference/dock-crash-pitfalls.md ("Local testing").
Packaging & build - reference/publishing-playbook.md section 2
- Ship an explicit file list (every
.py, every imported subpackage,metadata.txt, icon,LICENSE); strip__pycache__/*.pyc/.git. Top-level zip folder = the package name. - Self-verify the built zip: assert required entries present; reject binaries, archives,
__pycache__, compiledresources.py,docs/, and*.sh(see the gates section). - Icons: load from a file path (
os.path.join(plugin_dir, 'icons', 'icon.svg')), NOT compiledresources.py/pyrcc5- plugins.qgis.org bans generated resource files. - Declare it once in
plugin_build.conf;build_plugin.shandbuild_plugin.pyboth read it, so the local build and CI cannot drift apart.
The plugins.qgis.org gates - reference/publishing-playbook.md section 4
Run locally with uv before uploading. HIGH/critical Bandit findings and any secret are a hard block:
uv run --no-project --with bandit bandit -r . -x ./test,./tests,./docs
uv run --no-project --with detect-secrets detect-secrets scan --all-files
uv run --no-project --with flake8 flake8 . # authoritative style gate
- Iterate with
ruff check --fixbut verify withflake8(ruff lacks some E12x/W50x). - Common fixes: B608 SQL to allowlist +
# nosec B608; B112 try/except/continue to# nosec B112(use the correct code - B110 is pass, B112 is continue); XXE todefusedxml; bare except toexcept Exception:. .ruff_cache/CACHEDIR.TAGis a false-positive secret: gitignore it and delete caches before scanning.- The security scanner flags shell-script installers. A
*.shthat downloads a binary and moves it into a system path is exactly the pattern that gets a plugin rejected. Do not ship installer shell scripts - put the guidance in the README and give an in-app link (QDesktopServices.openUrlto the official releases page). The build scripts reject*.shin the zip by default.
Qt5 to Qt6 (QGIS 4) - reference/publishing-playbook.md section 6
qgisMaximumVersion=4.99in metadata.txt, or QGIS 4 rejects the plugin outright.- Scoped enums (work on PyQt5 >= 5.15 AND PyQt6):
Qt.AlignCentertoQt.AlignmentFlag.AlignCenter,Qt.UserRoletoQt.ItemDataRole.UserRole,Qt.red/darkGreentoQt.GlobalColor.red(lowercase colours are the #1 miss - grepQt\.[a-zA-Z], not[A-Z]),QDialogButtonBox.Okto.StandardButton.Ok,QAbstractItemView.SelectRowsto.SelectionBehavior.*,QFrame.StyledPanelto.Shape.*, and so on. .exec_()to.exec(). Removed:QRegExp,QDesktopWidget,QFontMetrics.width().- Static grep cannot reach enums in un-exercised paths - install and click every dialog/dock.
Versioning & release - reference/publishing-playbook.md sections 8-9
- Bump
version=in metadata.txt and any in-file__version__and add a datedCHANGELOG.mdentry (semver). Keep an[Unreleased]section.make tag V=x.y.zrefuses to tag if these disagree. - Release: gates + build clean, bump + commit + push, tag, CI builds the zip and runs the unit test and
cuts a GitHub Release, verify the CI asset, then manual upload to
plugins.qgis.org/plugins/<slug>/version/add/.
Windows / OSGeo4W
The rest of this file assumes a POSIX shell. These are the Windows differences that bite:
- Run under QGIS's Python via
python-qgis.bat, not a PYTHONPATH. Tests, builds, anything needing PyQGIS:"C:\OSGeo4W\bin\python-qgis.bat" -m unittest ...(or-m pytest,-m pip). It sets the full QGIS environment. Standalone installer:...\QGIS x.y\bin\python-qgis.bat. - Git Bash has NO
zip. Azip -qrbuild dies withzip: command not found. Usescripts/build_plugin.py(stdlibzipfile, same manifest and self-verify), or build from WSL / the OSGeo4W shell.deploy_local.shfalls back to the Python builder automatically. - cp1252 console produces Unicode "failures" that are LOCAL-ONLY noise.
print()or file writes of non-ASCII symbols raiseUnicodeEncodeError: 'charmap' codecon a Windows console but pass on Linux CI (UTF-8). Do not chase them; keep runtime output ASCII (->, not an arrow glyph). Conversely, anif os.name == 'nt'branch means the POSIX path is never exercised locally - run the fullunittest discoverand trust Linux CI, or a Windows-only pass will green a POSIX-only bug. - Pin linters EXACTLY to what CI pins. A newer local
uvx ruffreformats differently and fails CI'sruff format --check. Match it:uvx --from ruff==<ci-version> ruff format .. Also check whether CI has a test-companions job that regenerates fixtures; new tests may need that step run too. - Install the plugin locally mid-session: drop the folder into
%APPDATA%\QGIS\QGIS3\profiles\<profile>\python\plugins\<pkg>\, then in the Python Console:import importlib, qgis.utils as qu; importlib.invalidate_caches(); qu.updateAvailablePlugins(); qu.loadPlugin('<pkg>'); qu.startPlugin('<pkg>'). A folder added after QGIS started is not seen until the import-finder cache is refreshed. For an updated build usequ.reloadPlugin('<pkg>'). Cleanest is still Install from ZIP plus a restart. - PowerShell tooling gotchas: a line starting with a quoted path needs the call operator
& "C:\...\x.exe" args;$ErrorActionPreference='Stop'turns a native tool's stderr into a fatal error - use'Continue'and check$LASTEXITCODE; and useGet-Command foo -CommandType Applicationso a same-named$PROFILEfunction is not mistaken for a real executable.
Publishing extras proven in the field
- A metadata-only change still needs a version bump - plugins.qgis.org rejects a duplicate version.
email=in metadata.txt is PUBLIC (inside the downloadable zip and on the plugin page). Use a non-personal address, e.g.<name>@users.noreply.github.com, which satisfies the required field without exposing a real inbox. On PyPI,pyproject authors = [{ name = ... }]without an email keeps it off the PyPI page.- Uninstallable install: a plugin dropped in by hand (or via
zipfile.extractall, which is whatdeploy_local.shdoes) is NOT removable from the Plugin Manager. For a real install test usepyplugin_installer.instance().installFromZipFile(zip)so QGIS registers it. - Bandit
B310(urlopen scheme) on a fixedhttps://constant URL:# nosec B310(ruff's is# noqa: S310). Note QGIS guidance prefersQgsNetworkAccessManageroverurllibfor proxy handling. - Two-artifact release (a plugin that is also a pip package): one tag produces the plugin zip
(manual upload to plugins.qgis.org) and a GitHub Release that fires a PyPI publish workflow using
OIDC Trusted Publishing - no stored token; PyPI is updated from GitHub, not
twine. Wait for CI green before cutting the release.
When starting any plugin task
- If debugging a crash, read
reference/dock-crash-pitfalls.mdfirst and get a pristine-backup diagnostic. - If upgrading an old plugin, do the Qt6 section plus
qgisMaximumVersion, then gates, then build + deploy + click-test. - If publishing, run gates locally, then
scripts/build_plugin.shself-verify, then the release flow. - Always back up the file before a risky refactor (
cp x.py x.py.orig-<version>, gitignore*.orig-*) and deploy and test in a real QGIS - compile/flake8 clean is necessary, not sufficient.