[{"data":1,"prerenderedAt":2329},["ShallowReactive",2],{"doc:\u002Fspatial-data-processing-automation\u002Fdata-quality-and-topology-validation\u002Fbuild-data-quality-report-pyqgis":3},{"id":4,"title":5,"body":6,"description":2318,"extension":2319,"meta":2320,"navigation":276,"path":2325,"seo":2326,"stem":2327,"__hash__":2328},"docs\u002Fspatial-data-processing-automation\u002Fdata-quality-and-topology-validation\u002Fbuild-data-quality-report-pyqgis\u002Findex.md","Build a Data Quality Report in PyQGIS",{"type":7,"value":8,"toc":2305},"minimark",[9,13,22,31,182,187,215,219,222,549,568,571,704,713,717,720,792,1109,1123,1127,1130,1404,1409,1413,1416,1474,1824,1837,1841,1844,2147,2161,2165,2183,2187,2227,2231,2234,2238,2248,2254,2264,2270,2274,2301],[10,11,5],"h1",{"id":12},"build-a-data-quality-report-in-pyqgis",[14,15,16,17,21],"p",{},"Individual checks find individual problems. A quality report answers the question people actually ask: ",[18,19,20],"em",{},"is this delivery good enough to use?"," It runs a fixed set of checks against a dataset, stores every finding where it can be inspected on a map, summarises the result in a table anyone can read, and does all of this identically each time — so the report on March's delivery can be compared with February's, and a supplier can be shown exactly what failed.",[14,23,24,25,30],{},"This recipe belongs to ",[26,27,29],"a",{"href":28},"\u002Fspatial-data-processing-automation\u002Fdata-quality-and-topology-validation\u002F","Data Quality & Topology Validation",". It assembles the checks from the other recipes in that guide into one script with a common shape, writes all findings and a summary into a single GeoPackage, renders a short HTML overview, and sets thresholds that turn the result into a pass or a fail.",[14,32,33],{},[34,35,40,44,48,55,72,81,90,96,101,107,115,120,124,128,132,136,140,144,151,156,160,165,169,173,177],"svg",{"viewBox":36,"role":37,"ariaLabel":38,"xmlns":39},"0 0 760 300","img","A delivery passes through a list of checks; findings go to a GeoPackage, counts and thresholds to a summary and HTML page, ending in a pass or fail verdict","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[41,42,43],"title",{},"From delivery to verdict",[45,46,47],"desc",{},"A data delivery is read by a runner that executes a list of checks, each returning findings and a count. Findings go into one GeoPackage table per check, with geometry where a location exists. Counts and thresholds go into a summary table and an HTML page. The overall verdict is pass if every check is within its threshold, otherwise fail with the failing checks named.",[49,50],"rect",{"x":51,"y":51,"width":52,"height":53,"fill":54},"0","760","300","#f6f3ea",[56,57,58],"defs",{},[59,60,67],"marker",{"id":61,"viewBox":62,"refX":63,"refY":64,"markerWidth":65,"markerHeight":65,"orient":66},"dqrFlowArrow","0 0 10 10","8","5","7","auto-start-reverse",[68,69],"path",{"d":70,"fill":71},"M0 0 L10 5 L0 10 z","#2f3b35",[73,74,80],"text",{"x":75,"y":76,"style":77,"fill":78,"textAnchor":79},"380","28","text-anchor:middle;font-size:14px;font-family:sans-serif;font-weight:bold","#17211d","middle","One run, one file, one verdict",[49,82],{"x":83,"y":84,"width":85,"height":86,"rx":63,"fill":87,"stroke":88,"style":89},"24","110","150","80","#fffdf7","#59645f","stroke-width:2",[73,91,95],{"x":92,"y":93,"style":94,"fill":78,"textAnchor":79},"99","143.805","text-anchor:middle;font-size:11.5px;font-family:sans-serif;font-weight:bold","delivery",[73,97,100],{"x":92,"y":98,"style":99,"fill":88,"textAnchor":79},"163.755","text-anchor:middle;font-size:10.5px;font-family:sans-serif","parcels.gpkg",[102,103],"line",{"x1":104,"y1":85,"x2":105,"y2":85,"stroke":71,"style":106},"174","214","stroke-width:1.8;marker-end:url(#dqrFlowArrow)",[49,108],{"x":109,"y":110,"width":111,"height":112,"rx":63,"fill":113,"stroke":114,"style":89},"222","64","200","172","#eff3ff","#2563eb",[73,116,119],{"x":117,"y":118,"style":94,"fill":114,"textAnchor":79},"322","105.78","check runner",[73,121,123],{"x":117,"y":122,"style":99,"fill":71,"textAnchor":79},"129.78","validity",[73,125,127],{"x":117,"y":126,"style":99,"fill":71,"textAnchor":79},"153.78","gaps · overlaps",[73,129,131],{"x":117,"y":130,"style":99,"fill":71,"textAnchor":79},"177.78","duplicates",[73,133,135],{"x":117,"y":134,"style":99,"fill":71,"textAnchor":79},"201.78","attribute rules",[102,137],{"x1":138,"y1":84,"x2":139,"y2":86,"stroke":71,"style":106},"422","470",[102,141],{"x1":138,"y1":142,"x2":139,"y2":143,"stroke":71,"style":106},"190","220",[49,145],{"x":146,"y":147,"width":148,"height":110,"rx":63,"fill":149,"stroke":150,"style":89},"478","48","258","#eef7f4","#0f766e",[73,152,155],{"x":153,"y":154,"style":94,"fill":150,"textAnchor":79},"607","73.805","report.gpkg",[73,157,159],{"x":153,"y":158,"style":99,"fill":88,"textAnchor":79},"93.755","one table per check",[49,161],{"x":146,"y":162,"width":148,"height":110,"rx":63,"fill":163,"stroke":164,"style":89},"188","#fdf2e2","#b45309",[73,166,168],{"x":153,"y":167,"style":94,"fill":164,"textAnchor":79},"213.805","summary + HTML",[73,170,172],{"x":153,"y":171,"style":99,"fill":88,"textAnchor":79},"233.755","counts vs thresholds",[102,174],{"x1":153,"y1":175,"x2":153,"y2":176,"stroke":71,"style":106},"252","266",[73,178,181],{"x":153,"y":179,"style":94,"fill":180,"textAnchor":79},"284","#166534","PASS \u002F FAIL with reasons",[183,184,186],"h2",{"id":185},"prerequisites","Prerequisites",[188,189,190,199],"ul",{},[191,192,193,194,198],"li",{},"QGIS 3.34 LTR or newer, or the QGIS 4 series. The report runs inside QGIS or as a standalone script; for unattended runs see ",[26,195,197],{"href":196},"\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002Fhandle-errors-and-logging-in-unattended-scripts\u002F","handling errors and logging in unattended scripts",".",[191,200,201,202,206,207,211,212,198],{},"The individual checks you need. This page reuses the approach of the ",[26,203,205],{"href":204},"\u002Fspatial-data-processing-automation\u002Fdata-quality-and-topology-validation\u002Fcheck-geometry-validity-report-pyqgis\u002F","validity report",", ",[26,208,210],{"href":209},"\u002Fspatial-data-processing-automation\u002Fdata-quality-and-topology-validation\u002Ffind-duplicate-geometries-and-features-pyqgis\u002F","duplicate search"," and ",[26,213,135],{"href":214},"\u002Fspatial-data-processing-automation\u002Fdata-quality-and-topology-validation\u002Fvalidate-attribute-values-against-rules-pyqgis\u002F",[183,216,218],{"id":217},"give-every-check-the-same-shape","Give every check the same shape",[14,220,221],{},"The runner is simple when every check is a function that takes the layer and returns the same thing: a name, a layer of findings (possibly empty), and a count. Each check can be as elaborate as it needs to be inside.",[223,224,229],"pre",{"className":225,"code":226,"language":227,"meta":228,"style":228},"language-python shiki shiki-themes github-dark","from dataclasses import dataclass\nimport processing\nfrom qgis.core import QgsVectorLayer, QgsFeature, QgsProject\n\n@dataclass\nclass CheckResult:\n    name: str\n    description: str\n    findings: QgsVectorLayer   # memory layer, may be empty\n    count: int\n\ndef check_validity(layer):\n    r = processing.run(\"qgis:checkvalidity\", {\n        \"INPUT_LAYER\": layer, \"METHOD\": 2,\n        \"VALID_OUTPUT\": \"memory:\", \"INVALID_OUTPUT\": \"memory:\",\n        \"ERROR_OUTPUT\": \"memory:\"})\n    return CheckResult(\"invalid_geometry\", \"GEOS-invalid geometries\",\n                       r[\"ERROR_OUTPUT\"], r[\"INVALID_COUNT\"])\n\ndef check_exact_duplicates(layer):\n    r = processing.run(\"native:deleteduplicategeometries\", {\n        \"INPUT\": layer, \"OUTPUT\": \"memory:\", \"DUPLICATES\": \"memory:\"})\n    return CheckResult(\"duplicate_geometry\", \"features with identical geometry\",\n                       r[\"DUPLICATES\"], r[\"DUPLICATE_COUNT\"])\n\n","python","",[230,231,232,250,258,271,278,285,297,307,315,325,334,339,351,370,391,413,426,445,463,468,478,492,518,535],"code",{"__ignoreMap":228},[233,234,236,240,244,247],"span",{"class":102,"line":235},1,[233,237,239],{"class":238},"snl16","from",[233,241,243],{"class":242},"s95oV"," dataclasses ",[233,245,246],{"class":238},"import",[233,248,249],{"class":242}," dataclass\n",[233,251,253,255],{"class":102,"line":252},2,[233,254,246],{"class":238},[233,256,257],{"class":242}," processing\n",[233,259,261,263,266,268],{"class":102,"line":260},3,[233,262,239],{"class":238},[233,264,265],{"class":242}," qgis.core ",[233,267,246],{"class":238},[233,269,270],{"class":242}," QgsVectorLayer, QgsFeature, QgsProject\n",[233,272,274],{"class":102,"line":273},4,[233,275,277],{"emptyLinePlaceholder":276},true,"\n",[233,279,281],{"class":102,"line":280},5,[233,282,284],{"class":283},"svObZ","@dataclass\n",[233,286,288,291,294],{"class":102,"line":287},6,[233,289,290],{"class":238},"class",[233,292,293],{"class":283}," CheckResult",[233,295,296],{"class":242},":\n",[233,298,300,303],{"class":102,"line":299},7,[233,301,302],{"class":242},"    name: ",[233,304,306],{"class":305},"sDLfK","str\n",[233,308,310,313],{"class":102,"line":309},8,[233,311,312],{"class":242},"    description: ",[233,314,306],{"class":305},[233,316,318,321],{"class":102,"line":317},9,[233,319,320],{"class":242},"    findings: QgsVectorLayer   ",[233,322,324],{"class":323},"sjoCn","# memory layer, may be empty\n",[233,326,328,331],{"class":102,"line":327},10,[233,329,330],{"class":242},"    count: ",[233,332,333],{"class":305},"int\n",[233,335,337],{"class":102,"line":336},11,[233,338,277],{"emptyLinePlaceholder":276},[233,340,342,345,348],{"class":102,"line":341},12,[233,343,344],{"class":238},"def",[233,346,347],{"class":283}," check_validity",[233,349,350],{"class":242},"(layer):\n",[233,352,354,357,360,363,367],{"class":102,"line":353},13,[233,355,356],{"class":242},"    r ",[233,358,359],{"class":238},"=",[233,361,362],{"class":242}," processing.run(",[233,364,366],{"class":365},"sU2Wk","\"qgis:checkvalidity\"",[233,368,369],{"class":242},", {\n",[233,371,373,376,379,382,385,388],{"class":102,"line":372},14,[233,374,375],{"class":365},"        \"INPUT_LAYER\"",[233,377,378],{"class":242},": layer, ",[233,380,381],{"class":365},"\"METHOD\"",[233,383,384],{"class":242},": ",[233,386,387],{"class":305},"2",[233,389,390],{"class":242},",\n",[233,392,394,397,399,402,404,407,409,411],{"class":102,"line":393},15,[233,395,396],{"class":365},"        \"VALID_OUTPUT\"",[233,398,384],{"class":242},[233,400,401],{"class":365},"\"memory:\"",[233,403,206],{"class":242},[233,405,406],{"class":365},"\"INVALID_OUTPUT\"",[233,408,384],{"class":242},[233,410,401],{"class":365},[233,412,390],{"class":242},[233,414,416,419,421,423],{"class":102,"line":415},16,[233,417,418],{"class":365},"        \"ERROR_OUTPUT\"",[233,420,384],{"class":242},[233,422,401],{"class":365},[233,424,425],{"class":242},"})\n",[233,427,429,432,435,438,440,443],{"class":102,"line":428},17,[233,430,431],{"class":238},"    return",[233,433,434],{"class":242}," CheckResult(",[233,436,437],{"class":365},"\"invalid_geometry\"",[233,439,206],{"class":242},[233,441,442],{"class":365},"\"GEOS-invalid geometries\"",[233,444,390],{"class":242},[233,446,448,451,454,457,460],{"class":102,"line":447},18,[233,449,450],{"class":242},"                       r[",[233,452,453],{"class":365},"\"ERROR_OUTPUT\"",[233,455,456],{"class":242},"], r[",[233,458,459],{"class":365},"\"INVALID_COUNT\"",[233,461,462],{"class":242},"])\n",[233,464,466],{"class":102,"line":465},19,[233,467,277],{"emptyLinePlaceholder":276},[233,469,471,473,476],{"class":102,"line":470},20,[233,472,344],{"class":238},[233,474,475],{"class":283}," check_exact_duplicates",[233,477,350],{"class":242},[233,479,481,483,485,487,490],{"class":102,"line":480},21,[233,482,356],{"class":242},[233,484,359],{"class":238},[233,486,362],{"class":242},[233,488,489],{"class":365},"\"native:deleteduplicategeometries\"",[233,491,369],{"class":242},[233,493,495,498,500,503,505,507,509,512,514,516],{"class":102,"line":494},22,[233,496,497],{"class":365},"        \"INPUT\"",[233,499,378],{"class":242},[233,501,502],{"class":365},"\"OUTPUT\"",[233,504,384],{"class":242},[233,506,401],{"class":365},[233,508,206],{"class":242},[233,510,511],{"class":365},"\"DUPLICATES\"",[233,513,384],{"class":242},[233,515,401],{"class":365},[233,517,425],{"class":242},[233,519,521,523,525,528,530,533],{"class":102,"line":520},23,[233,522,431],{"class":238},[233,524,434],{"class":242},[233,526,527],{"class":365},"\"duplicate_geometry\"",[233,529,206],{"class":242},[233,531,532],{"class":365},"\"features with identical geometry\"",[233,534,390],{"class":242},[233,536,538,540,542,544,547],{"class":102,"line":537},24,[233,539,450],{"class":242},[233,541,511],{"class":365},[233,543,456],{"class":242},[233,545,546],{"class":365},"\"DUPLICATE_COUNT\"",[233,548,462],{"class":242},[14,550,551,555,556,559,560,563,564,567],{},[552,553,554],"strong",{},"Breakdown:"," The dataclass is a contract: the runner never needs to know what a check does, only how to read its result. Processing algorithms already return findings layers and counts, so wrapping them is a few lines; ",[230,557,558],{},"native:deleteduplicategeometries"," has a ",[230,561,562],{},"DUPLICATES"," output with the removed copies and a ",[230,565,566],{},"DUPLICATE_COUNT",". Custom checks — gaps, network dangles, attribute rules — build their own memory layer of findings as in the other recipes and return it in the same shape. Keep each check focused on one question, so the summary reads as a list of clear yes\u002Fno answers.",[14,569,570],{},"Expression-based checks — anything that can be phrased as \"features matching this filter are wrong\" — share one small helper:",[223,572,574],{"className":225,"code":573,"language":227,"meta":228,"style":228},"from qgis.core import QgsFeatureRequest\n\ndef _request(expression):\n    return QgsFeatureRequest().setFilterExpression(expression)\n\ndef check_missing_key(layer, field=\"parcel_id\"):\n    found = layer.materialize(_request(f'\"{field}\" IS NULL OR trim(\"{field}\") = \\'\\''))\n    return CheckResult(\"missing_key\", f\"{field} empty\", found, found.featureCount())\n",[230,575,576,587,591,601,608,612,630,676],{"__ignoreMap":228},[233,577,578,580,582,584],{"class":102,"line":235},[233,579,239],{"class":238},[233,581,265],{"class":242},[233,583,246],{"class":238},[233,585,586],{"class":242}," QgsFeatureRequest\n",[233,588,589],{"class":102,"line":252},[233,590,277],{"emptyLinePlaceholder":276},[233,592,593,595,598],{"class":102,"line":260},[233,594,344],{"class":238},[233,596,597],{"class":283}," _request",[233,599,600],{"class":242},"(expression):\n",[233,602,603,605],{"class":102,"line":273},[233,604,431],{"class":238},[233,606,607],{"class":242}," QgsFeatureRequest().setFilterExpression(expression)\n",[233,609,610],{"class":102,"line":280},[233,611,277],{"emptyLinePlaceholder":276},[233,613,614,616,619,622,624,627],{"class":102,"line":287},[233,615,344],{"class":238},[233,617,618],{"class":283}," check_missing_key",[233,620,621],{"class":242},"(layer, field",[233,623,359],{"class":238},[233,625,626],{"class":365},"\"parcel_id\"",[233,628,629],{"class":242},"):\n",[233,631,632,635,637,640,643,646,649,652,655,658,660,662,664,667,670,673],{"class":102,"line":299},[233,633,634],{"class":242},"    found ",[233,636,359],{"class":238},[233,638,639],{"class":242}," layer.materialize(_request(",[233,641,642],{"class":238},"f",[233,644,645],{"class":365},"'\"",[233,647,648],{"class":305},"{",[233,650,651],{"class":242},"field",[233,653,654],{"class":305},"}",[233,656,657],{"class":365},"\" IS NULL OR trim(\"",[233,659,648],{"class":305},[233,661,651],{"class":242},[233,663,654],{"class":305},[233,665,666],{"class":365},"\") = ",[233,668,669],{"class":305},"\\'\\'",[233,671,672],{"class":365},"'",[233,674,675],{"class":242},"))\n",[233,677,678,680,682,685,687,689,692,694,696,698,701],{"class":102,"line":309},[233,679,431],{"class":238},[233,681,434],{"class":242},[233,683,684],{"class":365},"\"missing_key\"",[233,686,206],{"class":242},[233,688,642],{"class":238},[233,690,691],{"class":365},"\"",[233,693,648],{"class":305},[233,695,651],{"class":242},[233,697,654],{"class":305},[233,699,700],{"class":365}," empty\"",[233,702,703],{"class":242},", found, found.featureCount())\n",[14,705,706,708,709,712],{},[552,707,554],{}," ",[230,710,711],{},"materialize"," returns a memory copy of the matching features, ready to be written to the report with their geometry — so a reviewer can see on the map where the keyless parcels are. Expression-based checks are the cheapest kind to add: one line of expression, one line of wrapper.",[183,714,716],{"id":715},"run-the-checks-and-collect-findings","Run the checks and collect findings",[14,718,719],{},"The runner calls each check, writes non-empty findings to the report GeoPackage as their own table, and keeps the counts for the summary.",[14,721,722],{},[34,723,726,729,732,735,738,743,748,752,756,760,763,767,772,776,780,784,788],{"viewBox":724,"role":37,"ariaLabel":725,"xmlns":39},"0 0 760 260","Contents of the report GeoPackage: one findings table per check, a summary table with counts, thresholds and status, and a metadata table",[41,727,728],{},"Inside the report GeoPackage",[45,730,731],{},"The report GeoPackage contains one table per check that found something, such as invalid_geometry points, duplicate_geometry polygons and missing_key polygons, each openable on a map. A summary table lists every check, including those with zero findings, with its count, threshold and status. A metadata table records the source path, the run time and the QGIS version.",[49,733],{"x":51,"y":51,"width":52,"height":734,"fill":54},"260",[73,736,737],{"x":75,"y":76,"style":77,"fill":78,"textAnchor":79},"Everything in one file",[49,739],{"x":83,"y":740,"width":741,"height":742,"rx":63,"fill":149,"stroke":150,"style":89},"52","340","180",[73,744,747],{"x":745,"y":746,"style":94,"fill":150,"textAnchor":79},"194","89.78","findings tables",[73,749,751],{"x":745,"y":750,"style":99,"fill":71,"textAnchor":79},"117.78","invalid_geometry · points",[73,753,755],{"x":745,"y":754,"style":99,"fill":71,"textAnchor":79},"145.78","duplicate_geometry · polygons",[73,757,759],{"x":745,"y":758,"style":99,"fill":71,"textAnchor":79},"173.78","missing_key · polygons",[73,761,762],{"x":745,"y":134,"style":99,"fill":88,"textAnchor":79},"open any on a map",[49,764],{"x":765,"y":740,"width":741,"height":766,"rx":63,"fill":163,"stroke":164,"style":89},"396","112",[73,768,771],{"x":769,"y":770,"style":94,"fill":164,"textAnchor":79},"566","85.78","summary",[73,773,775],{"x":769,"y":774,"style":99,"fill":71,"textAnchor":79},"111.78","check · count · threshold",[73,777,779],{"x":769,"y":778,"style":99,"fill":71,"textAnchor":79},"137.78","status PASS \u002F FAIL",[49,781],{"x":765,"y":782,"width":741,"height":783,"rx":63,"fill":87,"stroke":88,"style":89},"176","56",[73,785,787],{"x":769,"y":786,"style":94,"fill":78,"textAnchor":79},"197.805","run_info",[73,789,791],{"x":769,"y":790,"style":99,"fill":88,"textAnchor":79},"217.755","source, time, QGIS version",[223,793,795],{"className":225,"code":794,"language":227,"meta":228,"style":228},"from datetime import datetime\nfrom pathlib import Path\n\ndef run_report(layer, checks, out_dir=\"\u002Fdata\u002Fqa\"):\n    stamp = datetime.now().strftime(\"%Y%m%d_%H%M\")\n    report = str(Path(out_dir) \u002F f\"{layer.name()}_quality_{stamp}.gpkg\")\n    results, first = [], True\n    for check in checks:\n        res = check(layer)\n        results.append(res)\n        if res.count and res.findings.featureCount():\n            processing.run(\"native:savefeatures\", {\n                \"INPUT\": res.findings, \"OUTPUT\": report, \"LAYER_NAME\": res.name,\n                \"ACTION_ON_EXISTING_FILE\": 0 if first else 1})\n            first = False\n        print(f\"{res.name:\u003C22} {res.count:>7}\")\n    return report, results\n\nparcels = QgsProject.instance().mapLayersByName(\"parcels\")[0]\nreport, results = run_report(parcels, [check_validity, check_exact_duplicates,\n                                       check_missing_key])\n",[230,796,797,809,821,825,842,864,907,920,934,944,949,963,973,992,1015,1025,1062,1069,1073,1094,1104],{"__ignoreMap":228},[233,798,799,801,804,806],{"class":102,"line":235},[233,800,239],{"class":238},[233,802,803],{"class":242}," datetime ",[233,805,246],{"class":238},[233,807,808],{"class":242}," datetime\n",[233,810,811,813,816,818],{"class":102,"line":252},[233,812,239],{"class":238},[233,814,815],{"class":242}," pathlib ",[233,817,246],{"class":238},[233,819,820],{"class":242}," Path\n",[233,822,823],{"class":102,"line":260},[233,824,277],{"emptyLinePlaceholder":276},[233,826,827,829,832,835,837,840],{"class":102,"line":273},[233,828,344],{"class":238},[233,830,831],{"class":283}," run_report",[233,833,834],{"class":242},"(layer, checks, out_dir",[233,836,359],{"class":238},[233,838,839],{"class":365},"\"\u002Fdata\u002Fqa\"",[233,841,629],{"class":242},[233,843,844,847,849,852,855,858,861],{"class":102,"line":280},[233,845,846],{"class":242},"    stamp ",[233,848,359],{"class":238},[233,850,851],{"class":242}," datetime.now().strftime(",[233,853,854],{"class":365},"\"%Y%m",[233,856,857],{"class":305},"%d",[233,859,860],{"class":365},"_%H%M\"",[233,862,863],{"class":242},")\n",[233,865,866,869,871,874,877,880,883,885,887,890,892,895,897,900,902,905],{"class":102,"line":287},[233,867,868],{"class":242},"    report ",[233,870,359],{"class":238},[233,872,873],{"class":305}," str",[233,875,876],{"class":242},"(Path(out_dir) ",[233,878,879],{"class":238},"\u002F",[233,881,882],{"class":238}," f",[233,884,691],{"class":365},[233,886,648],{"class":305},[233,888,889],{"class":242},"layer.name()",[233,891,654],{"class":305},[233,893,894],{"class":365},"_quality_",[233,896,648],{"class":305},[233,898,899],{"class":242},"stamp",[233,901,654],{"class":305},[233,903,904],{"class":365},".gpkg\"",[233,906,863],{"class":242},[233,908,909,912,914,917],{"class":102,"line":299},[233,910,911],{"class":242},"    results, first ",[233,913,359],{"class":238},[233,915,916],{"class":242}," [], ",[233,918,919],{"class":305},"True\n",[233,921,922,925,928,931],{"class":102,"line":309},[233,923,924],{"class":238},"    for",[233,926,927],{"class":242}," check ",[233,929,930],{"class":238},"in",[233,932,933],{"class":242}," checks:\n",[233,935,936,939,941],{"class":102,"line":317},[233,937,938],{"class":242},"        res ",[233,940,359],{"class":238},[233,942,943],{"class":242}," check(layer)\n",[233,945,946],{"class":102,"line":327},[233,947,948],{"class":242},"        results.append(res)\n",[233,950,951,954,957,960],{"class":102,"line":336},[233,952,953],{"class":238},"        if",[233,955,956],{"class":242}," res.count ",[233,958,959],{"class":238},"and",[233,961,962],{"class":242}," res.findings.featureCount():\n",[233,964,965,968,971],{"class":102,"line":341},[233,966,967],{"class":242},"            processing.run(",[233,969,970],{"class":365},"\"native:savefeatures\"",[233,972,369],{"class":242},[233,974,975,978,981,983,986,989],{"class":102,"line":353},[233,976,977],{"class":365},"                \"INPUT\"",[233,979,980],{"class":242},": res.findings, ",[233,982,502],{"class":365},[233,984,985],{"class":242},": report, ",[233,987,988],{"class":365},"\"LAYER_NAME\"",[233,990,991],{"class":242},": res.name,\n",[233,993,994,997,999,1001,1004,1007,1010,1013],{"class":102,"line":372},[233,995,996],{"class":365},"                \"ACTION_ON_EXISTING_FILE\"",[233,998,384],{"class":242},[233,1000,51],{"class":305},[233,1002,1003],{"class":238}," if",[233,1005,1006],{"class":242}," first ",[233,1008,1009],{"class":238},"else",[233,1011,1012],{"class":305}," 1",[233,1014,425],{"class":242},[233,1016,1017,1020,1022],{"class":102,"line":393},[233,1018,1019],{"class":242},"            first ",[233,1021,359],{"class":238},[233,1023,1024],{"class":305}," False\n",[233,1026,1027,1030,1033,1035,1037,1039,1042,1045,1047,1050,1053,1056,1058,1060],{"class":102,"line":415},[233,1028,1029],{"class":305},"        print",[233,1031,1032],{"class":242},"(",[233,1034,642],{"class":238},[233,1036,691],{"class":365},[233,1038,648],{"class":305},[233,1040,1041],{"class":242},"res.name",[233,1043,1044],{"class":238},":\u003C22",[233,1046,654],{"class":305},[233,1048,1049],{"class":305}," {",[233,1051,1052],{"class":242},"res.count",[233,1054,1055],{"class":238},":>7",[233,1057,654],{"class":305},[233,1059,691],{"class":365},[233,1061,863],{"class":242},[233,1063,1064,1066],{"class":102,"line":428},[233,1065,431],{"class":238},[233,1067,1068],{"class":242}," report, results\n",[233,1070,1071],{"class":102,"line":447},[233,1072,277],{"emptyLinePlaceholder":276},[233,1074,1075,1078,1080,1083,1086,1089,1091],{"class":102,"line":465},[233,1076,1077],{"class":242},"parcels ",[233,1079,359],{"class":238},[233,1081,1082],{"class":242}," QgsProject.instance().mapLayersByName(",[233,1084,1085],{"class":365},"\"parcels\"",[233,1087,1088],{"class":242},")[",[233,1090,51],{"class":305},[233,1092,1093],{"class":242},"]\n",[233,1095,1096,1099,1101],{"class":102,"line":470},[233,1097,1098],{"class":242},"report, results ",[233,1100,359],{"class":238},[233,1102,1103],{"class":242}," run_report(parcels, [check_validity, check_exact_duplicates,\n",[233,1105,1106],{"class":102,"line":480},[233,1107,1108],{"class":242},"                                       check_missing_key])\n",[14,1110,1111,1113,1114,1117,1118,1122],{},[552,1112,554],{}," The first write creates the GeoPackage (",[230,1115,1116],{},"ACTION_ON_EXISTING_FILE: 0",") and later ones add tables to it. Checks with no findings write nothing, keeping the file small, but still appear in the summary with a count of zero — a passed check is a result worth recording. The timestamp in the file name keeps successive reports side by side. Running each check in sequence keeps memory use bounded; for very large layers, checks that do not depend on each other can run as ",[26,1119,1121],{"href":1120},"\u002Fqgis-plugin-development\u002Fbackground-tasks-and-plugin-performance\u002Frun-background-task-with-qgstask-pyqgis\u002F","background tasks"," in parallel.",[183,1124,1126],{"id":1125},"turn-counts-into-a-verdict","Turn counts into a verdict",[14,1128,1129],{},"A count is not a judgement until it is compared with a threshold. Thresholds encode the agreement with whoever produces the data: zero invalid geometries, fewer than ten duplicates, no missing keys.",[223,1131,1133],{"className":225,"code":1132,"language":227,"meta":228,"style":228},"THRESHOLDS = {\"invalid_geometry\": 0, \"duplicate_geometry\": 10, \"missing_key\": 0}\n\nsummary = QgsVectorLayer(\n    \"None?field=check:string(40)&field=description:string(120)\"\n    \"&field=count:integer&field=threshold:integer&field=status:string(4)\",\n    \"summary\", \"memory\")\nrows, failed = [], []\nfor res in results:\n    limit = THRESHOLDS.get(res.name, 0)\n    status = \"PASS\" if res.count \u003C= limit else \"FAIL\"\n    if status == \"FAIL\":\n        failed.append(res.name)\n    f = QgsFeature(summary.fields())\n    f.setAttributes([res.name, res.description, res.count, limit, status])\n    rows.append(f)\nsummary.dataProvider().addFeatures(rows)\nprocessing.run(\"native:savefeatures\", {\"INPUT\": summary, \"OUTPUT\": report,\n                                       \"LAYER_NAME\": \"summary\",\n                                       \"ACTION_ON_EXISTING_FILE\": 1})\nverdict = \"PASS\" if not failed else \"FAIL: \" + \", \".join(failed)\nprint(verdict)\n",[230,1134,1135,1171,1175,1185,1190,1197,1209,1219,1232,1249,1274,1290,1295,1305,1310,1315,1320,1341,1353,1365,1396],{"__ignoreMap":228},[233,1136,1137,1140,1143,1145,1147,1149,1151,1153,1155,1157,1160,1162,1164,1166,1168],{"class":102,"line":235},[233,1138,1139],{"class":305},"THRESHOLDS",[233,1141,1142],{"class":238}," =",[233,1144,1049],{"class":242},[233,1146,437],{"class":365},[233,1148,384],{"class":242},[233,1150,51],{"class":305},[233,1152,206],{"class":242},[233,1154,527],{"class":365},[233,1156,384],{"class":242},[233,1158,1159],{"class":305},"10",[233,1161,206],{"class":242},[233,1163,684],{"class":365},[233,1165,384],{"class":242},[233,1167,51],{"class":305},[233,1169,1170],{"class":242},"}\n",[233,1172,1173],{"class":102,"line":252},[233,1174,277],{"emptyLinePlaceholder":276},[233,1176,1177,1180,1182],{"class":102,"line":260},[233,1178,1179],{"class":242},"summary ",[233,1181,359],{"class":238},[233,1183,1184],{"class":242}," QgsVectorLayer(\n",[233,1186,1187],{"class":102,"line":273},[233,1188,1189],{"class":365},"    \"None?field=check:string(40)&field=description:string(120)\"\n",[233,1191,1192,1195],{"class":102,"line":280},[233,1193,1194],{"class":365},"    \"&field=count:integer&field=threshold:integer&field=status:string(4)\"",[233,1196,390],{"class":242},[233,1198,1199,1202,1204,1207],{"class":102,"line":287},[233,1200,1201],{"class":365},"    \"summary\"",[233,1203,206],{"class":242},[233,1205,1206],{"class":365},"\"memory\"",[233,1208,863],{"class":242},[233,1210,1211,1214,1216],{"class":102,"line":299},[233,1212,1213],{"class":242},"rows, failed ",[233,1215,359],{"class":238},[233,1217,1218],{"class":242}," [], []\n",[233,1220,1221,1224,1227,1229],{"class":102,"line":309},[233,1222,1223],{"class":238},"for",[233,1225,1226],{"class":242}," res ",[233,1228,930],{"class":238},[233,1230,1231],{"class":242}," results:\n",[233,1233,1234,1237,1239,1242,1245,1247],{"class":102,"line":317},[233,1235,1236],{"class":242},"    limit ",[233,1238,359],{"class":238},[233,1240,1241],{"class":305}," THRESHOLDS",[233,1243,1244],{"class":242},".get(res.name, ",[233,1246,51],{"class":305},[233,1248,863],{"class":242},[233,1250,1251,1254,1256,1259,1261,1263,1266,1269,1271],{"class":102,"line":327},[233,1252,1253],{"class":242},"    status ",[233,1255,359],{"class":238},[233,1257,1258],{"class":365}," \"PASS\"",[233,1260,1003],{"class":238},[233,1262,956],{"class":242},[233,1264,1265],{"class":238},"\u003C=",[233,1267,1268],{"class":242}," limit ",[233,1270,1009],{"class":238},[233,1272,1273],{"class":365}," \"FAIL\"\n",[233,1275,1276,1279,1282,1285,1288],{"class":102,"line":336},[233,1277,1278],{"class":238},"    if",[233,1280,1281],{"class":242}," status ",[233,1283,1284],{"class":238},"==",[233,1286,1287],{"class":365}," \"FAIL\"",[233,1289,296],{"class":242},[233,1291,1292],{"class":102,"line":341},[233,1293,1294],{"class":242},"        failed.append(res.name)\n",[233,1296,1297,1300,1302],{"class":102,"line":353},[233,1298,1299],{"class":242},"    f ",[233,1301,359],{"class":238},[233,1303,1304],{"class":242}," QgsFeature(summary.fields())\n",[233,1306,1307],{"class":102,"line":372},[233,1308,1309],{"class":242},"    f.setAttributes([res.name, res.description, res.count, limit, status])\n",[233,1311,1312],{"class":102,"line":393},[233,1313,1314],{"class":242},"    rows.append(f)\n",[233,1316,1317],{"class":102,"line":415},[233,1318,1319],{"class":242},"summary.dataProvider().addFeatures(rows)\n",[233,1321,1322,1325,1327,1330,1333,1336,1338],{"class":102,"line":428},[233,1323,1324],{"class":242},"processing.run(",[233,1326,970],{"class":365},[233,1328,1329],{"class":242},", {",[233,1331,1332],{"class":365},"\"INPUT\"",[233,1334,1335],{"class":242},": summary, ",[233,1337,502],{"class":365},[233,1339,1340],{"class":242},": report,\n",[233,1342,1343,1346,1348,1351],{"class":102,"line":447},[233,1344,1345],{"class":365},"                                       \"LAYER_NAME\"",[233,1347,384],{"class":242},[233,1349,1350],{"class":365},"\"summary\"",[233,1352,390],{"class":242},[233,1354,1355,1358,1360,1363],{"class":102,"line":465},[233,1356,1357],{"class":365},"                                       \"ACTION_ON_EXISTING_FILE\"",[233,1359,384],{"class":242},[233,1361,1362],{"class":305},"1",[233,1364,425],{"class":242},[233,1366,1367,1370,1372,1374,1376,1379,1382,1384,1387,1390,1393],{"class":102,"line":470},[233,1368,1369],{"class":242},"verdict ",[233,1371,359],{"class":238},[233,1373,1258],{"class":365},[233,1375,1003],{"class":238},[233,1377,1378],{"class":238}," not",[233,1380,1381],{"class":242}," failed ",[233,1383,1009],{"class":238},[233,1385,1386],{"class":365}," \"FAIL: \"",[233,1388,1389],{"class":238}," +",[233,1391,1392],{"class":365}," \", \"",[233,1394,1395],{"class":242},".join(failed)\n",[233,1397,1398,1401],{"class":102,"line":480},[233,1399,1400],{"class":305},"print",[233,1402,1403],{"class":242},"(verdict)\n",[14,1405,1406,1408],{},[552,1407,554],{}," Keeping thresholds in a dictionary separate from the checks lets the same checks serve different agreements — a strict threshold for the cadastre, a lenient one for a volunteered dataset. A check missing from the dictionary defaults to zero, the strictest reading, so forgetting a threshold never lets problems through. The verdict names the failing checks, which is what the reader of a one-line notification needs.",[183,1410,1412],{"id":1411},"render-a-short-html-overview","Render a short HTML overview",[14,1414,1415],{},"Not everyone opens GeoPackages. A small HTML page with the verdict and the summary table can be attached to an email or published next to the data.",[14,1417,1418],{},[34,1419,1422,1425,1428,1431,1434,1439,1446,1452,1458,1463,1467,1470],{"viewBox":1420,"role":37,"ariaLabel":1421,"xmlns":39},"0 0 760 250","Sketch of the HTML overview: dataset and time, a verdict line, a table of checks with counts and status, and a link to the detailed GeoPackage",[41,1423,1424],{},"The overview page",[45,1426,1427],{},"A simple HTML page with a header naming the dataset and run time, a large verdict line, PASS in green or FAIL in red, and a table listing every check with its count, threshold and status, failing rows highlighted. A footer gives the path of the GeoPackage holding the detailed findings.",[49,1429],{"x":51,"y":51,"width":52,"height":1430,"fill":54},"250",[73,1432,1433],{"x":75,"y":76,"style":77,"fill":78,"textAnchor":79},"What the reader sees first",[49,1435],{"x":1436,"y":1437,"width":1438,"height":142,"rx":63,"fill":87,"stroke":88,"style":89},"60","44","640",[73,1440,1445],{"x":1441,"y":1442,"style":1443,"fill":78,"textAnchor":1444},"90","76","text-anchor:start;font-size:12.0px;font-family:sans-serif;font-weight:bold","start","parcels — quality report 2026-10-02 06:10",[73,1447,1451],{"x":1441,"y":1448,"style":1449,"fill":1450,"textAnchor":1444},"108","text-anchor:start;font-size:14.0px;font-family:sans-serif;font-weight:bold","#b91c1c","FAIL: invalid_geometry",[102,1453],{"x1":1441,"y1":1454,"x2":1455,"y2":1454,"stroke":1456,"style":1457},"124","670","#d9d3c4","stroke-width:1.5",[73,1459,1462],{"x":1441,"y":1460,"style":1461,"fill":1450,"textAnchor":1444},"146","text-anchor:start;font-size:10.5px;font-family:monospace","invalid_geometry      7    0    FAIL",[73,1464,1466],{"x":1441,"y":1465,"style":1461,"fill":71,"textAnchor":1444},"168","duplicate_geometry    3   10    PASS",[73,1468,1469],{"x":1441,"y":142,"style":1461,"fill":71,"textAnchor":1444},"missing_key           0    0    PASS",[73,1471,1473],{"x":1441,"y":143,"style":1472,"fill":88,"textAnchor":1444},"text-anchor:start;font-size:10.0px;font-family:sans-serif","details: parcels_quality_20261002_0610.gpkg",[223,1475,1477],{"className":225,"code":1476,"language":227,"meta":228,"style":228},"import html\n\ndef write_html(path, layer_name, results, thresholds, verdict, report_path):\n    rows = \"\".join(\n        f\"\u003Ctr class='{'fail' if r.count > thresholds.get(r.name, 0) else 'pass'}'>\"\n        f\"\u003Ctd>{html.escape(r.name)}\u003C\u002Ftd>\u003Ctd>{html.escape(r.description)}\u003C\u002Ftd>\"\n        f\"\u003Ctd>{r.count}\u003C\u002Ftd>\u003Ctd>{thresholds.get(r.name, 0)}\u003C\u002Ftd>\u003C\u002Ftr>\"\n        for r in results)\n    page = f\"\"\"\u003C!doctype html>\u003Cmeta charset=\"utf-8\">\u003Ctitle>{html.escape(layer_name)} quality\u003C\u002Ftitle>\n\u003Cstyle>body{{font-family:sans-serif;max-width:46rem;margin:2rem auto}}\n.fail td{{color:#b91c1c;font-weight:bold}}td,th{{padding:.3rem .8rem;text-align:left}}\u003C\u002Fstyle>\n\u003Ch1>{html.escape(layer_name)} — quality report\u003C\u002Fh1>\n\u003Cp>\u003Cstrong>{html.escape(verdict)}\u003C\u002Fstrong> · {datetime.now():%Y-%m-%d %H:%M}\u003C\u002Fp>\n\u003Ctable>\u003Ctr>\u003Cth>check\u003C\u002Fth>\u003Cth>description\u003C\u002Fth>\u003Cth>count\u003C\u002Fth>\u003Cth>threshold\u003C\u002Fth>\u003C\u002Ftr>{rows}\u003C\u002Ftable>\n\u003Cp>Detailed findings: {html.escape(report_path)}\u003C\u002Fp>\"\"\"\n    Path(path).write_text(page, encoding=\"utf-8\")\n\nwrite_html(report.replace(\".gpkg\", \".html\"), parcels.name(), results,\n           THRESHOLDS, verdict, report)\n",[230,1478,1479,1486,1490,1500,1513,1552,1579,1609,1622,1644,1658,1684,1698,1750,1765,1780,1796,1800,1816],{"__ignoreMap":228},[233,1480,1481,1483],{"class":102,"line":235},[233,1482,246],{"class":238},[233,1484,1485],{"class":242}," html\n",[233,1487,1488],{"class":102,"line":252},[233,1489,277],{"emptyLinePlaceholder":276},[233,1491,1492,1494,1497],{"class":102,"line":260},[233,1493,344],{"class":238},[233,1495,1496],{"class":283}," write_html",[233,1498,1499],{"class":242},"(path, layer_name, results, thresholds, verdict, report_path):\n",[233,1501,1502,1505,1507,1510],{"class":102,"line":273},[233,1503,1504],{"class":242},"    rows ",[233,1506,359],{"class":238},[233,1508,1509],{"class":365}," \"\"",[233,1511,1512],{"class":242},".join(\n",[233,1514,1515,1518,1521,1523,1526,1528,1531,1534,1537,1539,1542,1544,1547,1549],{"class":102,"line":280},[233,1516,1517],{"class":238},"        f",[233,1519,1520],{"class":365},"\"\u003Ctr class='",[233,1522,648],{"class":305},[233,1524,1525],{"class":365},"'fail'",[233,1527,1003],{"class":238},[233,1529,1530],{"class":242}," r.count ",[233,1532,1533],{"class":238},">",[233,1535,1536],{"class":242}," thresholds.get(r.name, ",[233,1538,51],{"class":305},[233,1540,1541],{"class":242},") ",[233,1543,1009],{"class":238},[233,1545,1546],{"class":365}," 'pass'",[233,1548,654],{"class":305},[233,1550,1551],{"class":365},"'>\"\n",[233,1553,1554,1556,1559,1561,1564,1566,1569,1571,1574,1576],{"class":102,"line":287},[233,1555,1517],{"class":238},[233,1557,1558],{"class":365},"\"\u003Ctd>",[233,1560,648],{"class":305},[233,1562,1563],{"class":242},"html.escape(r.name)",[233,1565,654],{"class":305},[233,1567,1568],{"class":365},"\u003C\u002Ftd>\u003Ctd>",[233,1570,648],{"class":305},[233,1572,1573],{"class":242},"html.escape(r.description)",[233,1575,654],{"class":305},[233,1577,1578],{"class":365},"\u003C\u002Ftd>\"\n",[233,1580,1581,1583,1585,1587,1590,1592,1594,1596,1599,1601,1604,1606],{"class":102,"line":299},[233,1582,1517],{"class":238},[233,1584,1558],{"class":365},[233,1586,648],{"class":305},[233,1588,1589],{"class":242},"r.count",[233,1591,654],{"class":305},[233,1593,1568],{"class":365},[233,1595,648],{"class":305},[233,1597,1598],{"class":242},"thresholds.get(r.name, ",[233,1600,51],{"class":305},[233,1602,1603],{"class":242},")",[233,1605,654],{"class":305},[233,1607,1608],{"class":365},"\u003C\u002Ftd>\u003C\u002Ftr>\"\n",[233,1610,1611,1614,1617,1619],{"class":102,"line":309},[233,1612,1613],{"class":238},"        for",[233,1615,1616],{"class":242}," r ",[233,1618,930],{"class":238},[233,1620,1621],{"class":242}," results)\n",[233,1623,1624,1627,1629,1631,1634,1636,1639,1641],{"class":102,"line":317},[233,1625,1626],{"class":242},"    page ",[233,1628,359],{"class":238},[233,1630,882],{"class":238},[233,1632,1633],{"class":365},"\"\"\"\u003C!doctype html>\u003Cmeta charset=\"utf-8\">\u003Ctitle>",[233,1635,648],{"class":305},[233,1637,1638],{"class":242},"html.escape(layer_name)",[233,1640,654],{"class":305},[233,1642,1643],{"class":365}," quality\u003C\u002Ftitle>\n",[233,1645,1646,1649,1652,1655],{"class":102,"line":327},[233,1647,1648],{"class":365},"\u003Cstyle>body",[233,1650,1651],{"class":305},"{{",[233,1653,1654],{"class":365},"font-family:sans-serif;max-width:46rem;margin:2rem auto",[233,1656,1657],{"class":305},"}}\n",[233,1659,1660,1663,1665,1668,1671,1674,1676,1679,1681],{"class":102,"line":336},[233,1661,1662],{"class":365},".fail td",[233,1664,1651],{"class":305},[233,1666,1667],{"class":365},"color:#b91c1c;font-weight:bold",[233,1669,1670],{"class":305},"}}",[233,1672,1673],{"class":365},"td,th",[233,1675,1651],{"class":305},[233,1677,1678],{"class":365},"padding:.3rem .8rem;text-align:left",[233,1680,1670],{"class":305},[233,1682,1683],{"class":365},"\u003C\u002Fstyle>\n",[233,1685,1686,1689,1691,1693,1695],{"class":102,"line":341},[233,1687,1688],{"class":365},"\u003Ch1>",[233,1690,648],{"class":305},[233,1692,1638],{"class":242},[233,1694,654],{"class":305},[233,1696,1697],{"class":365}," — quality report\u003C\u002Fh1>\n",[233,1699,1700,1703,1705,1708,1710,1713,1715,1718,1721,1724,1727,1730,1732,1735,1737,1740,1742,1745,1747],{"class":102,"line":353},[233,1701,1702],{"class":365},"\u003Cp>\u003Cstrong>",[233,1704,648],{"class":305},[233,1706,1707],{"class":242},"html.escape(verdict)",[233,1709,654],{"class":305},[233,1711,1712],{"class":365},"\u003C\u002Fstrong> · ",[233,1714,648],{"class":305},[233,1716,1717],{"class":242},"datetime.now():",[233,1719,1720],{"class":238},"%",[233,1722,1723],{"class":242},"Y",[233,1725,1726],{"class":238},"-%",[233,1728,1729],{"class":242},"m",[233,1731,1726],{"class":238},[233,1733,1734],{"class":242},"d ",[233,1736,1720],{"class":238},[233,1738,1739],{"class":242},"H:",[233,1741,1720],{"class":238},[233,1743,1744],{"class":242},"M",[233,1746,654],{"class":305},[233,1748,1749],{"class":365},"\u003C\u002Fp>\n",[233,1751,1752,1755,1757,1760,1762],{"class":102,"line":372},[233,1753,1754],{"class":365},"\u003Ctable>\u003Ctr>\u003Cth>check\u003C\u002Fth>\u003Cth>description\u003C\u002Fth>\u003Cth>count\u003C\u002Fth>\u003Cth>threshold\u003C\u002Fth>\u003C\u002Ftr>",[233,1756,648],{"class":305},[233,1758,1759],{"class":242},"rows",[233,1761,654],{"class":305},[233,1763,1764],{"class":365},"\u003C\u002Ftable>\n",[233,1766,1767,1770,1772,1775,1777],{"class":102,"line":393},[233,1768,1769],{"class":365},"\u003Cp>Detailed findings: ",[233,1771,648],{"class":305},[233,1773,1774],{"class":242},"html.escape(report_path)",[233,1776,654],{"class":305},[233,1778,1779],{"class":365},"\u003C\u002Fp>\"\"\"\n",[233,1781,1782,1785,1789,1791,1794],{"class":102,"line":415},[233,1783,1784],{"class":242},"    Path(path).write_text(page, ",[233,1786,1788],{"class":1787},"s9osk","encoding",[233,1790,359],{"class":238},[233,1792,1793],{"class":365},"\"utf-8\"",[233,1795,863],{"class":242},[233,1797,1798],{"class":102,"line":428},[233,1799,277],{"emptyLinePlaceholder":276},[233,1801,1802,1805,1808,1810,1813],{"class":102,"line":447},[233,1803,1804],{"class":242},"write_html(report.replace(",[233,1806,1807],{"class":365},"\".gpkg\"",[233,1809,206],{"class":242},[233,1811,1812],{"class":365},"\".html\"",[233,1814,1815],{"class":242},"), parcels.name(), results,\n",[233,1817,1818,1821],{"class":102,"line":465},[233,1819,1820],{"class":305},"           THRESHOLDS",[233,1822,1823],{"class":242},", verdict, report)\n",[14,1825,1826,1828,1829,1832,1833,198],{},[552,1827,554],{}," Escaping every value keeps a stray ",[230,1830,1831],{},"\u003C"," in a description from breaking the page. The HTML is deliberately plain — it needs to survive email clients and intranet pages. For a richer report with maps of the findings, a QGIS print layout can be generated from the same GeoPackage, as in ",[26,1834,1836],{"href":1835},"\u002Fspatial-data-processing-automation\u002Fautomated-map-layout-generation\u002Fgenerate-report-with-qgsreport-pyqgis\u002F","generating a report with QgsReport",[183,1838,1840],{"id":1839},"run-it-against-every-delivery","Run it against every delivery",[14,1842,1843],{},"The value of a quality report grows with repetition. Wrapping the runner in a script that takes a path, exits with a non-zero status on failure and appends the verdict to a log makes it schedulable and usable in CI.",[223,1845,1847],{"className":225,"code":1846,"language":227,"meta":228,"style":228},"import sys\n\ndef main(path):\n    layer = QgsVectorLayer(path, Path(path).stem, \"ogr\")\n    if not layer.isValid():\n        print(f\"cannot open {path}\")\n        return 2\n    report, results = run_report(layer, [check_validity, check_exact_duplicates,\n                                         check_missing_key])\n    failed = [r.name for r in results if r.count > THRESHOLDS.get(r.name, 0)]\n    with open(\"\u002Fdata\u002Fqa\u002Fquality_log.csv\", \"a\", encoding=\"utf-8\") as log:\n        log.write(f\"{datetime.now():%Y-%m-%d %H:%M},{path},{'FAIL' if failed else 'PASS'},\"\n                  f\"{'|'.join(failed)}\\n\")\n    return 1 if failed else 0\n\nif __name__ == \"__main__\":\n    sys.exit(main(sys.argv[1]))\n",[230,1848,1849,1856,1860,1870,1885,1894,1915,1923,1933,1938,1974,2008,2081,2103,2118,2122,2137],{"__ignoreMap":228},[233,1850,1851,1853],{"class":102,"line":235},[233,1852,246],{"class":238},[233,1854,1855],{"class":242}," sys\n",[233,1857,1858],{"class":102,"line":252},[233,1859,277],{"emptyLinePlaceholder":276},[233,1861,1862,1864,1867],{"class":102,"line":260},[233,1863,344],{"class":238},[233,1865,1866],{"class":283}," main",[233,1868,1869],{"class":242},"(path):\n",[233,1871,1872,1875,1877,1880,1883],{"class":102,"line":273},[233,1873,1874],{"class":242},"    layer ",[233,1876,359],{"class":238},[233,1878,1879],{"class":242}," QgsVectorLayer(path, Path(path).stem, ",[233,1881,1882],{"class":365},"\"ogr\"",[233,1884,863],{"class":242},[233,1886,1887,1889,1891],{"class":102,"line":280},[233,1888,1278],{"class":238},[233,1890,1378],{"class":238},[233,1892,1893],{"class":242}," layer.isValid():\n",[233,1895,1896,1898,1900,1902,1905,1907,1909,1911,1913],{"class":102,"line":287},[233,1897,1029],{"class":305},[233,1899,1032],{"class":242},[233,1901,642],{"class":238},[233,1903,1904],{"class":365},"\"cannot open ",[233,1906,648],{"class":305},[233,1908,68],{"class":242},[233,1910,654],{"class":305},[233,1912,691],{"class":365},[233,1914,863],{"class":242},[233,1916,1917,1920],{"class":102,"line":299},[233,1918,1919],{"class":238},"        return",[233,1921,1922],{"class":305}," 2\n",[233,1924,1925,1928,1930],{"class":102,"line":309},[233,1926,1927],{"class":242},"    report, results ",[233,1929,359],{"class":238},[233,1931,1932],{"class":242}," run_report(layer, [check_validity, check_exact_duplicates,\n",[233,1934,1935],{"class":102,"line":317},[233,1936,1937],{"class":242},"                                         check_missing_key])\n",[233,1939,1940,1943,1945,1948,1950,1952,1954,1957,1960,1962,1964,1966,1969,1971],{"class":102,"line":327},[233,1941,1942],{"class":242},"    failed ",[233,1944,359],{"class":238},[233,1946,1947],{"class":242}," [r.name ",[233,1949,1223],{"class":238},[233,1951,1616],{"class":242},[233,1953,930],{"class":238},[233,1955,1956],{"class":242}," results ",[233,1958,1959],{"class":238},"if",[233,1961,1530],{"class":242},[233,1963,1533],{"class":238},[233,1965,1241],{"class":305},[233,1967,1968],{"class":242},".get(r.name, ",[233,1970,51],{"class":305},[233,1972,1973],{"class":242},")]\n",[233,1975,1976,1979,1982,1984,1987,1989,1992,1994,1996,1998,2000,2002,2005],{"class":102,"line":336},[233,1977,1978],{"class":238},"    with",[233,1980,1981],{"class":305}," open",[233,1983,1032],{"class":242},[233,1985,1986],{"class":365},"\"\u002Fdata\u002Fqa\u002Fquality_log.csv\"",[233,1988,206],{"class":242},[233,1990,1991],{"class":365},"\"a\"",[233,1993,206],{"class":242},[233,1995,1788],{"class":1787},[233,1997,359],{"class":238},[233,1999,1793],{"class":365},[233,2001,1541],{"class":242},[233,2003,2004],{"class":238},"as",[233,2006,2007],{"class":242}," log:\n",[233,2009,2010,2013,2015,2017,2019,2022,2025,2027,2030,2032,2035,2037,2039,2041,2044,2047,2049,2051,2054,2056,2058,2060,2062,2064,2067,2069,2071,2073,2076,2078],{"class":102,"line":341},[233,2011,2012],{"class":242},"        log.write(",[233,2014,642],{"class":238},[233,2016,691],{"class":365},[233,2018,648],{"class":305},[233,2020,2021],{"class":242},"datetime.now()",[233,2023,2024],{"class":238},":",[233,2026,1720],{"class":242},[233,2028,2029],{"class":238},"Y-",[233,2031,1720],{"class":242},[233,2033,2034],{"class":238},"m-",[233,2036,1720],{"class":242},[233,2038,1734],{"class":238},[233,2040,1720],{"class":242},[233,2042,2043],{"class":238},"H",[233,2045,2046],{"class":242},":%",[233,2048,1744],{"class":238},[233,2050,654],{"class":305},[233,2052,2053],{"class":365},",",[233,2055,648],{"class":305},[233,2057,68],{"class":242},[233,2059,654],{"class":305},[233,2061,2053],{"class":365},[233,2063,648],{"class":305},[233,2065,2066],{"class":365},"'FAIL'",[233,2068,1003],{"class":238},[233,2070,1381],{"class":242},[233,2072,1009],{"class":238},[233,2074,2075],{"class":365}," 'PASS'",[233,2077,654],{"class":305},[233,2079,2080],{"class":365},",\"\n",[233,2082,2083,2086,2088,2090,2093,2096,2099,2101],{"class":102,"line":353},[233,2084,2085],{"class":238},"                  f",[233,2087,691],{"class":365},[233,2089,648],{"class":305},[233,2091,2092],{"class":365},"'|'",[233,2094,2095],{"class":242},".join(failed)",[233,2097,2098],{"class":305},"}\\n",[233,2100,691],{"class":365},[233,2102,863],{"class":242},[233,2104,2105,2107,2109,2111,2113,2115],{"class":102,"line":372},[233,2106,431],{"class":238},[233,2108,1012],{"class":305},[233,2110,1003],{"class":238},[233,2112,1381],{"class":242},[233,2114,1009],{"class":238},[233,2116,2117],{"class":305}," 0\n",[233,2119,2120],{"class":102,"line":393},[233,2121,277],{"emptyLinePlaceholder":276},[233,2123,2124,2126,2129,2132,2135],{"class":102,"line":415},[233,2125,1959],{"class":238},[233,2127,2128],{"class":305}," __name__",[233,2130,2131],{"class":238}," ==",[233,2133,2134],{"class":365}," \"__main__\"",[233,2136,296],{"class":242},[233,2138,2139,2142,2144],{"class":102,"line":428},[233,2140,2141],{"class":242},"    sys.exit(main(sys.argv[",[233,2143,1362],{"class":305},[233,2145,2146],{"class":242},"]))\n",[14,2148,2149,2151,2152,2156,2157,2160],{},[552,2150,554],{}," Exit codes are how schedulers and CI systems understand success: 0 for pass, 1 for a failed check, 2 for a delivery that could not even be opened. The CSV log grows one line per run, which is enough to chart quality over time. Run it from cron or a task scheduler as in ",[26,2153,2155],{"href":2154},"\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002Fschedule-pyqgis-scripts-with-cron\u002F","scheduling PyQGIS scripts",", or from ",[230,2158,2159],{},"qgis_process"," if the checks are wrapped as a Processing script.",[183,2162,2164],{"id":2163},"qgis-version-compatibility","QGIS version compatibility",[14,2166,2167,2168,206,2171,206,2173,2176,2177,2179,2180,2182],{},"All algorithms used — ",[230,2169,2170],{},"qgis:checkvalidity",[230,2172,558],{},[230,2174,2175],{},"native:savefeatures"," — are present on QGIS 3.34 LTR, 3.40 LTR and QGIS 4 with the same parameters. The ",[230,2178,562],{}," output of ",[230,2181,558],{}," was added in the 3.x series; on older releases compare feature counts before and after instead. Python dataclasses need Python 3.7 or newer, which every supported QGIS ships.",[183,2184,2186],{"id":2185},"troubleshooting","Troubleshooting",[188,2188,2189,2198,2208,2218],{},[191,2190,2191,708,2194,2197],{},[552,2192,2193],{},"The report GeoPackage has only the last table.",[230,2195,2196],{},"ACTION_ON_EXISTING_FILE"," stayed at 0 for every write.",[191,2199,2200,2203,2204,198],{},[552,2201,2202],{},"A check takes far longer than the others."," It iterates without a request filter or spatial index; profile it with the techniques in ",[26,2205,2207],{"href":2206},"\u002Fqgis-plugin-development\u002Fbackground-tasks-and-plugin-performance\u002Fprofile-slow-pyqgis-code\u002F","profiling slow PyQGIS code",[191,2209,2210,2213,2214,2217],{},[552,2211,2212],{},"Counts differ between runs on the same data."," A check depends on a user setting, such as ",[230,2215,2216],{},"METHOD: 0"," in the validity check.",[191,2219,2220,2223,2224,198],{},[552,2221,2222],{},"The HTML shows garbled characters."," The file was opened without UTF-8; keep the ",[230,2225,2226],{},"meta charset",[183,2228,2230],{"id":2229},"conclusion","Conclusion",[14,2232,2233],{},"Give every check the same shape — name, findings layer, count — run them in sequence into one dated GeoPackage, compare counts with agreed thresholds for a named verdict, render a plain HTML summary, and wrap it all in a script with meaningful exit codes so every delivery gets the same report.",[183,2235,2237],{"id":2236},"frequently-asked-questions","Frequently Asked Questions",[14,2239,2240,2243,2244,2247],{},[552,2241,2242],{},"Can I add a check written as a Processing model?","\nYes. Run the model with ",[230,2245,2246],{},"processing.run(\"model:…\")"," inside a check function and return its output layer and feature count.",[14,2249,2250,2253],{},[552,2251,2252],{},"Should the report modify the data?","\nNo. Keep checking and fixing separate, so the report always describes the delivery as received.",[14,2255,2256,2259,2260,2263],{},[552,2257,2258],{},"How do I report on several layers at once?","\nCall ",[230,2261,2262],{},"run_report"," per layer and combine the summaries into one table with a layer column.",[14,2265,2266,2269],{},[552,2267,2268],{},"Where should thresholds live?","\nIn a small configuration file versioned with the data specification, so changes to the agreement are visible.",[183,2271,2273],{"id":2272},"related","Related",[188,2275,2276,2281,2286,2291,2296],{},[191,2277,2278,2280],{},[26,2279,29],{"href":28}," — the guide this recipe belongs to",[191,2282,2283],{},[26,2284,2285],{"href":204},"Build a Geometry Validity Report in PyQGIS",[191,2287,2288],{},[26,2289,2290],{"href":214},"Validate Attribute Values Against Rules in PyQGIS",[191,2292,2293],{},[26,2294,2295],{"href":209},"Find Duplicate Geometries and Features in PyQGIS",[191,2297,2298],{},[26,2299,2300],{"href":2154},"Schedule PyQGIS Scripts with Cron",[2302,2303,2304],"style",{},"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 .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sjoCn, html code.shiki .sjoCn{--shiki-default:#9AA79F}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}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 .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":228,"searchDepth":252,"depth":252,"links":2306},[2307,2308,2309,2310,2311,2312,2313,2314,2315,2316,2317],{"id":185,"depth":252,"text":186},{"id":217,"depth":252,"text":218},{"id":715,"depth":252,"text":716},{"id":1125,"depth":252,"text":1126},{"id":1411,"depth":252,"text":1412},{"id":1839,"depth":252,"text":1840},{"id":2163,"depth":252,"text":2164},{"id":2185,"depth":252,"text":2186},{"id":2229,"depth":252,"text":2230},{"id":2236,"depth":252,"text":2237},{"id":2272,"depth":252,"text":2273},"Combine geometry validity, topology, duplicate and attribute-rule checks into one repeatable quality report — a GeoPackage of findings, a summary table with pass\u002Ffail per check, an HTML overview, and a run you can schedule against every new data delivery.","md",{"slug":2321,"type":2322,"breadcrumb":2323,"datePublished":2324,"dateModified":2324},"build-data-quality-report-pyqgis","article","Build a Data Quality Report","2026-10-02","\u002Fspatial-data-processing-automation\u002Fdata-quality-and-topology-validation\u002Fbuild-data-quality-report-pyqgis",{"title":5,"description":2318},"spatial-data-processing-automation\u002Fdata-quality-and-topology-validation\u002Fbuild-data-quality-report-pyqgis\u002Findex","mGpYoK-oXMt3HGqEv2Ug2eSUa1Gvzlg_CMhDlSuOe5o",1790966261345]