cppmanlite 0.1.0__tar.gz → 0.1.1__tar.gz
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.
- {cppmanlite-0.1.0 → cppmanlite-0.1.1}/PKG-INFO +1 -1
- {cppmanlite-0.1.0 → cppmanlite-0.1.1}/cppmanlite/core.py +197 -39
- {cppmanlite-0.1.0 → cppmanlite-0.1.1}/cppmanlite.egg-info/PKG-INFO +1 -1
- {cppmanlite-0.1.0 → cppmanlite-0.1.1}/pyproject.toml +1 -1
- {cppmanlite-0.1.0 → cppmanlite-0.1.1}/LICENSE +0 -0
- {cppmanlite-0.1.0 → cppmanlite-0.1.1}/README.md +0 -0
- {cppmanlite-0.1.0 → cppmanlite-0.1.1}/cppmanlite/__init__.py +0 -0
- {cppmanlite-0.1.0 → cppmanlite-0.1.1}/cppmanlite/data/index.json +0 -0
- {cppmanlite-0.1.0 → cppmanlite-0.1.1}/cppmanlite.egg-info/SOURCES.txt +0 -0
- {cppmanlite-0.1.0 → cppmanlite-0.1.1}/cppmanlite.egg-info/dependency_links.txt +0 -0
- {cppmanlite-0.1.0 → cppmanlite-0.1.1}/cppmanlite.egg-info/requires.txt +0 -0
- {cppmanlite-0.1.0 → cppmanlite-0.1.1}/cppmanlite.egg-info/top_level.txt +0 -0
- {cppmanlite-0.1.0 → cppmanlite-0.1.1}/setup.cfg +0 -0
|
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|
|
12
12
|
import html
|
|
13
13
|
import json
|
|
14
14
|
import re
|
|
15
|
+
import textwrap
|
|
15
16
|
from pathlib import Path
|
|
16
17
|
from typing import Any
|
|
17
18
|
|
|
@@ -23,7 +24,7 @@ def _detect_pyodide() -> bool:
|
|
|
23
24
|
"""Return True if running under Pyodide."""
|
|
24
25
|
try:
|
|
25
26
|
import sys
|
|
26
|
-
return "pyodide" in sys.modules or "
|
|
27
|
+
return "pyodide" in sys.modules or "emscripten" in getattr(sys, "platform", "")
|
|
27
28
|
except Exception:
|
|
28
29
|
return False
|
|
29
30
|
|
|
@@ -35,13 +36,6 @@ _IS_PYODIDE = _detect_pyodide()
|
|
|
35
36
|
# Network fetch — urllib in CPython, pyodide.http in Pyodide
|
|
36
37
|
# --------------------------------------------------------------------------- #
|
|
37
38
|
|
|
38
|
-
def _fetch_url(url: str, timeout: int = 15) -> str:
|
|
39
|
-
"""Fetch a URL and return text. Uses urllib (CPython) or pyfetch (Pyodide)."""
|
|
40
|
-
if _IS_PYODIDE:
|
|
41
|
-
return _fetch_pyodide(url)
|
|
42
|
-
return _fetch_urllib(url, timeout)
|
|
43
|
-
|
|
44
|
-
|
|
45
39
|
def _fetch_urllib(url: str, timeout: int) -> str:
|
|
46
40
|
import urllib.request
|
|
47
41
|
req = urllib.request.Request(url, headers={"User-Agent": "cppmanlite/0.1"})
|
|
@@ -49,12 +43,11 @@ def _fetch_urllib(url: str, timeout: int) -> str:
|
|
|
49
43
|
return resp.read().decode("utf-8", errors="replace")
|
|
50
44
|
|
|
51
45
|
|
|
52
|
-
def _fetch_pyodide(url: str) -> str:
|
|
53
|
-
"""Fetch via pyodide.http.pyfetch (async
|
|
54
|
-
auto-awaits top-level coroutines)."""
|
|
46
|
+
async def _fetch_pyodide(url: str) -> str:
|
|
47
|
+
"""Fetch via pyodide.http.pyfetch (async)."""
|
|
55
48
|
from pyodide.http import pyfetch
|
|
56
|
-
resp = pyfetch(url, headers={"User-Agent": "cppmanlite/0.1"})
|
|
57
|
-
return resp.string
|
|
49
|
+
resp = await pyfetch(url, headers={"User-Agent": "cppmanlite/0.1"})
|
|
50
|
+
return await resp.string()
|
|
58
51
|
|
|
59
52
|
|
|
60
53
|
# --------------------------------------------------------------------------- #
|
|
@@ -71,6 +64,10 @@ _INDEX_FALLBACK_URL = "https://dive4dec.github.io/cppmanlite/index.json"
|
|
|
71
64
|
# cppreference page base URL (redirects /w/cpp/... → /cpp/...)
|
|
72
65
|
_PAGE_BASE = "https://en.cppreference.com/w"
|
|
73
66
|
|
|
67
|
+
# GitHub Pages mirror — used as fallback in Pyodide (browser CORS blocks
|
|
68
|
+
# direct fetches to en.cppreference.com which doesn't send CORS headers).
|
|
69
|
+
_PAGES_MIRROR = "https://dive4dec.github.io/cppmanlite"
|
|
70
|
+
|
|
74
71
|
|
|
75
72
|
def _load_index() -> list[dict[str, str]]:
|
|
76
73
|
"""Load the search index, fetching it if necessary."""
|
|
@@ -83,15 +80,27 @@ def _load_index() -> list[dict[str, str]]:
|
|
|
83
80
|
else:
|
|
84
81
|
# Fetch from GitHub Pages (works in both CPython and Pyodide)
|
|
85
82
|
try:
|
|
86
|
-
_INDEX = json.loads(
|
|
83
|
+
_INDEX = json.loads(_fetch_url_sync(_INDEX_FALLBACK_URL))
|
|
87
84
|
except Exception:
|
|
88
85
|
_INDEX = []
|
|
89
86
|
return _INDEX
|
|
90
87
|
|
|
91
88
|
|
|
92
|
-
|
|
89
|
+
def _fetch_url_sync(url: str) -> str:
|
|
90
|
+
"""Synchronous fetch for index loading — blocks in CPython, raises in Pyodide.
|
|
91
|
+
|
|
92
|
+
In Pyodide, index should be bundled in the wheel so this is never called.
|
|
93
|
+
If it is, we try asyncio.run as a fallback.
|
|
94
|
+
"""
|
|
95
|
+
if _IS_PYODIDE:
|
|
96
|
+
import asyncio
|
|
97
|
+
return asyncio.run(_fetch_pyodide(url))
|
|
98
|
+
return _fetch_urllib(url, 15)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# --------------------------------------------------------------------------- #
|
|
93
102
|
# Search
|
|
94
|
-
# ---------------------------------------------------------------------------
|
|
103
|
+
# --------------------------------------------------------------------------- #
|
|
95
104
|
|
|
96
105
|
|
|
97
106
|
def search(query: str, limit: int = 20) -> list[dict[str, str]]:
|
|
@@ -136,9 +145,9 @@ def list_pages(limit: int = 0) -> list[dict[str, str]]:
|
|
|
136
145
|
return idx if limit == 0 else idx[:limit]
|
|
137
146
|
|
|
138
147
|
|
|
139
|
-
# ---------------------------------------------------------------------------
|
|
148
|
+
# --------------------------------------------------------------------------- #
|
|
140
149
|
# Page fetching and rendering
|
|
141
|
-
# ---------------------------------------------------------------------------
|
|
150
|
+
# --------------------------------------------------------------------------- #
|
|
142
151
|
|
|
143
152
|
_CONTENT_RE = re.compile(
|
|
144
153
|
r'<div id="mw-content-text"[^>]*>(.*?)(?:</div>\s*<!--|\Z)',
|
|
@@ -147,9 +156,11 @@ _CONTENT_RE = re.compile(
|
|
|
147
156
|
_SCRIPT_RE = re.compile(r"<script[^>]*>.*?</script>", re.DOTALL)
|
|
148
157
|
_STYLE_RE = re.compile(r"<style[^>]*>.*?</style>", re.DOTALL)
|
|
149
158
|
_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
150
|
-
_EDIT_RE = re.compile(r'<span class="mw-editsection">.*?</span>', re.DOTALL)
|
|
151
|
-
|
|
159
|
+
_EDIT_RE = re.compile(r'<span class="(?:mw-)?editsection[^"]*">.*?</span>', re.DOTALL)
|
|
152
160
|
|
|
161
|
+
# Strip cppreference navigation chrome (t-navbar has nested divs — match
|
|
162
|
+
# the outermost by greedy-matching to the closing </div> that is followed
|
|
163
|
+
# by a non-navbar block element or end-of-string).
|
|
153
164
|
_NAVBAR_RE = re.compile(
|
|
154
165
|
r'<div class="t-navbar"[^>]*>.*?(?:</div>\s*(?=<div|<h[1-6]|<table|<p|\Z))',
|
|
155
166
|
re.DOTALL,
|
|
@@ -157,11 +168,31 @@ _NAVBAR_RE = re.compile(
|
|
|
157
168
|
_NV_TABLE_RE = re.compile(r'<table class="t-nv-begin"[^>]*>.*?</table>', re.DOTALL)
|
|
158
169
|
|
|
159
170
|
|
|
160
|
-
def
|
|
161
|
-
"""
|
|
171
|
+
def _fetch_page_sync(url: str) -> str:
|
|
172
|
+
"""Synchronous page fetch (CPython only)."""
|
|
162
173
|
full_url = f"{_PAGE_BASE}/{url}" if not url.startswith("http") else url
|
|
163
|
-
html_raw =
|
|
164
|
-
|
|
174
|
+
html_raw = _fetch_urllib(full_url, 15)
|
|
175
|
+
return _clean_page_html(html_raw)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def _fetch_page_async(url: str) -> str:
|
|
179
|
+
"""Async page fetch (Pyodide). Tries cppreference.com first, then
|
|
180
|
+
falls back to the GitHub Pages mirror (CORS-safe)."""
|
|
181
|
+
full_url = f"{_PAGE_BASE}/{url}" if not url.startswith("http") else url
|
|
182
|
+
try:
|
|
183
|
+
html_raw = await _fetch_pyodide(full_url)
|
|
184
|
+
return _clean_page_html(html_raw)
|
|
185
|
+
except Exception:
|
|
186
|
+
# CORS or network error — fall back to GitHub Pages mirror.
|
|
187
|
+
# Mirror pages are pre-stripped HTML (no #mw-content-text wrapper),
|
|
188
|
+
# so we clean them differently.
|
|
189
|
+
mirror_url = f"{_PAGES_MIRROR}/docs/{url}"
|
|
190
|
+
html_raw = await _fetch_pyodide(mirror_url)
|
|
191
|
+
return _clean_mirror_html(html_raw)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _clean_page_html(html_raw: str) -> str:
|
|
195
|
+
"""Extract and clean the main content from a cppreference page."""
|
|
165
196
|
m = _CONTENT_RE.search(html_raw)
|
|
166
197
|
if not m:
|
|
167
198
|
return "<p>Could not extract page content.</p>"
|
|
@@ -171,7 +202,8 @@ def _fetch_page(url: str) -> str:
|
|
|
171
202
|
content = _STYLE_RE.sub("", content)
|
|
172
203
|
content = _COMMENT_RE.sub("", content)
|
|
173
204
|
content = _EDIT_RE.sub("", content)
|
|
174
|
-
# Strip residual [edit] markers
|
|
205
|
+
# Strip any residual [edit] markers (from [edit] entities)
|
|
206
|
+
content = re.sub(r"[edit]", "", content)
|
|
175
207
|
content = re.sub(r"\[edit\]", "", content)
|
|
176
208
|
# Strip cppreference navigation chrome (t-navbar, t-nv-begin tables)
|
|
177
209
|
content = _NAVBAR_RE.sub("", content)
|
|
@@ -182,10 +214,111 @@ def _fetch_page(url: str) -> str:
|
|
|
182
214
|
return content
|
|
183
215
|
|
|
184
216
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
217
|
+
def _clean_mirror_html(html_raw: str) -> str:
|
|
218
|
+
"""Clean a pre-stripped page from the GitHub Pages mirror.
|
|
219
|
+
|
|
220
|
+
Mirror pages are raw HTML from the cppreference archive — they don't
|
|
221
|
+
have the #mw-content-text wrapper, but they do have t-navbar and
|
|
222
|
+
t-nv-begin tables that need stripping.
|
|
223
|
+
"""
|
|
224
|
+
content = html_raw
|
|
225
|
+
content = _SCRIPT_RE.sub("", content)
|
|
226
|
+
content = _STYLE_RE.sub("", content)
|
|
227
|
+
content = _COMMENT_RE.sub("", content)
|
|
228
|
+
content = _EDIT_RE.sub("", content)
|
|
229
|
+
content = re.sub(r"[edit]", "", content)
|
|
230
|
+
content = re.sub(r"\[edit\]", "", content)
|
|
231
|
+
content = _NAVBAR_RE.sub("", content)
|
|
232
|
+
content = _NV_TABLE_RE.sub("", content)
|
|
233
|
+
# Fix relative URLs in archive pages (../../cpp/... → /w/cpp/...)
|
|
234
|
+
content = re.sub(
|
|
235
|
+
r'href="(\.\./)*([^"]+\.html)"',
|
|
236
|
+
lambda m: f'href="https://en.cppreference.com/w/{m.group(2)}"',
|
|
237
|
+
content,
|
|
238
|
+
)
|
|
239
|
+
return content
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# --------------------------------------------------------------------------- #
|
|
243
|
+
# HTML → plain-text conversion (for terminal output)
|
|
244
|
+
# --------------------------------------------------------------------------- #
|
|
188
245
|
|
|
246
|
+
# Tags that should produce a line break
|
|
247
|
+
_BLOCK_TAGS = {"p", "div", "br", "tr", "li", "h1", "h2", "h3", "h4", "h5", "h6",
|
|
248
|
+
"hr", "table", "ul", "ol", "pre", "blockquote", "section"}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _html_to_text(html_str: str, width: int = 80) -> str:
|
|
252
|
+
"""Convert HTML to readable plain text with proper line breaks."""
|
|
253
|
+
# NB: strip HTML tags BEFORE decoding entities, otherwise
|
|
254
|
+
# <class T> becomes <class T> and gets eaten as a fake tag.
|
|
255
|
+
|
|
256
|
+
text = html_str
|
|
257
|
+
|
|
258
|
+
# Replace block-level tags with newlines (before stripping all tags)
|
|
259
|
+
for tag in _BLOCK_TAGS:
|
|
260
|
+
text = re.sub(rf"<{tag}[^>]*>", "\n", text, flags=re.IGNORECASE)
|
|
261
|
+
text = re.sub(rf"</{tag}>", "\n", text, flags=re.IGNORECASE)
|
|
262
|
+
|
|
263
|
+
# <td> / <th> → tab separator
|
|
264
|
+
text = re.sub(r"<t[dh][^>]*>", "\t", text, flags=re.IGNORECASE)
|
|
265
|
+
text = re.sub(r"</t[dh]>", "", text, flags=re.IGNORECASE)
|
|
266
|
+
|
|
267
|
+
# <code> / <tt> → backtick wrapping (strip the tag, keep content)
|
|
268
|
+
text = re.sub(r"<code[^>]*>", "`", text, flags=re.IGNORECASE)
|
|
269
|
+
text = re.sub(r"</code>", "`", text, flags=re.IGNORECASE)
|
|
270
|
+
text = re.sub(r"<tt[^>]*>", "`", text, flags=re.IGNORECASE)
|
|
271
|
+
text = re.sub(r"</tt>", "`", text, flags=re.IGNORECASE)
|
|
272
|
+
|
|
273
|
+
# <b> / <strong> → ** (bold marker)
|
|
274
|
+
text = re.sub(r"<b[^>]*>", "**", text, flags=re.IGNORECASE)
|
|
275
|
+
text = re.sub(r"</b>", "**", text, flags=re.IGNORECASE)
|
|
276
|
+
text = re.sub(r"<strong[^>]*>", "**", text, flags=re.IGNORECASE)
|
|
277
|
+
text = re.sub(r"</strong>", "**", text, flags=re.IGNORECASE)
|
|
278
|
+
|
|
279
|
+
# <i> / <em> → * (italic marker)
|
|
280
|
+
text = re.sub(r"<i[^>]*>", "*", text, flags=re.IGNORECASE)
|
|
281
|
+
text = re.sub(r"</i>", "*", text, flags=re.IGNORECASE)
|
|
282
|
+
text = re.sub(r"<em[^>]*>", "*", text, flags=re.IGNORECASE)
|
|
283
|
+
text = re.sub(r"</em>", "*", text, flags=re.IGNORECASE)
|
|
284
|
+
|
|
285
|
+
# Strip all remaining tags
|
|
286
|
+
text = re.sub(r"<[^>]+>", "", text)
|
|
287
|
+
|
|
288
|
+
# NOW decode entities (safe — no more HTML tags to confuse)
|
|
289
|
+
text = html.unescape(text)
|
|
290
|
+
|
|
291
|
+
# Process line by line
|
|
292
|
+
lines = text.split("\n")
|
|
293
|
+
result = []
|
|
294
|
+
for line in lines:
|
|
295
|
+
# Expand tabs to 4 spaces
|
|
296
|
+
line = line.expandtabs(4)
|
|
297
|
+
# Collapse multiple spaces (but preserve indentation)
|
|
298
|
+
stripped = line.lstrip()
|
|
299
|
+
indent = line[: len(line) - len(stripped)]
|
|
300
|
+
stripped = re.sub(r" +", " ", stripped).strip()
|
|
301
|
+
if stripped:
|
|
302
|
+
# Wrap long lines
|
|
303
|
+
wrapped = textwrap.fill(stripped, width=width,
|
|
304
|
+
initial_indent=indent,
|
|
305
|
+
subsequent_indent=indent + " ")
|
|
306
|
+
result.append(wrapped)
|
|
307
|
+
elif result and result[-1]: # preserve blank lines between content
|
|
308
|
+
result.append("")
|
|
309
|
+
|
|
310
|
+
# Remove leading/trailing blank lines
|
|
311
|
+
while result and not result[0]:
|
|
312
|
+
result.pop(0)
|
|
313
|
+
while result and not result[-1]:
|
|
314
|
+
result.pop()
|
|
315
|
+
|
|
316
|
+
return "\n".join(result)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# --------------------------------------------------------------------------- #
|
|
320
|
+
# Display
|
|
321
|
+
# --------------------------------------------------------------------------- #
|
|
189
322
|
|
|
190
323
|
def _is_jupyter() -> bool:
|
|
191
324
|
try:
|
|
@@ -225,20 +358,28 @@ def _format_page_html(content: str) -> str:
|
|
|
225
358
|
)
|
|
226
359
|
|
|
227
360
|
|
|
228
|
-
# ---------------------------------------------------------------------------
|
|
361
|
+
# --------------------------------------------------------------------------- #
|
|
229
362
|
# Public API
|
|
230
|
-
# ---------------------------------------------------------------------------
|
|
363
|
+
# --------------------------------------------------------------------------- #
|
|
231
364
|
|
|
232
365
|
|
|
233
366
|
def man(query: str) -> Any:
|
|
234
367
|
"""Display a C++ documentation page (like ``man`` for C++).
|
|
235
368
|
|
|
236
369
|
In Jupyter: renders HTML inline.
|
|
237
|
-
In terminal: prints plain text.
|
|
370
|
+
In terminal: prints formatted plain text.
|
|
371
|
+
In Pyodide: returns a coroutine (auto-awaited by the Pyodide REPL).
|
|
238
372
|
|
|
239
373
|
Args:
|
|
240
374
|
query: Page title or URL path (e.g. "std::vector" or "cpp/container/vector").
|
|
241
375
|
"""
|
|
376
|
+
if _IS_PYODIDE:
|
|
377
|
+
return _man_async(query)
|
|
378
|
+
return _man_sync(query)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _man_sync(query: str) -> None:
|
|
382
|
+
"""Synchronous man() for CPython / terminal."""
|
|
242
383
|
results = search(query, limit=1)
|
|
243
384
|
if not results:
|
|
244
385
|
msg = f"No documentation found for '{query}'."
|
|
@@ -249,18 +390,35 @@ def man(query: str) -> Any:
|
|
|
249
390
|
print(msg)
|
|
250
391
|
return
|
|
251
392
|
url = results[0]["url"]
|
|
252
|
-
|
|
393
|
+
title = results[0]["title"]
|
|
394
|
+
content = _fetch_page_sync(url)
|
|
395
|
+
if _is_jupyter():
|
|
396
|
+
from IPython.display import HTML, display
|
|
397
|
+
|
|
398
|
+
display(HTML(_format_page_html(content)))
|
|
399
|
+
else:
|
|
400
|
+
# Terminal: print formatted text
|
|
401
|
+
print(f"\n{'=' * 80}\n{title}\n{'=' * 80}\n")
|
|
402
|
+
print(_html_to_text(content, width=80))
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
async def _man_async(query: str) -> None:
|
|
406
|
+
"""Async man() for Pyodide."""
|
|
407
|
+
results = search(query, limit=1)
|
|
408
|
+
if not results:
|
|
409
|
+
print(f"No documentation found for '{query}'.")
|
|
410
|
+
return
|
|
411
|
+
url = results[0]["url"]
|
|
412
|
+
title = results[0]["title"]
|
|
413
|
+
content = await _fetch_page_async(url)
|
|
253
414
|
if _is_jupyter():
|
|
254
415
|
from IPython.display import HTML, display
|
|
255
416
|
|
|
256
417
|
display(HTML(_format_page_html(content)))
|
|
257
418
|
else:
|
|
258
|
-
#
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
text = re.sub(r"\[edit\]", "", text)
|
|
262
|
-
text = re.sub(r"\s+", " ", text).strip()
|
|
263
|
-
print(text[:4000])
|
|
419
|
+
# Pyodide console / terminal
|
|
420
|
+
print(f"\n{'=' * 80}\n{title}\n{'=' * 80}\n")
|
|
421
|
+
print(_html_to_text(content, width=80))
|
|
264
422
|
|
|
265
423
|
|
|
266
424
|
def help(query: str) -> Any:
|
|
@@ -280,7 +438,7 @@ def refresh_index() -> int:
|
|
|
280
438
|
global _INDEX
|
|
281
439
|
_INDEX = []
|
|
282
440
|
url = "https://dive4dec.github.io/cppmanlite/index.json"
|
|
283
|
-
_INDEX = json.loads(
|
|
441
|
+
_INDEX = json.loads(_fetch_url_sync(url))
|
|
284
442
|
return len(_INDEX)
|
|
285
443
|
|
|
286
444
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|