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,237 @@
1
+ """CodeWiki client: bootstrap + Google batchexecute RPC."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import ssl
9
+ import time
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ import httpx
14
+
15
+ from repowiki.services.codewiki import boq
16
+ from repowiki.services.codewiki.wiki import Wiki, parse as parse_wiki
17
+
18
+ SITE = "https://codewiki.google"
19
+ ENDPOINT = f"{SITE}/_/BoqAngularSdlcAgentsUi/data/batchexecute"
20
+ USER_AGENT = (
21
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
22
+ "(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"
23
+ )
24
+ CACHE_TTL_SECS = 6 * 60 * 60
25
+
26
+ FALLBACK_BL = "boq_sdlc-agents-ui_20260504.02_p0"
27
+ FALLBACK_SID = "-8491411211174446345"
28
+
29
+ _WIZ_RE = re.compile(r"WIZ_global_data\s*=\s*(\{[\s\S]*?\});")
30
+
31
+
32
+ class CodeWikiError(Exception):
33
+ """Base error for CodeWiki."""
34
+
35
+
36
+ class CodeWikiConnectionError(CodeWikiError):
37
+ """Failed to reach codewiki.google."""
38
+
39
+
40
+ @dataclass
41
+ class _Bootstrap:
42
+ bl: str
43
+ sid: str
44
+ fetched_at: float = 0.0
45
+
46
+
47
+ def extract_bootstrap(html: str) -> _Bootstrap:
48
+ match = _WIZ_RE.search(html)
49
+ if match is None:
50
+ raise ValueError("WIZ_global_data not found in page HTML")
51
+ try:
52
+ data = json.loads(match.group(1))
53
+ except json.JSONDecodeError as exc:
54
+ raise ValueError("WIZ_global_data is not valid JSON") from exc
55
+ bl = data.get("cfb2h")
56
+ sid = data.get("FdrFJe")
57
+ if not isinstance(bl, str):
58
+ raise ValueError("missing cfb2h (build label) in WIZ_global_data")
59
+ if not isinstance(sid, str):
60
+ raise ValueError("missing FdrFJe (session id) in WIZ_global_data")
61
+ return _Bootstrap(bl=bl, sid=sid, fetched_at=time.time())
62
+
63
+
64
+ def _cache_path() -> Path | None:
65
+ if env := os.environ.get("CODEWIKI_CACHE_DIR"):
66
+ return Path(env) / "bootstrap.json"
67
+ base = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")
68
+ return Path(base) / "codewiki" / "bootstrap.json"
69
+
70
+
71
+ def _read_cache(path: Path) -> _Bootstrap | None:
72
+ try:
73
+ data = json.loads(path.read_text(encoding="utf-8"))
74
+ return _Bootstrap(
75
+ bl=data["bl"], sid=data["sid"], fetched_at=float(data.get("fetched_at", 0))
76
+ )
77
+ except (OSError, ValueError, KeyError, TypeError):
78
+ return None
79
+
80
+
81
+ def _write_cache(path: Path | None, bs: _Bootstrap) -> None:
82
+ if path is None:
83
+ return
84
+ try:
85
+ path.parent.mkdir(parents=True, exist_ok=True)
86
+ path.write_text(
87
+ json.dumps({"bl": bs.bl, "sid": bs.sid, "fetched_at": bs.fetched_at}),
88
+ encoding="utf-8",
89
+ )
90
+ except OSError:
91
+ pass
92
+
93
+
94
+ async def _fetch_bootstrap(http: httpx.AsyncClient) -> _Bootstrap:
95
+ resp = await http.get(SITE)
96
+ resp.raise_for_status()
97
+ return extract_bootstrap(resp.text)
98
+
99
+
100
+ async def _load_bootstrap(http: httpx.AsyncClient) -> _Bootstrap:
101
+ path = _cache_path()
102
+ cached = _read_cache(path) if path is not None else None
103
+ if cached is not None and time.time() - cached.fetched_at < CACHE_TTL_SECS:
104
+ return cached
105
+ try:
106
+ fresh = await _fetch_bootstrap(http)
107
+ except Exception:
108
+ return _Bootstrap(bl=FALLBACK_BL, sid=FALLBACK_SID, fetched_at=time.time())
109
+ _write_cache(path, fresh)
110
+ return fresh
111
+
112
+
113
+ class CodeWikiClient:
114
+ """Stateless client; the bootstrap pair is cached on disk (6h TTL)."""
115
+
116
+ def __init__(self, transport: httpx.AsyncBaseTransport | None = None) -> None:
117
+ self._transport = transport
118
+
119
+ def _client(self) -> httpx.AsyncClient:
120
+ return httpx.AsyncClient(
121
+ timeout=30.0,
122
+ verify=True,
123
+ headers={"User-Agent": USER_AGENT},
124
+ transport=self._transport,
125
+ )
126
+
127
+ async def read_wiki(self, repo: str) -> Wiki:
128
+ async with self._client() as http:
129
+ bs = await _load_bootstrap(http)
130
+ inner = json.dumps([_github_url(repo)])
131
+ payload = await self._call(http, bs, "VSX6ub", repo, inner)
132
+ return parse_wiki(payload)
133
+
134
+ async def ask(self, repo: str, question: str) -> str:
135
+ async with self._client() as http:
136
+ bs = await _load_bootstrap(http)
137
+ inner = json.dumps([[[question, "user"]], [None, _github_url(repo)]])
138
+ payload = await self._call(http, bs, "EgIxfe", repo, inner)
139
+ if isinstance(payload, list) and payload and isinstance(payload[0], str):
140
+ return payload[0]
141
+ raise CodeWikiError("EgIxfe response did not contain an answer string")
142
+
143
+ async def github_head(self, repo: str) -> dict:
144
+ """Return the GitHub HEAD sha and commit date for ``repo``.
145
+
146
+ Used to compare the wiki's ``commit_sha`` against the live branch to
147
+ detect a stale wiki.
148
+ """
149
+ owner, name = repo.split("/", 1)
150
+ try:
151
+ async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
152
+ resp = await client.get(
153
+ f"https://api.github.com/repos/{owner}/{name}/commits/HEAD"
154
+ )
155
+ resp.raise_for_status()
156
+ commit = resp.json()
157
+ except httpx.TransportError as exc:
158
+ message = f"Failed to connect to GitHub: {exc}"
159
+ if _is_cert_error(exc):
160
+ message += (
161
+ " (TLS certificate verification failed; set SSL_CERT_FILE to "
162
+ "your CA bundle to trust a proxy/mirror)"
163
+ )
164
+ raise CodeWikiConnectionError(message) from exc
165
+ except httpx.HTTPStatusError as exc:
166
+ raise CodeWikiError(
167
+ f"GitHub API returned HTTP {exc.response.status_code}"
168
+ ) from exc
169
+ if not isinstance(commit, dict):
170
+ raise CodeWikiError("unexpected GitHub response")
171
+ committer = commit.get("commit", {}).get("committer") or {}
172
+ when = committer.get("date") if isinstance(committer, dict) else None
173
+ return {"sha": commit.get("sha") or "", "when": when}
174
+
175
+ async def _call(
176
+ self,
177
+ http: httpx.AsyncClient,
178
+ bs: _Bootstrap,
179
+ rpc_id: str,
180
+ repo: str,
181
+ inner_json: str,
182
+ ) -> object:
183
+ params = {
184
+ "rpcids": rpc_id,
185
+ "source-path": f"/github.com/{repo}",
186
+ "bl": bs.bl,
187
+ "f.sid": bs.sid,
188
+ "hl": "en-US",
189
+ "_reqid": _pseudo_reqid(),
190
+ "rt": "c",
191
+ }
192
+ headers = {
193
+ "Accept-Language": "en-US,en;q=0.9",
194
+ "Origin": SITE,
195
+ "Referer": f"{SITE}/github.com/{repo}",
196
+ "X-Same-Domain": "1",
197
+ "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
198
+ }
199
+ try:
200
+ resp = await http.post(
201
+ ENDPOINT,
202
+ params=params,
203
+ headers=headers,
204
+ content=boq.encode_request(rpc_id, inner_json),
205
+ )
206
+ except httpx.TransportError as exc:
207
+ message = f"Failed to connect to CodeWiki: {exc}"
208
+ if _is_cert_error(exc):
209
+ message += (
210
+ " (TLS certificate verification failed; set SSL_CERT_FILE to "
211
+ "your CA bundle to trust a proxy/mirror)"
212
+ )
213
+ raise CodeWikiConnectionError(message) from exc
214
+ if resp.status_code != 200:
215
+ raise CodeWikiError(
216
+ f"batchexecute returned HTTP {resp.status_code}: {resp.text[:200]}"
217
+ )
218
+ try:
219
+ return boq.decode_response(resp.text, rpc_id)
220
+ except ValueError as exc:
221
+ raise CodeWikiError(str(exc)) from exc
222
+
223
+
224
+ def _github_url(repo: str) -> str:
225
+ return f"https://github.com/{repo}"
226
+
227
+
228
+ def _pseudo_reqid() -> int:
229
+ return 100_000 + (int(time.time()) % 900_000)
230
+
231
+
232
+ def _is_cert_error(exc: BaseException) -> bool:
233
+ while exc is not None:
234
+ if isinstance(exc, ssl.SSLCertVerificationError):
235
+ return True
236
+ exc = exc.__cause__ or exc.__context__
237
+ return False
@@ -0,0 +1,162 @@
1
+ """Parse and render CodeWiki VSX6ub wiki payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from urllib.parse import unquote
7
+
8
+ _GITHUB_PREFIX = "https://github.com"
9
+
10
+
11
+ @dataclass
12
+ class Section:
13
+ title: str
14
+ level: int
15
+ markdown: str
16
+ diagrams: list[str] = field(default_factory=list)
17
+
18
+
19
+ @dataclass
20
+ class Wiki:
21
+ repo_slug: str
22
+ commit_sha: str
23
+ sections: list[Section]
24
+
25
+
26
+ def parse(payload: object) -> Wiki:
27
+ """Parse a VSX6ub payload: ``[wiki, [null, url], true, n]``."""
28
+ if not isinstance(payload, list) or not payload:
29
+ raise ValueError("missing wiki container")
30
+ container = payload[0]
31
+ if not isinstance(container, list) or not container:
32
+ raise ValueError("missing wiki container")
33
+ header = container[0]
34
+ if not isinstance(header, list) or not header:
35
+ raise ValueError("missing wiki header array")
36
+ repo_slug = header[0] if header and isinstance(header[0], str) else ""
37
+ if not repo_slug:
38
+ raise ValueError("missing repo slug")
39
+ commit_sha = header[1] if len(header) > 1 and isinstance(header[1], str) else ""
40
+ raw_sections = container[1] if len(container) > 1 else None
41
+ if not isinstance(raw_sections, list):
42
+ raise ValueError("missing sections array")
43
+ sections = [parse_section(s, i) for i, s in enumerate(raw_sections)]
44
+ return Wiki(repo_slug=repo_slug, commit_sha=commit_sha, sections=sections)
45
+
46
+
47
+ def parse_section(value: object, index: int) -> Section:
48
+ if not isinstance(value, list):
49
+ raise ValueError(f"section #{index} is not an array")
50
+ title = value[0] if value and isinstance(value[0], str) else ""
51
+ if not title:
52
+ raise ValueError(f"missing title in section #{index}")
53
+ level = value[1] if len(value) > 1 and isinstance(value[1], int) else 1
54
+ markdown = ""
55
+ if len(value) > 5 and isinstance(value[5], str):
56
+ markdown = value[5]
57
+ elif len(value) > 4 and isinstance(value[4], str):
58
+ markdown = value[4]
59
+ diagrams = extract_diagrams(value[7] if len(value) > 7 else None)
60
+ return Section(title=title, level=level, markdown=markdown, diagrams=diagrams)
61
+
62
+
63
+ def extract_diagrams(outer: object) -> list[str]:
64
+ out: list[str] = []
65
+ if not isinstance(outer, list):
66
+ return out
67
+ for group in outer:
68
+ if not isinstance(group, list):
69
+ continue
70
+ for diagram in group:
71
+ if not isinstance(diagram, list):
72
+ continue
73
+ if len(diagram) > 4 and isinstance(diagram[4], str) and diagram[4]:
74
+ out.append(diagram[4])
75
+ return out
76
+
77
+
78
+ def render_structure(wiki: Wiki) -> str:
79
+ lines = []
80
+ for s in wiki.sections:
81
+ indent = " " * max(0, s.level - 1)
82
+ lines.append(f"{indent}- {s.title}")
83
+ return "\n".join(lines) + ("\n" if lines else "")
84
+
85
+
86
+ def render_section(s: Section) -> str:
87
+ """Render a single section as Markdown (heading + body + dot diagrams)."""
88
+ hashes = "#" * min(max(s.level, 1), 6)
89
+ parts = [f"{hashes} {s.title}\n"]
90
+ body = resolve_links(s.markdown)
91
+ parts.append(body)
92
+ if body and not body.endswith("\n"):
93
+ parts.append("\n")
94
+ for dot in s.diagrams:
95
+ parts.append(f"\n```dot\n{dot.strip()}\n```\n")
96
+ parts.append("\n")
97
+ return "".join(parts)
98
+
99
+
100
+ def render_markdown(wiki: Wiki) -> str:
101
+ parts = [f"# {wiki.repo_slug} (commit {wiki.commit_sha})\n"]
102
+ for s in wiki.sections:
103
+ parts.append(render_section(s))
104
+ return "".join(parts)
105
+
106
+
107
+ def render_page(wiki: Wiki, title: str) -> str:
108
+ """Render the single section whose title matches (case-insensitive exact).
109
+
110
+ Raises ValueError listing available titles if none matches.
111
+ """
112
+ target = title.strip().lower()
113
+ for s in wiki.sections:
114
+ if s.title.strip().lower() == target:
115
+ return render_section(s).strip()
116
+ available = "\n".join(f" - {s.title}" for s in wiki.sections) or " - (none)"
117
+ raise ValueError(f"Page '{title}' not found. Available pages:\n{available}")
118
+
119
+
120
+ def resolve_links(markdown: str) -> str:
121
+ """Rewrite ``](%2F...)`` / ``](/...)`` targets to ``](https://github.com/...)``."""
122
+ out: list[str] = []
123
+ rest = markdown
124
+ while True:
125
+ open_idx = rest.find("](")
126
+ if open_idx == -1:
127
+ out.append(rest)
128
+ break
129
+ out.append(rest[:open_idx])
130
+ after = rest[open_idx + 2:]
131
+ end = _find_link_end(after)
132
+ if end is None:
133
+ out.append("](")
134
+ rest = after
135
+ continue
136
+ out.append("](")
137
+ out.append(_rewrite_target(after[:end]))
138
+ out.append(")")
139
+ rest = after[end + 1:]
140
+ return "".join(out)
141
+
142
+
143
+ def _find_link_end(s: str) -> int | None:
144
+ depth = 0
145
+ for i, ch in enumerate(s):
146
+ if ch == "(":
147
+ depth += 1
148
+ elif ch == ")":
149
+ if depth == 0:
150
+ return i
151
+ depth -= 1
152
+ return None
153
+
154
+
155
+ def _rewrite_target(target: str) -> str:
156
+ needs_rewrite = target.startswith(("%2F", "%2f", "/"))
157
+ if not needs_rewrite:
158
+ return target
159
+ decoded = unquote(target)
160
+ if decoded.startswith("/"):
161
+ return f"{_GITHUB_PREFIX}{decoded}"
162
+ return decoded
@@ -0,0 +1,5 @@
1
+ """DeepWiki service."""
2
+
3
+ from repowiki.services.deepwiki.cli import register
4
+
5
+ __all__ = ["register"]