cppmanlite 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.
cppmanlite/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """cppmanlite — lightweight serverless C++ documentation lookup.
2
+
3
+ A pure-Python package for searching and displaying C++ documentation
4
+ from cppreference.com. Works in standard Python, Jupyter notebooks,
5
+ and Pyodide (no C dependencies).
6
+
7
+ Usage:
8
+ import cppmanlite
9
+ cppmanlite.search("vector") # list matching pages
10
+ cppmanlite.man("std::vector") # display a page
11
+ cppmanlite.help("sort") # alias for search
12
+
13
+ In Jupyter, results render as HTML with clickable links.
14
+ """
15
+
16
+ from .core import search, man, help as help_query, list_pages, refresh_index
17
+
18
+ __version__ = "0.1.0"
19
+ __all__ = ["search", "man", "help_query", "list_pages", "refresh_index"]
cppmanlite/core.py ADDED
@@ -0,0 +1,288 @@
1
+ """Core search and display logic for cppmanlite.
2
+
3
+ No external dependencies — pure stdlib. Fetches pages on-demand from
4
+ cppreference.com when not bundled locally.
5
+
6
+ Works in CPython, Jupyter, and Pyodide (browser). In Pyodide, network
7
+ fetches use the browser's Fetch API via pyodide.http instead of urllib.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import html
13
+ import json
14
+ import re
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ # --------------------------------------------------------------------------- #
19
+ # Environment detection
20
+ # --------------------------------------------------------------------------- #
21
+
22
+ def _detect_pyodide() -> bool:
23
+ """Return True if running under Pyodide."""
24
+ try:
25
+ import sys
26
+ return "pyodide" in sys.modules or "pyodide" in getattr(sys, "platform", "")
27
+ except Exception:
28
+ return False
29
+
30
+
31
+ _IS_PYODIDE = _detect_pyodide()
32
+
33
+
34
+ # --------------------------------------------------------------------------- #
35
+ # Network fetch — urllib in CPython, pyodide.http in Pyodide
36
+ # --------------------------------------------------------------------------- #
37
+
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
+ def _fetch_urllib(url: str, timeout: int) -> str:
46
+ import urllib.request
47
+ req = urllib.request.Request(url, headers={"User-Agent": "cppmanlite/0.1"})
48
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
49
+ return resp.read().decode("utf-8", errors="replace")
50
+
51
+
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)."""
55
+ from pyodide.http import pyfetch
56
+ resp = pyfetch(url, headers={"User-Agent": "cppmanlite/0.1"})
57
+ return resp.string
58
+
59
+
60
+ # --------------------------------------------------------------------------- #
61
+ # Index management
62
+ # --------------------------------------------------------------------------- #
63
+
64
+ _INDEX: list[dict[str, str]] = []
65
+ _INDEX_PATH = Path(__file__).parent / "data" / "index.json"
66
+
67
+ # When running in Pyodide the bundled index.json ships inside the wheel;
68
+ # when running in CPython without the bundle, fetch from GitHub Pages.
69
+ _INDEX_FALLBACK_URL = "https://dive4dec.github.io/cppmanlite/index.json"
70
+
71
+ # cppreference page base URL (redirects /w/cpp/... → /cpp/...)
72
+ _PAGE_BASE = "https://en.cppreference.com/w"
73
+
74
+
75
+ def _load_index() -> list[dict[str, str]]:
76
+ """Load the search index, fetching it if necessary."""
77
+ global _INDEX
78
+ if _INDEX:
79
+ return _INDEX
80
+ if _INDEX_PATH.exists():
81
+ with open(_INDEX_PATH, encoding="utf-8") as f:
82
+ _INDEX = json.load(f)
83
+ else:
84
+ # Fetch from GitHub Pages (works in both CPython and Pyodide)
85
+ try:
86
+ _INDEX = json.loads(_fetch_url(_INDEX_FALLBACK_URL))
87
+ except Exception:
88
+ _INDEX = []
89
+ return _INDEX
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Search
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ def search(query: str, limit: int = 20) -> list[dict[str, str]]:
98
+ """Search C++ documentation pages.
99
+
100
+ Args:
101
+ query: Search term (e.g. "vector", "std::sort", "shared_ptr").
102
+ limit: Maximum number of results.
103
+
104
+ Returns:
105
+ List of dicts with keys: title, url, snippet.
106
+ """
107
+ idx = _load_index()
108
+ if not idx:
109
+ return []
110
+ q = query.lower().strip()
111
+ # Normalise std:: prefix
112
+ q_norm = re.sub(r"^std::", "", q)
113
+ results = []
114
+ for entry in idx:
115
+ title = entry.get("title", "").lower()
116
+ url = entry.get("url", "").lower()
117
+ # Score: exact match > starts with > contains in title > contains in URL
118
+ score = 0
119
+ if title == q or title == q_norm:
120
+ score = 100
121
+ elif title.startswith(q) or title.startswith(q_norm):
122
+ score = 80
123
+ elif q in title or q_norm in title:
124
+ score = 60
125
+ elif q in url or q_norm in url:
126
+ score = 40
127
+ if score > 0:
128
+ results.append({**entry, "_score": score})
129
+ results.sort(key=lambda x: (-x["_score"], x.get("title", "")))
130
+ return [{k: v for k, v in r.items() if k != "_score"} for r in results[:limit]]
131
+
132
+
133
+ def list_pages(limit: int = 0) -> list[dict[str, str]]:
134
+ """List all indexed pages (for debugging/browsing)."""
135
+ idx = _load_index()
136
+ return idx if limit == 0 else idx[:limit]
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Page fetching and rendering
141
+ # ---------------------------------------------------------------------------
142
+
143
+ _CONTENT_RE = re.compile(
144
+ r'<div id="mw-content-text"[^>]*>(.*?)(?:</div>\s*<!--|\Z)',
145
+ re.DOTALL,
146
+ )
147
+ _SCRIPT_RE = re.compile(r"<script[^>]*>.*?</script>", re.DOTALL)
148
+ _STYLE_RE = re.compile(r"<style[^>]*>.*?</style>", re.DOTALL)
149
+ _COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
150
+ _EDIT_RE = re.compile(r'<span class="mw-editsection">.*?</span>', re.DOTALL)
151
+
152
+
153
+ _NAVBAR_RE = re.compile(
154
+ r'<div class="t-navbar"[^>]*>.*?(?:</div>\s*(?=<div|<h[1-6]|<table|<p|\Z))',
155
+ re.DOTALL,
156
+ )
157
+ _NV_TABLE_RE = re.compile(r'<table class="t-nv-begin"[^>]*>.*?</table>', re.DOTALL)
158
+
159
+
160
+ def _fetch_page(url: str) -> str:
161
+ """Fetch a cppreference page and extract the main content HTML."""
162
+ 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
165
+ m = _CONTENT_RE.search(html_raw)
166
+ if not m:
167
+ return "<p>Could not extract page content.</p>"
168
+ content = m.group(1)
169
+ # Clean up
170
+ content = _SCRIPT_RE.sub("", content)
171
+ content = _STYLE_RE.sub("", content)
172
+ content = _COMMENT_RE.sub("", content)
173
+ content = _EDIT_RE.sub("", content)
174
+ # Strip residual [edit] markers left by mw-editsection removal
175
+ content = re.sub(r"\[edit\]", "", content)
176
+ # Strip cppreference navigation chrome (t-navbar, t-nv-begin tables)
177
+ content = _NAVBAR_RE.sub("", content)
178
+ content = _NV_TABLE_RE.sub("", content)
179
+ # Fix relative URLs
180
+ content = re.sub(r'href="/w/', 'href="https://en.cppreference.com/w/', content)
181
+ content = re.sub(r'src="/', 'src="https://en.cppreference.com/', content)
182
+ return content
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # Display
187
+ # ---------------------------------------------------------------------------
188
+
189
+
190
+ def _is_jupyter() -> bool:
191
+ try:
192
+ from IPython.display import HTML, display # noqa: F401
193
+
194
+ get_ipython # type: ignore[name-defined]
195
+ return True
196
+ except Exception:
197
+ return False
198
+
199
+
200
+ def _format_search_html(results: list[dict[str, str]]) -> str:
201
+ rows = []
202
+ for r in results:
203
+ title = html.escape(r.get("title", ""))
204
+ url = html.escape(r.get("url", ""))
205
+ snippet = html.escape(r.get("snippet", ""))[:120]
206
+ rows.append(
207
+ f'<tr><td><a href="https://en.cppreference.com/w/{url}" '
208
+ f'target="_blank">{title}</a></td>'
209
+ f'<td><code>{snippet}</code></td></tr>'
210
+ )
211
+ return (
212
+ '<table style="font-size:14px;border-collapse:collapse">'
213
+ "<tr><th>Title</th><th>Path</th></tr>"
214
+ + "\n".join(rows)
215
+ + "</table>"
216
+ )
217
+
218
+
219
+ def _format_page_html(content: str) -> str:
220
+ return (
221
+ '<div style="max-height:600px;overflow:auto;'
222
+ 'border:1px solid #ddd;padding:16px;font-size:14px">'
223
+ + content
224
+ + "</div>"
225
+ )
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Public API
230
+ # ---------------------------------------------------------------------------
231
+
232
+
233
+ def man(query: str) -> Any:
234
+ """Display a C++ documentation page (like ``man`` for C++).
235
+
236
+ In Jupyter: renders HTML inline.
237
+ In terminal: prints plain text.
238
+
239
+ Args:
240
+ query: Page title or URL path (e.g. "std::vector" or "cpp/container/vector").
241
+ """
242
+ results = search(query, limit=1)
243
+ if not results:
244
+ msg = f"No documentation found for '{query}'."
245
+ if _is_jupyter():
246
+ from IPython.display import HTML, display
247
+
248
+ display(HTML(f"<p>{html.escape(msg)}</p>"))
249
+ print(msg)
250
+ return
251
+ url = results[0]["url"]
252
+ content = _fetch_page(url)
253
+ if _is_jupyter():
254
+ from IPython.display import HTML, display
255
+
256
+ display(HTML(_format_page_html(content)))
257
+ 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])
264
+
265
+
266
+ def help(query: str) -> Any:
267
+ """Search C++ documentation (alias for :func:`search`).
268
+
269
+ Args:
270
+ query: Search term.
271
+ """
272
+ return search(query)
273
+
274
+
275
+ def refresh_index() -> int:
276
+ """Re-download the search index from GitHub Pages.
277
+
278
+ Returns the number of indexed pages.
279
+ """
280
+ global _INDEX
281
+ _INDEX = []
282
+ url = "https://dive4dec.github.io/cppmanlite/index.json"
283
+ _INDEX = json.loads(_fetch_url(url))
284
+ return len(_INDEX)
285
+
286
+
287
+ # Re-export ``help`` under a safe alias to avoid shadowing builtin
288
+ help_query = help