[{"data":1,"prerenderedAt":1521},["ShallowReactive",2],{"doc:\u002Fqgis-plugin-development\u002Fpublishing-to-the-qgis-plugin-repository\u002Fpackage-qgis-plugin-as-zip":3},{"id":4,"title":5,"body":6,"description":1510,"extension":1511,"meta":1512,"navigation":325,"path":1517,"seo":1518,"stem":1519,"__hash__":1520},"docs\u002Fqgis-plugin-development\u002Fpublishing-to-the-qgis-plugin-repository\u002Fpackage-qgis-plugin-as-zip\u002Findex.md","Package a QGIS Plugin as a Zip",{"type":7,"value":8,"toc":1497},"minimark",[9,13,22,31,171,176,208,212,243,273,282,286,289,348,380,384,1008,1025,1098,1199,1203,1206,1223,1233,1237,1240,1246,1252,1263,1267,1336,1342,1346,1406,1410,1418,1422,1431,1437,1443,1452,1458,1464,1468,1493],[10,11,5],"h1",{"id":12},"package-a-qgis-plugin-as-a-zip",[14,15,16,17,21],"p",{},"The QGIS plugin format is a zip file, which sounds like it needs no explanation until the upload is rejected for the third time. The rules are simple and unforgiving: one top-level folder inside the archive, named exactly as the plugin's package, containing a valid ",[18,19,20],"code",{},"metadata.txt",", with no development detritus and no compiled Python. Getting them right by hand works once; getting them right on every release needs a build script.",[14,23,24,25,30],{},"This recipe belongs to ",[26,27,29],"a",{"href":28},"\u002Fqgis-plugin-development\u002Fpublishing-to-the-qgis-plugin-repository\u002F","Publishing to the QGIS Plugin Repository",". It covers the required structure, what to include and leave out, compiling resources and translations before packaging, and a repeatable build that produces a clean archive every time.",[14,32,33],{},[34,35,40,44,48,55,64,74,80,90,96,106,113,116,119,122,126,131,136,140,143,148,151,154,157,161,163,167],"svg",{"viewBox":36,"role":37,"ariaLabel":38,"xmlns":39},"0 0 760 276","img","Comparison of a correctly structured plugin zip with a single top level folder against a flat zip whose files sit at the root, which the installer rejects","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[41,42,43],"title",{},"The one structural rule",[45,46,47],"desc",{},"A valid plugin archive contains exactly one top level folder, named as the plugin package, holding the init file, metadata and everything else. An archive whose files sit at the root of the zip, or which contains a wrapper folder with a different name, is rejected by the installer or installs under a name that does not match the package.",[49,50],"rect",{"x":51,"y":51,"width":52,"height":53,"fill":54},"0","760","276","#f6f3ea",[56,57,63],"text",{"x":58,"y":59,"style":60,"fill":61,"textAnchor":62},"380","28","text-anchor:middle;font-size:14px;font-weight:bold;font-family:sans-serif","#17211d","middle","One folder in, named exactly as the package",[49,65],{"x":66,"y":67,"width":68,"height":69,"rx":70,"fill":71,"stroke":72,"style":73},"20","48","348","204","10","#edf8e9","#15803d","stroke-width:2.5",[56,75,79],{"x":76,"y":77,"style":78,"fill":72,"textAnchor":62},"194","74","text-anchor:middle;font-size:12px;font-weight:bold;font-family:sans-serif","parcel tools.zip — correct",[49,81],{"x":82,"y":83,"width":84,"height":85,"rx":86,"fill":87,"stroke":88,"style":89},"44","90","300","30","6","#eef7f4","#0f766e","stroke-width:2",[56,91,95],{"x":92,"y":93,"style":94,"fill":88},"60","110","font-size:11px;font-weight:bold;font-family:sans-serif","parcel tools\u002F",[49,97],{"x":98,"y":99,"width":100,"height":101,"rx":102,"fill":103,"stroke":104,"style":105},"72","126","272","26","5","#fffdf7","#59645f","stroke-width:1.2",[56,107,112],{"x":108,"y":109,"style":110,"fill":111},"88","144","font-size:11px;font-family:sans-serif","#2f3b35","init.py — classFactory lives here",[49,114],{"x":98,"y":115,"width":100,"height":101,"rx":102,"fill":103,"stroke":104,"style":105},"158",[56,117,20],{"x":108,"y":118,"style":110,"fill":111},"176",[49,120],{"x":98,"y":121,"width":100,"height":101,"rx":102,"fill":103,"stroke":104,"style":105},"190",[56,123,125],{"x":108,"y":124,"style":110,"fill":111},"208","i18n\u002F, icon.png, the modules",[56,127,130],{"x":76,"y":128,"style":129,"fill":72,"textAnchor":62},"238","text-anchor:middle;font-size:11px;font-weight:bold;font-family:sans-serif","installs and loads",[49,132],{"x":133,"y":67,"width":68,"height":69,"rx":70,"fill":134,"stroke":135,"style":73},"392","#fdf2e2","#b91c1c",[56,137,139],{"x":138,"y":77,"style":78,"fill":135,"textAnchor":62},"566","flat zip — rejected",[49,141],{"x":142,"y":83,"width":84,"height":101,"rx":102,"fill":103,"stroke":104,"style":105},"416",[56,144,147],{"x":145,"y":146,"style":110,"fill":111},"432","108","init.py",[49,149],{"x":142,"y":150,"width":84,"height":101,"rx":102,"fill":103,"stroke":104,"style":105},"122",[56,152,20],{"x":145,"y":153,"style":110,"fill":111},"140",[49,155],{"x":142,"y":156,"width":84,"height":101,"rx":102,"fill":103,"stroke":104,"style":105},"154",[56,158,160],{"x":145,"y":159,"style":110,"fill":111},"172","the modules, at the root",[49,162],{"x":142,"y":121,"width":84,"height":85,"rx":102,"fill":54,"stroke":135,"style":89},[56,164,166],{"x":145,"y":165,"style":94,"fill":135},"210","no package folder — no plugin",[56,168,170],{"x":138,"y":128,"style":169,"fill":135,"textAnchor":62},"text-anchor:middle;font-size:11px;font-family:sans-serif","zip the folder, not its contents",[172,173,175],"h2",{"id":174},"prerequisites","Prerequisites",[177,178,179,187,198],"ul",{},[180,181,182,186],"li",{},[183,184,185],"strong",{},"QGIS 3.34 LTR"," (bundled Python 3.12) or newer for testing the result.",[180,188,189,190,192,193,197],{},"A working plugin with a valid ",[18,191,20],{}," — see ",[26,194,196],{"href":195},"\u002Fqgis-plugin-development\u002Fpublishing-to-the-qgis-plugin-repository\u002Fwrite-metadata-txt-qgis-plugin\u002F","Write metadata.txt for a QGIS Plugin",".",[180,199,200,203,204,207],{},[18,201,202],{},"lrelease"," if the plugin ships translations, and ",[18,205,206],{},"pyrcc5"," if it uses a compiled resources file.",[172,209,211],{"id":210},"what-goes-in-and-what-does-not","What goes in, and what does not",[14,213,214,217,218,220,221,224,225,220,228,231,232,235,236,239,240,197],{},[183,215,216],{},"Include:"," every Python module the plugin imports, ",[18,219,20],{},", ",[18,222,223],{},"__init__.py"," with ",[18,226,227],{},"classFactory",[18,229,230],{},".ui"," files, icons and images, compiled ",[18,233,234],{},".qm"," translation files, a ",[18,237,238],{},"LICENSE",", and a short ",[18,241,242],{},"README",[14,244,245,248,249,252,253,256,257,260,261,264,265,268,269,272],{},[183,246,247],{},"Exclude:"," ",[18,250,251],{},"__pycache__"," folders and ",[18,254,255],{},".pyc"," files, ",[18,258,259],{},".git",", tests and test data, ",[18,262,263],{},".ts"," translation sources, development configuration such as ",[18,266,267],{},".vscode"," or ",[18,270,271],{},".idea",", and anything large the plugin does not need at run time. Sample datasets are the usual offender — a plugin should be a few hundred kilobytes, and a fifty-megabyte upload is nearly always a folder of test data somebody forgot.",[14,274,275,276,278,279,281],{},"Two exclusions matter more than tidiness. Compiled ",[18,277,255],{}," files from a different Python version can shadow the real source and produce failures that make no sense. And a ",[18,280,259],{}," folder in a public archive frequently contains history somebody did not intend to publish.",[172,283,285],{"id":284},"compile-what-needs-compiling","Compile what needs compiling",[14,287,288],{},"Two artefacts must be built before packaging, and both are easy to forget because the plugin works without them in development.",[290,291,296],"pre",{"className":292,"code":293,"language":294,"meta":295,"style":295},"language-bash shiki shiki-themes github-dark","# translations: .ts sources become .qm binaries\nlrelease i18n\u002Fparcel_tools_de.ts i18n\u002Fparcel_tools_fr.ts\n\n# resources, if the plugin uses a .qrc file\npyrcc5 -o resources.py resources.qrc\n","bash","",[18,297,298,307,320,327,333],{"__ignoreMap":295},[299,300,303],"span",{"class":301,"line":302},"line",1,[299,304,306],{"class":305},"sjoCn","# translations: .ts sources become .qm binaries\n",[299,308,310,313,317],{"class":301,"line":309},2,[299,311,202],{"class":312},"svObZ",[299,314,316],{"class":315},"sU2Wk"," i18n\u002Fparcel_tools_de.ts",[299,318,319],{"class":315}," i18n\u002Fparcel_tools_fr.ts\n",[299,321,323],{"class":301,"line":322},3,[299,324,326],{"emptyLinePlaceholder":325},true,"\n",[299,328,330],{"class":301,"line":329},4,[299,331,332],{"class":305},"# resources, if the plugin uses a .qrc file\n",[299,334,336,338,342,345],{"class":301,"line":335},5,[299,337,206],{"class":312},[299,339,341],{"class":340},"sDLfK"," -o",[299,343,344],{"class":315}," resources.py",[299,346,347],{"class":315}," resources.qrc\n",[14,349,350,248,353,355,356,358,359,361,362,364,365,367,368,371,372,375,376,379],{},[183,351,352],{},"Breakdown:",[18,354,202],{}," produces the ",[18,357,234],{}," files the plugin loads at run time; ship those and leave the ",[18,360,263],{}," sources out. Skipping this step is the single most common reason a fully translated plugin appears in English for every user — the developer's machine has the ",[18,363,234],{}," files from an earlier build, and the archive does not. ",[18,366,206],{}," compiles a Qt resource file into an importable Python module, which is how icons are referenced as ",[18,369,370],{},":\u002Fplugins\u002Fparcel_tools\u002Ficon.png","; the generated ",[18,373,374],{},"resources.py"," must be in the archive, while the ",[18,377,378],{},".qrc"," need not be. Neither step is run by the zip command, so both belong in the build script.",[172,381,383],{"id":382},"a-repeatable-build-script","A repeatable build script",[290,385,389],{"className":386,"code":387,"language":388,"meta":295,"style":295},"language-python shiki shiki-themes github-dark","#!\u002Fusr\u002Fbin\u002Fenv python3\n\"\"\"Build an installable plugin archive.\"\"\"\nimport shutil\nimport subprocess\nimport zipfile\nfrom pathlib import Path\n\nPACKAGE = \"parcel_tools\"\nSOURCE = Path(__file__).parent \u002F PACKAGE\nBUILD = Path(__file__).parent \u002F \"build\"\n\nEXCLUDE_DIRS = {\"__pycache__\", \".git\", \"tests\", \".idea\", \".vscode\"}\nEXCLUDE_SUFFIXES = {\".pyc\", \".pyo\", \".ts\", \".qrc\"}\n\n\ndef read_version():\n    for line in (SOURCE \u002F \"metadata.txt\").read_text().splitlines():\n        if line.startswith(\"version=\"):\n            return line.split(\"=\", 1)[1].strip()\n    raise RuntimeError(\"no version in metadata.txt\")\n\n\ndef build():\n    subprocess.check_call([\"lrelease\"] + [str(p) for p in SOURCE.glob(\"i18n\u002F*.ts\")])\n\n    if BUILD.exists():\n        shutil.rmtree(BUILD)\n    BUILD.mkdir()\n\n    archive = BUILD \u002F f\"{PACKAGE}-{read_version()}.zip\"\n    with zipfile.ZipFile(archive, \"w\", zipfile.ZIP_DEFLATED) as zf:\n        for path in sorted(SOURCE.rglob(\"*\")):\n            if any(part in EXCLUDE_DIRS for part in path.parts):\n                continue\n            if path.suffix in EXCLUDE_SUFFIXES or not path.is_file():\n                continue\n            zf.write(path, path.relative_to(SOURCE.parent))\n\n    print(f\"built {archive}\")\n\n\nif __name__ == \"__main__\":\n    build()\n","python",[18,390,391,396,401,411,418,425,439,444,456,479,498,503,540,570,575,580,592,618,633,658,676,681,686,696,740,745,757,767,776,781,818,845,872,900,906,928,933,944,949,974,979,984,1002],{"__ignoreMap":295},[299,392,393],{"class":301,"line":302},[299,394,395],{"class":305},"#!\u002Fusr\u002Fbin\u002Fenv python3\n",[299,397,398],{"class":301,"line":309},[299,399,400],{"class":315},"\"\"\"Build an installable plugin archive.\"\"\"\n",[299,402,403,407],{"class":301,"line":322},[299,404,406],{"class":405},"snl16","import",[299,408,410],{"class":409},"s95oV"," shutil\n",[299,412,413,415],{"class":301,"line":329},[299,414,406],{"class":405},[299,416,417],{"class":409}," subprocess\n",[299,419,420,422],{"class":301,"line":335},[299,421,406],{"class":405},[299,423,424],{"class":409}," zipfile\n",[299,426,428,431,434,436],{"class":301,"line":427},6,[299,429,430],{"class":405},"from",[299,432,433],{"class":409}," pathlib ",[299,435,406],{"class":405},[299,437,438],{"class":409}," Path\n",[299,440,442],{"class":301,"line":441},7,[299,443,326],{"emptyLinePlaceholder":325},[299,445,447,450,453],{"class":301,"line":446},8,[299,448,449],{"class":340},"PACKAGE",[299,451,452],{"class":405}," =",[299,454,455],{"class":315}," \"parcel_tools\"\n",[299,457,459,462,464,467,470,473,476],{"class":301,"line":458},9,[299,460,461],{"class":340},"SOURCE",[299,463,452],{"class":405},[299,465,466],{"class":409}," Path(",[299,468,469],{"class":340},"__file__",[299,471,472],{"class":409},").parent ",[299,474,475],{"class":405},"\u002F",[299,477,478],{"class":340}," PACKAGE\n",[299,480,482,485,487,489,491,493,495],{"class":301,"line":481},10,[299,483,484],{"class":340},"BUILD",[299,486,452],{"class":405},[299,488,466],{"class":409},[299,490,469],{"class":340},[299,492,472],{"class":409},[299,494,475],{"class":405},[299,496,497],{"class":315}," \"build\"\n",[299,499,501],{"class":301,"line":500},11,[299,502,326],{"emptyLinePlaceholder":325},[299,504,506,509,511,514,517,519,522,524,527,529,532,534,537],{"class":301,"line":505},12,[299,507,508],{"class":340},"EXCLUDE_DIRS",[299,510,452],{"class":405},[299,512,513],{"class":409}," {",[299,515,516],{"class":315},"\"__pycache__\"",[299,518,220],{"class":409},[299,520,521],{"class":315},"\".git\"",[299,523,220],{"class":409},[299,525,526],{"class":315},"\"tests\"",[299,528,220],{"class":409},[299,530,531],{"class":315},"\".idea\"",[299,533,220],{"class":409},[299,535,536],{"class":315},"\".vscode\"",[299,538,539],{"class":409},"}\n",[299,541,543,546,548,550,553,555,558,560,563,565,568],{"class":301,"line":542},13,[299,544,545],{"class":340},"EXCLUDE_SUFFIXES",[299,547,452],{"class":405},[299,549,513],{"class":409},[299,551,552],{"class":315},"\".pyc\"",[299,554,220],{"class":409},[299,556,557],{"class":315},"\".pyo\"",[299,559,220],{"class":409},[299,561,562],{"class":315},"\".ts\"",[299,564,220],{"class":409},[299,566,567],{"class":315},"\".qrc\"",[299,569,539],{"class":409},[299,571,573],{"class":301,"line":572},14,[299,574,326],{"emptyLinePlaceholder":325},[299,576,578],{"class":301,"line":577},15,[299,579,326],{"emptyLinePlaceholder":325},[299,581,583,586,589],{"class":301,"line":582},16,[299,584,585],{"class":405},"def",[299,587,588],{"class":312}," read_version",[299,590,591],{"class":409},"():\n",[299,593,595,598,601,604,607,609,612,615],{"class":301,"line":594},17,[299,596,597],{"class":405},"    for",[299,599,600],{"class":409}," line ",[299,602,603],{"class":405},"in",[299,605,606],{"class":409}," (",[299,608,461],{"class":340},[299,610,611],{"class":405}," \u002F",[299,613,614],{"class":315}," \"metadata.txt\"",[299,616,617],{"class":409},").read_text().splitlines():\n",[299,619,621,624,627,630],{"class":301,"line":620},18,[299,622,623],{"class":405},"        if",[299,625,626],{"class":409}," line.startswith(",[299,628,629],{"class":315},"\"version=\"",[299,631,632],{"class":409},"):\n",[299,634,636,639,642,645,647,650,653,655],{"class":301,"line":635},19,[299,637,638],{"class":405},"            return",[299,640,641],{"class":409}," line.split(",[299,643,644],{"class":315},"\"=\"",[299,646,220],{"class":409},[299,648,649],{"class":340},"1",[299,651,652],{"class":409},")[",[299,654,649],{"class":340},[299,656,657],{"class":409},"].strip()\n",[299,659,661,664,667,670,673],{"class":301,"line":660},20,[299,662,663],{"class":405},"    raise",[299,665,666],{"class":340}," RuntimeError",[299,668,669],{"class":409},"(",[299,671,672],{"class":315},"\"no version in metadata.txt\"",[299,674,675],{"class":409},")\n",[299,677,679],{"class":301,"line":678},21,[299,680,326],{"emptyLinePlaceholder":325},[299,682,684],{"class":301,"line":683},22,[299,685,326],{"emptyLinePlaceholder":325},[299,687,689,691,694],{"class":301,"line":688},23,[299,690,585],{"class":405},[299,692,693],{"class":312}," build",[299,695,591],{"class":409},[299,697,699,702,705,708,711,714,717,720,723,726,728,731,734,737],{"class":301,"line":698},24,[299,700,701],{"class":409},"    subprocess.check_call([",[299,703,704],{"class":315},"\"lrelease\"",[299,706,707],{"class":409},"] ",[299,709,710],{"class":405},"+",[299,712,713],{"class":409}," [",[299,715,716],{"class":340},"str",[299,718,719],{"class":409},"(p) ",[299,721,722],{"class":405},"for",[299,724,725],{"class":409}," p ",[299,727,603],{"class":405},[299,729,730],{"class":340}," SOURCE",[299,732,733],{"class":409},".glob(",[299,735,736],{"class":315},"\"i18n\u002F*.ts\"",[299,738,739],{"class":409},")])\n",[299,741,743],{"class":301,"line":742},25,[299,744,326],{"emptyLinePlaceholder":325},[299,746,748,751,754],{"class":301,"line":747},26,[299,749,750],{"class":405},"    if",[299,752,753],{"class":340}," BUILD",[299,755,756],{"class":409},".exists():\n",[299,758,760,763,765],{"class":301,"line":759},27,[299,761,762],{"class":409},"        shutil.rmtree(",[299,764,484],{"class":340},[299,766,675],{"class":409},[299,768,770,773],{"class":301,"line":769},28,[299,771,772],{"class":340},"    BUILD",[299,774,775],{"class":409},".mkdir()\n",[299,777,779],{"class":301,"line":778},29,[299,780,326],{"emptyLinePlaceholder":325},[299,782,784,787,790,792,794,797,800,803,806,809,812,815],{"class":301,"line":783},30,[299,785,786],{"class":409},"    archive ",[299,788,789],{"class":405},"=",[299,791,753],{"class":340},[299,793,611],{"class":405},[299,795,796],{"class":405}," f",[299,798,799],{"class":315},"\"",[299,801,802],{"class":340},"{PACKAGE}",[299,804,805],{"class":315},"-",[299,807,808],{"class":340},"{",[299,810,811],{"class":409},"read_version()",[299,813,814],{"class":340},"}",[299,816,817],{"class":315},".zip\"\n",[299,819,821,824,827,830,833,836,839,842],{"class":301,"line":820},31,[299,822,823],{"class":405},"    with",[299,825,826],{"class":409}," zipfile.ZipFile(archive, ",[299,828,829],{"class":315},"\"w\"",[299,831,832],{"class":409},", zipfile.",[299,834,835],{"class":340},"ZIP_DEFLATED",[299,837,838],{"class":409},") ",[299,840,841],{"class":405},"as",[299,843,844],{"class":409}," zf:\n",[299,846,848,851,854,856,859,861,863,866,869],{"class":301,"line":847},32,[299,849,850],{"class":405},"        for",[299,852,853],{"class":409}," path ",[299,855,603],{"class":405},[299,857,858],{"class":340}," sorted",[299,860,669],{"class":409},[299,862,461],{"class":340},[299,864,865],{"class":409},".rglob(",[299,867,868],{"class":315},"\"*\"",[299,870,871],{"class":409},")):\n",[299,873,875,878,881,884,886,889,892,895,897],{"class":301,"line":874},33,[299,876,877],{"class":405},"            if",[299,879,880],{"class":340}," any",[299,882,883],{"class":409},"(part ",[299,885,603],{"class":405},[299,887,888],{"class":340}," EXCLUDE_DIRS",[299,890,891],{"class":405}," for",[299,893,894],{"class":409}," part ",[299,896,603],{"class":405},[299,898,899],{"class":409}," path.parts):\n",[299,901,903],{"class":301,"line":902},34,[299,904,905],{"class":405},"                continue\n",[299,907,909,911,914,916,919,922,925],{"class":301,"line":908},35,[299,910,877],{"class":405},[299,912,913],{"class":409}," path.suffix ",[299,915,603],{"class":405},[299,917,918],{"class":340}," EXCLUDE_SUFFIXES",[299,920,921],{"class":405}," or",[299,923,924],{"class":405}," not",[299,926,927],{"class":409}," path.is_file():\n",[299,929,931],{"class":301,"line":930},36,[299,932,905],{"class":405},[299,934,936,939,941],{"class":301,"line":935},37,[299,937,938],{"class":409},"            zf.write(path, path.relative_to(",[299,940,461],{"class":340},[299,942,943],{"class":409},".parent))\n",[299,945,947],{"class":301,"line":946},38,[299,948,326],{"emptyLinePlaceholder":325},[299,950,952,955,957,960,963,965,968,970,972],{"class":301,"line":951},39,[299,953,954],{"class":340},"    print",[299,956,669],{"class":409},[299,958,959],{"class":405},"f",[299,961,962],{"class":315},"\"built ",[299,964,808],{"class":340},[299,966,967],{"class":409},"archive",[299,969,814],{"class":340},[299,971,799],{"class":315},[299,973,675],{"class":409},[299,975,977],{"class":301,"line":976},40,[299,978,326],{"emptyLinePlaceholder":325},[299,980,982],{"class":301,"line":981},41,[299,983,326],{"emptyLinePlaceholder":325},[299,985,987,990,993,996,999],{"class":301,"line":986},42,[299,988,989],{"class":405},"if",[299,991,992],{"class":340}," __name__",[299,994,995],{"class":405}," ==",[299,997,998],{"class":315}," \"__main__\"",[299,1000,1001],{"class":409},":\n",[299,1003,1005],{"class":301,"line":1004},43,[299,1006,1007],{"class":409},"    build()\n",[14,1009,1010,1012,1013,1017,1018,1021,1022,1024],{},[183,1011,352],{}," Writing each file with a path relative to the ",[1014,1015,1016],"em",{},"parent"," of the source folder is what puts everything inside a top-level ",[18,1019,1020],{},"parcel_tools\u002F"," directory in the archive — relative to the source folder itself would produce the flat zip the installer rejects. Reading the version out of ",[18,1023,20],{}," means the file name always matches what the plugin reports, so nobody has to guess which of three zips on the desktop is current. Clearing the build folder first prevents a stale archive being mistaken for a fresh one. Sorting the paths makes the archive byte-comparable between runs, which is quietly useful when checking that a release contains only the changes you expect.",[14,1026,1027],{},[34,1028,1031,1034,1037,1040,1043,1047,1051,1055,1059,1063,1066,1069,1074,1077,1080,1083,1086,1089,1092,1095],{"viewBox":1029,"role":37,"ariaLabel":1030,"xmlns":39},"0 0 760 250","Diagram of what belongs in a plugin archive and what should be excluded, with the size consequence of each mistake",[41,1032,1033],{},"What goes in the archive",[45,1035,1036],{},"The archive should contain the Python modules, metadata, user interface files, icons, compiled translations and a licence. It should exclude compiled Python caches, the version control folder, tests and test data, translation sources and editor configuration. Test data and a version control folder are the two exclusions that account for almost every oversized upload.",[49,1038],{"x":51,"y":51,"width":52,"height":1039,"fill":54},"250",[56,1041,1042],{"x":58,"y":59,"style":60,"fill":61,"textAnchor":62},"A plugin should be a few hundred kilobytes",[49,1044],{"x":66,"y":1045,"width":68,"height":1046,"rx":70,"fill":71,"stroke":72,"style":73},"52","180",[56,1048,1050],{"x":76,"y":1049,"style":78,"fill":72,"textAnchor":62},"78","include",[56,1052,1054],{"x":76,"y":1053,"style":169,"fill":111,"textAnchor":62},"106","every imported module",[56,1056,1058],{"x":76,"y":1057,"style":169,"fill":111,"textAnchor":62},"128","metadata.txt and init.py",[56,1060,1062],{"x":76,"y":1061,"style":169,"fill":111,"textAnchor":62},"150",".ui files, icons, resources.py",[56,1064,1065],{"x":76,"y":159,"style":169,"fill":111,"textAnchor":62},"compiled .qm translations",[56,1067,1068],{"x":76,"y":76,"style":169,"fill":111,"textAnchor":62},"a LICENSE and a README",[56,1070,1073],{"x":76,"y":1071,"style":1072,"fill":72,"textAnchor":62},"218","text-anchor:middle;font-size:10px;font-family:sans-serif","everything needed at run time",[49,1075],{"x":133,"y":1045,"width":68,"height":1046,"rx":70,"fill":134,"stroke":1076,"style":73},"#b45309",[56,1078,1079],{"x":138,"y":1049,"style":78,"fill":1076,"textAnchor":62},"exclude",[56,1081,1082],{"x":138,"y":1053,"style":169,"fill":111,"textAnchor":62},"pycache folders and .pyc",[56,1084,1085],{"x":138,"y":1057,"style":169,"fill":111,"textAnchor":62},"the .git folder",[56,1087,1088],{"x":138,"y":1061,"style":129,"fill":135,"textAnchor":62},"tests and test data",[56,1090,1091],{"x":138,"y":159,"style":169,"fill":111,"textAnchor":62},".ts translation sources",[56,1093,1094],{"x":138,"y":76,"style":169,"fill":111,"textAnchor":62},"editor configuration",[56,1096,1097],{"x":138,"y":1071,"style":1072,"fill":1076,"textAnchor":62},"the bold two explain most oversized uploads",[14,1099,1100],{},[34,1101,1103,1106,1109,1111,1126,1129,1135,1140,1144,1149,1153,1156,1159,1163,1166,1170,1174,1177,1182,1185,1188,1192,1195],{"viewBox":1029,"role":37,"ariaLabel":1102,"xmlns":39},"Sequence of a plugin release from compiling translations and resources through building the archive, testing it in a clean profile, and uploading it",[41,1104,1105],{},"The release sequence, with the test that catches most mistakes",[45,1107,1108],{},"A release compiles translations and resources, builds the archive excluding development files, then installs that archive into a clean QGIS profile to confirm it loads. Only after the clean-profile test does the archive go to the repository. The test is what catches missing files, since the developer's own profile already has them.",[49,1110],{"x":51,"y":51,"width":52,"height":1039,"fill":54},[1112,1113,1114],"defs",{},[1115,1116,1122],"marker",{"id":1117,"viewBox":1118,"refX":1119,"refY":102,"markerWidth":1120,"markerHeight":1120,"orient":1121},"pkgArrow","0 0 10 10","8","7","auto-start-reverse",[1123,1124],"path",{"d":1125,"fill":111},"M0 0 L10 5 L0 10 z",[56,1127,1128],{"x":58,"y":59,"style":60,"fill":61,"textAnchor":62},"Test the archive, not the folder you develop in",[49,1130],{"x":1131,"y":1132,"width":1133,"height":1134,"rx":1119,"fill":87,"stroke":88,"style":89},"14","64","164","70",[56,1136,1139],{"x":1137,"y":1138,"style":129,"fill":88,"textAnchor":62},"96","92","compile",[56,1141,1143],{"x":1137,"y":1142,"style":169,"fill":111,"textAnchor":62},"112","translations, resources",[49,1145],{"x":1146,"y":1132,"width":1133,"height":1134,"rx":1119,"fill":1147,"stroke":1148,"style":89},"200","#eff3ff","#2563eb",[56,1150,1152],{"x":1151,"y":1138,"style":129,"fill":1148,"textAnchor":62},"282","build the zip",[56,1154,1155],{"x":1151,"y":1142,"style":169,"fill":111,"textAnchor":62},"excluding dev files",[49,1157],{"x":1158,"y":1132,"width":1046,"height":1134,"rx":1119,"fill":134,"stroke":1076,"style":73},"386",[56,1160,1162],{"x":1161,"y":1138,"style":129,"fill":1076,"textAnchor":62},"476","install in a clean profile",[56,1164,1165],{"x":1161,"y":1142,"style":169,"fill":111,"textAnchor":62},"the step people skip",[49,1167],{"x":1168,"y":1132,"width":1169,"height":1134,"rx":1119,"fill":71,"stroke":72,"style":89},"588","160",[56,1171,1173],{"x":1172,"y":1138,"style":129,"fill":72,"textAnchor":62},"668","upload",[56,1175,1176],{"x":1172,"y":1142,"style":169,"fill":111,"textAnchor":62},"tag the release too",[301,1178],{"x1":1179,"y1":1180,"x2":76,"y2":1180,"stroke":111,"style":1181},"178","99","stroke-width:2;marker-end:url(#pkgArrow)",[301,1183],{"x1":1184,"y1":1180,"x2":58,"y2":1180,"stroke":111,"style":1181},"364",[301,1186],{"x1":138,"y1":1180,"x2":1187,"y2":1180,"stroke":111,"style":1181},"582",[49,1189],{"x":153,"y":1190,"width":1191,"height":92,"rx":1119,"fill":103,"stroke":104,"style":89},"170","480",[56,1193,1194],{"x":58,"y":76,"style":129,"fill":61,"textAnchor":62},"qgis --profile release_test",[56,1196,1198],{"x":58,"y":1197,"style":169,"fill":111,"textAnchor":62},"214","a profile with none of your development leftovers in it",[172,1200,1202],{"id":1201},"test-the-archive-before-uploading","Test the archive before uploading",[14,1204,1205],{},"Install the zip you built, in a profile that has none of your development state:",[1207,1208,1209,1214,1217,1220],"ol",{},[180,1210,1211,1212,197],{},"Launch QGIS with a fresh profile: ",[18,1213,1194],{},[180,1215,1216],{},"Plugins → Manage and Install Plugins → Install from ZIP, and choose the built archive.",[180,1218,1219],{},"Enable it, run its main action, open its options page, and check the Python error log.",[180,1221,1222],{},"Switch the interface language to one you ship a translation for, restart, and confirm the strings changed.",[14,1224,1225,1227,1228,1232],{},[183,1226,352],{}," The clean profile is what makes this a real test. In your development profile the plugin folder already exists, the translations are already compiled, and any file you forgot to include is still there on disk — so the archive appears to work while being incomplete. This four-step check takes two minutes and catches the great majority of \"it worked for me\" release failures. The same reasoning applies to a continuous-integration job that builds the archive and installs it in a container, which the setup in ",[26,1229,1231],{"href":1230},"\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Frun-qgis-plugin-tests-in-github-actions\u002F","Run QGIS Plugin Tests in GitHub Actions"," already provides most of.",[172,1234,1236],{"id":1235},"keep-the-build-honest","Keep the build honest",[14,1238,1239],{},"Three habits keep releases boring, which is what you want from them.",[14,1241,1242,1245],{},[183,1243,1244],{},"Build from a clean checkout."," A build script run in your working directory can pick up files you have not committed, producing an archive that cannot be reproduced from the repository. Build from a fresh clone, or from an export, and the archive and the tag always agree.",[14,1247,1248,1251],{},[183,1249,1250],{},"Never edit the archive."," Fixing a file inside the zip after building it produces a release nobody can rebuild. Fix the source, bump the version, build again.",[14,1253,1254,1257,1258,1262],{},[183,1255,1256],{},"Automate it on tag."," A workflow that builds and attaches the archive whenever a version tag is pushed removes the last opportunity for a hand-made mistake, and gives every release a downloadable artefact with a matching tag — the versioning discipline in ",[26,1259,1261],{"href":1260},"\u002Fqgis-plugin-development\u002Fpublishing-to-the-qgis-plugin-repository\u002Fversion-and-changelog-qgis-plugin\u002F","Version and Changelog a QGIS Plugin"," assumes exactly this.",[172,1264,1266],{"id":1265},"qgis-version-compatibility","QGIS version compatibility",[1268,1269,1270,1286],"table",{},[1271,1272,1273],"thead",{},[1274,1275,1276,1280,1283],"tr",{},[1277,1278,1279],"th",{},"QGIS version",[1277,1281,1282],{},"Python",[1277,1284,1285],{},"Notes",[1287,1288,1289,1305,1315,1326],"tbody",{},[1274,1290,1291,1295,1298],{},[1292,1293,1294],"td",{},"3.22 LTR",[1292,1296,1297],{},"3.9",[1292,1299,1300,1301,1304],{},"Same archive format; test against the oldest version your ",[18,1302,1303],{},"qgisMinimumVersion"," claims.",[1274,1306,1307,1310,1312],{},[1292,1308,1309],{},"3.28 LTR",[1292,1311,1297],{},[1292,1313,1314],{},"Identical.",[1274,1316,1317,1320,1323],{},[1292,1318,1319],{},"3.34 LTR",[1292,1321,1322],{},"3.12",[1292,1324,1325],{},"Baseline for this page.",[1274,1327,1328,1331,1333],{},[1292,1329,1330],{},"3.40 \u002F 3.44",[1292,1332,1322],{},[1292,1334,1335],{},"Identical; Install from ZIP unchanged.",[14,1337,1338,1339,1341],{},"One archive serves every QGIS version it declares support for, so the compatibility work is in ",[18,1340,20],{}," and in your code, not in the packaging.",[172,1343,1345],{"id":1344},"troubleshooting","Troubleshooting",[177,1347,1348,1357,1368,1382,1391,1397],{},[180,1349,1350,1353,1354,1356],{},[183,1351,1352],{},"The installer says the plugin is not valid."," No single top-level folder, or ",[18,1355,20],{}," is missing from it. Open the zip and look.",[180,1358,1359,1362,1363,1365,1366,197],{},[183,1360,1361],{},"It installs but does not appear."," The folder name does not match the package, or ",[18,1364,223],{}," has no ",[18,1367,227],{},[180,1369,1370,1373,1374,1376,1377,1379,1380,197],{},[183,1371,1372],{},"It works from the folder but not from the zip."," A file was excluded that is actually needed — usually a ",[18,1375,234],{},", a ",[18,1378,230],{}," or the compiled ",[18,1381,374],{},[180,1383,1384,1387,1388,1390],{},[183,1385,1386],{},"The upload is rejected for size."," Test data or a ",[18,1389,259],{}," folder got in. Check what the archive actually contains.",[180,1392,1393,1396],{},[183,1394,1395],{},"Icons are missing after install."," The resources file was not compiled, or the compiled module was excluded.",[180,1398,1399,1402,1403,1405],{},[183,1400,1401],{},"The wrong version installs."," The archive name and the version in ",[18,1404,20],{}," disagree. Read the version from the file, as the script does.",[172,1407,1409],{"id":1408},"conclusion","Conclusion",[14,1411,1412,1413,220,1415,1417],{},"A plugin archive is a zip containing exactly one folder named as the package, holding ",[18,1414,223],{},[18,1416,20],{}," and everything the plugin needs at run time — with development files, compiled Python and translation sources left out. Compile translations and resources first, build with a script that derives the version from the metadata, and install the result into a clean profile before uploading. Automate it on a tag and releases stop being an event.",[172,1419,1421],{"id":1420},"frequently-asked-questions","Frequently Asked Questions",[14,1423,1424,1427,1428,1430],{},[183,1425,1426],{},"Can I just zip the folder from my file manager?","\nYes, if you zip the folder itself rather than its contents and have already removed ",[18,1429,251],{}," and friends. A script is worth it by the second release.",[14,1432,1433,1436],{},[183,1434,1435],{},"Does the zip name matter?","\nNot to the installer, which reads the folder name and the metadata. It matters to humans, so include the plugin name and the version.",[14,1438,1439,1442],{},[183,1440,1441],{},"Should tests be in the archive?","\nNo. They add weight and are irrelevant at run time. Keep them in the repository.",[14,1444,1445,1448,1449,1451],{},[183,1446,1447],{},"How do I ship a plugin that needs a third-party library?","\nDeclare it in ",[18,1450,20],{}," and fail with a clear message when it is missing. Vendoring a library into the archive is a last resort and creates conflicts with other plugins.",[14,1453,1454,1457],{},[183,1455,1456],{},"Can I distribute the zip directly instead of using the repository?","\nYes — users can install from ZIP, and organisations often host an internal repository. The archive format is the same either way.",[14,1459,1460,1463],{},[183,1461,1462],{},"Why is my plugin folder named differently after install?","\nQGIS uses the folder name inside the archive. If that does not match the package name your imports use, the plugin will not load.",[172,1465,1467],{"id":1466},"related","Related",[177,1469,1470,1475,1479,1483,1489],{},[180,1471,1472,1474],{},[26,1473,29],{"href":28}," — the guide this recipe belongs to",[180,1476,1477],{},[26,1478,196],{"href":195},[180,1480,1481],{},[26,1482,1261],{"href":1260},[180,1484,1485],{},[26,1486,1488],{"href":1487},"\u002Fqgis-plugin-development\u002Fplugin-settings-and-localization\u002Ftranslate-qgis-plugin-with-qt-linguist\u002F","Translate a QGIS Plugin with Qt Linguist",[180,1490,1491],{},[26,1492,1231],{"href":1230},[1494,1495,1496],"style",{},"html pre.shiki code .sjoCn, html code.shiki .sjoCn{--shiki-default:#9AA79F}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 .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}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 .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}",{"title":295,"searchDepth":309,"depth":309,"links":1498},[1499,1500,1501,1502,1503,1504,1505,1506,1507,1508,1509],{"id":174,"depth":309,"text":175},{"id":210,"depth":309,"text":211},{"id":284,"depth":309,"text":285},{"id":382,"depth":309,"text":383},{"id":1201,"depth":309,"text":1202},{"id":1235,"depth":309,"text":1236},{"id":1265,"depth":309,"text":1266},{"id":1344,"depth":309,"text":1345},{"id":1408,"depth":309,"text":1409},{"id":1420,"depth":309,"text":1421},{"id":1466,"depth":309,"text":1467},"Build an installable plugin archive — the folder-inside-the-zip rule, what to include and exclude, compiling resources and translations, and a repeatable build script that produces the same file every time.","md",{"slug":1513,"type":1514,"breadcrumb":1515,"datePublished":1516,"dateModified":1516},"package-qgis-plugin-as-zip","article","Package as a Zip","2026-08-15","\u002Fqgis-plugin-development\u002Fpublishing-to-the-qgis-plugin-repository\u002Fpackage-qgis-plugin-as-zip",{"title":5,"description":1510},"qgis-plugin-development\u002Fpublishing-to-the-qgis-plugin-repository\u002Fpackage-qgis-plugin-as-zip\u002Findex","CZQSq5IorcZgFWteLTiTroJ77GSd4RYWi0kl5sSB8wI",1786789584661]