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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cppmanlite
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Lightweight serverless C++ documentation lookup — pure Python, no C deps
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://dive4dec.github.io/cppmanlite/
@@ -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 "pyodide" in getattr(sys, "platform", "")
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 under the hood, but Pyodide
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(_fetch_url(_INDEX_FALLBACK_URL))
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 _fetch_page(url: str) -> str:
161
- """Fetch a cppreference page and extract the main content HTML."""
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 = _fetch_url(full_url)
164
- # Extract #mw-content-text
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 left by mw-editsection removal
205
+ # Strip any residual [edit] markers (from &#91;edit&#93; entities)
206
+ content = re.sub(r"&#91;edit&#93;", "", 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
- # Display
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"&#91;edit&#93;", "", 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
+ # &lt;class T&gt; 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
- content = _fetch_page(url)
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
- # Strip HTML tags for terminal, then decode entities
259
- text = re.sub(r"<[^>]+>", "", content)
260
- text = html.unescape(text)
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(_fetch_url(url))
441
+ _INDEX = json.loads(_fetch_url_sync(url))
284
442
  return len(_INDEX)
285
443
 
286
444
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cppmanlite
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Lightweight serverless C++ documentation lookup — pure Python, no C deps
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://dive4dec.github.io/cppmanlite/
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cppmanlite"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "Lightweight serverless C++ documentation lookup — pure Python, no C deps"
9
9
  readme = "README.md"
10
10
  license = "MIT"
File without changes
File without changes
File without changes