Symptom Pair and What Each Traceback Means
A binding transform walks a Perspective Tree's items structure using the selection path (for example 0/0/0/0). With a string index it fails with:
TypeError: com.inductiveautomation.perspective.gateway.script.JsonifiableArrayList indices must be integers
After wrapping the index in int(), it fails again a few lines later, this time demanding a string key. Switching between .get() and bracket indexing makes no difference.
The two errors come from two different container types. JsonifiableArrayList is the scripting wrapper for a JSON array and accepts only integer positions. A JSON object wrapper accepts only string keys. When a single variable holds a list on some iterations and an object on others, no index type satisfies every pass through the loop.
| Observed error | Container actually indexed | Root cause |
|---|---|---|
JsonifiableArrayList indices must be integers on the first loop pass |
Array (top-level items) |
Path segments from split("/") are strings, and they were used without conversion |
| Key must be a string, raised later in the loop | Object (a tree node) | The loop skipped the items step one level early, so it applied an integer index to a node object |
Error at current_node["data"] for a single-level path |
Array | The same off-by-one error in the other direction: the loop fetched items after the final node |
IndexError when nothing is selected |
Selection array |
selection[0] runs before the length check |
Data Shape Check Before Writing Any Traversal
First confirm how the tree data alternates between arrays and objects. Each level in a Perspective Tree has this shape:
items : array -> index with int
[n] : object -> keys: label, expanded, icon, data, items
data : object -> EqID, EqType, EqEnabled, EqName, EqPath
items : array -> next level down (empty [] at a leaf)
Each segment of the selection path is the position of a node inside its parent's items array. In the sample hierarchy, 0/0/0/0 resolves as GPA → Virginia → Mixing → Mixer 1, and its data.EqPath is GPA/Virginia/Mixing/Mixer 1. A path of 0/0/0/0/1 points to Sweeper under Mixer 1.
The consequence is fixed: every path segment requires exactly two indexing steps. First apply the integer to an array, which returns a node object. Then read "items" from that node to get the array for the next segment. On the last segment, stop after the first step and keep the node.
Check: In the Designer Property Editor, expand props.items and follow your test path by hand. Write down which node label you expect for that path. Do not move on until you have a known-good pair of path and expected EqPath.
Type Trace of the Failing Loop
The original loop reuses one variable, current_node, for both the sibling array and the selected node. It decides when to stop descending with a counter compared against len(path) - 1. Because the counter is incremented before the comparison, it reaches len(path) - 1 one iteration before the last segment.
Here is the trace for path 0/0/0/0 with the int() version (path_array_final_index = 3):
| Pass | Type before .get(int)
|
Result | Counter after ++ | Fetch items? |
Type at end of pass |
|---|---|---|---|---|---|
| 1 | array | node GPA | 1 | yes (1 ≠ 3) | array |
| 2 | array | node Virginia | 2 | yes (2 ≠ 3) | array |
| 3 | array | node Mixing | 3 | no (3 = 3) | object |
| 4 | object | fails: object wants a string key | — | — | — |
With the string version, pass 1 fails immediately because "0" is applied to an array. That is why flipping the index type only moves the failure. Each version breaks on a different container.
To see this on your own data, log the type at every step before changing any code:
logger = system.util.getLogger("TreeTraverse")
for seg in path:
logger.info("before index %s: %s" % (seg, type(current_node)))
# ... existing loop body ...
Check: In the Gateway log, the type printed before each integer index must be the array wrapper every time. If an object type appears before an integer index, you have reproduced the fault.
Corrected Traversal: One List Variable, One Node Variable
Keep the sibling array and the selected node in separate variables. The loop then has a single uniform body with no counter and no special case for the last segment:
def transform(self, value, quality, timestamp):
empty = {"EqEnabled": "", "EqID": "", "EqName": "", "EqPath": "", "EqType": ""}
selection = self.props.selection
if not selection or not selection[0]:
return empty
try:
indices = [int(seg) for seg in selection[0].split("/")]
except ValueError:
return empty
siblings = value # array of nodes at the current level
node = None # the node selected at the current level
for i in indices:
if i < 0 or i >= len(siblings):
return empty # stale path after the tree was rebuilt
node = siblings[i] # array[int] -> object
siblings = node["items"] # object[str] -> array
data = node["data"]
result = {}
for key in empty:
result[key] = data[key]
return result
Each iteration performs exactly one integer index on an array and one string key on an object, so the types can no longer drift. Reading items after the final node is harmless because the value is simply discarded. The names carry the types: siblings is always an array and node is always an object.
A more compact version wraps the root array in a fake parent object so that every step is the same expression:
item = {"items": value}
for n in self.props.selection[0].split("/"):
item = item["items"][int(n)]
# item is now the selected node; item["data"] holds EqID, EqName, ...
If you prefer to start from the component property instead of the transform input:
indices = [int(x) for x in self.props.selection[0].split("/")]
items = self.props.items
for i in indices:
item = items[i]
items = item.items
Attribute access (item.items) works on Perspective property wrappers. Use item["items"] if the data may arrive as a plain Python dict from another script. Brackets work for both.
Check: Apply your known-good test path. The transform preview in the binding dialog must show the EqPath you wrote down earlier. Do not move on until it does.
Guards for Empty and Stale Selections
The original condition if self.props.selection[0] and len(self.props.selection) > 0 evaluates in the wrong order. When nothing is selected, selection[0] raises an IndexError before the length test runs. Test the container first, then the element, as the corrected code does.
Two more cases need a defined return value rather than an exception:
-
Stale path. When
props.itemsis rebuilt, for example after a query refresh removes a mixer, the selection string can point past the end of an array. The bounds check returns the empty structure instead of raising. -
Non-numeric segment. A malformed or manually written selection value fails
int(). TheValueErrorguard catches it.
Return a fixed-shape dictionary in every case. Downstream bindings on EqID, EqName, and the other keys then never see a missing key, and nothing turns into an error overlay.
Check: Clear the Tree selection. The bound property must show the empty structure with all five keys present, and no transform error overlay should appear.
Binding Trigger: What Re-runs the Transform
A transform runs only when its binding's source value changes. In the failing script, value is the tree datasource, while the selection is read from self.props.selection inside the script. Reading a property inside a script does not subscribe the binding to it. If the binding source is props.items, clicking a different node will not re-run the transform, and the output keeps showing the previous result.
| Binding source | Re-runs on selection change | Re-runs on items change |
|---|---|---|
props.items, reading selection inside the script |
No | Yes |
props.selection, reading self.props.items inside the script |
Yes | No |
| A binding that takes both properties as inputs, or a property change script on each | Yes | Yes |
For a detail panel driven by user clicks, bind on the selection. If the tree data also refreshes while a node is selected, use a binding type that accepts both properties as inputs so that either change re-evaluates the transform.
Check: In a running session, click three different nodes at different depths. The output must update on every click without a page reload.
Shortcut: Selection Data Instead of Traversal
If you only need the selected node's own data block, you may not need to traverse at all. The Tree publishes the selected item's data next to props.selection. Confirm the exact property name in the Property Editor for your Ignition version, and bind to it directly.
Keep the traversal when either of these applies:
- You need the node's children (
items) or aggregate values from its subtree. Selection data gives you only the node itself. - The tree structure is rewritten at runtime. The selection-data property can behave unexpectedly while
props.itemsis being replaced. Deriving the node from the currentitemsplus the path, with bounds checks, gives you consistent behavior.
Check: If you switch to selection data, compare its EqPath against the traversal result for the same click. Keep the approach that stays correct while the tree refreshes.
End-to-End Verification
- Select
0(root node). Expected output:EqName=GPA,EqType=enterprise. This single-segment path exercised the off-by-one error in the original code. - Select
0/0/0/0. Expected output:EqPath=GPA/Virginia/Mixing/Mixer 1,EqType=line,EqID=1. - Select
0/0/0/3/1. Expected output:EqName=Cell 2,EqID=11,EqType=cell. This checks a deep leaf whoseitemsis empty. - Select
0/0/0/2(Mixer 3, emptyitems). The output must showEqID=8with no error from the trailingitemsread. - Clear the selection. The output must return the empty five-key structure.
- With
Mixer 4selected, remove it from the data source and refreshprops.items. The output must fall back to the empty structure or the new occupant of that index, never a traceback. - Watch the Gateway log through steps 1-6 and remove the diagnostic logger. Zero
TypeErrorentries from the transform confirms the fix.
FAQ
Why does JsonifiableArrayList say indices must be integers when I call .get()?
The segments from selection[0].split("/") are strings, and an array wrapper accepts only integer positions. Convert each segment with int() before indexing an array. Apply string keys such as "items" and "data" only to node objects.
Why does converting the index to int just move the error to another line?
The loop variable changes from an array to a node object partway through. The counter compared against len(path) - 1 skips the items step one level early, so the integer lands on an object. Keep the sibling array and the node in separate variables, and do both indexing steps on every segment.
Why does my Perspective Tree transform not update when I click a different node?
The binding source is props.items, and the script reads self.props.selection without subscribing to it. Bind on props.selection instead, or use a binding that takes both properties as inputs, then confirm the output changes on each click in a live session.