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

  1. 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.

  2. Step-by-step walkthrough — build and delete a hierarchy cell by cell.

  3. 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: detach also removes its relations, propagate cascades to its WeakNode children, and on_delete picks the strategy (restrict refuses 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()