WeakNode and Nested WeakNode (Interactive)
This notebook shows how to build Document → Section → Page hierarchies with WeakNode, how to inspect the graph state after each operation, and how to delete nodes with different strategies.
You watch the graph change in the cvcdocdb graph viewer (cvcdocdb.viewer.show), a widget that reads the graph straight from the Python kernel: add or delete nodes from the control panel and the viewer updates in place. It needs a frontend that supports anywidget, such as JupyterLab, Jupyter Notebook, VS Code or Colab.
[ ]:
import importlib.util
# The graph viewer needs cvcdocdb with the [notebook] extra (anywidget).
if (importlib.util.find_spec("cvcdocdb") is None
or importlib.util.find_spec("cvcdocdb.viewer") is None
or importlib.util.find_spec("anywidget") is None):
print("⚠️ Installing cvcdocdb[notebook]...")
%pip install -q --upgrade "cvcdocdb[notebook]"
print("✅ Installation complete. Restart the kernel if an import below fails.")
else:
print("✅ cvcdocdb and anywidget are already installed.")
Overview
Graph viewer — every node and relation of the graph, coloured by label. Click a node to see its primary key and properties, double-click it to expand its neighbours; search and filter from the sidebar. It only reads: all changes go through the Python API.
Step-by-step walkthrough — build and delete a hierarchy cell by cell.
Control panel — add documents, sections and pages, or delete a node with the strategy of your choice, and see the result at once.
After a change made from Python, viewer.refresh() tells the viewer to read the graph again.
[ ]:
import os
import tempfile
import uuid
from IPython.display import clear_output, display
import ipywidgets as widgets
from cvcdocdb import NetworkXGraph, Node, WeakNode
from cvcdocdb.viewer import show
persistence_path = os.path.join(tempfile.gettempdir(), f"cvcdocdb_tutorial_weaknodes_{uuid.uuid4().hex}.pkl")
graph = NetworkXGraph(persistence_path=persistence_path)
registry = {}
[ ]:
# ── Core graph operations ─────────────────────────────────────────
def key_for_document(doc_id):
return f"doc:{doc_id}"
def key_for_section(doc_id, section_id):
return f"section:{doc_id}:{section_id}"
def key_for_page(doc_id, section_id, page_id):
return f"page:{doc_id}:{section_id}:{page_id}"
def show_state(title="State"):
print(f"\n=== {title} ===")
print("nodes:", graph.get_nodes())
print("edges:", graph.get_edges())
def add_document(doc_id):
k = key_for_document(doc_id)
node = Node(pk={"doc": doc_id}, main_label="Document")
graph.insertNode(node, replace=True)
registry[k] = node
return k
def add_section(doc_id, section_id):
parent_key = key_for_document(doc_id)
if parent_key not in registry:
add_document(doc_id)
parent = registry[parent_key]
k = key_for_section(doc_id, section_id)
node = WeakNode(parent=parent, pk={"section": section_id}, main_label="Section")
graph.insertNode(node, insert_parent=True, replace=True)
registry[k] = node
return k
def add_page(doc_id, section_id, page_id):
section_key = key_for_section(doc_id, section_id)
if section_key not in registry:
add_section(doc_id, section_id)
parent = registry[section_key]
k = key_for_page(doc_id, section_id, page_id)
node = WeakNode(parent=parent, pk={"page": page_id}, main_label="Page")
graph.insertNode(node, insert_parent=True, replace=True)
registry[k] = node
return k
def delete_key(k, detach=True, propagation=False, on_delete="cascade"):
node = registry.get(k)
if node is None:
raise KeyError(f"Unknown key: {k}")
graph.deleteNode(node, detach=detach, propagation=propagation, on_delete=on_delete)
# A cascade can delete more than the node itself: forget every node that's gone.
for key, other in list(registry.items()):
if graph.checkNode(other) is None:
del registry[key]
The graph viewer
show(graph, load_all=True) draws the whole graph (fine for small graphs like this one; without load_all you add nodes from the table or by searching). The viewer stays empty until the first nodes are added below.
[ ]:
viewer = show(graph, load_all=True, height=520)
viewer
Step-by-step walkthrough
Build a hierarchy and watch the viewer above update after each cell. Each WeakNode is linked to its parent by a relation that carries the cascade (_propagate) flag:
[ ]:
add_document("DOC-001")
add_section("DOC-001", 1)
add_page("DOC-001", 1, 1)
add_page("DOC-001", 1, 2)
viewer.refresh()
show_state("After adding a Document, a Section and two Pages")
Delete the section with on_delete="cascade" and propagation=False: the section and its relations go, but its pages stay as orphans. Try propagation=True instead to delete the pages too.
[ ]:
delete_key("section:DOC-001:1", detach=True, propagation=False, on_delete="cascade")
viewer.refresh()
show_state("After deleting the Section with ON DELETE CASCADE")
Interactive editor
Use the control panel to add or delete nodes. The panel includes a second view of the same viewer, so you see each change right below the buttons (the viewer above updates too).
+ Document / + Section / + Page create the node and any missing parent.
Delete removes the selected node:
detachalso removes its relations,propagatecascades to its WeakNode children, andon_deletepicks the strategy (restrictrefuses to delete a node that still has relations).
[ ]:
# ── Control panel ─────────────────────────────────────────────────
label = {"description_width": "initial"}
doc_w = widgets.Text(value="DOC-002", description="doc:", style=label, layout=widgets.Layout(width="200px"))
section_w = widgets.IntText(value=1, description="section:", style=label, layout=widgets.Layout(width="150px"))
page_w = widgets.IntText(value=1, description="page:", style=label, layout=widgets.Layout(width="130px"))
delete_dropdown = widgets.Dropdown(options=[], description="delete:", style=label, layout=widgets.Layout(width="280px"))
detach_w = widgets.Checkbox(value=True, description="detach", indent=False, layout=widgets.Layout(width="auto"))
prop_w = widgets.Checkbox(value=False, description="propagate", indent=False, layout=widgets.Layout(width="auto"))
on_delete_w = widgets.Dropdown(
options=["cascade", "restrict", "set_null"],
value="cascade",
description="on_delete:",
style=label,
layout=widgets.Layout(width="190px"),
)
out = widgets.Output(layout=widgets.Layout(max_height="120px", overflow="auto"))
def refresh_delete_options():
opts = sorted(registry.keys()) or ["(none)"]
current = delete_dropdown.value
delete_dropdown.options = opts
delete_dropdown.value = current if current in opts else opts[0]
def after_change():
refresh_delete_options()
viewer.refresh()
def act_add(add, *args):
with out:
clear_output(wait=True)
print(f"✓ Added: {add(*args)}")
after_change()
def act_delete(_):
with out:
clear_output(wait=True)
k = delete_dropdown.value
if not k or k == "(none)":
print("No node selected for deletion")
return
try:
delete_key(k, detach=detach_w.value, propagation=prop_w.value, on_delete=on_delete_w.value)
print(f"✓ Deleted: {k}")
except Exception as err:
print(f"✗ Delete error: {err}")
after_change()
btn_doc = widgets.Button(description="+ Document", button_style="success", tooltip="Add a Document")
btn_sec = widgets.Button(description="+ Section", button_style="info", tooltip="Add a Section under the doc")
btn_page = widgets.Button(description="+ Page", button_style="warning", tooltip="Add a Page under the section")
btn_del = widgets.Button(description="Delete", button_style="danger", tooltip="Delete the selected node")
btn_doc.on_click(lambda _: act_add(add_document, doc_w.value))
btn_sec.on_click(lambda _: act_add(add_section, doc_w.value, section_w.value))
btn_page.on_click(lambda _: act_add(add_page, doc_w.value, section_w.value, page_w.value))
btn_del.on_click(act_delete)
refresh_delete_options()
display(widgets.VBox([
widgets.HBox([doc_w, section_w, page_w]),
widgets.HBox([btn_doc, btn_sec, btn_page]),
widgets.HBox([delete_dropdown, detach_w, prop_w, on_delete_w, btn_del]),
out,
viewer,
]))
Cleanup
Run All would close the editor as soon as it opened, so cleaning up is a function: call cleanup() when you are done. The temporary file is also removed when the kernel shuts down.
[ ]:
import atexit
@atexit.register
def cleanup():
viewer.close()
graph.close()
try:
os.remove(persistence_path)
except FileNotFoundError:
pass
print("Closed graph and removed temp persistence file")
# cleanup()