hanzo-mcp 0.6.12__py3-none-any.whl → 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of hanzo-mcp might be problematic. Click here for more details.
- hanzo_mcp/__init__.py +2 -2
- hanzo_mcp/analytics/__init__.py +5 -0
- hanzo_mcp/analytics/posthog_analytics.py +364 -0
- hanzo_mcp/cli.py +5 -5
- hanzo_mcp/cli_enhanced.py +7 -7
- hanzo_mcp/cli_plugin.py +91 -0
- hanzo_mcp/config/__init__.py +1 -1
- hanzo_mcp/config/settings.py +70 -7
- hanzo_mcp/config/tool_config.py +20 -6
- hanzo_mcp/dev_server.py +3 -3
- hanzo_mcp/prompts/project_system.py +1 -1
- hanzo_mcp/server.py +40 -3
- hanzo_mcp/server_enhanced.py +69 -0
- hanzo_mcp/tools/__init__.py +140 -31
- hanzo_mcp/tools/agent/__init__.py +85 -4
- hanzo_mcp/tools/agent/agent_tool.py +104 -6
- hanzo_mcp/tools/agent/agent_tool_v2.py +459 -0
- hanzo_mcp/tools/agent/clarification_protocol.py +220 -0
- hanzo_mcp/tools/agent/clarification_tool.py +68 -0
- hanzo_mcp/tools/agent/claude_cli_tool.py +125 -0
- hanzo_mcp/tools/agent/claude_desktop_auth.py +508 -0
- hanzo_mcp/tools/agent/cli_agent_base.py +191 -0
- hanzo_mcp/tools/agent/code_auth.py +436 -0
- hanzo_mcp/tools/agent/code_auth_tool.py +194 -0
- hanzo_mcp/tools/agent/codex_cli_tool.py +123 -0
- hanzo_mcp/tools/agent/critic_tool.py +376 -0
- hanzo_mcp/tools/agent/gemini_cli_tool.py +128 -0
- hanzo_mcp/tools/agent/grok_cli_tool.py +128 -0
- hanzo_mcp/tools/agent/iching_tool.py +380 -0
- hanzo_mcp/tools/agent/network_tool.py +273 -0
- hanzo_mcp/tools/agent/prompt.py +62 -20
- hanzo_mcp/tools/agent/review_tool.py +433 -0
- hanzo_mcp/tools/agent/swarm_tool.py +535 -0
- hanzo_mcp/tools/agent/swarm_tool_v2.py +594 -0
- hanzo_mcp/tools/common/__init__.py +15 -1
- hanzo_mcp/tools/common/base.py +5 -4
- hanzo_mcp/tools/common/batch_tool.py +103 -11
- hanzo_mcp/tools/common/config_tool.py +2 -2
- hanzo_mcp/tools/common/context.py +2 -2
- hanzo_mcp/tools/common/context_fix.py +26 -0
- hanzo_mcp/tools/common/critic_tool.py +196 -0
- hanzo_mcp/tools/common/decorators.py +208 -0
- hanzo_mcp/tools/common/enhanced_base.py +106 -0
- hanzo_mcp/tools/common/fastmcp_pagination.py +369 -0
- hanzo_mcp/tools/common/forgiving_edit.py +243 -0
- hanzo_mcp/tools/common/mode.py +116 -0
- hanzo_mcp/tools/common/mode_loader.py +105 -0
- hanzo_mcp/tools/common/paginated_base.py +230 -0
- hanzo_mcp/tools/common/paginated_response.py +307 -0
- hanzo_mcp/tools/common/pagination.py +226 -0
- hanzo_mcp/tools/common/permissions.py +1 -1
- hanzo_mcp/tools/common/personality.py +936 -0
- hanzo_mcp/tools/common/plugin_loader.py +287 -0
- hanzo_mcp/tools/common/stats.py +4 -4
- hanzo_mcp/tools/common/tool_list.py +4 -1
- hanzo_mcp/tools/common/truncate.py +101 -0
- hanzo_mcp/tools/common/validation.py +1 -1
- hanzo_mcp/tools/config/__init__.py +3 -1
- hanzo_mcp/tools/config/config_tool.py +1 -1
- hanzo_mcp/tools/config/mode_tool.py +209 -0
- hanzo_mcp/tools/database/__init__.py +1 -1
- hanzo_mcp/tools/editor/__init__.py +1 -1
- hanzo_mcp/tools/filesystem/__init__.py +48 -14
- hanzo_mcp/tools/filesystem/ast_multi_edit.py +562 -0
- hanzo_mcp/tools/filesystem/batch_search.py +3 -3
- hanzo_mcp/tools/filesystem/diff.py +2 -2
- hanzo_mcp/tools/filesystem/directory_tree_paginated.py +338 -0
- hanzo_mcp/tools/filesystem/rules_tool.py +235 -0
- hanzo_mcp/tools/filesystem/{unified_search.py → search_tool.py} +12 -12
- hanzo_mcp/tools/filesystem/{symbols_unified.py → symbols_tool.py} +104 -5
- hanzo_mcp/tools/filesystem/watch.py +3 -2
- hanzo_mcp/tools/jupyter/__init__.py +2 -2
- hanzo_mcp/tools/jupyter/jupyter.py +1 -1
- hanzo_mcp/tools/llm/__init__.py +3 -3
- hanzo_mcp/tools/llm/llm_tool.py +648 -143
- hanzo_mcp/tools/lsp/__init__.py +5 -0
- hanzo_mcp/tools/lsp/lsp_tool.py +512 -0
- hanzo_mcp/tools/mcp/__init__.py +2 -2
- hanzo_mcp/tools/mcp/{mcp_unified.py → mcp_tool.py} +3 -3
- hanzo_mcp/tools/memory/__init__.py +76 -0
- hanzo_mcp/tools/memory/knowledge_tools.py +518 -0
- hanzo_mcp/tools/memory/memory_tools.py +456 -0
- hanzo_mcp/tools/search/__init__.py +6 -0
- hanzo_mcp/tools/search/find_tool.py +581 -0
- hanzo_mcp/tools/search/unified_search.py +953 -0
- hanzo_mcp/tools/shell/__init__.py +11 -6
- hanzo_mcp/tools/shell/auto_background.py +203 -0
- hanzo_mcp/tools/shell/base_process.py +57 -29
- hanzo_mcp/tools/shell/bash_session_executor.py +1 -1
- hanzo_mcp/tools/shell/{bash_unified.py → bash_tool.py} +18 -34
- hanzo_mcp/tools/shell/command_executor.py +2 -2
- hanzo_mcp/tools/shell/{npx_unified.py → npx_tool.py} +16 -33
- hanzo_mcp/tools/shell/open.py +2 -2
- hanzo_mcp/tools/shell/{process_unified.py → process_tool.py} +1 -1
- hanzo_mcp/tools/shell/run_command_windows.py +1 -1
- hanzo_mcp/tools/shell/streaming_command.py +594 -0
- hanzo_mcp/tools/shell/uvx.py +47 -2
- hanzo_mcp/tools/shell/uvx_background.py +47 -2
- hanzo_mcp/tools/shell/{uvx_unified.py → uvx_tool.py} +16 -33
- hanzo_mcp/tools/todo/__init__.py +14 -19
- hanzo_mcp/tools/todo/todo.py +22 -1
- hanzo_mcp/tools/vector/__init__.py +1 -1
- hanzo_mcp/tools/vector/infinity_store.py +2 -2
- hanzo_mcp/tools/vector/project_manager.py +1 -1
- hanzo_mcp/types.py +23 -0
- hanzo_mcp-0.7.0.dist-info/METADATA +516 -0
- hanzo_mcp-0.7.0.dist-info/RECORD +180 -0
- {hanzo_mcp-0.6.12.dist-info → hanzo_mcp-0.7.0.dist-info}/entry_points.txt +1 -0
- hanzo_mcp/tools/common/palette.py +0 -344
- hanzo_mcp/tools/common/palette_loader.py +0 -108
- hanzo_mcp/tools/config/palette_tool.py +0 -179
- hanzo_mcp/tools/llm/llm_unified.py +0 -851
- hanzo_mcp-0.6.12.dist-info/METADATA +0 -339
- hanzo_mcp-0.6.12.dist-info/RECORD +0 -135
- hanzo_mcp-0.6.12.dist-info/licenses/LICENSE +0 -21
- {hanzo_mcp-0.6.12.dist-info → hanzo_mcp-0.7.0.dist-info}/WHEEL +0 -0
- {hanzo_mcp-0.6.12.dist-info → hanzo_mcp-0.7.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Automatic pagination response wrapper for MCP tools.
|
|
2
|
+
|
|
3
|
+
This module provides utilities to automatically paginate tool responses
|
|
4
|
+
when they exceed token limits.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from typing import Any, Dict, List, Optional, Union
|
|
9
|
+
from hanzo_mcp.tools.common.truncate import estimate_tokens
|
|
10
|
+
from hanzo_mcp.tools.common.pagination import CursorManager
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AutoPaginatedResponse:
|
|
14
|
+
"""Automatically paginate responses that exceed token limits."""
|
|
15
|
+
|
|
16
|
+
# MCP token limit with safety buffer
|
|
17
|
+
MAX_TOKENS = 20000 # Leave 5k buffer from 25k limit
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def create_response(
|
|
21
|
+
content: Union[str, Dict[str, Any], List[Any]],
|
|
22
|
+
cursor: Optional[str] = None,
|
|
23
|
+
max_tokens: int = MAX_TOKENS
|
|
24
|
+
) -> Dict[str, Any]:
|
|
25
|
+
"""Create a response that automatically paginates if too large.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
content: The content to return
|
|
29
|
+
cursor: Optional cursor from request
|
|
30
|
+
max_tokens: Maximum tokens allowed in response
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Dict with content and optional nextCursor
|
|
34
|
+
"""
|
|
35
|
+
# Handle different content types
|
|
36
|
+
if isinstance(content, str):
|
|
37
|
+
return AutoPaginatedResponse._handle_string_response(
|
|
38
|
+
content, cursor, max_tokens
|
|
39
|
+
)
|
|
40
|
+
elif isinstance(content, list):
|
|
41
|
+
return AutoPaginatedResponse._handle_list_response(
|
|
42
|
+
content, cursor, max_tokens
|
|
43
|
+
)
|
|
44
|
+
elif isinstance(content, dict):
|
|
45
|
+
# If dict already has pagination info, return as-is
|
|
46
|
+
if "nextCursor" in content or "cursor" in content:
|
|
47
|
+
return content
|
|
48
|
+
# Otherwise treat as single item
|
|
49
|
+
return AutoPaginatedResponse._handle_dict_response(
|
|
50
|
+
content, cursor, max_tokens
|
|
51
|
+
)
|
|
52
|
+
else:
|
|
53
|
+
# Convert to string for other types
|
|
54
|
+
return AutoPaginatedResponse._handle_string_response(
|
|
55
|
+
str(content), cursor, max_tokens
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _handle_string_response(
|
|
60
|
+
content: str,
|
|
61
|
+
cursor: Optional[str],
|
|
62
|
+
max_tokens: int
|
|
63
|
+
) -> Dict[str, Any]:
|
|
64
|
+
"""Handle pagination for string responses."""
|
|
65
|
+
# Parse cursor to get offset
|
|
66
|
+
offset = 0
|
|
67
|
+
if cursor:
|
|
68
|
+
cursor_data = CursorManager.parse_cursor(cursor)
|
|
69
|
+
if cursor_data and "offset" in cursor_data:
|
|
70
|
+
offset = cursor_data["offset"]
|
|
71
|
+
|
|
72
|
+
# For strings, paginate by lines
|
|
73
|
+
lines = content.split('\n')
|
|
74
|
+
|
|
75
|
+
if offset >= len(lines):
|
|
76
|
+
return {"content": "", "message": "No more content"}
|
|
77
|
+
|
|
78
|
+
# Build response line by line, checking tokens
|
|
79
|
+
result_lines = []
|
|
80
|
+
current_tokens = 0
|
|
81
|
+
line_index = offset
|
|
82
|
+
|
|
83
|
+
# Add header if this is a continuation
|
|
84
|
+
if offset > 0:
|
|
85
|
+
header = f"[Continued from line {offset + 1}]\n"
|
|
86
|
+
current_tokens = estimate_tokens(header)
|
|
87
|
+
result_lines.append(header)
|
|
88
|
+
|
|
89
|
+
while line_index < len(lines):
|
|
90
|
+
line = lines[line_index]
|
|
91
|
+
line_tokens = estimate_tokens(line + "\n")
|
|
92
|
+
|
|
93
|
+
# Check if adding this line would exceed limit
|
|
94
|
+
if current_tokens + line_tokens > max_tokens:
|
|
95
|
+
# Need to paginate
|
|
96
|
+
if not result_lines:
|
|
97
|
+
# Single line too long, truncate it
|
|
98
|
+
truncated_line = line[:1000] + "... [line truncated]"
|
|
99
|
+
result_lines.append(truncated_line)
|
|
100
|
+
line_index += 1
|
|
101
|
+
break
|
|
102
|
+
|
|
103
|
+
result_lines.append(line)
|
|
104
|
+
current_tokens += line_tokens
|
|
105
|
+
line_index += 1
|
|
106
|
+
|
|
107
|
+
# Build response
|
|
108
|
+
response = {
|
|
109
|
+
"content": '\n'.join(result_lines)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# Add pagination info
|
|
113
|
+
if line_index < len(lines):
|
|
114
|
+
response["nextCursor"] = CursorManager.create_offset_cursor(line_index)
|
|
115
|
+
response["pagination_info"] = {
|
|
116
|
+
"current_lines": f"{offset + 1}-{line_index}",
|
|
117
|
+
"total_lines": len(lines),
|
|
118
|
+
"has_more": True
|
|
119
|
+
}
|
|
120
|
+
else:
|
|
121
|
+
response["pagination_info"] = {
|
|
122
|
+
"current_lines": f"{offset + 1}-{len(lines)}",
|
|
123
|
+
"total_lines": len(lines),
|
|
124
|
+
"has_more": False
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return response
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _handle_list_response(
|
|
131
|
+
items: List[Any],
|
|
132
|
+
cursor: Optional[str],
|
|
133
|
+
max_tokens: int
|
|
134
|
+
) -> Dict[str, Any]:
|
|
135
|
+
"""Handle pagination for list responses."""
|
|
136
|
+
# Parse cursor to get offset
|
|
137
|
+
offset = 0
|
|
138
|
+
if cursor:
|
|
139
|
+
cursor_data = CursorManager.parse_cursor(cursor)
|
|
140
|
+
if cursor_data and "offset" in cursor_data:
|
|
141
|
+
offset = cursor_data["offset"]
|
|
142
|
+
|
|
143
|
+
if offset >= len(items):
|
|
144
|
+
return {"items": [], "message": "No more items"}
|
|
145
|
+
|
|
146
|
+
# Build response item by item, checking tokens
|
|
147
|
+
result_items = []
|
|
148
|
+
current_tokens = 100 # Base overhead
|
|
149
|
+
item_index = offset
|
|
150
|
+
|
|
151
|
+
# Add header if continuation
|
|
152
|
+
header_obj = {}
|
|
153
|
+
if offset > 0:
|
|
154
|
+
header_obj["continuation_from"] = offset
|
|
155
|
+
current_tokens += 50
|
|
156
|
+
|
|
157
|
+
while item_index < len(items):
|
|
158
|
+
item = items[item_index]
|
|
159
|
+
|
|
160
|
+
# Estimate tokens for this item
|
|
161
|
+
item_str = json.dumps(item) if not isinstance(item, str) else item
|
|
162
|
+
item_tokens = estimate_tokens(item_str)
|
|
163
|
+
|
|
164
|
+
# Check if adding this item would exceed limit
|
|
165
|
+
if current_tokens + item_tokens > max_tokens:
|
|
166
|
+
if not result_items:
|
|
167
|
+
# Single item too large, truncate it
|
|
168
|
+
if isinstance(item, str):
|
|
169
|
+
truncated = item[:5000] + "... [truncated]"
|
|
170
|
+
result_items.append(truncated)
|
|
171
|
+
else:
|
|
172
|
+
result_items.append({"error": "Item too large", "index": item_index})
|
|
173
|
+
item_index += 1
|
|
174
|
+
break
|
|
175
|
+
|
|
176
|
+
result_items.append(item)
|
|
177
|
+
current_tokens += item_tokens
|
|
178
|
+
item_index += 1
|
|
179
|
+
|
|
180
|
+
# Build response
|
|
181
|
+
response = {
|
|
182
|
+
"items": result_items
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if header_obj:
|
|
186
|
+
response.update(header_obj)
|
|
187
|
+
|
|
188
|
+
# Add pagination info
|
|
189
|
+
if item_index < len(items):
|
|
190
|
+
response["nextCursor"] = CursorManager.create_offset_cursor(item_index)
|
|
191
|
+
response["pagination_info"] = {
|
|
192
|
+
"returned_items": len(result_items),
|
|
193
|
+
"total_items": len(items),
|
|
194
|
+
"has_more": True,
|
|
195
|
+
"next_index": item_index
|
|
196
|
+
}
|
|
197
|
+
else:
|
|
198
|
+
response["pagination_info"] = {
|
|
199
|
+
"returned_items": len(result_items),
|
|
200
|
+
"total_items": len(items),
|
|
201
|
+
"has_more": False
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return response
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def _handle_dict_response(
|
|
208
|
+
content: Dict[str, Any],
|
|
209
|
+
cursor: Optional[str],
|
|
210
|
+
max_tokens: int
|
|
211
|
+
) -> Dict[str, Any]:
|
|
212
|
+
"""Handle pagination for dict responses."""
|
|
213
|
+
# For dicts, check if it's too large as-is
|
|
214
|
+
content_str = json.dumps(content, indent=2)
|
|
215
|
+
content_tokens = estimate_tokens(content_str)
|
|
216
|
+
|
|
217
|
+
if content_tokens <= max_tokens:
|
|
218
|
+
# Fits within limit
|
|
219
|
+
return content
|
|
220
|
+
|
|
221
|
+
# Too large - need to paginate
|
|
222
|
+
# Strategy: Convert to key-value pairs and paginate
|
|
223
|
+
items = list(content.items())
|
|
224
|
+
offset = 0
|
|
225
|
+
|
|
226
|
+
if cursor:
|
|
227
|
+
cursor_data = CursorManager.parse_cursor(cursor)
|
|
228
|
+
if cursor_data and "offset" in cursor_data:
|
|
229
|
+
offset = cursor_data["offset"]
|
|
230
|
+
|
|
231
|
+
if offset >= len(items):
|
|
232
|
+
return {"content": {}, "message": "No more content"}
|
|
233
|
+
|
|
234
|
+
# Build paginated dict
|
|
235
|
+
result = {}
|
|
236
|
+
current_tokens = 100 # Base overhead
|
|
237
|
+
|
|
238
|
+
for i in range(offset, len(items)):
|
|
239
|
+
key, value = items[i]
|
|
240
|
+
|
|
241
|
+
# Estimate tokens for this entry
|
|
242
|
+
entry_str = json.dumps({key: value})
|
|
243
|
+
entry_tokens = estimate_tokens(entry_str)
|
|
244
|
+
|
|
245
|
+
if current_tokens + entry_tokens > max_tokens:
|
|
246
|
+
if not result:
|
|
247
|
+
# Single entry too large
|
|
248
|
+
result[key] = "[Value too large - use specific key access]"
|
|
249
|
+
break
|
|
250
|
+
|
|
251
|
+
result[key] = value
|
|
252
|
+
current_tokens += entry_tokens
|
|
253
|
+
|
|
254
|
+
# Wrap in response
|
|
255
|
+
response = {
|
|
256
|
+
"content": result
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
# Add pagination info
|
|
260
|
+
processed = offset + len(result)
|
|
261
|
+
if processed < len(items):
|
|
262
|
+
response["nextCursor"] = CursorManager.create_offset_cursor(processed)
|
|
263
|
+
response["pagination_info"] = {
|
|
264
|
+
"keys_returned": len(result),
|
|
265
|
+
"total_keys": len(items),
|
|
266
|
+
"has_more": True
|
|
267
|
+
}
|
|
268
|
+
else:
|
|
269
|
+
response["pagination_info"] = {
|
|
270
|
+
"keys_returned": len(result),
|
|
271
|
+
"total_keys": len(items),
|
|
272
|
+
"has_more": False
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return response
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def paginate_if_needed(
|
|
279
|
+
response: Any,
|
|
280
|
+
cursor: Optional[str] = None,
|
|
281
|
+
force_pagination: bool = False
|
|
282
|
+
) -> Union[str, Dict[str, Any]]:
|
|
283
|
+
"""Wrap a response with automatic pagination if needed.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
response: The response to potentially paginate
|
|
287
|
+
cursor: Optional cursor from request
|
|
288
|
+
force_pagination: Force pagination even for small responses
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
Original response if small enough, otherwise paginated dict
|
|
292
|
+
"""
|
|
293
|
+
# Quick check - if response is already paginated, return as-is
|
|
294
|
+
if isinstance(response, dict) and ("nextCursor" in response or "pagination_info" in response):
|
|
295
|
+
return response
|
|
296
|
+
|
|
297
|
+
# For small responses, don't paginate unless forced
|
|
298
|
+
if not force_pagination:
|
|
299
|
+
try:
|
|
300
|
+
response_str = json.dumps(response) if not isinstance(response, str) else response
|
|
301
|
+
if len(response_str) < 10000: # Quick heuristic
|
|
302
|
+
return response
|
|
303
|
+
except:
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
# Create paginated response
|
|
307
|
+
return AutoPaginatedResponse.create_response(response, cursor)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Pagination utilities for MCP tools.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for implementing cursor-based pagination
|
|
4
|
+
according to the MCP pagination protocol.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any, Dict, List, Optional, Tuple, TypeVar, Generic
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
T = TypeVar('T')
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class PaginationParams:
|
|
18
|
+
"""Parameters for pagination."""
|
|
19
|
+
cursor: Optional[str] = None
|
|
20
|
+
page_size: int = 100 # Default page size
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class PaginatedResponse(Generic[T]):
|
|
25
|
+
"""A paginated response containing items and optional next cursor."""
|
|
26
|
+
items: List[T]
|
|
27
|
+
next_cursor: Optional[str] = None
|
|
28
|
+
|
|
29
|
+
def to_dict(self, items_key: str = "items") -> Dict[str, Any]:
|
|
30
|
+
"""Convert to dictionary format for MCP response.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
items_key: The key to use for items in the response
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Dictionary with items and optional nextCursor
|
|
37
|
+
"""
|
|
38
|
+
result = {items_key: self.items}
|
|
39
|
+
if self.next_cursor:
|
|
40
|
+
result["nextCursor"] = self.next_cursor
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CursorManager:
|
|
45
|
+
"""Manages cursor creation and parsing for pagination."""
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def create_cursor(data: Dict[str, Any]) -> str:
|
|
49
|
+
"""Create an opaque cursor from data.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
data: Data to encode in the cursor
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Base64-encoded cursor string
|
|
56
|
+
"""
|
|
57
|
+
json_data = json.dumps(data, separators=(',', ':'))
|
|
58
|
+
return base64.b64encode(json_data.encode()).decode()
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def parse_cursor(cursor: str) -> Optional[Dict[str, Any]]:
|
|
62
|
+
"""Parse a cursor string back to data.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
cursor: Base64-encoded cursor string
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Decoded data or None if invalid
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
decoded = base64.b64decode(cursor.encode()).decode()
|
|
72
|
+
return json.loads(decoded)
|
|
73
|
+
except (ValueError, json.JSONDecodeError):
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def create_offset_cursor(offset: int) -> str:
|
|
78
|
+
"""Create a cursor for offset-based pagination.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
offset: The offset for the next page
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Cursor string
|
|
85
|
+
"""
|
|
86
|
+
return CursorManager.create_cursor({"offset": offset})
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def parse_offset_cursor(cursor: Optional[str]) -> int:
|
|
90
|
+
"""Parse an offset cursor.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
cursor: Cursor string or None
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Offset value (0 if cursor is None or invalid)
|
|
97
|
+
"""
|
|
98
|
+
if not cursor:
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
data = CursorManager.parse_cursor(cursor)
|
|
102
|
+
if not data or "offset" not in data:
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
return int(data["offset"])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Paginator(Generic[T]):
|
|
109
|
+
"""Generic paginator for any list of items."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, items: List[T], page_size: int = 100):
|
|
112
|
+
"""Initialize paginator.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
items: List of items to paginate
|
|
116
|
+
page_size: Number of items per page
|
|
117
|
+
"""
|
|
118
|
+
self.items = items
|
|
119
|
+
self.page_size = page_size
|
|
120
|
+
|
|
121
|
+
def get_page(self, cursor: Optional[str] = None) -> PaginatedResponse[T]:
|
|
122
|
+
"""Get a page of results.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
cursor: Optional cursor for the page
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Paginated response with items and next cursor
|
|
129
|
+
"""
|
|
130
|
+
offset = CursorManager.parse_offset_cursor(cursor)
|
|
131
|
+
|
|
132
|
+
# Get the page of items
|
|
133
|
+
start = offset
|
|
134
|
+
end = min(start + self.page_size, len(self.items))
|
|
135
|
+
page_items = self.items[start:end]
|
|
136
|
+
|
|
137
|
+
# Create next cursor if there are more items
|
|
138
|
+
next_cursor = None
|
|
139
|
+
if end < len(self.items):
|
|
140
|
+
next_cursor = CursorManager.create_offset_cursor(end)
|
|
141
|
+
|
|
142
|
+
return PaginatedResponse(items=page_items, next_cursor=next_cursor)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class StreamPaginator(Generic[T]):
|
|
146
|
+
"""Paginator for streaming/generator-based results."""
|
|
147
|
+
|
|
148
|
+
def __init__(self, page_size: int = 100):
|
|
149
|
+
"""Initialize stream paginator.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
page_size: Number of items per page
|
|
153
|
+
"""
|
|
154
|
+
self.page_size = page_size
|
|
155
|
+
|
|
156
|
+
def paginate_stream(
|
|
157
|
+
self,
|
|
158
|
+
stream_generator,
|
|
159
|
+
cursor: Optional[str] = None
|
|
160
|
+
) -> PaginatedResponse[T]:
|
|
161
|
+
"""Paginate results from a stream/generator.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
stream_generator: Generator function that yields items
|
|
165
|
+
cursor: Optional cursor for resuming
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Paginated response
|
|
169
|
+
"""
|
|
170
|
+
items = []
|
|
171
|
+
skip_count = 0
|
|
172
|
+
|
|
173
|
+
# Parse cursor to get skip count
|
|
174
|
+
if cursor:
|
|
175
|
+
cursor_data = CursorManager.parse_cursor(cursor)
|
|
176
|
+
if cursor_data and "skip" in cursor_data:
|
|
177
|
+
skip_count = cursor_data["skip"]
|
|
178
|
+
|
|
179
|
+
# Skip items based on cursor
|
|
180
|
+
item_count = 0
|
|
181
|
+
for item in stream_generator():
|
|
182
|
+
if item_count < skip_count:
|
|
183
|
+
item_count += 1
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
items.append(item)
|
|
187
|
+
if len(items) >= self.page_size:
|
|
188
|
+
# We have a full page, create cursor for next page
|
|
189
|
+
next_cursor = CursorManager.create_cursor({
|
|
190
|
+
"skip": skip_count + len(items)
|
|
191
|
+
})
|
|
192
|
+
return PaginatedResponse(items=items, next_cursor=next_cursor)
|
|
193
|
+
|
|
194
|
+
# No more items
|
|
195
|
+
return PaginatedResponse(items=items, next_cursor=None)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def paginate_list(
|
|
199
|
+
items: List[T],
|
|
200
|
+
cursor: Optional[str] = None,
|
|
201
|
+
page_size: int = 100
|
|
202
|
+
) -> PaginatedResponse[T]:
|
|
203
|
+
"""Convenience function to paginate a list.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
items: List to paginate
|
|
207
|
+
cursor: Optional cursor
|
|
208
|
+
page_size: Items per page
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Paginated response
|
|
212
|
+
"""
|
|
213
|
+
paginator = Paginator(items, page_size)
|
|
214
|
+
return paginator.get_page(cursor)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def validate_cursor(cursor: str) -> bool:
|
|
218
|
+
"""Validate that a cursor is properly formatted.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
cursor: Cursor string to validate
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
True if valid, False otherwise
|
|
225
|
+
"""
|
|
226
|
+
return CursorManager.parse_cursor(cursor) is not None
|