From d03ef9814e005196be73e9a1f6f413c8035deaa8 Mon Sep 17 00:00:00 2001 From: Ray Date: Thu, 1 Oct 2026 19:39:48 +0800 Subject: [PATCH 1/4] feat(sdk): node navigation helpers get_node, get_node_parent, get_node_path, get_node_map They take the tree, not a doc_id: fetch it once (get_document_structure) and navigate locally, instead of one request per step. get_node / get_node_parent / get_node_path share one depth-first walk, O(n) per call; trees run tens to a few hundred nodes. They return the tree's own nodes rather than copies (unlike get_nodes / get_leaf_nodes), so node["nodes"] keeps working. get_node_map is create_node_mapping under a name in the same family. It leaves out include_page_ranges, which reads page_index and raises KeyError on local trees. create_node_mapping stays as the 0.2.8 surface. --- pageindex/utils.py | 24 ++++++++++++++++++++++++ tests/test_package_surface.py | 19 ++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/pageindex/utils.py b/pageindex/utils.py index 269218117..bdb4c38be 100644 --- a/pageindex/utils.py +++ b/pageindex/utils.py @@ -1276,6 +1276,30 @@ def get_all_nodes(tree): } return mapping +def get_node_path(tree, node_id): + """[top-level ancestor, ..., node] for node_id; [] if absent.""" + for node in [tree] if isinstance(tree, dict) else tree: + if node.get('node_id') == node_id: + return [node] + path = get_node_path(node.get('nodes') or [], node_id) + if path: + return [node] + path + return [] + +def get_node(tree, node_id): + """The node with node_id, or None.""" + path = get_node_path(tree, node_id) + return path[-1] if path else None + +def get_node_parent(tree, node_id): + """The parent of node_id; None for a top-level or absent node.""" + path = get_node_path(tree, node_id) + return path[-2] if len(path) > 1 else None + +def get_node_map(tree): + """{node_id: node} for every node in tree.""" + return create_node_mapping(tree) + def print_tree(tree, exclude_fields=None, indent=0): """Outline view; passing exclude_fields gives the 0.2.8 pprint view.""" if exclude_fields is not None: diff --git a/tests/test_package_surface.py b/tests/test_package_surface.py index 8ac7b2faf..0661bd139 100644 --- a/tests/test_package_surface.py +++ b/tests/test_package_surface.py @@ -3,7 +3,9 @@ import subprocess import sys -from pageindex.utils import create_node_mapping, print_tree, remove_fields +from pageindex.utils import (create_node_mapping, get_node, get_node_map, + get_node_parent, get_node_path, print_tree, + remove_fields) TREE = [ {"title": "Root", "node_id": "0000", "page_index": 1, @@ -47,6 +49,21 @@ def test_print_tree_exclude_fields(capsys): assert "[0000] Root" in capsys.readouterr().out +# ── tree navigation ── + +def test_node_navigation(): + root, child, tail = TREE[0], TREE[0]["nodes"][0], TREE[1] + assert get_node(TREE, "0001") is child + assert get_node(TREE, "9999") is None + assert get_node_parent(TREE, "0001") is root + assert get_node_parent(TREE, "0002") is None + assert get_node_path(TREE, "0001") == [root, child] + assert get_node_path(TREE, "0002") == [tail] + assert get_node_path(TREE, "9999") == [] + assert get_node(root, "0001") is child + assert get_node_map(TREE) == {"0000": root, "0001": child, "0002": tail} + + # ── import cost: the SDK must not pay for the indexing stack ── def test_import_pageindex_is_lazy(): From 847f1b0cc8159b305b3a7d49e13c1206f4428a3d Mon Sep 17 00:00:00 2001 From: Ray Date: Thu, 1 Oct 2026 20:35:44 +0800 Subject: [PATCH 2/4] fix(sdk): node helpers reject wrong input; raw trees keep their page ranges get_node, get_node_parent, get_node_path and get_node_map returned None / [] / {} when handed the whole get_tree() response or a None tree, and get_node matched nothing for an int id. Callers (AI-written ones especially) read that as "node absent". They now raise TypeError naming get_document_structure(doc_id) / get_tree(doc_id)['result']. create_node_mapping(include_page_ranges=True) read node["page_index"], so a raw tree from page_index_main (start_index/end_index) raised KeyError although it already carries its ranges. A node's own start_index/end_index now win; page_index trees map as before. --- pageindex/utils.py | 17 ++++++++++++++--- tests/test_package_surface.py | 23 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/pageindex/utils.py b/pageindex/utils.py index bdb4c38be..eac507b7b 100644 --- a/pageindex/utils.py +++ b/pageindex/utils.py @@ -1254,7 +1254,8 @@ def load(self, user_opt=None) -> config: def create_node_mapping(tree, include_page_ranges=False, max_page=None): """Map node_id to node; with include_page_ranges, to {"node", "start_index", - "end_index"} (end = next node's page_index, or max_page for the last node).""" + "end_index"}: a node's own start_index/end_index if it has them, else its + page_index and the next node's page_index (max_page for the last node).""" def get_all_nodes(tree): if isinstance(tree, dict): return [tree] + [node for child in tree.get('nodes', []) for node in get_all_nodes(child)] @@ -1271,13 +1272,22 @@ def get_all_nodes(tree): end_page = all_nodes[i + 1].get("page_index") if i + 1 < len(all_nodes) else max_page mapping[node["node_id"]] = { "node": node, - "start_index": node["page_index"], - "end_index": end_page, + "start_index": node.get("start_index", node.get("page_index")), + "end_index": node.get("end_index", end_page), } return mapping +def _require_node_tree(tree): + if not (isinstance(tree, list) or (isinstance(tree, dict) and 'node_id' in tree)): + raise TypeError("tree must be a node list such as get_document_structure(doc_id) " + "or get_tree(doc_id)['result'], not the whole get_tree response; " + f"got {type(tree).__name__}") + def get_node_path(tree, node_id): """[top-level ancestor, ..., node] for node_id; [] if absent.""" + _require_node_tree(tree) + if not isinstance(node_id, str): + raise TypeError(f"node_id must be a str like '0007', got {node_id!r}") for node in [tree] if isinstance(tree, dict) else tree: if node.get('node_id') == node_id: return [node] @@ -1298,6 +1308,7 @@ def get_node_parent(tree, node_id): def get_node_map(tree): """{node_id: node} for every node in tree.""" + _require_node_tree(tree) return create_node_mapping(tree) def print_tree(tree, exclude_fields=None, indent=0): diff --git a/tests/test_package_surface.py b/tests/test_package_surface.py index 0661bd139..702da7b4a 100644 --- a/tests/test_package_surface.py +++ b/tests/test_package_surface.py @@ -3,6 +3,8 @@ import subprocess import sys +import pytest + from pageindex.utils import (create_node_mapping, get_node, get_node_map, get_node_parent, get_node_path, print_tree, remove_fields) @@ -40,6 +42,15 @@ def test_create_node_mapping_page_ranges(): assert mapping["0002"] == {"node": TREE[1], "start_index": 5, "end_index": 9} +def test_create_node_mapping_page_ranges_raw_tree(): + child = {"title": "B", "node_id": "0001", "start_index": 2, "end_index": 4} + raw = [{"title": "A", "node_id": "0000", "start_index": 1, "end_index": 2, + "nodes": [child]}] + mapping = create_node_mapping(raw, include_page_ranges=True, max_page=9) + assert mapping["0000"] == {"node": raw[0], "start_index": 1, "end_index": 2} + assert mapping["0001"] == {"node": child, "start_index": 2, "end_index": 4} + + def test_print_tree_exclude_fields(capsys): print_tree(TREE, exclude_fields=["text"]) out = capsys.readouterr().out @@ -64,6 +75,18 @@ def test_node_navigation(): assert get_node_map(TREE) == {"0000": root, "0001": child, "0002": tail} +def test_node_navigation_rejects_wrong_input(): + envelope = {"doc_id": "d", "status": "completed", "result": TREE} + with pytest.raises(TypeError, match="got dict"): + get_node(envelope, "0001") + with pytest.raises(TypeError, match="got dict"): + get_node_map(envelope) + with pytest.raises(TypeError, match="got NoneType"): + get_node_map(None) + with pytest.raises(TypeError, match="node_id"): + get_node(TREE, 1) + + # ── import cost: the SDK must not pay for the indexing stack ── def test_import_pageindex_is_lazy(): From 0db1facf60d9584ace355c28011d00fd436faa3e Mon Sep 17 00:00:00 2001 From: Ray Date: Thu, 1 Oct 2026 20:40:31 +0800 Subject: [PATCH 3/4] revert(sdk): leave create_node_mapping's page ranges to the unified tree #541 makes the same change (a node's own start_index/end_index win) with its own test, and the two versions conflict on merge. The node helpers here don't depend on it. --- pageindex/utils.py | 7 +++---- tests/test_package_surface.py | 9 --------- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/pageindex/utils.py b/pageindex/utils.py index eac507b7b..431e12f0c 100644 --- a/pageindex/utils.py +++ b/pageindex/utils.py @@ -1254,8 +1254,7 @@ def load(self, user_opt=None) -> config: def create_node_mapping(tree, include_page_ranges=False, max_page=None): """Map node_id to node; with include_page_ranges, to {"node", "start_index", - "end_index"}: a node's own start_index/end_index if it has them, else its - page_index and the next node's page_index (max_page for the last node).""" + "end_index"} (end = next node's page_index, or max_page for the last node).""" def get_all_nodes(tree): if isinstance(tree, dict): return [tree] + [node for child in tree.get('nodes', []) for node in get_all_nodes(child)] @@ -1272,8 +1271,8 @@ def get_all_nodes(tree): end_page = all_nodes[i + 1].get("page_index") if i + 1 < len(all_nodes) else max_page mapping[node["node_id"]] = { "node": node, - "start_index": node.get("start_index", node.get("page_index")), - "end_index": node.get("end_index", end_page), + "start_index": node["page_index"], + "end_index": end_page, } return mapping diff --git a/tests/test_package_surface.py b/tests/test_package_surface.py index 702da7b4a..6fb421ffd 100644 --- a/tests/test_package_surface.py +++ b/tests/test_package_surface.py @@ -42,15 +42,6 @@ def test_create_node_mapping_page_ranges(): assert mapping["0002"] == {"node": TREE[1], "start_index": 5, "end_index": 9} -def test_create_node_mapping_page_ranges_raw_tree(): - child = {"title": "B", "node_id": "0001", "start_index": 2, "end_index": 4} - raw = [{"title": "A", "node_id": "0000", "start_index": 1, "end_index": 2, - "nodes": [child]}] - mapping = create_node_mapping(raw, include_page_ranges=True, max_page=9) - assert mapping["0000"] == {"node": raw[0], "start_index": 1, "end_index": 2} - assert mapping["0001"] == {"node": child, "start_index": 2, "end_index": 4} - - def test_print_tree_exclude_fields(capsys): print_tree(TREE, exclude_fields=["text"]) out = capsys.readouterr().out From 4ec54ef5afc3fed9239b3817e4146ed98077fc82 Mon Sep 17 00:00:00 2001 From: Ray Date: Thu, 1 Oct 2026 21:10:16 +0800 Subject: [PATCH 4/4] fix(sdk): get_node_map survives a node whose nodes is None create_node_mapping walked children with tree.get('nodes', []), so a node carrying nodes: None raised TypeError while get_node and get_node_path, which use `or []`, handled the same tree. Use `or []` in the shared walker so every create_node_mapping caller is covered. #541 does not touch this line; a trial merge with its head 79d88e8 is clean. --- pageindex/utils.py | 2 +- tests/test_package_surface.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/pageindex/utils.py b/pageindex/utils.py index 431e12f0c..31ee768b1 100644 --- a/pageindex/utils.py +++ b/pageindex/utils.py @@ -1257,7 +1257,7 @@ def create_node_mapping(tree, include_page_ranges=False, max_page=None): "end_index"} (end = next node's page_index, or max_page for the last node).""" def get_all_nodes(tree): if isinstance(tree, dict): - return [tree] + [node for child in tree.get('nodes', []) for node in get_all_nodes(child)] + return [tree] + [node for child in tree.get('nodes') or [] for node in get_all_nodes(child)] elif isinstance(tree, list): return [node for item in tree for node in get_all_nodes(item)] return [] diff --git a/tests/test_package_surface.py b/tests/test_package_surface.py index 6fb421ffd..743a5da4a 100644 --- a/tests/test_package_surface.py +++ b/tests/test_package_surface.py @@ -64,6 +64,8 @@ def test_node_navigation(): assert get_node_path(TREE, "9999") == [] assert get_node(root, "0001") is child assert get_node_map(TREE) == {"0000": root, "0001": child, "0002": tail} + leaf = {"node_id": "0000", "nodes": None} + assert get_node_map([leaf]) == {"0000": leaf} def test_node_navigation_rejects_wrong_input():