[{"data":1,"prerenderedAt":1203},["ShallowReactive",2],{"doc:\u002Fqgis-plugin-development\u002Fattribute-forms-and-layer-actions\u002Fadd-python-logic-to-attribute-form-pyqgis":3},{"id":4,"title":5,"body":6,"description":1192,"extension":1193,"meta":1194,"navigation":251,"path":1199,"seo":1200,"stem":1201,"__hash__":1202},"docs\u002Fqgis-plugin-development\u002Fattribute-forms-and-layer-actions\u002Fadd-python-logic-to-attribute-form-pyqgis\u002Findex.md","Add Python Logic to an Attribute Form in PyQGIS",{"type":7,"value":8,"toc":1180},"minimark",[9,13,22,31,184,189,211,215,218,444,478,482,489,565,698,718,722,725,835,851,855,858,932,963,991,997,1001,1030,1034,1082,1086,1094,1098,1104,1118,1127,1141,1145,1176],[10,11,5],"h1",{"id":12},"add-python-logic-to-an-attribute-form-in-pyqgis",[14,15,16,17,21],"p",{},"Widgets, defaults and constraints cover most of what a form needs, and all of them are declarative: they describe rules, and QGIS enforces them. Occasionally a form has to ",[18,19,20],"em",{},"do"," something — look up a record in another system when an asset id is typed, fill three fields from one selection, grey out a section until a checkbox is ticked, or run a check that no expression can express. For that, QGIS lets each layer's form run a Python function when it opens.",[14,23,24,25,30],{},"This recipe belongs to ",[26,27,29],"a",{"href":28},"\u002Fqgis-plugin-development\u002Fattribute-forms-and-layer-actions\u002F","Attribute Forms & Layer Actions in PyQGIS",". It configures form init code from a script, writes an init function that reacts to edits, adds a save-time check, and explains where to keep the code so it survives being shared.",[14,32,33],{},[34,35,40,44,48,55,72,81,91,97,102,109,115,119,122,126,135,139,144,148,152,158,162,166,170,174,180],"svg",{"viewBox":36,"role":37,"ariaLabel":38,"xmlns":39},"0 0 760 300","img","How form init code runs: when a user opens the attribute form QGIS builds the widgets, then calls the named Python function with the form, the layer and the feature, and that function connects to widget signals to react to later edits","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[41,42,43],"title",{},"The init function runs once per form",[45,46,47],"desc",{},"A user opens a feature form. QGIS builds widgets for every field according to the form configuration. It then imports the init code and calls the named function with three arguments: the form dialog, the layer and the feature. The function looks up widgets by field name and connects handlers to signals. After it returns, later edits by the user trigger those handlers, which can change other widgets or show warnings.",[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},"fpInitArrow","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-weight:bold;font-family:sans-serif","#17211d","middle","Wire up once, react for as long as the form is open",[49,82],{"x":83,"y":84,"width":85,"height":86,"rx":87,"fill":88,"stroke":89,"style":90},"20","62","150","80","10","#fffdf7","#59645f","stroke-width:2",[73,92,96],{"x":93,"y":94,"style":95,"fill":78,"textAnchor":79},"95","96","text-anchor:middle;font-size:11px;font-weight:bold;font-family:sans-serif","user opens",[73,98,101],{"x":93,"y":99,"style":100,"fill":71,"textAnchor":79},"116","text-anchor:middle;font-size:10px;font-family:sans-serif","feature form",[103,104],"line",{"x1":105,"y1":106,"x2":107,"y2":106,"stroke":71,"style":108},"170","102","202","stroke-width:1.8;marker-end:url(#fpInitArrow)",[49,110],{"x":111,"y":84,"width":112,"height":86,"rx":87,"fill":113,"stroke":114,"style":90},"210","160","#eff3ff","#2563eb",[73,116,118],{"x":117,"y":94,"style":95,"fill":114,"textAnchor":79},"290","QGIS builds",[73,120,121],{"x":117,"y":99,"style":100,"fill":71,"textAnchor":79},"widgets per field",[103,123],{"x1":124,"y1":106,"x2":125,"y2":106,"stroke":71,"style":108},"370","402",[49,127],{"x":128,"y":129,"width":130,"height":131,"rx":87,"fill":132,"stroke":133,"style":134},"410","52","326","100","#eef7f4","#0f766e","stroke-width:2.5",[73,136,138],{"x":137,"y":86,"style":95,"fill":133,"textAnchor":79},"573","calls your init function",[73,140,143],{"x":137,"y":141,"style":142,"fill":71,"textAnchor":79},"106","text-anchor:middle;font-size:10.5px;font-family:monospace","form_open(dialog, layer, feature)",[73,145,147],{"x":137,"y":146,"style":100,"fill":71,"textAnchor":79},"132","find widgets, connect signals, return",[103,149],{"x1":137,"y1":150,"x2":137,"y2":151,"stroke":71,"style":108},"152","188",[49,153],{"x":128,"y":154,"width":130,"height":155,"rx":87,"fill":156,"stroke":157,"style":90},"196","84","#fdf2e2","#b45309",[73,159,161],{"x":137,"y":160,"style":95,"fill":157,"textAnchor":79},"222","later: your handlers fire",[73,163,165],{"x":137,"y":164,"style":100,"fill":71,"textAnchor":79},"246","value changed → fill, enable, warn",[73,167,169],{"x":137,"y":168,"style":100,"fill":71,"textAnchor":79},"264","OK pressed → check, accept or refuse",[49,171],{"x":83,"y":154,"width":172,"height":155,"rx":87,"fill":88,"stroke":89,"style":173},"350","stroke-width:1.8",[73,175,179],{"x":176,"y":177,"style":178,"fill":71,"textAnchor":79},"195","226","text-anchor:middle;font-size:10.5px;font-family:sans-serif","runs for every form opened — keep it fast,",[73,181,183],{"x":176,"y":182,"style":178,"fill":71,"textAnchor":79},"248","and never block on a slow network call",[185,186,188],"h2",{"id":187},"prerequisites","Prerequisites",[190,191,192,200,208],"ul",{},[193,194,195,199],"li",{},[196,197,198],"strong",{},"QGIS 3.40 LTR"," or newer, or the QGIS 4 series.",[193,201,202,203,207],{},"A layer with a configured form — ideally already using ",[26,204,206],{"href":205},"\u002Fqgis-plugin-development\u002Fattribute-forms-and-layer-actions\u002Fconfigure-editor-widgets-pyqgis\u002F","editor widgets",", since init code works best as a thin layer on top of them.",[193,209,210],{},"A reason to need Python. Check first whether a default value, a constraint expression or a conditional visibility rule already does the job; those are faster, portable to mobile apps, and involve no executable code.",[185,212,214],{"id":213},"configure-the-init-function","Configure the init function",[14,216,217],{},"A layer's form configuration names the function to call and where its code comes from. The simplest source is code stored inside the project itself.",[219,220,225],"pre",{"className":221,"code":222,"language":223,"meta":224,"style":224},"language-python shiki shiki-themes github-dark","from qgis.core import Qgis, QgsProject\n\ninspections = QgsProject.instance().mapLayersByName(\"inspections\")[0]\n\nINIT_CODE = '''\nfrom qgis.PyQt.QtWidgets import QComboBox, QLineEdit\n\ndef form_open(dialog, layer, feature):\n    condition = dialog.findChild(QComboBox, \"condition\")\n    action = dialog.findChild(QLineEdit, \"action_required\")\n    if condition is None or action is None:\n        return\n\n    def on_condition_changed(_index):\n        poor = condition.currentData() in (1, 2)\n        action.setEnabled(poor)\n        if not poor:\n            action.clear()\n\n    condition.currentIndexChanged.connect(on_condition_changed)\n    on_condition_changed(condition.currentIndex())\n'''\n\nconfig = inspections.editFormConfig()\nconfig.setInitCodeSource(Qgis.AttributeFormPythonInitCodeSource.Dialog)\nconfig.setInitFunction(\"form_open\")\nconfig.setInitCode(INIT_CODE)\ninspections.setEditFormConfig(config)\n","python","",[226,227,228,246,253,278,283,295,301,306,312,318,324,330,336,341,347,353,359,365,371,376,382,388,394,399,410,416,428,438],"code",{"__ignoreMap":224},[229,230,232,236,240,243],"span",{"class":103,"line":231},1,[229,233,235],{"class":234},"snl16","from",[229,237,239],{"class":238},"s95oV"," qgis.core ",[229,241,242],{"class":234},"import",[229,244,245],{"class":238}," Qgis, QgsProject\n",[229,247,249],{"class":103,"line":248},2,[229,250,252],{"emptyLinePlaceholder":251},true,"\n",[229,254,256,259,262,265,269,272,275],{"class":103,"line":255},3,[229,257,258],{"class":238},"inspections ",[229,260,261],{"class":234},"=",[229,263,264],{"class":238}," QgsProject.instance().mapLayersByName(",[229,266,268],{"class":267},"sU2Wk","\"inspections\"",[229,270,271],{"class":238},")[",[229,273,51],{"class":274},"sDLfK",[229,276,277],{"class":238},"]\n",[229,279,281],{"class":103,"line":280},4,[229,282,252],{"emptyLinePlaceholder":251},[229,284,286,289,292],{"class":103,"line":285},5,[229,287,288],{"class":274},"INIT_CODE",[229,290,291],{"class":234}," =",[229,293,294],{"class":267}," '''\n",[229,296,298],{"class":103,"line":297},6,[229,299,300],{"class":267},"from qgis.PyQt.QtWidgets import QComboBox, QLineEdit\n",[229,302,304],{"class":103,"line":303},7,[229,305,252],{"emptyLinePlaceholder":251},[229,307,309],{"class":103,"line":308},8,[229,310,311],{"class":267},"def form_open(dialog, layer, feature):\n",[229,313,315],{"class":103,"line":314},9,[229,316,317],{"class":267},"    condition = dialog.findChild(QComboBox, \"condition\")\n",[229,319,321],{"class":103,"line":320},10,[229,322,323],{"class":267},"    action = dialog.findChild(QLineEdit, \"action_required\")\n",[229,325,327],{"class":103,"line":326},11,[229,328,329],{"class":267},"    if condition is None or action is None:\n",[229,331,333],{"class":103,"line":332},12,[229,334,335],{"class":267},"        return\n",[229,337,339],{"class":103,"line":338},13,[229,340,252],{"emptyLinePlaceholder":251},[229,342,344],{"class":103,"line":343},14,[229,345,346],{"class":267},"    def on_condition_changed(_index):\n",[229,348,350],{"class":103,"line":349},15,[229,351,352],{"class":267},"        poor = condition.currentData() in (1, 2)\n",[229,354,356],{"class":103,"line":355},16,[229,357,358],{"class":267},"        action.setEnabled(poor)\n",[229,360,362],{"class":103,"line":361},17,[229,363,364],{"class":267},"        if not poor:\n",[229,366,368],{"class":103,"line":367},18,[229,369,370],{"class":267},"            action.clear()\n",[229,372,374],{"class":103,"line":373},19,[229,375,252],{"emptyLinePlaceholder":251},[229,377,379],{"class":103,"line":378},20,[229,380,381],{"class":267},"    condition.currentIndexChanged.connect(on_condition_changed)\n",[229,383,385],{"class":103,"line":384},21,[229,386,387],{"class":267},"    on_condition_changed(condition.currentIndex())\n",[229,389,391],{"class":103,"line":390},22,[229,392,393],{"class":267},"'''\n",[229,395,397],{"class":103,"line":396},23,[229,398,252],{"emptyLinePlaceholder":251},[229,400,402,405,407],{"class":103,"line":401},24,[229,403,404],{"class":238},"config ",[229,406,261],{"class":234},[229,408,409],{"class":238}," inspections.editFormConfig()\n",[229,411,413],{"class":103,"line":412},25,[229,414,415],{"class":238},"config.setInitCodeSource(Qgis.AttributeFormPythonInitCodeSource.Dialog)\n",[229,417,419,422,425],{"class":103,"line":418},26,[229,420,421],{"class":238},"config.setInitFunction(",[229,423,424],{"class":267},"\"form_open\"",[229,426,427],{"class":238},")\n",[229,429,431,434,436],{"class":103,"line":430},27,[229,432,433],{"class":238},"config.setInitCode(",[229,435,288],{"class":274},[229,437,427],{"class":238},[229,439,441],{"class":103,"line":440},28,[229,442,443],{"class":238},"inspections.setEditFormConfig(config)\n",[14,445,446,449,450,453,454,457,458,461,462,465,466,469,470,473,474,477],{},[196,447,448],{},"Breakdown:"," ",[226,451,452],{},"editFormConfig()"," returns a copy, so changes do nothing until ",[226,455,456],{},"setEditFormConfig"," writes it back — the most common reason init code \"never runs\". The ",[226,459,460],{},"Dialog"," source stores the code text in the project. QGIS names each editor widget after its field, which is what makes ",[226,463,464],{},"findChild(QComboBox, \"condition\")"," work; the widget class depends on the editor widget type, so a value map is a ",[226,467,468],{},"QComboBox"," and a plain text field a ",[226,471,472],{},"QLineEdit",". Calling the handler once at the end sets the initial state for the feature being opened, not only for later changes. ",[226,475,476],{},"currentData()"," returns the stored value from the value map rather than the label, so the comparison is against codes.",[185,479,481],{"id":480},"react-through-the-form-not-the-widgets","React through the form, not the widgets",[14,483,484,485,488],{},"Reaching into individual Qt widgets is fragile: a widget type change in the Layer Properties dialog breaks the ",[226,486,487],{},"findChild"," call. The form object itself emits a signal whenever any field's value changes, and can set values by field name, which keeps the code independent of widget classes.",[14,490,491],{},[34,492,495,498,501,504,508,514,519,524,528,532,536,540,545,550,553,556,559,562],{"viewBox":493,"role":37,"ariaLabel":494,"xmlns":39},"0 0 760 262","Two ways to react to edits: connecting to a specific Qt widget ties the code to that widget class, connecting to the form's widgetValueChanged signal and using changeAttribute works whatever widget each field uses",[41,496,497],{},"Talk to the form, not its widgets",[45,499,500],{},"Left: code that finds a QComboBox named condition and connects to currentIndexChanged breaks when the field is switched to a value relation or a radio-style widget. Right: code that connects to the form's widgetValueChanged signal receives the field name and new value for any widget type, and uses changeAttribute to set other fields by name.",[49,502],{"x":51,"y":51,"width":52,"height":503,"fill":54},"262",[73,505,507],{"x":75,"y":506,"style":77,"fill":78,"textAnchor":79},"26","Survive a widget change in Layer Properties",[49,509],{"x":510,"y":511,"width":512,"height":154,"rx":87,"fill":156,"stroke":513,"style":134},"24","46","344","#b91c1c",[73,515,518],{"x":154,"y":516,"style":517,"fill":513,"textAnchor":79},"72","text-anchor:middle;font-size:12px;font-weight:bold;font-family:sans-serif","widget-level",[73,520,523],{"x":154,"y":521,"style":522,"fill":71,"textAnchor":79},"104","text-anchor:middle;font-size:10px;font-family:monospace","findChild(QComboBox, …)",[73,525,527],{"x":154,"y":526,"style":522,"fill":71,"textAnchor":79},"126",".currentIndexChanged",[73,529,531],{"x":154,"y":530,"style":178,"fill":71,"textAnchor":79},"162","switch field to ValueRelation",[73,533,535],{"x":154,"y":534,"style":178,"fill":71,"textAnchor":79},"184","→ findChild returns None",[73,537,539],{"x":154,"y":538,"style":100,"fill":513,"textAnchor":79},"220","logic silently stops",[49,541],{"x":542,"y":511,"width":512,"height":154,"rx":87,"fill":543,"stroke":544,"style":134},"392","#e8efe6","#15803d",[73,546,549],{"x":547,"y":516,"style":517,"fill":548,"textAnchor":79},"564","#166534","form-level",[73,551,552],{"x":547,"y":521,"style":522,"fill":71,"textAnchor":79},"dialog.widgetValueChanged",[73,554,555],{"x":547,"y":526,"style":522,"fill":71,"textAnchor":79},"dialog.changeAttribute(name, v)",[73,557,558],{"x":547,"y":530,"style":178,"fill":71,"textAnchor":79},"receives field name + value",[73,560,561],{"x":547,"y":534,"style":178,"fill":71,"textAnchor":79},"for every widget type",[73,563,564],{"x":547,"y":538,"style":100,"fill":548,"textAnchor":79},"keeps working",[219,566,568],{"className":221,"code":567,"language":223,"meta":224,"style":224},"INIT_CODE = '''\nfrom qgis.core import QgsProject, QgsFeatureRequest, QgsExpression\n\ndef form_open(dialog, layer, feature):\n    assets = QgsProject.instance().mapLayersByName(\"asset_register\")\n    if not assets:\n        return\n    assets = assets[0]\n\n    def on_value_changed(field_name, value, attribute_changed):\n        if field_name != \"asset_id\" or not attribute_changed or not value:\n            return\n        expr = f\"\\\\\"asset_id\\\\\" = {QgsExpression.quotedValue(value)}\"\n        match = next(assets.getFeatures(\n            QgsFeatureRequest().setFilterExpression(expr).setLimit(1)), None)\n        if match is None:\n            dialog.displayWarning(f\"Asset {value} is not in the register\")\n            return\n        dialog.changeAttribute(\"asset_type\", match[\"asset_type\"])\n        dialog.changeAttribute(\"installed_year\", match[\"installed_year\"])\n\n    dialog.widgetValueChanged.connect(on_value_changed)\n'''\n",[226,569,570,578,583,587,591,596,601,605,610,614,619,624,629,645,650,655,660,671,675,680,685,689,694],{"__ignoreMap":224},[229,571,572,574,576],{"class":103,"line":231},[229,573,288],{"class":274},[229,575,291],{"class":234},[229,577,294],{"class":267},[229,579,580],{"class":103,"line":248},[229,581,582],{"class":267},"from qgis.core import QgsProject, QgsFeatureRequest, QgsExpression\n",[229,584,585],{"class":103,"line":255},[229,586,252],{"emptyLinePlaceholder":251},[229,588,589],{"class":103,"line":280},[229,590,311],{"class":267},[229,592,593],{"class":103,"line":285},[229,594,595],{"class":267},"    assets = QgsProject.instance().mapLayersByName(\"asset_register\")\n",[229,597,598],{"class":103,"line":297},[229,599,600],{"class":267},"    if not assets:\n",[229,602,603],{"class":103,"line":303},[229,604,335],{"class":267},[229,606,607],{"class":103,"line":308},[229,608,609],{"class":267},"    assets = assets[0]\n",[229,611,612],{"class":103,"line":314},[229,613,252],{"emptyLinePlaceholder":251},[229,615,616],{"class":103,"line":320},[229,617,618],{"class":267},"    def on_value_changed(field_name, value, attribute_changed):\n",[229,620,621],{"class":103,"line":326},[229,622,623],{"class":267},"        if field_name != \"asset_id\" or not attribute_changed or not value:\n",[229,625,626],{"class":103,"line":332},[229,627,628],{"class":267},"            return\n",[229,630,631,634,637,640,642],{"class":103,"line":338},[229,632,633],{"class":267},"        expr = f\"",[229,635,636],{"class":274},"\\\\",[229,638,639],{"class":267},"\"asset_id",[229,641,636],{"class":274},[229,643,644],{"class":267},"\" = {QgsExpression.quotedValue(value)}\"\n",[229,646,647],{"class":103,"line":343},[229,648,649],{"class":267},"        match = next(assets.getFeatures(\n",[229,651,652],{"class":103,"line":349},[229,653,654],{"class":267},"            QgsFeatureRequest().setFilterExpression(expr).setLimit(1)), None)\n",[229,656,657],{"class":103,"line":355},[229,658,659],{"class":267},"        if match is None:\n",[229,661,662,665,668],{"class":103,"line":361},[229,663,664],{"class":267},"            dialog.displayWarning(f\"Asset ",[229,666,667],{"class":274},"{value}",[229,669,670],{"class":267}," is not in the register\")\n",[229,672,673],{"class":103,"line":367},[229,674,628],{"class":267},[229,676,677],{"class":103,"line":373},[229,678,679],{"class":267},"        dialog.changeAttribute(\"asset_type\", match[\"asset_type\"])\n",[229,681,682],{"class":103,"line":378},[229,683,684],{"class":267},"        dialog.changeAttribute(\"installed_year\", match[\"installed_year\"])\n",[229,686,687],{"class":103,"line":384},[229,688,252],{"emptyLinePlaceholder":251},[229,690,691],{"class":103,"line":390},[229,692,693],{"class":267},"    dialog.widgetValueChanged.connect(on_value_changed)\n",[229,695,696],{"class":103,"line":396},[229,697,393],{"class":267},[14,699,700,449,702,705,706,709,710,713,714,717],{},[196,701,448],{},[226,703,704],{},"widgetValueChanged"," passes the field name, the new value and a flag that is true when the change came from the user rather than from the form being populated — checking it avoids a lookup firing for every field as the form loads. ",[226,707,708],{},"changeAttribute"," sets a value by field name through whatever widget that field uses, and marks the feature as modified. ",[226,711,712],{},"displayWarning"," shows a message bar inside the form rather than a modal dialog, which does not interrupt typing. The lookup uses ",[226,715,716],{},"setLimit(1)"," against a local layer so it returns instantly; the doubled backslashes are there because the code sits inside a Python string that is itself stored as code.",[185,719,721],{"id":720},"refuse-to-save-when-a-check-fails","Refuse to save when a check fails",[14,723,724],{},"Constraints cover most save-time rules. When a rule needs Python — a check against an external service, a cross-feature rule that is too slow as an expression — intercept the form's accept.",[219,726,728],{"className":221,"code":727,"language":223,"meta":224,"style":224},"INIT_CODE = '''\nfrom qgis.PyQt.QtWidgets import QDialogButtonBox\n\ndef form_open(dialog, layer, feature):\n    parent = dialog.parent()\n    buttons = parent.findChild(QDialogButtonBox) if parent else None\n    if buttons is None:\n        return\n\n    dialog.disconnectButtonBox()\n\n    def on_accept():\n        values = {name: dialog.feature()[name] for name in (\"condition\", \"notes\")}\n        if values[\"condition\"] in (1, 2) and not (values[\"notes\"] or \"\").strip():\n            dialog.displayWarning(\"A poor condition needs a note before saving\")\n            return\n        if dialog.save():\n            parent.accept()\n\n    buttons.accepted.connect(on_accept)\n    buttons.rejected.connect(parent.reject)\n'''\n",[226,729,730,738,743,747,751,756,761,766,770,774,779,783,788,793,798,803,807,812,817,821,826,831],{"__ignoreMap":224},[229,731,732,734,736],{"class":103,"line":231},[229,733,288],{"class":274},[229,735,291],{"class":234},[229,737,294],{"class":267},[229,739,740],{"class":103,"line":248},[229,741,742],{"class":267},"from qgis.PyQt.QtWidgets import QDialogButtonBox\n",[229,744,745],{"class":103,"line":255},[229,746,252],{"emptyLinePlaceholder":251},[229,748,749],{"class":103,"line":280},[229,750,311],{"class":267},[229,752,753],{"class":103,"line":285},[229,754,755],{"class":267},"    parent = dialog.parent()\n",[229,757,758],{"class":103,"line":297},[229,759,760],{"class":267},"    buttons = parent.findChild(QDialogButtonBox) if parent else None\n",[229,762,763],{"class":103,"line":303},[229,764,765],{"class":267},"    if buttons is None:\n",[229,767,768],{"class":103,"line":308},[229,769,335],{"class":267},[229,771,772],{"class":103,"line":314},[229,773,252],{"emptyLinePlaceholder":251},[229,775,776],{"class":103,"line":320},[229,777,778],{"class":267},"    dialog.disconnectButtonBox()\n",[229,780,781],{"class":103,"line":326},[229,782,252],{"emptyLinePlaceholder":251},[229,784,785],{"class":103,"line":332},[229,786,787],{"class":267},"    def on_accept():\n",[229,789,790],{"class":103,"line":338},[229,791,792],{"class":267},"        values = {name: dialog.feature()[name] for name in (\"condition\", \"notes\")}\n",[229,794,795],{"class":103,"line":343},[229,796,797],{"class":267},"        if values[\"condition\"] in (1, 2) and not (values[\"notes\"] or \"\").strip():\n",[229,799,800],{"class":103,"line":349},[229,801,802],{"class":267},"            dialog.displayWarning(\"A poor condition needs a note before saving\")\n",[229,804,805],{"class":103,"line":355},[229,806,628],{"class":267},[229,808,809],{"class":103,"line":361},[229,810,811],{"class":267},"        if dialog.save():\n",[229,813,814],{"class":103,"line":367},[229,815,816],{"class":267},"            parent.accept()\n",[229,818,819],{"class":103,"line":373},[229,820,252],{"emptyLinePlaceholder":251},[229,822,823],{"class":103,"line":378},[229,824,825],{"class":267},"    buttons.accepted.connect(on_accept)\n",[229,827,828],{"class":103,"line":384},[229,829,830],{"class":267},"    buttons.rejected.connect(parent.reject)\n",[229,832,833],{"class":103,"line":390},[229,834,393],{"class":267},[14,836,837,449,839,842,843,846,847,850],{},[196,838,448],{},[226,840,841],{},"disconnectButtonBox"," detaches QGIS's own OK handling so your function decides whether to save. ",[226,844,845],{},"dialog.feature()"," returns the feature with the values currently in the widgets, not the values last committed. ",[226,848,849],{},"dialog.save()"," pushes those values into the layer's edit buffer and returns false if a hard constraint fails, so constraints still apply. The check here duplicates a constraint on purpose, to show the shape; in real use keep rules that expressions can express as constraints, where they also protect edits from the attribute table and from mobile apps, and reserve this for checks that cannot be written any other way.",[185,852,854],{"id":853},"keep-the-code-where-it-can-be-maintained","Keep the code where it can be maintained",[14,856,857],{},"Code pasted into a project is invisible to version control, cannot be reviewed, and is copied into every project that duplicates the layer. For anything beyond a few lines, keep it in a Python module and point the form at it.",[14,859,860],{},[34,861,864,867,870,873,876,878,881,884,888,892,895,898,901,904,907,910,913,916,920,923,926,929],{"viewBox":862,"role":37,"ariaLabel":863,"xmlns":39},"0 0 760 250","Three places init code can live: embedded in the project, in a file on disk, or in a module on the Python path such as one shipped with a plugin, compared by how they are shared, versioned and trusted",[41,865,866],{},"Dialog, file or environment",[45,868,869],{},"Dialog: code stored inside the project file, travels with it, hard to version, and may be blocked by project trust settings. File: code in a .py file referenced by path, easy to version but breaks if the path changes. Environment: a function importable from the Python path, such as a module in your plugin, versioned and updated with the plugin, and the right choice for organisation-wide forms.",[49,871],{"x":51,"y":51,"width":52,"height":872,"fill":54},"250",[73,874,875],{"x":75,"y":506,"style":77,"fill":78,"textAnchor":79},"Where should the form code live?",[49,877],{"x":510,"y":511,"width":160,"height":534,"rx":87,"fill":156,"stroke":157,"style":134},[73,879,460],{"x":880,"y":516,"style":517,"fill":157,"textAnchor":79},"135",[73,882,883],{"x":880,"y":131,"style":178,"fill":71,"textAnchor":79},"inside the project",[73,885,887],{"x":880,"y":886,"style":178,"fill":71,"textAnchor":79},"124","travels with it",[73,889,891],{"x":880,"y":890,"style":178,"fill":71,"textAnchor":79},"148","hard to review",[73,893,894],{"x":880,"y":154,"style":100,"fill":89,"textAnchor":79},"a few lines, one project",[49,896],{"x":897,"y":511,"width":160,"height":534,"rx":87,"fill":113,"stroke":114,"style":134},"269",[73,899,900],{"x":75,"y":516,"style":517,"fill":114,"textAnchor":79},"File",[73,902,903],{"x":75,"y":131,"style":178,"fill":71,"textAnchor":79},"a .py by path",[73,905,906],{"x":75,"y":886,"style":178,"fill":71,"textAnchor":79},"can be versioned",[73,908,909],{"x":75,"y":890,"style":178,"fill":71,"textAnchor":79},"breaks if path moves",[73,911,912],{"x":75,"y":154,"style":100,"fill":89,"textAnchor":79},"a shared network folder",[49,914],{"x":915,"y":511,"width":160,"height":534,"rx":87,"fill":543,"stroke":544,"style":134},"514",[73,917,919],{"x":918,"y":516,"style":517,"fill":548,"textAnchor":79},"625","Environment",[73,921,922],{"x":918,"y":131,"style":178,"fill":71,"textAnchor":79},"module on Python path",[73,924,925],{"x":918,"y":886,"style":178,"fill":71,"textAnchor":79},"shipped in a plugin",[73,927,928],{"x":918,"y":890,"style":178,"fill":71,"textAnchor":79},"updates with the plugin",[73,930,931],{"x":918,"y":154,"style":100,"fill":89,"textAnchor":79},"organisation-wide forms",[219,933,935],{"className":221,"code":934,"language":223,"meta":224,"style":224},"config = inspections.editFormConfig()\nconfig.setInitCodeSource(Qgis.AttributeFormPythonInitCodeSource.Environment)\nconfig.setInitFunction(\"survey_forms.inspections.form_open\")\ninspections.setEditFormConfig(config)\n",[226,936,937,945,950,959],{"__ignoreMap":224},[229,938,939,941,943],{"class":103,"line":231},[229,940,404],{"class":238},[229,942,261],{"class":234},[229,944,409],{"class":238},[229,946,947],{"class":103,"line":248},[229,948,949],{"class":238},"config.setInitCodeSource(Qgis.AttributeFormPythonInitCodeSource.Environment)\n",[229,951,952,954,957],{"class":103,"line":255},[229,953,421],{"class":238},[229,955,956],{"class":267},"\"survey_forms.inspections.form_open\"",[229,958,427],{"class":238},[229,960,961],{"class":103,"line":280},[229,962,443],{"class":238},[14,964,965,967,968,970,971,974,975,978,979,983,984,986,987,990],{},[196,966,448],{}," With the ",[226,969,919],{}," source, the init function is a dotted path to a function importable from QGIS's Python path. A plugin called ",[226,972,973],{},"survey_forms"," with an ",[226,976,977],{},"inspections.py"," module is on that path once installed, so every project that uses the layer gets the current version of the logic whenever the plugin updates — and the code lives in a repository with tests, like the rest of the plugin, as described in ",[26,980,982],{"href":981},"\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Funit-test-qgis-plugin-with-pytest\u002F","unit testing a QGIS plugin",". The ",[226,985,900],{}," source sits between the two: ",[226,988,989],{},"config.setInitFilePath(\"\u002Fsrv\u002Fgis\u002Fforms\u002Finspections.py\")"," with the function name alone, which suits a team without a plugin but with a shared drive.",[14,992,993,994,996],{},"Security deserves a sentence. Code stored in a project runs on the machine of whoever opens it. Recent QGIS releases can ask before running embedded Python from a project, depending on each user's security settings, and organisations often disable it entirely. The ",[226,995,919],{}," route avoids the question, because the code comes from an installed plugin the user already trusts.",[185,998,1000],{"id":999},"qgis-version-compatibility","QGIS version compatibility",[14,1002,1003,1006,1007,1010,1011,1014,1015,1018,1019,1021,1022,1025,1026,1029],{},[226,1004,1005],{},"Qgis.AttributeFormPythonInitCodeSource"," is the 3.32+ name; earlier releases use ",[226,1008,1009],{},"QgsEditFormConfig.CodeSourceDialog",", ",[226,1012,1013],{},"CodeSourceFile"," and ",[226,1016,1017],{},"CodeSourceEnvironment",". The QGIS 4 series accepts only the scoped form. ",[226,1020,704],{}," has carried the ",[226,1023,1024],{},"attributeChanged"," flag since 3.0. On QGIS 4, PyQt6 requires scoped Qt enums inside init code too, such as ",[226,1027,1028],{},"QDialogButtonBox.StandardButton.Ok",".",[185,1031,1033],{"id":1032},"troubleshooting","Troubleshooting",[190,1035,1036,1044,1052,1061,1076],{},[193,1037,1038,449,1041,1043],{},[196,1039,1040],{},"The function never runs.",[226,1042,456],{}," was not called, the function name does not match, or project code execution is disabled in the user's settings.",[193,1045,1046,1051],{},[196,1047,1048,1050],{},[226,1049,487],{}," returns None."," The field uses a different widget class, or the form uses a drag-and-drop layout that nests widgets — use form-level signals instead.",[193,1053,1054,1057,1058,1060],{},[196,1055,1056],{},"Handlers fire repeatedly as the form opens."," Check the ",[226,1059,1024],{}," flag.",[193,1062,1063,1066,1067,1070,1071,1075],{},[196,1064,1065],{},"Errors appear only in the log."," Exceptions in init code go to the ",[18,1068,1069],{},"Python"," tab of the message log; ",[26,1072,1074],{"href":1073},"\u002Fpyqgis-fundamentals-environment-setup\u002Fdebugging-pyqgis-scripts\u002Flog-messages-to-qgis-message-log-pyqgis\u002F","logging to the message log"," makes your own messages land there too.",[193,1077,1078,1081],{},[196,1079,1080],{},"Works in QGIS, not in QField."," Mobile apps do not run Python form code; use constraints and expressions for anything that must work in the field.",[185,1083,1085],{"id":1084},"conclusion","Conclusion",[14,1087,1088,1089,1014,1091,1093],{},"Reach for Python in a form only after widgets, defaults, constraints and visibility expressions have run out. When you do, react through the form's ",[226,1090,704],{},[226,1092,708],{}," rather than individual widgets, intercept saving only for checks nothing else can express, and ship the code as a module in a plugin so it is versioned, tested and trusted.",[185,1095,1097],{"id":1096},"frequently-asked-questions","Frequently Asked Questions",[14,1099,1100,1103],{},[196,1101,1102],{},"Is init code called for the attribute table too?","\nNo. It runs for feature forms. Edits made directly in table cells use widgets and constraints but not init code.",[14,1105,1106,1109,1110,1113,1114,1117],{},[196,1107,1108],{},"Can I use the init function to open a custom dialog instead?","\nYou can, but it is cleaner to replace the whole form with a ",[226,1111,1112],{},".ui"," file via ",[226,1115,1116],{},"setUiForm"," and keep the init function for behaviour.",[14,1119,1120,1123,1126],{},[196,1121,1122],{},"Does the form know whether it is adding or editing?",[226,1124,1125],{},"dialog.mode()"," returns the form mode, distinguishing new features, edits, multi-edit and search.",[14,1128,1129,1132,1133,1136,1137,1140],{},[196,1130,1131],{},"Can init code access the map canvas?","\nYes, through ",[226,1134,1135],{},"qgis.utils.iface"," when running in QGIS Desktop; guard it, because it is ",[226,1138,1139],{},"None"," in QGIS Server and standalone scripts.",[185,1142,1144],{"id":1143},"related","Related",[190,1146,1147,1152,1158,1164,1170],{},[193,1148,1149,1151],{},[26,1150,29],{"href":28}," — the guide this recipe belongs to",[193,1153,1154],{},[26,1155,1157],{"href":1156},"\u002Fqgis-plugin-development\u002Fattribute-forms-and-layer-actions\u002Fset-default-values-and-field-constraints-pyqgis\u002F","Set Default Values and Field Constraints in PyQGIS",[193,1159,1160],{},[26,1161,1163],{"href":1162},"\u002Fqgis-plugin-development\u002Fattribute-forms-and-layer-actions\u002Fbuild-tabbed-attribute-form-layout-pyqgis\u002F","Build a Tabbed Attribute Form Layout in PyQGIS",[193,1165,1166],{},[26,1167,1169],{"href":1168},"\u002Fpyqgis-fundamentals-environment-setup\u002Fqgis-api-architecture\u002Funderstand-qgis-signals-and-slots-pyqgis\u002F","Understand QGIS Signals and Slots in PyQGIS",[193,1171,1172],{},[26,1173,1175],{"href":1174},"\u002Fqgis-plugin-development\u002Fqt-designer-for-gis-interfaces\u002Fload-ui-file-at-runtime-pyqgis\u002F","Load a .ui File at Runtime in PyQGIS",[1177,1178,1179],"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 .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);}",{"title":224,"searchDepth":248,"depth":248,"links":1181},[1182,1183,1184,1185,1186,1187,1188,1189,1190,1191],{"id":187,"depth":248,"text":188},{"id":213,"depth":248,"text":214},{"id":480,"depth":248,"text":481},{"id":720,"depth":248,"text":721},{"id":853,"depth":248,"text":854},{"id":999,"depth":248,"text":1000},{"id":1032,"depth":248,"text":1033},{"id":1084,"depth":248,"text":1085},{"id":1096,"depth":248,"text":1097},{"id":1143,"depth":248,"text":1144},"Attach Python init code to a QGIS attribute form — find field widgets, react to value changes, fill dependent fields, block saving with a custom check — and choose between embedded code, a file and a module shipped with a plugin.","md",{"slug":1195,"type":1196,"breadcrumb":1197,"datePublished":1198,"dateModified":1198},"add-python-logic-to-attribute-form-pyqgis","article","Python Logic in an Attribute Form","2026-09-17","\u002Fqgis-plugin-development\u002Fattribute-forms-and-layer-actions\u002Fadd-python-logic-to-attribute-form-pyqgis",{"title":5,"description":1192},"qgis-plugin-development\u002Fattribute-forms-and-layer-actions\u002Fadd-python-logic-to-attribute-form-pyqgis\u002Findex","7K6NFSHK6qrHkkm2K-A-hVd-pOykK7gsb0TUgZObVA4",1789632907416]