[{"data":1,"prerenderedAt":1770},["ShallowReactive",2],{"doc:\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Flint-and-type-check-qgis-plugin":3},{"id":4,"title":5,"body":6,"description":1759,"extension":1760,"meta":1761,"navigation":243,"path":1766,"seo":1767,"stem":1768,"__hash__":1769},"docs\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Flint-and-type-check-qgis-plugin\u002Findex.md","Lint and Type-Check a QGIS Plugin",{"type":7,"value":8,"toc":1743},"minimark",[9,13,17,26,174,179,194,198,204,280,342,369,373,380,433,453,488,621,642,646,649,687,692,696,699,773,862,899,908,912,920,1078,1087,1091,1094,1207,1237,1241,1244,1571,1595,1599,1602,1612,1616,1622,1626,1667,1671,1674,1678,1684,1690,1700,1706,1710,1740],[10,11,5],"h1",{"id":12},"lint-and-type-check-a-qgis-plugin",[14,15,16],"p",{},"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.",[14,18,19,20,25],{},"This recipe belongs to ",[21,22,24],"a",{"href":23},"\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002F","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.",[14,27,28],{},[29,30,35,39,43,50,67,76,85,91,96,100,104,109,113,116,119,122,127,131,134,137,140,146,150,153,156,159,166,170],"svg",{"viewBox":31,"role":32,"ariaLabel":33,"xmlns":34},"0 0 760 236","img","Four layers of checking from formatting through linting and type checking to tests, ordered by cost","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[36,37,38],"title",{},"Layers of checking for a plugin",[40,41,42],"desc",{},"Formatting 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.",[44,45],"rect",{"x":46,"y":46,"width":47,"height":48,"fill":49},"0","760","236","#f6f3ea",[51,52,53],"defs",{},[54,55,62],"marker",{"id":56,"viewBox":57,"refX":58,"refY":59,"markerWidth":60,"markerHeight":60,"orient":61},"lcLayersArrow","0 0 10 10","8","5","7","auto-start-reverse",[63,64],"path",{"d":65,"fill":66},"M0 0 L10 5 L0 10 z","#2f3b35",[68,69,75],"text",{"x":70,"y":71,"style":72,"fill":73,"textAnchor":74},"380","28","text-anchor:middle;font-size:14px;font-family:sans-serif;font-weight:bold","#17211d","middle","Cheap checks first, tests last",[44,77],{"x":78,"y":79,"width":80,"height":81,"rx":58,"fill":82,"stroke":83,"style":84},"24","60","165","160","#fffdf7","#59645f","stroke-width:2",[68,86,90],{"x":87,"y":88,"style":89,"fill":73,"textAnchor":74},"106.5","98.6","text-anchor:middle;font-size:11.0px;font-family:sans-serif;font-weight:bold","format",[68,92,95],{"x":87,"y":93,"style":94,"fill":66,"textAnchor":74},"128.6","text-anchor:middle;font-size:10.0px;font-family:sans-serif","ruff format",[68,97,99],{"x":87,"y":98,"style":94,"fill":83,"textAnchor":74},"158.6","style",[68,101,103],{"x":87,"y":102,"style":94,"fill":83,"textAnchor":74},"188.6","instant",[44,105],{"x":106,"y":79,"width":80,"height":81,"rx":58,"fill":107,"stroke":108,"style":84},"209","#eef7f4","#0f766e",[68,110,112],{"x":111,"y":88,"style":89,"fill":108,"textAnchor":74},"291.5","lint",[68,114,115],{"x":111,"y":93,"style":94,"fill":66,"textAnchor":74},"ruff check",[68,117,118],{"x":111,"y":98,"style":94,"fill":83,"textAnchor":74},"undefined names",[68,120,121],{"x":111,"y":102,"style":94,"fill":83,"textAnchor":74},"\u003C 1 s",[44,123],{"x":124,"y":79,"width":80,"height":81,"rx":58,"fill":125,"stroke":126,"style":84},"394","#eff3ff","#2563eb",[68,128,130],{"x":129,"y":88,"style":89,"fill":126,"textAnchor":74},"476.5","types",[68,132,133],{"x":129,"y":93,"style":94,"fill":66,"textAnchor":74},"mypy + stubs",[68,135,136],{"x":129,"y":98,"style":94,"fill":83,"textAnchor":74},"wrong types",[68,138,139],{"x":129,"y":102,"style":94,"fill":83,"textAnchor":74},"seconds",[44,141],{"x":142,"y":79,"width":143,"height":81,"rx":58,"fill":144,"stroke":145,"style":84},"579","157","#fdf2e2","#b45309",[68,147,149],{"x":148,"y":88,"style":89,"fill":145,"textAnchor":74},"657.5","tests",[68,151,152],{"x":148,"y":93,"style":94,"fill":66,"textAnchor":74},"pytest + QGIS",[68,154,155],{"x":148,"y":98,"style":94,"fill":83,"textAnchor":74},"behaviour",[68,157,158],{"x":148,"y":102,"style":94,"fill":83,"textAnchor":74},"minutes",[160,161],"line",{"x1":162,"y1":163,"x2":164,"y2":163,"stroke":66,"style":165},"189","140","207","stroke-width:1.8;marker-end:url(#lcLayersArrow)",[160,167],{"x1":168,"y1":163,"x2":169,"y2":163,"stroke":66,"style":165},"374","392",[160,171],{"x1":172,"y1":163,"x2":173,"y2":163,"stroke":66,"style":165},"559","577",[175,176,178],"h2",{"id":177},"prerequisites","Prerequisites",[180,181,182,191],"ul",{},[183,184,185,186,190],"li",{},"A plugin in a Git repository with a ",[187,188,189],"code",{},"pyproject.toml"," (or willingness to add one).",[183,192,193],{},"Python 3.10+ for running the tools; they do not need QGIS installed, except for mypy, which benefits from stubs.",[175,195,197],{"id":196},"configure-ruff","Configure ruff",[14,199,200,201,203],{},"Ruff is a fast linter and formatter that replaces flake8, isort and black. One section in ",[187,202,189],{}," configures it for the plugin.",[205,206,211],"pre",{"className":207,"code":208,"language":209,"meta":210,"style":210},"language-toml shiki shiki-themes github-dark","[tool.ruff]\nline-length = 100\ntarget-version = \"py310\"\nextend-exclude = [\"resources.py\", \"ui_*.py\", \"help\u002F\"]\n\n[tool.ruff.lint]\nselect = [\"E\", \"F\", \"W\", \"I\", \"B\", \"UP\", \"N\"]\nignore = [\"N802\", \"N803\", \"N815\"]\n\n[tool.ruff.lint.isort]\nknown-third-party = [\"qgis\"]\n","toml","",[187,212,213,220,226,232,238,245,251,257,263,268,274],{"__ignoreMap":210},[214,215,217],"span",{"class":160,"line":216},1,[214,218,219],{},"[tool.ruff]\n",[214,221,223],{"class":160,"line":222},2,[214,224,225],{},"line-length = 100\n",[214,227,229],{"class":160,"line":228},3,[214,230,231],{},"target-version = \"py310\"\n",[214,233,235],{"class":160,"line":234},4,[214,236,237],{},"extend-exclude = [\"resources.py\", \"ui_*.py\", \"help\u002F\"]\n",[214,239,241],{"class":160,"line":240},5,[214,242,244],{"emptyLinePlaceholder":243},true,"\n",[214,246,248],{"class":160,"line":247},6,[214,249,250],{},"[tool.ruff.lint]\n",[214,252,254],{"class":160,"line":253},7,[214,255,256],{},"select = [\"E\", \"F\", \"W\", \"I\", \"B\", \"UP\", \"N\"]\n",[214,258,260],{"class":160,"line":259},8,[214,261,262],{},"ignore = [\"N802\", \"N803\", \"N815\"]\n",[214,264,266],{"class":160,"line":265},9,[214,267,244],{"emptyLinePlaceholder":243},[214,269,271],{"class":160,"line":270},10,[214,272,273],{},"[tool.ruff.lint.isort]\n",[214,275,277],{"class":160,"line":276},11,[214,278,279],{},"known-third-party = [\"qgis\"]\n",[205,281,285],{"className":282,"code":283,"language":284,"meta":210,"style":210},"language-bash shiki shiki-themes github-dark","pip install ruff\nruff check .          # report problems\nruff check . --fix    # fix what can be fixed automatically\nruff format .         # format consistently\n","bash",[187,286,287,300,315,330],{"__ignoreMap":210},[214,288,289,293,297],{"class":160,"line":216},[214,290,292],{"class":291},"svObZ","pip",[214,294,296],{"class":295},"sU2Wk"," install",[214,298,299],{"class":295}," ruff\n",[214,301,302,305,308,311],{"class":160,"line":222},[214,303,304],{"class":291},"ruff",[214,306,307],{"class":295}," check",[214,309,310],{"class":295}," .",[214,312,314],{"class":313},"sjoCn","          # report problems\n",[214,316,317,319,321,323,327],{"class":160,"line":228},[214,318,304],{"class":291},[214,320,307],{"class":295},[214,322,310],{"class":295},[214,324,326],{"class":325},"sDLfK"," --fix",[214,328,329],{"class":313},"    # fix what can be fixed automatically\n",[214,331,332,334,337,339],{"class":160,"line":234},[214,333,304],{"class":291},[214,335,336],{"class":295}," format",[214,338,310],{"class":295},[214,340,341],{"class":313},"         # format consistently\n",[14,343,344,348,349,352,353,356,357,360,361,364,365,368],{},[345,346,347],"strong",{},"Breakdown:"," ",[187,350,351],{},"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 (",[187,354,355],{},"F",", the most valuable: undefined names and unused imports), style, import sorting, bugbear's likely-bug patterns and pyupgrade. The ignored ",[187,358,359],{},"N8xx"," rules would otherwise flag Qt-style camelCase method names like ",[187,362,363],{},"initGui"," and ",[187,366,367],{},"mousePressEvent",", which a plugin must use.",[175,370,372],{"id":371},"type-check-with-mypy-and-pyqgis-stubs","Type-check with mypy and PyQGIS stubs",[14,374,375,376,379],{},"Type checking needs to know what ",[187,377,378],{},"QgsVectorLayer.getFeatures"," returns. Type stubs for PyQGIS and PyQt supply that without importing QGIS itself.",[14,381,382],{},[29,383,386,389,392,395,398,403,408,413,417,422,427,430],{"viewBox":384,"role":32,"ariaLabel":385,"xmlns":34},"0 0 760 206","Mypy without stubs treating qgis as Any compared with stubs that provide real types and catch None handling",[36,387,388],{},"How stubs let mypy understand PyQGIS",[40,390,391],{},"The 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.",[44,393],{"x":46,"y":46,"width":47,"height":394,"fill":49},"206",[68,396,397],{"x":70,"y":71,"style":72,"fill":73,"textAnchor":74},"Without stubs everything is Any",[44,399],{"x":78,"y":79,"width":400,"height":401,"rx":58,"fill":144,"stroke":402,"style":84},"330","130","#b91c1c",[68,404,407],{"x":162,"y":405,"style":406,"fill":402,"textAnchor":74},"98.78","text-anchor:middle;font-size:11.5px;font-family:sans-serif;font-weight:bold","no stubs",[68,409,412],{"x":162,"y":410,"style":411,"fill":66,"textAnchor":74},"128.78","text-anchor:middle;font-size:10.5px;font-family:sans-serif","qgis → Any",[68,414,416],{"x":162,"y":415,"style":411,"fill":83,"textAnchor":74},"158.78","nothing checked",[44,418],{"x":419,"y":79,"width":400,"height":401,"rx":58,"fill":420,"stroke":421,"style":84},"406","#e8efe6","#15803d",[68,423,426],{"x":424,"y":88,"style":89,"fill":425,"textAnchor":74},"571","#166534","with stubs",[68,428,429],{"x":424,"y":93,"style":94,"fill":66,"textAnchor":74},"mapLayer → QgsMapLayer | None",[68,431,432],{"x":424,"y":98,"style":94,"fill":83,"textAnchor":74},"missing None check flagged",[205,434,436],{"className":282,"code":435,"language":284,"meta":210,"style":210},"pip install mypy pyqgis-stubs pyqt5-stubs\n",[187,437,438],{"__ignoreMap":210},[214,439,440,442,444,447,450],{"class":160,"line":216},[214,441,292],{"class":291},[214,443,296],{"class":295},[214,445,446],{"class":295}," mypy",[214,448,449],{"class":295}," pyqgis-stubs",[214,451,452],{"class":295}," pyqt5-stubs\n",[205,454,456],{"className":207,"code":455,"language":209,"meta":210,"style":210},"[tool.mypy]\npython_version = \"3.10\"\nignore_missing_imports = true\nwarn_unused_ignores = true\ncheck_untyped_defs = true\nexclude = [\"resources\\\\.py$\", \"ui_.*\\\\.py$\", \"test\u002F\"]\n",[187,457,458,463,468,473,478,483],{"__ignoreMap":210},[214,459,460],{"class":160,"line":216},[214,461,462],{},"[tool.mypy]\n",[214,464,465],{"class":160,"line":222},[214,466,467],{},"python_version = \"3.10\"\n",[214,469,470],{"class":160,"line":228},[214,471,472],{},"ignore_missing_imports = true\n",[214,474,475],{"class":160,"line":234},[214,476,477],{},"warn_unused_ignores = true\n",[214,479,480],{"class":160,"line":240},[214,481,482],{},"check_untyped_defs = true\n",[214,484,485],{"class":160,"line":247},[214,486,487],{},"exclude = [\"resources\\\\.py$\", \"ui_.*\\\\.py$\", \"test\u002F\"]\n",[205,489,493],{"className":490,"code":491,"language":492,"meta":210,"style":210},"language-python shiki shiki-themes github-dark","from qgis.core import QgsProject, QgsVectorLayer\n\ndef asset_layer(layer_id: str) -> QgsVectorLayer | None:\n    layer = QgsProject.instance().mapLayer(layer_id)\n    if isinstance(layer, QgsVectorLayer):\n        return layer\n    return None\n\ndef feature_count(layer_id: str) -> int:\n    layer = asset_layer(layer_id)\n    return layer.featureCount()      # mypy: Item \"None\" has no attribute \"featureCount\"\n","python",[187,494,495,511,515,541,552,563,571,579,583,602,611],{"__ignoreMap":210},[214,496,497,501,505,508],{"class":160,"line":216},[214,498,500],{"class":499},"snl16","from",[214,502,504],{"class":503},"s95oV"," qgis.core ",[214,506,507],{"class":499},"import",[214,509,510],{"class":503}," QgsProject, QgsVectorLayer\n",[214,512,513],{"class":160,"line":222},[214,514,244],{"emptyLinePlaceholder":243},[214,516,517,520,523,526,529,532,535,538],{"class":160,"line":228},[214,518,519],{"class":499},"def",[214,521,522],{"class":291}," asset_layer",[214,524,525],{"class":503},"(layer_id: ",[214,527,528],{"class":325},"str",[214,530,531],{"class":503},") -> QgsVectorLayer ",[214,533,534],{"class":499},"|",[214,536,537],{"class":325}," None",[214,539,540],{"class":503},":\n",[214,542,543,546,549],{"class":160,"line":234},[214,544,545],{"class":503},"    layer ",[214,547,548],{"class":499},"=",[214,550,551],{"class":503}," QgsProject.instance().mapLayer(layer_id)\n",[214,553,554,557,560],{"class":160,"line":240},[214,555,556],{"class":499},"    if",[214,558,559],{"class":325}," isinstance",[214,561,562],{"class":503},"(layer, QgsVectorLayer):\n",[214,564,565,568],{"class":160,"line":247},[214,566,567],{"class":499},"        return",[214,569,570],{"class":503}," layer\n",[214,572,573,576],{"class":160,"line":253},[214,574,575],{"class":499},"    return",[214,577,578],{"class":325}," None\n",[214,580,581],{"class":160,"line":259},[214,582,244],{"emptyLinePlaceholder":243},[214,584,585,587,590,592,594,597,600],{"class":160,"line":265},[214,586,519],{"class":499},[214,588,589],{"class":291}," feature_count",[214,591,525],{"class":503},[214,593,528],{"class":325},[214,595,596],{"class":503},") -> ",[214,598,599],{"class":325},"int",[214,601,540],{"class":503},[214,603,604,606,608],{"class":160,"line":270},[214,605,545],{"class":503},[214,607,548],{"class":499},[214,609,610],{"class":503}," asset_layer(layer_id)\n",[214,612,613,615,618],{"class":160,"line":276},[214,614,575],{"class":499},[214,616,617],{"class":503}," layer.featureCount()      ",[214,619,620],{"class":313},"# mypy: Item \"None\" has no attribute \"featureCount\"\n",[14,622,623,625,626,629,630,633,634,637,638,641],{},[345,624,347],{}," The stubs package describes PyQGIS classes and their signatures for type checkers; PyQt stubs do the same for Qt. ",[187,627,628],{},"ignore_missing_imports"," stops mypy failing on modules without stubs, such as ",[187,631,632],{},"qgis.utils",". ",[187,635,636],{},"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 ",[187,639,640],{},"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.",[175,643,645],{"id":644},"annotate-gradually","Annotate gradually",[14,647,648],{},"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.",[205,650,652],{"className":207,"code":651,"language":209,"meta":210,"style":210},"[[tool.mypy.overrides]]\nmodule = [\"asset_sync.legacy.*\"]\nignore_errors = true\n\n[[tool.mypy.overrides]]\nmodule = [\"asset_sync.core.*\"]\ndisallow_untyped_defs = true\n",[187,653,654,659,664,669,673,677,682],{"__ignoreMap":210},[214,655,656],{"class":160,"line":216},[214,657,658],{},"[[tool.mypy.overrides]]\n",[214,660,661],{"class":160,"line":222},[214,662,663],{},"module = [\"asset_sync.legacy.*\"]\n",[214,665,666],{"class":160,"line":228},[214,667,668],{},"ignore_errors = true\n",[214,670,671],{"class":160,"line":234},[214,672,244],{"emptyLinePlaceholder":243},[214,674,675],{"class":160,"line":240},[214,676,658],{},[214,678,679],{"class":160,"line":247},[214,680,681],{},"module = [\"asset_sync.core.*\"]\n",[214,683,684],{"class":160,"line":253},[214,685,686],{},"disallow_untyped_defs = true\n",[14,688,689,691],{},[345,690,347],{}," 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.",[175,693,695],{"id":694},"add-pre-commit-hooks","Add pre-commit hooks",[14,697,698],{},"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.",[14,700,701],{},[29,702,705,708,711,714,721,724,728,733,737,741,744,748,752,755,759,762,764,769],{"viewBox":703,"role":32,"ariaLabel":704,"xmlns":34},"0 0 760 196","Pre-commit hooks running fast checks locally on commit and GitHub Actions running all checks on push",[36,706,707],{},"Where checks run",[40,709,710],{},"On 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.",[44,712],{"x":46,"y":46,"width":47,"height":713,"fill":49},"196",[51,715,716],{},[54,717,719],{"id":718,"viewBox":57,"refX":58,"refY":59,"markerWidth":60,"markerHeight":60,"orient":61},"lcFlowArrow",[63,720],{"d":65,"fill":66},[68,722,723],{"x":70,"y":71,"style":72,"fill":73,"textAnchor":74},"Fast locally, complete in CI",[44,725],{"x":78,"y":79,"width":726,"height":727,"rx":58,"fill":107,"stroke":108,"style":84},"200","120",[68,729,732],{"x":730,"y":731,"style":89,"fill":108,"textAnchor":74},"124","97.6","git commit",[68,734,736],{"x":730,"y":735,"style":94,"fill":83,"textAnchor":74},"123.6","pre-commit:",[68,738,740],{"x":730,"y":739,"style":94,"fill":83,"textAnchor":74},"149.6","ruff format, check",[44,742],{"x":743,"y":79,"width":726,"height":727,"rx":58,"fill":125,"stroke":126,"style":84},"280",[68,745,747],{"x":70,"y":746,"style":89,"fill":126,"textAnchor":74},"110.6","git push",[68,749,751],{"x":70,"y":750,"style":94,"fill":83,"textAnchor":74},"136.6","triggers CI",[44,753],{"x":754,"y":79,"width":726,"height":727,"rx":58,"fill":420,"stroke":421,"style":84},"536",[68,756,758],{"x":757,"y":731,"style":89,"fill":425,"textAnchor":74},"636","GitHub Actions",[68,760,761],{"x":757,"y":735,"style":94,"fill":83,"textAnchor":74},"ruff · mypy",[68,763,152],{"x":757,"y":739,"style":94,"fill":83,"textAnchor":74},[160,765],{"x1":766,"y1":727,"x2":767,"y2":727,"stroke":66,"style":768},"224","278","stroke-width:1.8;marker-end:url(#lcFlowArrow)",[160,770],{"x1":771,"y1":727,"x2":772,"y2":727,"stroke":66,"style":768},"480","534",[205,774,778],{"className":775,"code":776,"language":777,"meta":210,"style":210},"language-yaml shiki shiki-themes github-dark","# .pre-commit-config.yaml\nrepos:\n  - repo: https:\u002F\u002Fgithub.com\u002Fastral-sh\u002Fruff-pre-commit\n    rev: v0.6.9\n    hooks:\n      - id: ruff\n        args: [--fix]\n      - id: ruff-format\n","yaml",[187,779,780,785,793,807,817,824,837,851],{"__ignoreMap":210},[214,781,782],{"class":160,"line":216},[214,783,784],{"class":313},"# .pre-commit-config.yaml\n",[214,786,787,791],{"class":160,"line":222},[214,788,790],{"class":789},"s4JwU","repos",[214,792,540],{"class":503},[214,794,795,798,801,804],{"class":160,"line":228},[214,796,797],{"class":503},"  - ",[214,799,800],{"class":789},"repo",[214,802,803],{"class":503},": ",[214,805,806],{"class":295},"https:\u002F\u002Fgithub.com\u002Fastral-sh\u002Fruff-pre-commit\n",[214,808,809,812,814],{"class":160,"line":234},[214,810,811],{"class":789},"    rev",[214,813,803],{"class":503},[214,815,816],{"class":295},"v0.6.9\n",[214,818,819,822],{"class":160,"line":240},[214,820,821],{"class":789},"    hooks",[214,823,540],{"class":503},[214,825,826,829,832,834],{"class":160,"line":247},[214,827,828],{"class":503},"      - ",[214,830,831],{"class":789},"id",[214,833,803],{"class":503},[214,835,836],{"class":295},"ruff\n",[214,838,839,842,845,848],{"class":160,"line":253},[214,840,841],{"class":789},"        args",[214,843,844],{"class":503},": [",[214,846,847],{"class":295},"--fix",[214,849,850],{"class":503},"]\n",[214,852,853,855,857,859],{"class":160,"line":259},[214,854,828],{"class":503},[214,856,831],{"class":789},[214,858,803],{"class":503},[214,860,861],{"class":295},"ruff-format\n",[205,863,865],{"className":282,"code":864,"language":284,"meta":210,"style":210},"pip install pre-commit\npre-commit install          # once per clone\npre-commit run --all-files  # check everything now\n",[187,866,867,876,886],{"__ignoreMap":210},[214,868,869,871,873],{"class":160,"line":216},[214,870,292],{"class":291},[214,872,296],{"class":295},[214,874,875],{"class":295}," pre-commit\n",[214,877,878,881,883],{"class":160,"line":222},[214,879,880],{"class":291},"pre-commit",[214,882,296],{"class":295},[214,884,885],{"class":313},"          # once per clone\n",[214,887,888,890,893,896],{"class":160,"line":228},[214,889,880],{"class":291},[214,891,892],{"class":295}," run",[214,894,895],{"class":325}," --all-files",[214,897,898],{"class":313},"  # check everything now\n",[14,900,901,903,904,907],{},[345,902,347],{}," 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 ",[187,905,906],{},"rev"," to a release so everyone runs the same version, and update it deliberately.",[175,909,911],{"id":910},"run-the-checks-in-github-actions","Run the checks in GitHub Actions",[14,913,914,915,919],{},"The same commands run in CI on every push and pull request, next to the test suite from ",[21,916,918],{"href":917},"\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Frun-qgis-plugin-tests-in-github-actions\u002F","running plugin tests in GitHub Actions",".",[205,921,923],{"className":775,"code":922,"language":777,"meta":210,"style":210},"# .github\u002Fworkflows\u002Flint.yml\nname: lint\non: [push, pull_request]\njobs:\n  static:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\u002Fcheckout@v4\n      - uses: actions\u002Fsetup-python@v5\n        with:\n          python-version: \"3.12\"\n      - run: pip install ruff mypy pyqgis-stubs pyqt5-stubs\n      - run: ruff check .\n      - run: ruff format --check .\n      - run: mypy asset_sync\n",[187,924,925,930,940,958,965,972,982,989,1001,1012,1019,1029,1042,1054,1066],{"__ignoreMap":210},[214,926,927],{"class":160,"line":216},[214,928,929],{"class":313},"# .github\u002Fworkflows\u002Flint.yml\n",[214,931,932,935,937],{"class":160,"line":222},[214,933,934],{"class":789},"name",[214,936,803],{"class":503},[214,938,939],{"class":295},"lint\n",[214,941,942,945,947,950,953,956],{"class":160,"line":228},[214,943,944],{"class":325},"on",[214,946,844],{"class":503},[214,948,949],{"class":295},"push",[214,951,952],{"class":503},", ",[214,954,955],{"class":295},"pull_request",[214,957,850],{"class":503},[214,959,960,963],{"class":160,"line":234},[214,961,962],{"class":789},"jobs",[214,964,540],{"class":503},[214,966,967,970],{"class":160,"line":240},[214,968,969],{"class":789},"  static",[214,971,540],{"class":503},[214,973,974,977,979],{"class":160,"line":247},[214,975,976],{"class":789},"    runs-on",[214,978,803],{"class":503},[214,980,981],{"class":295},"ubuntu-latest\n",[214,983,984,987],{"class":160,"line":253},[214,985,986],{"class":789},"    steps",[214,988,540],{"class":503},[214,990,991,993,996,998],{"class":160,"line":259},[214,992,828],{"class":503},[214,994,995],{"class":789},"uses",[214,997,803],{"class":503},[214,999,1000],{"class":295},"actions\u002Fcheckout@v4\n",[214,1002,1003,1005,1007,1009],{"class":160,"line":265},[214,1004,828],{"class":503},[214,1006,995],{"class":789},[214,1008,803],{"class":503},[214,1010,1011],{"class":295},"actions\u002Fsetup-python@v5\n",[214,1013,1014,1017],{"class":160,"line":270},[214,1015,1016],{"class":789},"        with",[214,1018,540],{"class":503},[214,1020,1021,1024,1026],{"class":160,"line":276},[214,1022,1023],{"class":789},"          python-version",[214,1025,803],{"class":503},[214,1027,1028],{"class":295},"\"3.12\"\n",[214,1030,1032,1034,1037,1039],{"class":160,"line":1031},12,[214,1033,828],{"class":503},[214,1035,1036],{"class":789},"run",[214,1038,803],{"class":503},[214,1040,1041],{"class":295},"pip install ruff mypy pyqgis-stubs pyqt5-stubs\n",[214,1043,1045,1047,1049,1051],{"class":160,"line":1044},13,[214,1046,828],{"class":503},[214,1048,1036],{"class":789},[214,1050,803],{"class":503},[214,1052,1053],{"class":295},"ruff check .\n",[214,1055,1057,1059,1061,1063],{"class":160,"line":1056},14,[214,1058,828],{"class":503},[214,1060,1036],{"class":789},[214,1062,803],{"class":503},[214,1064,1065],{"class":295},"ruff format --check .\n",[214,1067,1069,1071,1073,1075],{"class":160,"line":1068},15,[214,1070,828],{"class":503},[214,1072,1036],{"class":789},[214,1074,803],{"class":503},[214,1076,1077],{"class":295},"mypy asset_sync\n",[14,1079,1080,1082,1083,1086],{},[345,1081,347],{}," Static checks need no QGIS container, so this job finishes in well under a minute and reports problems before the slower test job does. ",[187,1084,1085],{},"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.",[175,1088,1090],{"id":1089},"rules-that-matter-most-in-plugins","Rules that matter most in plugins",[14,1092,1093],{},"A few rule families catch plugin-specific mistakes particularly well.",[205,1095,1097],{"className":490,"code":1096,"language":492,"meta":210,"style":210},"import os                      # F401 unused import — dead code from an old feature\n\ndef on_clicked(self):\n    if self.layer is None:\n        self.iface.messageBar().pushWarning(\"Asset Sync\", f\"No layer: {layr}\")   # F821 undefined name\n    try:\n        self.sync()\n    except:                    # E722 bare except hides KeyboardInterrupt and real bugs\n        pass\n",[187,1098,1099,1109,1113,1123,1140,1177,1184,1191,1202],{"__ignoreMap":210},[214,1100,1101,1103,1106],{"class":160,"line":216},[214,1102,507],{"class":499},[214,1104,1105],{"class":503}," os                      ",[214,1107,1108],{"class":313},"# F401 unused import — dead code from an old feature\n",[214,1110,1111],{"class":160,"line":222},[214,1112,244],{"emptyLinePlaceholder":243},[214,1114,1115,1117,1120],{"class":160,"line":228},[214,1116,519],{"class":499},[214,1118,1119],{"class":291}," on_clicked",[214,1121,1122],{"class":503},"(self):\n",[214,1124,1125,1127,1130,1133,1136,1138],{"class":160,"line":234},[214,1126,556],{"class":499},[214,1128,1129],{"class":325}," self",[214,1131,1132],{"class":503},".layer ",[214,1134,1135],{"class":499},"is",[214,1137,537],{"class":325},[214,1139,540],{"class":503},[214,1141,1142,1145,1148,1151,1153,1156,1159,1162,1165,1168,1171,1174],{"class":160,"line":240},[214,1143,1144],{"class":325},"        self",[214,1146,1147],{"class":503},".iface.messageBar().pushWarning(",[214,1149,1150],{"class":295},"\"Asset Sync\"",[214,1152,952],{"class":503},[214,1154,1155],{"class":499},"f",[214,1157,1158],{"class":295},"\"No layer: ",[214,1160,1161],{"class":325},"{",[214,1163,1164],{"class":503},"layr",[214,1166,1167],{"class":325},"}",[214,1169,1170],{"class":295},"\"",[214,1172,1173],{"class":503},")   ",[214,1175,1176],{"class":313},"# F821 undefined name\n",[214,1178,1179,1182],{"class":160,"line":247},[214,1180,1181],{"class":499},"    try",[214,1183,540],{"class":503},[214,1185,1186,1188],{"class":160,"line":253},[214,1187,1144],{"class":325},[214,1189,1190],{"class":503},".sync()\n",[214,1192,1193,1196,1199],{"class":160,"line":259},[214,1194,1195],{"class":499},"    except",[214,1197,1198],{"class":503},":                    ",[214,1200,1201],{"class":313},"# E722 bare except hides KeyboardInterrupt and real bugs\n",[214,1203,1204],{"class":160,"line":265},[214,1205,1206],{"class":499},"        pass\n",[14,1208,1209,1211,1212,1215,1216,1219,1220,1223,1224,1227,1228,1231,1232,1236],{},[345,1210,347],{}," Undefined names (",[187,1213,1214],{},"F821",") in rarely run error branches are the classic plugin crash that only users see. Bare ",[187,1217,1218],{},"except"," (",[187,1221,1222],{},"E722",") and bugbear's ",[187,1225,1226],{},"B"," rules flag error handling that swallows the exception you needed to see. Unused imports (",[187,1229,1230],{},"F401",") of Qt modules that moved between PyQt5 and PyQt6 are an easy cleanup before ",[21,1233,1235],{"href":1234},"\u002Fqgis-plugin-development\u002Fplugin-boilerplate-structure\u002F","QGIS 4 migration",". Each of these takes seconds to fix once flagged and hours to diagnose from a user report.",[175,1238,1240],{"id":1239},"catch-qt-6-problems-early","Catch Qt 6 problems early",[14,1242,1243],{},"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.",[205,1245,1247],{"className":490,"code":1246,"language":492,"meta":210,"style":210},"# tools\u002Fcheck_qt6.py\nimport pathlib, re, sys\n\nPATTERNS = {\n    r\"from PyQt5\": \"import through qgis.PyQt instead of PyQt5\",\n    r\"\\.exec_\\(\": \"exec_() is removed in PyQt6; use exec()\",\n    r\"Qt\\.(AlignLeft|AlignRight|Checked|Unchecked)\\b\": \"use scoped enums, e.g. Qt.AlignmentFlag.AlignLeft\",\n    r\"QAction\\b.*QtWidgets\": \"QAction moved to QtGui in Qt 6\",\n}\n\nproblems = 0\nfor path in pathlib.Path(\".\").rglob(\"*.py\"):\n    for n, line in enumerate(path.read_text(encoding=\"utf-8\").splitlines(), 1):\n        for pattern, advice in PATTERNS.items():\n            if re.search(pattern, line):\n                print(f\"{path}:{n}: {advice}\")\n                problems += 1\nsys.exit(1 if problems else 0)\n",[187,1248,1249,1254,1261,1265,1276,1297,1322,1366,1393,1398,1402,1412,1438,1471,1487,1495,1537,1549],{"__ignoreMap":210},[214,1250,1251],{"class":160,"line":216},[214,1252,1253],{"class":313},"# tools\u002Fcheck_qt6.py\n",[214,1255,1256,1258],{"class":160,"line":222},[214,1257,507],{"class":499},[214,1259,1260],{"class":503}," pathlib, re, sys\n",[214,1262,1263],{"class":160,"line":228},[214,1264,244],{"emptyLinePlaceholder":243},[214,1266,1267,1270,1273],{"class":160,"line":234},[214,1268,1269],{"class":325},"PATTERNS",[214,1271,1272],{"class":499}," =",[214,1274,1275],{"class":503}," {\n",[214,1277,1278,1281,1283,1287,1289,1291,1294],{"class":160,"line":240},[214,1279,1280],{"class":499},"    r",[214,1282,1170],{"class":295},[214,1284,1286],{"class":1285},"sns5M","from PyQt5",[214,1288,1170],{"class":295},[214,1290,803],{"class":503},[214,1292,1293],{"class":295},"\"import through qgis.PyQt instead of PyQt5\"",[214,1295,1296],{"class":503},",\n",[214,1298,1299,1301,1303,1307,1310,1313,1315,1317,1320],{"class":160,"line":247},[214,1300,1280],{"class":499},[214,1302,1170],{"class":295},[214,1304,1306],{"class":1305},"sRjNt","\\.",[214,1308,1309],{"class":1285},"exec_",[214,1311,1312],{"class":1305},"\\(",[214,1314,1170],{"class":295},[214,1316,803],{"class":503},[214,1318,1319],{"class":295},"\"exec_() is removed in PyQt6; use exec()\"",[214,1321,1296],{"class":503},[214,1323,1324,1326,1328,1331,1333,1336,1339,1341,1344,1346,1349,1351,1354,1357,1359,1361,1364],{"class":160,"line":253},[214,1325,1280],{"class":499},[214,1327,1170],{"class":295},[214,1329,1330],{"class":1285},"Qt",[214,1332,1306],{"class":1305},[214,1334,1335],{"class":325},"(",[214,1337,1338],{"class":1285},"AlignLeft",[214,1340,534],{"class":499},[214,1342,1343],{"class":1285},"AlignRight",[214,1345,534],{"class":499},[214,1347,1348],{"class":1285},"Checked",[214,1350,534],{"class":499},[214,1352,1353],{"class":1285},"Unchecked",[214,1355,1356],{"class":325},")\\b",[214,1358,1170],{"class":295},[214,1360,803],{"class":503},[214,1362,1363],{"class":295},"\"use scoped enums, e.g. Qt.AlignmentFlag.AlignLeft\"",[214,1365,1296],{"class":503},[214,1367,1368,1370,1372,1375,1378,1381,1384,1386,1388,1391],{"class":160,"line":259},[214,1369,1280],{"class":499},[214,1371,1170],{"class":295},[214,1373,1374],{"class":1285},"QAction",[214,1376,1377],{"class":325},"\\b.",[214,1379,1380],{"class":499},"*",[214,1382,1383],{"class":1285},"QtWidgets",[214,1385,1170],{"class":295},[214,1387,803],{"class":503},[214,1389,1390],{"class":295},"\"QAction moved to QtGui in Qt 6\"",[214,1392,1296],{"class":503},[214,1394,1395],{"class":160,"line":265},[214,1396,1397],{"class":503},"}\n",[214,1399,1400],{"class":160,"line":270},[214,1401,244],{"emptyLinePlaceholder":243},[214,1403,1404,1407,1409],{"class":160,"line":276},[214,1405,1406],{"class":503},"problems ",[214,1408,548],{"class":499},[214,1410,1411],{"class":325}," 0\n",[214,1413,1414,1417,1420,1423,1426,1429,1432,1435],{"class":160,"line":1031},[214,1415,1416],{"class":499},"for",[214,1418,1419],{"class":503}," path ",[214,1421,1422],{"class":499},"in",[214,1424,1425],{"class":503}," pathlib.Path(",[214,1427,1428],{"class":295},"\".\"",[214,1430,1431],{"class":503},").rglob(",[214,1433,1434],{"class":295},"\"*.py\"",[214,1436,1437],{"class":503},"):\n",[214,1439,1440,1443,1446,1448,1451,1454,1458,1460,1463,1466,1469],{"class":160,"line":1044},[214,1441,1442],{"class":499},"    for",[214,1444,1445],{"class":503}," n, line ",[214,1447,1422],{"class":499},[214,1449,1450],{"class":325}," enumerate",[214,1452,1453],{"class":503},"(path.read_text(",[214,1455,1457],{"class":1456},"s9osk","encoding",[214,1459,548],{"class":499},[214,1461,1462],{"class":295},"\"utf-8\"",[214,1464,1465],{"class":503},").splitlines(), ",[214,1467,1468],{"class":325},"1",[214,1470,1437],{"class":503},[214,1472,1473,1476,1479,1481,1484],{"class":160,"line":1056},[214,1474,1475],{"class":499},"        for",[214,1477,1478],{"class":503}," pattern, advice ",[214,1480,1422],{"class":499},[214,1482,1483],{"class":325}," PATTERNS",[214,1485,1486],{"class":503},".items():\n",[214,1488,1489,1492],{"class":160,"line":1068},[214,1490,1491],{"class":499},"            if",[214,1493,1494],{"class":503}," re.search(pattern, line):\n",[214,1496,1498,1501,1503,1505,1507,1509,1511,1513,1516,1518,1521,1523,1525,1527,1530,1532,1534],{"class":160,"line":1497},16,[214,1499,1500],{"class":325},"                print",[214,1502,1335],{"class":503},[214,1504,1155],{"class":499},[214,1506,1170],{"class":295},[214,1508,1161],{"class":325},[214,1510,63],{"class":503},[214,1512,1167],{"class":325},[214,1514,1515],{"class":295},":",[214,1517,1161],{"class":325},[214,1519,1520],{"class":503},"n",[214,1522,1167],{"class":325},[214,1524,803],{"class":295},[214,1526,1161],{"class":325},[214,1528,1529],{"class":503},"advice",[214,1531,1167],{"class":325},[214,1533,1170],{"class":295},[214,1535,1536],{"class":503},")\n",[214,1538,1540,1543,1546],{"class":160,"line":1539},17,[214,1541,1542],{"class":503},"                problems ",[214,1544,1545],{"class":499},"+=",[214,1547,1548],{"class":325}," 1\n",[214,1550,1552,1555,1557,1560,1563,1566,1569],{"class":160,"line":1551},18,[214,1553,1554],{"class":503},"sys.exit(",[214,1556,1468],{"class":325},[214,1558,1559],{"class":499}," if",[214,1561,1562],{"class":503}," problems ",[214,1564,1565],{"class":499},"else",[214,1567,1568],{"class":325}," 0",[214,1570,1536],{"class":503},[14,1572,1573,1575,1576,1579,1580,1583,1584,1587,1588,1590,1591,1594],{},[345,1574,347],{}," Each pattern pairs a regular expression with advice. Direct ",[187,1577,1578],{},"PyQt5"," imports are the most important: importing from ",[187,1581,1582],{},"qgis.PyQt"," lets the same code load against either Qt version. ",[187,1585,1586],{},"exec_()"," disappears in PyQt6, unscoped enum access changes, and ",[187,1589,1374],{}," moves module. Scoped enums such as ",[187,1592,1593],{},"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.",[175,1596,1598],{"id":1597},"keep-the-checks-trusted","Keep the checks trusted",[14,1600,1601],{},"Static checks only help if people believe their failures. A few habits keep the signal clean as the plugin grows.",[14,1603,1604,1605,1608,1609,1611],{},"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 ",[187,1606,1607],{},"# 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 ",[187,1610,189],{},", under version control, so the editor, the commit hook and CI all apply the same rules.",[175,1613,1615],{"id":1614},"qgis-version-compatibility","QGIS version compatibility",[14,1617,1618,1619,1621],{},"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 ",[187,1620,1582],{}," so both PyQt5 and PyQt6 resolve.",[175,1623,1625],{"id":1624},"troubleshooting","Troubleshooting",[180,1627,1628,1645,1651,1657],{},[183,1629,1630,1633,1634,1637,1638,1637,1641,1644],{},[345,1631,1632],{},"Ruff flags Qt method names."," Ignore the ",[187,1635,1636],{},"N802","\u002F",[187,1639,1640],{},"N803",[187,1642,1643],{},"N815"," naming rules.",[183,1646,1647,1650],{},[345,1648,1649],{},"Mypy reports hundreds of errors at once."," Start with overrides that skip legacy modules.",[183,1652,1653,1656],{},[345,1654,1655],{},"Everything from qgis is Any."," Stubs are not installed in the environment mypy runs in.",[183,1658,1659,1662,1663,1666],{},[345,1660,1661],{},"Generated files fail checks."," Exclude ",[187,1664,1665],{},"resources.py"," and compiled UI modules.",[175,1668,1670],{"id":1669},"conclusion","Conclusion",[14,1672,1673],{},"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.",[175,1675,1677],{"id":1676},"frequently-asked-questions","Frequently Asked Questions",[14,1679,1680,1683],{},[345,1681,1682],{},"Do I still need tests if I type-check?","\nYes; types prove the code fits together, tests prove it does the right thing.",[14,1685,1686,1689],{},[345,1687,1688],{},"Can I use pylint instead of ruff?","\nYes, though it is much slower; ruff covers the most valuable rules.",[14,1691,1692,1695,1696,919],{},[345,1693,1694],{},"Should the plugin ZIP include pyproject.toml?","\nNo; exclude development files when ",[21,1697,1699],{"href":1698},"\u002Fqgis-plugin-development\u002Fpublishing-to-the-qgis-plugin-repository\u002Fpackage-qgis-plugin-as-zip\u002F","packaging the plugin",[14,1701,1702,1705],{},[345,1703,1704],{},"Does the QGIS plugin repository run these checks?","\nIt runs its own security scans; your lint and type checks are for your own quality.",[175,1707,1709],{"id":1708},"related","Related",[180,1711,1712,1717,1723,1728,1734],{},[183,1713,1714,1716],{},[21,1715,24],{"href":23}," — the guide this recipe belongs to",[183,1718,1719],{},[21,1720,1722],{"href":1721},"\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Funit-test-qgis-plugin-with-pytest\u002F","Unit Test a QGIS Plugin with pytest",[183,1724,1725],{},[21,1726,1727],{"href":917},"Run QGIS Plugin Tests in GitHub Actions",[183,1729,1730],{},[21,1731,1733],{"href":1732},"\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Fmock-qgis-interface-in-plugin-tests\u002F","Mock the QGIS Interface in Plugin Tests",[183,1735,1736],{},[21,1737,1739],{"href":1738},"\u002Fqgis-plugin-development\u002Fpublishing-to-the-qgis-plugin-repository\u002Fautomate-plugin-release-with-github-actions\u002F","Automate Plugin Releases with GitHub Actions",[99,1741,1742],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sjoCn, html code.shiki .sjoCn{--shiki-default:#9AA79F}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}html pre.shiki code .sns5M, html code.shiki .sns5M{--shiki-default:#DBEDFF}html pre.shiki code .sRjNt, html code.shiki .sRjNt{--shiki-default:#85E89D;--shiki-default-font-weight:bold}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":210,"searchDepth":222,"depth":222,"links":1744},[1745,1746,1747,1748,1749,1750,1751,1752,1753,1754,1755,1756,1757,1758],{"id":177,"depth":222,"text":178},{"id":196,"depth":222,"text":197},{"id":371,"depth":222,"text":372},{"id":644,"depth":222,"text":645},{"id":694,"depth":222,"text":695},{"id":910,"depth":222,"text":911},{"id":1089,"depth":222,"text":1090},{"id":1239,"depth":222,"text":1240},{"id":1597,"depth":222,"text":1598},{"id":1614,"depth":222,"text":1615},{"id":1624,"depth":222,"text":1625},{"id":1669,"depth":222,"text":1670},{"id":1676,"depth":222,"text":1677},{"id":1708,"depth":222,"text":1709},"Catch bugs in a QGIS plugin before users do — ruff for linting and formatting, mypy with PyQGIS stubs for type checking, handling the qgis and PyQt imports, pre-commit hooks, and running the same checks in GitHub Actions alongside the test suite.","md",{"slug":1762,"type":1763,"breadcrumb":1764,"datePublished":1765,"dateModified":1765},"lint-and-type-check-qgis-plugin","article","Lint and Type-Check a Plugin","2026-10-02","\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Flint-and-type-check-qgis-plugin",{"title":5,"description":1759},"qgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Flint-and-type-check-qgis-plugin\u002Findex","JxtFR2mNMqTOmWd0UHObr7NPk2bVIe8_dF9-PkGqi_A",1790966259702]