pyrepowiki-cli 0.1.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.
@@ -0,0 +1,164 @@
1
+ """MCP client wrapper for the DeepWiki API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from contextlib import AsyncExitStack
8
+ from typing import Any
9
+
10
+ import httpx
11
+ from mcp import ClientSession
12
+ from mcp.client.streamable_http import streamablehttp_client
13
+
14
+ from repowiki.shared.model import Answer
15
+
16
+ logging.getLogger("httpx_sse").setLevel(logging.ERROR)
17
+ logging.getLogger("mcp.client.streamable_http").setLevel(logging.ERROR)
18
+
19
+ DEFAULT_MCP_URL = "https://mcp.deepwiki.com/mcp"
20
+
21
+
22
+ class DeepWikiError(Exception):
23
+ """Base exception for DeepWiki errors."""
24
+
25
+
26
+ class ConnectionError(DeepWikiError):
27
+ """Failed to connect to the DeepWiki server."""
28
+
29
+
30
+ class ToolError(DeepWikiError):
31
+ """Error returned from an MCP tool execution."""
32
+
33
+
34
+ def _root_cause(exc: BaseException) -> BaseException:
35
+ """Unwrap an exception group (Python 3.11+) to its first leaf cause."""
36
+ while (sub := getattr(exc, "exceptions", None)):
37
+ exc = sub[0]
38
+ return exc
39
+
40
+
41
+ def _is_connection_failure(exc: BaseException) -> bool:
42
+ if isinstance(exc, (OSError, httpx.TransportError)):
43
+ return True
44
+ message = str(exc).lower()
45
+ return "connection" in message or "timeout" in message
46
+
47
+
48
+ class DeepWikiClient:
49
+ """Client for interacting with DeepWiki via MCP.
50
+
51
+ Can be used one-shot (each call opens and closes its own connection) or as
52
+ a persistent session via the async context manager, which reuses a single
53
+ connection across calls — the REPL uses this to avoid re-handshaking per
54
+ question.
55
+ """
56
+
57
+ def __init__(self, base_url: str | None = None) -> None:
58
+ self.base_url = base_url or os.environ.get("DEEPWIKI_MCP_URL", DEFAULT_MCP_URL)
59
+ self._stack: AsyncExitStack | None = None
60
+ self._session: ClientSession | None = None
61
+
62
+ # -- persistent session management -------------------------------------
63
+
64
+ async def __aenter__(self) -> "DeepWikiClient":
65
+ await self.open()
66
+ return self
67
+
68
+ async def __aexit__(self, *exc_info: object) -> None:
69
+ await self.close()
70
+
71
+ async def open(self) -> None:
72
+ """Open and initialize a session, retrying once on connection failure."""
73
+ last: ConnectionError | None = None
74
+ for _ in range(2):
75
+ try:
76
+ await self._open_once()
77
+ return
78
+ except ConnectionError as exc:
79
+ last = exc
80
+ assert last is not None
81
+ raise last
82
+
83
+ async def close(self) -> None:
84
+ """Close the open session, if any."""
85
+ stack, self._stack = self._stack, None
86
+ self._session = None
87
+ if stack is not None:
88
+ await stack.aclose()
89
+
90
+ async def _open_once(self) -> None:
91
+ stack = AsyncExitStack()
92
+ try:
93
+ read, write, _ = await stack.enter_async_context(
94
+ streamablehttp_client(self.base_url)
95
+ )
96
+ session = await stack.enter_async_context(ClientSession(read, write))
97
+ await session.initialize()
98
+ except Exception as exc:
99
+ await stack.aclose()
100
+ root = _root_cause(exc)
101
+ if _is_connection_failure(root):
102
+ raise ConnectionError(
103
+ f"Failed to connect to DeepWiki server: {root}"
104
+ ) from exc
105
+ raise ToolError(f"Failed to initialize MCP session: {root}") from exc
106
+ self._stack = stack
107
+ self._session = session
108
+
109
+ # -- tool calls ---------------------------------------------------------
110
+
111
+ async def _invoke(self, tool_name: str, arguments: dict[str, Any]) -> str:
112
+ assert self._session is not None
113
+ try:
114
+ result = await self._session.call_tool(tool_name, arguments)
115
+ except Exception as exc:
116
+ root = _root_cause(exc)
117
+ if _is_connection_failure(root):
118
+ raise ConnectionError(
119
+ f"Failed to connect to DeepWiki server: {root}"
120
+ ) from exc
121
+ raise ToolError(f"Tool '{tool_name}' failed: {root}") from exc
122
+ return self._extract_text_content(result)
123
+
124
+ async def _call_oneshot(self, tool_name: str, arguments: dict[str, Any]) -> str:
125
+ try:
126
+ await self.open()
127
+ return await self._invoke(tool_name, arguments)
128
+ finally:
129
+ await self.close()
130
+
131
+ async def _call_persistent(self, tool_name: str, arguments: dict[str, Any]) -> str:
132
+ for attempt in range(2):
133
+ try:
134
+ return await self._invoke(tool_name, arguments)
135
+ except ConnectionError as exc:
136
+ if attempt == 0:
137
+ await self.close()
138
+ await self.open()
139
+ continue
140
+ raise
141
+
142
+ def _extract_text_content(self, result: Any) -> str:
143
+ parts = [c.text for c in result.content if getattr(c, "type", None) == "text"]
144
+ text = "\n".join(parts)
145
+ if result.isError:
146
+ raise ToolError(text or "Unknown tool error")
147
+ return text
148
+
149
+ async def _call_tool(self, tool_name: str, arguments: dict[str, Any]) -> str:
150
+ if self._session is None:
151
+ return await self._call_oneshot(tool_name, arguments)
152
+ return await self._call_persistent(tool_name, arguments)
153
+
154
+ async def read_wiki_structure(self, repo_name: str) -> str:
155
+ return await self._call_tool("read_wiki_structure", {"repoName": repo_name})
156
+
157
+ async def read_wiki_contents(self, repo_name: str) -> str:
158
+ return await self._call_tool("read_wiki_contents", {"repoName": repo_name})
159
+
160
+ async def ask_question(self, repo_name: str, question: str) -> Answer:
161
+ text = await self._call_tool(
162
+ "ask_question", {"repoName": repo_name, "question": question}
163
+ )
164
+ return Answer(body=text)
@@ -0,0 +1,74 @@
1
+ """Codemap JSON → Mermaid flowchart rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+
8
+ _TRACE_COLORS = [
9
+ ("#e8f5e9", "#4caf50"),
10
+ ("#e3f2fd", "#2196f3"),
11
+ ("#fff3e0", "#ff9800"),
12
+ ("#f3e5f5", "#9c27b0"),
13
+ ("#fff8e1", "#ffc107"),
14
+ ("#fce4ec", "#e91e63"),
15
+ ("#e0f2f1", "#009688"),
16
+ ("#fbe9e7", "#ff5722"),
17
+ ]
18
+
19
+
20
+ def _sanitize_id(value: object) -> str:
21
+ return re.sub(r"[^a-zA-Z0-9]", "_", str(value))
22
+
23
+
24
+ def _escape_label(value: str) -> str:
25
+ return value.replace('"', "#quot;").replace("\n", " ")
26
+
27
+
28
+ def _short_path(path: str) -> str:
29
+ return path.rsplit("/", 1)[-1]
30
+
31
+
32
+ def codemap_to_mermaid(text: str) -> str | None:
33
+ """Return a Mermaid flowchart for a codemap JSON blob, else None."""
34
+ try:
35
+ data = json.loads(text)
36
+ except (json.JSONDecodeError, TypeError):
37
+ return None
38
+ if not isinstance(data, dict) or not isinstance(data.get("traces"), list):
39
+ return None
40
+ return _render(data["traces"])
41
+
42
+
43
+ def _render(traces: list[dict]) -> str:
44
+ lines = ["flowchart TB"]
45
+ for i, trace in enumerate(traces):
46
+ sg_id = _sanitize_id(f"trace_{trace.get('id', i)}")
47
+ title = _escape_label(str(trace.get("title", "")))
48
+ lines.append("")
49
+ lines.append(f' subgraph {sg_id}["{trace.get("id", i)}. {title}"]')
50
+ locations = trace.get("locations", [])
51
+ for loc in locations:
52
+ loc_id = _sanitize_id(f"loc_{loc.get('id')}")
53
+ loc_title = _escape_label(str(loc.get("title", "")))
54
+ filename = _short_path(str(loc.get("path", "")))
55
+ label = f"{loc_title}\\n{filename}:{loc.get('lineNumber', '')}"
56
+ lines.append(f' {loc_id}["{label}"]')
57
+ lines.append(" end")
58
+ for j in range(len(locations) - 1):
59
+ a = _sanitize_id(f"loc_{locations[j].get('id')}")
60
+ b = _sanitize_id(f"loc_{locations[j + 1].get('id')}")
61
+ lines.append(f" {a} --> {b}")
62
+ for i in range(len(traces) - 1):
63
+ curr = traces[i].get("locations", [])
64
+ nxt = traces[i + 1].get("locations", [])
65
+ if curr and nxt:
66
+ a = _sanitize_id(f"loc_{curr[-1].get('id')}")
67
+ b = _sanitize_id(f"loc_{nxt[0].get('id')}")
68
+ lines.append(f" {a} -.-> {b}")
69
+ lines.append("")
70
+ for i, trace in enumerate(traces):
71
+ sg_id = _sanitize_id(f"trace_{trace.get('id', i)}")
72
+ fill, stroke = _TRACE_COLORS[i % len(_TRACE_COLORS)]
73
+ lines.append(f" style {sg_id} fill:{fill},stroke:{stroke},stroke-width:2px")
74
+ return "\n".join(lines)
@@ -0,0 +1,355 @@
1
+ """Reverse-engineered api.devin.ai client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import re
9
+ import ssl
10
+ import time
11
+ from collections.abc import Callable
12
+ from contextlib import nullcontext
13
+ from uuid import uuid4
14
+
15
+ import httpx
16
+ import websockets
17
+ from websockets.exceptions import ConnectionClosedOK, WebSocketException
18
+
19
+ from repowiki.services.deepwiki.client import ConnectionError, ToolError
20
+ from repowiki.shared.model import Answer, Reference, SourceFile
21
+
22
+
23
+ _CITE_RE = re.compile(r"<cite\s+([^>]*?)\s*/?>")
24
+ _CITE_ATTR_RE = re.compile(r'(\w+)="([^"]*)"')
25
+
26
+
27
+ def _parse_cites(body: str) -> tuple[str, list[Reference]]:
28
+ """Replace inline ``<cite …/>`` tags with ``[i]`` markers, returning references.
29
+
30
+ Some responses encode citations as self-closing tags carrying ``path`` and
31
+ ``start`` (an ``X-Y`` line range) instead of separate ``reference`` events.
32
+ """
33
+ references: list[Reference] = []
34
+
35
+ def _replace(match: re.Match[str]) -> str:
36
+ attrs = dict(_CITE_ATTR_RE.findall(match.group(1)))
37
+ path = attrs.get("path", "")
38
+ if not path:
39
+ return match.group(0)
40
+ parts = attrs.get("start", "").split("-")
41
+ try:
42
+ range_start = int(parts[0])
43
+ range_end = int(parts[1]) if len(parts) > 1 else range_start
44
+ except (ValueError, IndexError):
45
+ range_start = range_end = None
46
+ references.append(Reference(path, range_start, range_end))
47
+ return f"[{len(references)}]"
48
+
49
+ return _CITE_RE.sub(_replace, body), references
50
+
51
+
52
+ def parse_response(query: dict, query_id: str | None) -> Answer:
53
+ """Assemble an Answer from a single query block's response events."""
54
+ body: list[str] = []
55
+ summary: list[str] = []
56
+ references: list[Reference] = []
57
+ sources: list[SourceFile] = []
58
+ seen: set[tuple[str, str]] = set()
59
+ stats: dict[str, float] = {}
60
+
61
+ for event in query.get("response", []):
62
+ kind = event.get("type")
63
+ data = event.get("data")
64
+ if kind == "chunk":
65
+ body.append(data)
66
+ elif kind == "summary_chunk":
67
+ summary.append(data)
68
+ elif kind == "reference":
69
+ references.append(
70
+ Reference(
71
+ file_path=data["file_path"],
72
+ range_start=data.get("range_start"),
73
+ range_end=data.get("range_end"),
74
+ )
75
+ )
76
+ body.append(f"[{len(references)}]")
77
+ elif kind == "file_contents":
78
+ repo, path, content = data
79
+ key = (repo, path)
80
+ if key not in seen:
81
+ seen.add(key)
82
+ sources.append(SourceFile(repo=repo, path=path, content=content))
83
+ elif kind == "stats":
84
+ stats[data["key"]] = data["value"]
85
+ elif kind == "done":
86
+ break
87
+
88
+ body_text = "".join(body)
89
+ if not references and "<cite" in body_text:
90
+ body_text, references = _parse_cites(body_text)
91
+
92
+ return Answer(
93
+ body=body_text,
94
+ summary="".join(summary) or None,
95
+ references=references,
96
+ sources=sources,
97
+ stats=stats,
98
+ query_id=query_id,
99
+ )
100
+
101
+
102
+ DEFAULT_API_URL = "https://api.devin.ai"
103
+
104
+ ENGINE_MAP = {"fast": "multihop_faster", "deep": "agent", "codemap": "codemap"}
105
+
106
+
107
+ def _http_detail(exc: httpx.HTTPStatusError) -> str:
108
+ """Surface FastAPI's ``{"detail": ...}`` body so the CLI can classify it."""
109
+ try:
110
+ detail = exc.response.json().get("detail")
111
+ except Exception:
112
+ return ""
113
+ if isinstance(detail, str) and detail:
114
+ return f": {detail}"
115
+ return ""
116
+
117
+
118
+ def _is_cert_error(exc: BaseException) -> bool:
119
+ """True if any cause in the exception chain is a TLS cert verification error."""
120
+ while exc is not None:
121
+ if isinstance(exc, ssl.SSLCertVerificationError):
122
+ return True
123
+ exc = exc.__cause__ or exc.__context__
124
+ return False
125
+
126
+
127
+ def _connection_message(host: str, exc: httpx.TransportError) -> str:
128
+ """Build a connection error message, naming the host and hinting on TLS failure."""
129
+ message = f"Failed to connect to {host}: {exc}"
130
+ if _is_cert_error(exc):
131
+ message += (
132
+ " (TLS certificate verification failed; set SSL_CERT_FILE "
133
+ "to your CA bundle to trust a proxy/mirror)"
134
+ )
135
+ return message
136
+
137
+
138
+ class DevinClient:
139
+ """Client for the reverse-engineered api.devin.ai Q&A endpoints."""
140
+
141
+ def __init__(self, base_url: str | None = None) -> None:
142
+ self.base_url = base_url or os.environ.get("DEEPWIKI_API_URL", DEFAULT_API_URL)
143
+
144
+ async def _get_json(
145
+ self,
146
+ path: str,
147
+ *,
148
+ params: dict | None = None,
149
+ client: httpx.AsyncClient | None = None,
150
+ ) -> dict:
151
+ ctx = nullcontext(client) if client is not None else httpx.AsyncClient(
152
+ base_url=self.base_url, timeout=30.0
153
+ )
154
+ try:
155
+ async with ctx as c:
156
+ resp = await c.get(path, params=params)
157
+ resp.raise_for_status()
158
+ return resp.json()
159
+ except httpx.TransportError as exc:
160
+ raise ConnectionError(_connection_message("Devin server", exc)) from exc
161
+ except httpx.HTTPStatusError as exc:
162
+ raise ToolError(
163
+ f"Devin API returned HTTP {exc.response.status_code}{_http_detail(exc)}"
164
+ ) from exc
165
+
166
+ async def _post_json(
167
+ self,
168
+ path: str,
169
+ *,
170
+ params: dict | None = None,
171
+ json: dict | None = None,
172
+ client: httpx.AsyncClient | None = None,
173
+ ) -> dict:
174
+ ctx = nullcontext(client) if client is not None else httpx.AsyncClient(
175
+ base_url=self.base_url, timeout=30.0
176
+ )
177
+ try:
178
+ async with ctx as c:
179
+ resp = await c.post(path, params=params, json=json)
180
+ resp.raise_for_status()
181
+ return resp.json()
182
+ except httpx.TransportError as exc:
183
+ raise ConnectionError(_connection_message("Devin server", exc)) from exc
184
+ except httpx.HTTPStatusError as exc:
185
+ raise ToolError(
186
+ f"Devin API returned HTTP {exc.response.status_code}{_http_detail(exc)}"
187
+ ) from exc
188
+
189
+ async def ask(
190
+ self,
191
+ repos: list[str],
192
+ question: str,
193
+ *,
194
+ mode: str = "fast",
195
+ query_id: str | None = None,
196
+ timeout: float = 120.0,
197
+ poll_interval: float = 2.0,
198
+ context: str = "",
199
+ generate_summary: bool = True,
200
+ on_chunk: Callable[[str], None] | None = None,
201
+ ) -> Answer:
202
+ engine_id = ENGINE_MAP.get(mode)
203
+ if engine_id is None:
204
+ raise ToolError(f"unknown mode {mode!r}")
205
+ qid = query_id or str(uuid4())
206
+ payload = {
207
+ "engine_id": engine_id,
208
+ "user_query": question,
209
+ "keywords": [],
210
+ "repo_names": list(repos),
211
+ "additional_context": context,
212
+ "query_id": qid,
213
+ "use_notes": False,
214
+ "attached_context": [],
215
+ "generate_summary": generate_summary,
216
+ }
217
+ async with httpx.AsyncClient(base_url=self.base_url, timeout=30.0) as client:
218
+ await self._post_json("/ada/query", json=payload, client=client)
219
+ if on_chunk is not None:
220
+ events = await self._stream_chunks(qid, on_chunk, timeout=timeout)
221
+ return parse_response({"response": events}, qid)
222
+ return await self._poll_query(
223
+ qid, client=client, poll_interval=poll_interval, timeout=timeout
224
+ )
225
+
226
+ async def _stream_chunks(
227
+ self, qid: str, on_chunk: Callable[[str], None], *, timeout: float
228
+ ) -> list[dict]:
229
+ """Stream events over the WebSocket, emitting answer text, until 'done'.
230
+
231
+ Chunk text and inline citation markers (``[i]`` emitted at each
232
+ ``reference`` event) are forwarded through ``on_chunk``, so the streamed
233
+ body shows citations inline and lines up with the final ``## Sources``.
234
+
235
+ Returns the full event list so the caller can assemble a complete
236
+ Answer (body, summary, references, sources) without a follow-up GET.
237
+ """
238
+ ws_url = f"{self.base_url.replace('http', 'ws', 1)}/ada/ws/query/{qid}"
239
+ deadline = time.monotonic() + timeout
240
+ events: list[dict] = []
241
+ ref_count = 0
242
+ try:
243
+ async with websockets.connect(ws_url) as ws:
244
+ while True:
245
+ remaining = deadline - time.monotonic()
246
+ if remaining <= 0:
247
+ raise ToolError("timed out waiting for answer")
248
+ try:
249
+ raw = await asyncio.wait_for(ws.recv(), timeout=remaining)
250
+ except asyncio.TimeoutError:
251
+ raise ToolError("timed out waiting for answer")
252
+ except ConnectionClosedOK:
253
+ break
254
+ try:
255
+ msg = json.loads(raw)
256
+ except json.JSONDecodeError:
257
+ continue
258
+ if not isinstance(msg, dict):
259
+ continue
260
+ kind = msg.get("type")
261
+ if kind is None:
262
+ continue
263
+ if kind == "done":
264
+ break
265
+ events.append(msg)
266
+ if kind == "chunk":
267
+ on_chunk(msg.get("data", ""))
268
+ elif kind == "reference":
269
+ ref_count += 1
270
+ on_chunk(f"[{ref_count}]")
271
+ except (OSError, WebSocketException) as exc:
272
+ raise ConnectionError(f"WebSocket connection failed: {exc}") from exc
273
+ return events
274
+
275
+ async def _poll_query(
276
+ self,
277
+ qid: str,
278
+ *,
279
+ client: httpx.AsyncClient,
280
+ poll_interval: float,
281
+ timeout: float,
282
+ ) -> Answer:
283
+ deadline = time.monotonic() + timeout
284
+ while True:
285
+ await asyncio.sleep(poll_interval)
286
+ if time.monotonic() > deadline:
287
+ raise ToolError("timed out waiting for answer")
288
+ data = await self._get_json(f"/ada/query/{qid}", client=client)
289
+ queries = data.get("queries")
290
+ if not queries:
291
+ raise ToolError("Devin API returned no query results")
292
+ query = queries[-1]
293
+ if query.get("state") in ("done", "error"):
294
+ break
295
+ if query.get("error"):
296
+ raise ToolError(str(query["error"]))
297
+ return parse_response(query, qid)
298
+
299
+ async def list_public_indexes(self, search: str) -> dict:
300
+ return await self._get_json("/ada/list_public_indexes", params={"search_repo": search})
301
+
302
+ async def public_repo_indexing_status(self, repo: str) -> dict:
303
+ return await self._get_json("/ada/public_repo_indexing_status", params={"repo_name": repo})
304
+
305
+ async def warm_public_repo(self, repo: str) -> dict:
306
+ return await self._post_json("/ada/warm_public_repo", params={"repo_name": repo})
307
+
308
+ async def repo_index(self, repo: str) -> dict | None:
309
+ """Return the ``list_public_indexes`` entry matching ``repo`` exactly.
310
+
311
+ The search is fuzzy, so filter to the entry whose ``repo_name`` equals
312
+ the requested repo; return ``None`` when the repo has no index.
313
+ """
314
+ data = await self.list_public_indexes(repo)
315
+ for entry in data.get("indices") or []:
316
+ if entry.get("repo_name") == repo:
317
+ return entry
318
+ return None
319
+
320
+ async def github_head(self, repo: str) -> dict:
321
+ """Return the GitHub HEAD sha and commit date for ``repo``.
322
+
323
+ ``id`` in the index (e.g. ``…/IDPFold2/5315b279``) ends in a short
324
+ commit sha, so compare it against GitHub HEAD to detect a stale wiki.
325
+ """
326
+ owner, name = repo.split("/", 1)
327
+ try:
328
+ async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
329
+ resp = await client.get(
330
+ f"https://api.github.com/repos/{owner}/{name}/commits/HEAD"
331
+ )
332
+ resp.raise_for_status()
333
+ try:
334
+ commit = resp.json()
335
+ except json.JSONDecodeError as exc:
336
+ raise ToolError("invalid JSON response from GitHub") from exc
337
+ except httpx.TransportError as exc:
338
+ raise ConnectionError(_connection_message("GitHub", exc)) from exc
339
+ except httpx.HTTPStatusError as exc:
340
+ raise ToolError(f"GitHub API returned HTTP {exc.response.status_code}") from exc
341
+ if not isinstance(commit, dict):
342
+ raise ToolError("unexpected GitHub response")
343
+ committer = commit.get("commit", {}).get("committer") or {}
344
+ when = committer.get("date") if isinstance(committer, dict) else None
345
+ return {"sha": commit.get("sha") or "", "when": when}
346
+
347
+ async def get_query(self, query_id: str) -> Answer:
348
+ data = await self._get_json(f"/ada/query/{query_id}")
349
+ queries = data.get("queries")
350
+ if not queries:
351
+ raise ToolError("Devin API returned no query results")
352
+ query = queries[-1]
353
+ if query.get("error"):
354
+ raise ToolError(str(query["error"]))
355
+ return parse_response(query, query_id)
@@ -0,0 +1,5 @@
1
+ """Zread service: query zread.ai docs for public GitHub repositories."""
2
+
3
+ from repowiki.services.zread.cli import register
4
+
5
+ __all__ = ["register"]