Tree Navigation¶
Since v1.8.0
The BoQTree API provides a read-only tree view over a procurement BoQ with parent references, depth tracking, sibling navigation, and indexed lookups. It wraps the existing Pydantic models without modifying them.
Why a Tree API?¶
The core BoQ → Lot → BoQBody → BoQCtgy → Item model is a downward-only nested structure — you can iterate items, but you cannot ask "what category does this item belong to?" or "what are the siblings of this section?". Every application building a UI tree, a copy/paste feature, or a structured report ends up reimplementing the same recursive walk.
BoQTree solves this by building a lightweight node graph on top of the existing models, with O(1) access to parent, children, depth, and siblings.
Quick Start¶
from pygaeb import GAEBParser, BoQTree
doc = GAEBParser.parse("tender.X83")
tree = BoQTree(doc.award.boq)
# Find an item and navigate up
node = tree.find_item("01.01.0010")
print(node.parent.label) # "Mauerwerk" (category label)
print(node.parent.parent.label) # "Rohbau" (parent category)
print(node.depth) # 4
print(node.label_path) # ["BoQ", "Default", "Rohbau", "Mauerwerk", "Mauerwerk Innenwand"]
Node Kinds¶
Every node in the tree has a kind that tells you what it wraps:
| Kind | Wraps | Typical depth |
|---|---|---|
NodeKind.ROOT |
BoQ |
0 |
NodeKind.LOT |
Lot |
1 |
NodeKind.CATEGORY |
BoQCtgy |
2+ |
NodeKind.ITEM |
Item |
leaf level |
Navigating the Tree¶
Parent, children, siblings¶
from pygaeb.api.boq_tree import NodeKind
for lot_node in tree.lots:
print(lot_node.label, lot_node.depth)
for child in lot_node.children:
if child.kind == NodeKind.CATEGORY:
print(f" {child.rno} {child.label}")
# Siblings
for sib in child.siblings:
print(f" sibling: {sib.label}")
# Items in this category
for item_node in child.iter_items():
print(f" {item_node.item.oz} {item_node.item.short_text}")
print(f" parent: {item_node.parent.label}")
Ancestors and path¶
node = tree.find_item("01.02.0030")
# All ancestors from root to parent (excludes self)
for ancestor in node.ancestors:
print(f" {ancestor.kind.value}: {ancestor.label}")
# Full path from root to self (includes self)
print(node.path)
# Human-readable breadcrumb
print(" > ".join(node.label_path))
Sibling navigation¶
node = tree.find_item("01.02.0010")
print(node.index) # 0 (first among siblings)
print(node.next_sibling) # BoQNode for the next item
print(node.prev_sibling) # None (first item)
Lookups¶
Find item by OZ (O(1))¶
find_item() accepts either the leaf RNoPart ("0010") or the full OZ
("01.01.0010"):
node = tree.find_item("01.01.0010") # or tree.find_item("0010")
if node:
print(node.item.short_text, node.item.total_price)
print(node.item.full_oz) # "01.01.0010"
Item.ozholds only this item's own segment (the leafRNoPart). Useitem.full_ozfor the complete ordinal number built from the whole category/lot chain, oritem.full_oz_with("-")for a custom separator.
Find category by rno¶
Find all categories with same rno (multi-lot)¶
In multi-lot documents, the same rno can appear in different lots:
nodes = tree.find_all_categories("01")
for n in nodes:
lot_label = n.parent.label # which lot this category belongs to
print(f" {lot_label}: {n.label}")
Subtree Queries¶
Every BoQNode supports queries over its descendants:
# All items under a specific category
rohbau = tree.find_category("01")
for item_node in rohbau.iter_items():
print(item_node.item.oz)
# All categories (recursive)
for cat_node in tree.root.iter_categories():
print(f"{' ' * cat_node.depth}{cat_node.rno} {cat_node.label}")
# Find by predicate
expensive = tree.root.find_all(
lambda n: n.kind == NodeKind.ITEM and n.item.total_price and n.item.total_price > 50000
)
for n in expensive:
print(n.item.oz, n.item.total_price)
Tree Traversal¶
Depth-first (DFS)¶
for node in tree.walk():
indent = " " * node.depth
print(f"{indent}{node.kind.value}: {node.label}")
Breadth-first (BFS)¶
Type-Safe Model Access¶
Each node provides a type-safe accessor that raises TypeError if you use the wrong one:
lot_node = tree.lots[0]
lot_node.lot # Lot model — works
lot_node.item # TypeError: Cannot access .item on a lot node
item_node = tree.find_item("01.01.0010")
item_node.item # Item model — works
item_node.category # TypeError: Cannot access .category on an item node
Convenience Properties¶
These work on any node kind, giving you a unified interface:
| Property | ROOT | LOT | CATEGORY | ITEM |
|---|---|---|---|---|
label |
"BoQ" |
lot.label |
ctgy.label |
item.short_text |
rno |
"" |
lot.rno |
ctgy.rno |
item.oz |
label_path |
["BoQ"] |
["BoQ", "Default"] |
["BoQ", ..., "Rohbau"] |
["BoQ", ..., "Mauerwerk Innenwand"] |
Building a UI Tree¶
A common use case is rendering the BoQ as a hierarchical tree (React, Qt, etc.):
def to_ui_tree(node):
result = {
"kind": node.kind.value,
"label": node.label,
"rno": node.rno,
"depth": node.depth,
}
if node.kind == NodeKind.ITEM:
result["oz"] = node.item.oz
result["qty"] = str(node.item.qty)
result["unit"] = node.item.unit
result["total"] = str(node.item.total_price)
if node.children:
result["children"] = [to_ui_tree(child) for child in node.children]
return result
tree_data = to_ui_tree(tree.root)
Counts¶
print(tree.node_count) # Total nodes (root + lots + categories + items)
print(tree.item_count) # Just items
print(tree.is_multi_lot) # True if > 1 lot
print(len(tree.lots)) # Number of lots
Design Notes¶
- Read-only —
BoQNode.childrenis a tuple (immutable). The tree API does not support mutation (move, detach, duplicate). This is planned for a future release. - Zero model impact — The underlying
BoQ,Lot,BoQCtgy, andItemmodels are not modified.BoQNode.modelreturns the exact same Pydantic object (same identity). - O(n) construction — Building the tree is a single pass over all nodes. For a 10,000-item document, this takes < 5ms.
- Opt-in — If you never construct a
BoQTree, the tree code never runs. There is no performance cost for users who don't need it. - Procurement BoQ only — The current tree API works with
BoQfrom procurement documents. Cost (ElementalCosting) and quantity (QtyDetermination) tree adapters may be added in a future release.