zealpy 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.
zealpy/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """
2
+ zealpy
3
+ ~~~~~~
4
+
5
+ Lightweight offline documentation browser and search engine for Dash / Zeal docsets.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ __version__ = "0.1.0"
11
+ __author__ = "Zealpy Contributors"
12
+ __license__ = "GPL-3.0-or-later"
13
+
14
+ from zealpy.docset import Docset, DocsetEntry, DocsetManager
15
+ from zealpy.server import run_server
16
+
17
+
18
+ def search(query: str, docset: str = None, limit: int = 50):
19
+ """Quick helper to search symbols across docsets."""
20
+ manager = DocsetManager()
21
+ return manager.search(query, docset_name=docset, total_limit=limit)
22
+
23
+
24
+ def serve(port: int = 8080, host: str = "127.0.0.1", open_browser: bool = True):
25
+ """Starts the local documentation server."""
26
+ run_server(host=host, port=port, open_browser=open_browser)
27
+
28
+
29
+ __all__ = [
30
+ "Docset",
31
+ "DocsetEntry",
32
+ "DocsetManager",
33
+ "search",
34
+ "serve",
35
+ "__version__",
36
+ ]
zealpy/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from zealpy.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
zealpy/cli.py ADDED
@@ -0,0 +1,252 @@
1
+ """
2
+ zealpy.cli
3
+ ~~~~~~~~~~
4
+
5
+ Command-line interface for zealpy.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ from zealpy import __version__
16
+ from zealpy.docset import DocsetManager
17
+ from zealpy.feeds import (
18
+ POPULAR_DOCSETS,
19
+ download_docset,
20
+ install_from_file,
21
+ list_available_docsets,
22
+ )
23
+ from zealpy.server import run_server
24
+
25
+ # ANSI Colors for terminal output
26
+ BOLD = "\033[1m"
27
+ DIM = "\033[2m"
28
+ CYAN = "\033[36m"
29
+ GREEN = "\033[32m"
30
+ YELLOW = "\033[33m"
31
+ BLUE = "\033[34m"
32
+ MAGENTA = "\033[35m"
33
+ RESET = "\033[0m"
34
+
35
+
36
+ def format_type_badge(type_str: str) -> str:
37
+ """Returns colorized type badge."""
38
+ t = type_str.lower()
39
+ if "class" in t:
40
+ return f"{MAGENTA}[Class]{RESET}"
41
+ if "method" in t:
42
+ return f"{BLUE}[Method]{RESET}"
43
+ if "func" in t:
44
+ return f"{GREEN}[Function]{RESET}"
45
+ if "mod" in t or "pkg" in t:
46
+ return f"{YELLOW}[Module]{RESET}"
47
+ return f"{CYAN}[{type_str}]{RESET}"
48
+
49
+
50
+ def cmd_serve(args: argparse.Namespace) -> None:
51
+ """Starts the local documentation server."""
52
+ run_server(
53
+ host=args.host,
54
+ port=args.port,
55
+ docset_dir=args.docset_dir,
56
+ open_browser=not args.no_browser,
57
+ )
58
+
59
+
60
+ def cmd_search(args: argparse.Namespace) -> None:
61
+ """Searches symbols in terminal."""
62
+ extra = [args.docset_dir] if args.docset_dir else None
63
+ manager = DocsetManager(extra_paths=extra)
64
+
65
+ results = manager.search(
66
+ query=args.query,
67
+ docset_name=args.docset,
68
+ entry_type=args.type,
69
+ total_limit=args.limit,
70
+ )
71
+
72
+ if not results:
73
+ print(f"No symbols found matching '{args.query}'.")
74
+ return
75
+
76
+ print(f"\nFound {BOLD}{len(results)}{RESET} symbol(s) for '{args.query}':\n")
77
+ for r in results:
78
+ badge = format_type_badge(r.type)
79
+ print(f" {badge:<18} {BOLD}{r.name:<32}{RESET} {DIM}({r.docset}){RESET}")
80
+ if args.verbose:
81
+ print(f" {DIM}Path: {r.path}{RESET}")
82
+ print()
83
+
84
+
85
+ def cmd_list(args: argparse.Namespace) -> None:
86
+ """Lists all installed docsets."""
87
+ extra = [args.docset_dir] if args.docset_dir else None
88
+ manager = DocsetManager(extra_paths=extra)
89
+
90
+ if not manager.docsets:
91
+ print("\nNo docsets found locally.")
92
+ print("To download docsets, run: zealpy available")
93
+ print("Or: zealpy download <name>\n")
94
+ return
95
+
96
+ print(f"\n{BOLD}Installed Docsets ({len(manager.docsets)}):{RESET}\n")
97
+ for name, ds in sorted(manager.docsets.items()):
98
+ entries = ds.count_entries()
99
+ print(f" * {BOLD}{ds.name:<24}{RESET} [{entries:>6} symbols] {DIM}{ds.path}{RESET}")
100
+ print()
101
+
102
+
103
+ def cmd_available(args: argparse.Namespace) -> None:
104
+ """Lists popular docsets available for download."""
105
+ items = list_available_docsets()
106
+ print(f"\n{BOLD}Available Official Docsets ({len(items)}):{RESET}\n")
107
+ print(f" {'Name':<18} {'Description'}")
108
+ print(f" {'-'*18} {'-'*45}")
109
+ for item in items:
110
+ print(f" {CYAN}{item['name']:<18}{RESET} {item['title']}")
111
+ print(f"\nInstall any docset using: {BOLD}zealpy download <name>{RESET}\n")
112
+
113
+
114
+ def cmd_download(args: argparse.Namespace) -> None:
115
+ """Downloads a docset from official mirrors."""
116
+ name = args.name.strip()
117
+ print(f"\nDownloading docset {BOLD}{name}{RESET}...")
118
+
119
+ def progress(read_bytes: int, total_bytes: int) -> None:
120
+ mb_read = read_bytes / (1024 * 1024)
121
+ mb_total = total_bytes / (1024 * 1024)
122
+ percent = (read_bytes / total_bytes) * 100 if total_bytes > 0 else 0
123
+ sys.stdout.write(f"\r Progress: {mb_read:.1f} MB / {mb_total:.1f} MB ({percent:.0f}%)")
124
+ sys.stdout.flush()
125
+
126
+ try:
127
+ path = download_docset(name, target_dir=args.docset_dir, progress_callback=progress)
128
+ print(f"\n\n{GREEN}Successfully installed {name}!{RESET}")
129
+ print(f"Installed at: {path}\n")
130
+ except Exception as e:
131
+ print(f"\n{YELLOW}Failed to download docset:{RESET} {e}\n", file=sys.stderr)
132
+ sys.exit(1)
133
+
134
+
135
+ def cmd_add(args: argparse.Namespace) -> None:
136
+ """Imports a local docset or archive."""
137
+ src = Path(args.path)
138
+ print(f"\nImporting docset from {src}...")
139
+ try:
140
+ path = install_from_file(src, target_dir=args.docset_dir)
141
+ print(f"{GREEN}Successfully imported!{RESET}")
142
+ print(f"Location: {path}\n")
143
+ except Exception as e:
144
+ print(f"Failed to import: {e}", file=sys.stderr)
145
+ sys.exit(1)
146
+
147
+
148
+ def cmd_info(args: argparse.Namespace) -> None:
149
+ """Shows detailed information about a docset."""
150
+ extra = [args.docset_dir] if args.docset_dir else None
151
+ manager = DocsetManager(extra_paths=extra)
152
+ ds = manager.get(args.name)
153
+
154
+ if not ds:
155
+ print(f"Docset '{args.name}' not found.", file=sys.stderr)
156
+ sys.exit(1)
157
+
158
+ print(f"\n{BOLD}Docset: {ds.name}{RESET}")
159
+ print(f" Identifier: {ds.identifier}")
160
+ print(f" Path: {ds.path}")
161
+ print(f" Database: {ds.db_path}")
162
+ print(f" Total: {ds.count_entries()} symbols\n")
163
+
164
+ types = ds.get_types()
165
+ if types:
166
+ print(f" {BOLD}Symbol Types:{RESET}")
167
+ for t, count in types[:12]:
168
+ print(f" * {t:<20} {count:>6}")
169
+ print()
170
+
171
+
172
+ def build_parser() -> argparse.ArgumentParser:
173
+ """Builds argument parser for zealpy CLI."""
174
+ parser = argparse.ArgumentParser(
175
+ prog="zealpy",
176
+ description="Zealpy: Lightweight Offline Documentation Browser and Search Engine for Dash / Zeal docsets.",
177
+ epilog="Examples:\n"
178
+ " zealpy serve # Start local documentation server\n"
179
+ " zealpy search QComboBox # Search symbol across docsets\n"
180
+ " zealpy search groupby -d Pandas\n"
181
+ " zealpy download Python # Download official Python docs\n"
182
+ " zealpy list # List installed docsets\n",
183
+ formatter_class=argparse.RawDescriptionHelpFormatter,
184
+ )
185
+
186
+ parser.add_argument("-v", "--version", action="version", version=f"zealpy {__version__}")
187
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
188
+
189
+ # serve
190
+ p_serve = subparsers.add_parser("serve", help="Start local web server and documentation viewer")
191
+ p_serve.add_argument("-p", "--port", type=int, default=8080, help="Port to listen on (default: 8080)")
192
+ p_serve.add_argument("-H", "--host", default="127.0.0.1", help="Host address (default: 127.0.0.1)")
193
+ p_serve.add_argument("--no-browser", action="store_true", help="Do not open browser automatically")
194
+ p_serve.add_argument("-d", "--docset-dir", help="Path to custom docsets directory")
195
+ p_serve.set_defaults(func=cmd_serve)
196
+
197
+ # search
198
+ p_search = subparsers.add_parser("search", help="Search symbol/function across docsets")
199
+ p_search.add_argument("query", help="Symbol or function name to search for")
200
+ p_search.add_argument("-d", "--docset", help="Filter by docset name")
201
+ p_search.add_argument("-t", "--type", help="Filter by symbol type (Class, Method, Function, etc.)")
202
+ p_search.add_argument("-n", "--limit", type=int, default=25, help="Maximum number of results")
203
+ p_search.add_argument("-V", "--verbose", action="store_true", help="Show full entry paths")
204
+ p_search.add_argument("--docset-dir", help="Path to custom docsets directory")
205
+ p_search.set_defaults(func=cmd_search)
206
+
207
+ # list
208
+ p_list = subparsers.add_parser("list", help="List all locally installed docsets")
209
+ p_list.add_argument("-d", "--docset-dir", help="Path to custom docsets directory")
210
+ p_list.set_defaults(func=cmd_list)
211
+
212
+ # available
213
+ p_avail = subparsers.add_parser("available", help="List popular docsets available for download")
214
+ p_avail.set_defaults(func=cmd_available)
215
+
216
+ # download
217
+ p_down = subparsers.add_parser("download", help="Download and install an official docset")
218
+ p_down.add_argument("name", help="Name of docset (e.g. Python, Qt, Pandas, Flask)")
219
+ p_down.add_argument("-d", "--docset-dir", help="Target installation directory")
220
+ p_down.set_defaults(func=cmd_download)
221
+
222
+ # add
223
+ p_add = subparsers.add_parser("add", help="Import a local .docset folder or archive (.tgz, .zip)")
224
+ p_add.add_argument("path", help="Path to .docset folder or archive")
225
+ p_add.add_argument("-d", "--docset-dir", help="Target installation directory")
226
+ p_add.set_defaults(func=cmd_add)
227
+
228
+ # info
229
+ p_info = subparsers.add_parser("info", help="Show metadata and statistics for a docset")
230
+ p_info.add_argument("name", help="Name of docset")
231
+ p_info.add_argument("-d", "--docset-dir", help="Path to custom docsets directory")
232
+ p_info.set_defaults(func=cmd_info)
233
+
234
+ return parser
235
+
236
+
237
+ def main() -> None:
238
+ """Main CLI entrypoint."""
239
+ parser = build_parser()
240
+ if len(sys.argv) == 1:
241
+ parser.print_help()
242
+ sys.exit(0)
243
+
244
+ args = parser.parse_args()
245
+ if hasattr(args, "func"):
246
+ args.func(args)
247
+ else:
248
+ parser.print_help()
249
+
250
+
251
+ if __name__ == "__main__":
252
+ main()
zealpy/docset.py ADDED
@@ -0,0 +1,330 @@
1
+ """
2
+ zealpy.docset
3
+ ~~~~~~~~~~~~~
4
+
5
+ Docset parser and search engine for Dash / Zeal documentation sets.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import plistlib
12
+ import sqlite3
13
+ import sys
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Dict, Iterator, List, Optional, Tuple
17
+
18
+
19
+ @dataclass
20
+ class DocsetEntry:
21
+ """Represents an indexed symbol / entry inside a docset."""
22
+ name: str
23
+ type: str
24
+ path: str
25
+ docset: str
26
+
27
+ @property
28
+ def url(self) -> str:
29
+ """URL path for local HTTP server serving."""
30
+ # Clean up possible anchor or leading slashes
31
+ clean_path = self.path.lstrip("/")
32
+ return f"/docs/{self.docset}/{clean_path}"
33
+
34
+
35
+ class Docset:
36
+ """Represents a single .docset bundle."""
37
+
38
+ def __init__(self, path: Path | str) -> None:
39
+ self.path = Path(path).resolve()
40
+ if not self.path.is_dir() or not self.path.name.endswith(".docset"):
41
+ raise ValueError(f"Not a valid .docset directory: {self.path}")
42
+
43
+ self.contents_dir = self.path / "Contents"
44
+ self.resources_dir = self.contents_dir / "Resources"
45
+ self.documents_dir = self.resources_dir / "Documents"
46
+ self.db_path = self.resources_dir / "docSet.dsidx"
47
+ self.plist_path = self.contents_dir / "Info.plist"
48
+
49
+ self.identifier = self.path.stem
50
+ self.name = self.path.stem
51
+ self.feed_url: Optional[str] = None
52
+ self._table_type: Optional[str] = None
53
+
54
+ self._load_plist()
55
+
56
+ def _load_plist(self) -> None:
57
+ """Loads metadata from Info.plist if present."""
58
+ if self.plist_path.is_file():
59
+ try:
60
+ with open(self.plist_path, "rb") as fp:
61
+ data = plistlib.load(fp)
62
+ self.identifier = data.get("CFBundleIdentifier", self.identifier)
63
+ self.name = (
64
+ data.get("DocSetPlatformFamily")
65
+ or data.get("CFBundleName")
66
+ or data.get("CFBundleIdentifier")
67
+ or self.name
68
+ )
69
+ self.feed_url = data.get("DashDocSetFeedURL")
70
+ except Exception:
71
+ pass
72
+
73
+ def _get_connection(self) -> sqlite3.Connection:
74
+ """Opens a read-only SQLite connection to the index."""
75
+ if not self.db_path.is_file():
76
+ raise FileNotFoundError(f"Database not found: {self.db_path}")
77
+ uri = f"file:{self.db_path.as_posix()}?mode=ro"
78
+ conn = sqlite3.connect(uri, uri=True, check_same_thread=False)
79
+ conn.row_factory = sqlite3.Row
80
+ return conn
81
+
82
+ def _detect_schema(self, conn: sqlite3.Connection) -> str:
83
+ """Detects whether index uses searchIndex or CoreData format."""
84
+ if self._table_type:
85
+ return self._table_type
86
+
87
+ cursor = conn.cursor()
88
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
89
+ tables = {row[0].lower() for row in cursor.fetchall()}
90
+
91
+ if "searchindex" in tables:
92
+ self._table_type = "searchIndex"
93
+ elif "zloadingentry" in tables:
94
+ self._table_type = "zloadingentry"
95
+ elif "ztoken" in tables:
96
+ self._table_type = "ztoken"
97
+ else:
98
+ self._table_type = "unknown"
99
+
100
+ return self._table_type
101
+
102
+ def count_entries(self) -> int:
103
+ """Returns the total number of indexed symbols."""
104
+ if not self.db_path.is_file():
105
+ return 0
106
+ try:
107
+ with self._get_connection() as conn:
108
+ schema = self._detect_schema(conn)
109
+ cursor = conn.cursor()
110
+ if schema == "searchIndex":
111
+ cursor.execute("SELECT COUNT(*) FROM searchIndex")
112
+ elif schema == "zloadingentry":
113
+ cursor.execute("SELECT COUNT(*) FROM zloadingentry")
114
+ elif schema == "ztoken":
115
+ cursor.execute("SELECT COUNT(*) FROM ztoken")
116
+ else:
117
+ return 0
118
+ return cursor.fetchone()[0]
119
+ except Exception:
120
+ return 0
121
+
122
+ def get_types(self) -> List[Tuple[str, int]]:
123
+ """Returns list of (type, count) found in the docset."""
124
+ if not self.db_path.is_file():
125
+ return []
126
+ try:
127
+ with self._get_connection() as conn:
128
+ schema = self._detect_schema(conn)
129
+ cursor = conn.cursor()
130
+ if schema == "searchIndex":
131
+ cursor.execute(
132
+ "SELECT type, COUNT(*) FROM searchIndex GROUP BY type ORDER BY COUNT(*) DESC"
133
+ )
134
+ elif schema == "zloadingentry":
135
+ cursor.execute(
136
+ "SELECT ZTYPE, COUNT(*) FROM zloadingentry GROUP BY ZTYPE ORDER BY COUNT(*) DESC"
137
+ )
138
+ else:
139
+ return []
140
+ return [(row[0] or "Unknown", row[1]) for row in cursor.fetchall()]
141
+ except Exception:
142
+ return []
143
+
144
+ def search(
145
+ self,
146
+ query: str,
147
+ entry_type: Optional[str] = None,
148
+ limit: int = 50,
149
+ ) -> List[DocsetEntry]:
150
+ """Searches symbols matching the query with intelligent ranking."""
151
+ if not self.db_path.is_file() or not query.strip():
152
+ return []
153
+
154
+ query = query.strip()
155
+ results: List[DocsetEntry] = []
156
+
157
+ try:
158
+ with self._get_connection() as conn:
159
+ schema = self._detect_schema(conn)
160
+ cursor = conn.cursor()
161
+
162
+ if schema == "searchIndex":
163
+ sql = """
164
+ SELECT name, type, path,
165
+ CASE
166
+ WHEN name = ? THEN 1
167
+ WHEN name LIKE ? || '%' THEN 2
168
+ WHEN name LIKE '%' || ? || '%' THEN 3
169
+ ELSE 4
170
+ END AS rank
171
+ FROM searchIndex
172
+ WHERE name LIKE '%' || ? || '%'
173
+ """
174
+ params: list = [query, query, query, query]
175
+ if entry_type:
176
+ sql += " AND type = ?"
177
+ params.append(entry_type)
178
+ sql += " ORDER BY rank ASC, LENGTH(name) ASC, name ASC LIMIT ?"
179
+ params.append(limit)
180
+
181
+ cursor.execute(sql, params)
182
+ for row in cursor.fetchall():
183
+ results.append(
184
+ DocsetEntry(
185
+ name=row["name"],
186
+ type=row["type"] or "Unknown",
187
+ path=row["path"],
188
+ docset=self.identifier,
189
+ )
190
+ )
191
+
192
+ elif schema == "zloadingentry":
193
+ sql = """
194
+ SELECT ZNAME AS name, ZTYPE AS type, ZPATH AS path,
195
+ CASE
196
+ WHEN ZNAME = ? THEN 1
197
+ WHEN ZNAME LIKE ? || '%' THEN 2
198
+ WHEN ZNAME LIKE '%' || ? || '%' THEN 3
199
+ ELSE 4
200
+ END AS rank
201
+ FROM zloadingentry
202
+ WHERE ZNAME LIKE '%' || ? || '%'
203
+ """
204
+ params = [query, query, query, query]
205
+ if entry_type:
206
+ sql += " AND ZTYPE = ?"
207
+ params.append(entry_type)
208
+ sql += " ORDER BY rank ASC, LENGTH(ZNAME) ASC, ZNAME ASC LIMIT ?"
209
+ params.append(limit)
210
+
211
+ cursor.execute(sql, params)
212
+ for row in cursor.fetchall():
213
+ results.append(
214
+ DocsetEntry(
215
+ name=row["name"],
216
+ type=row["type"] or "Unknown",
217
+ path=row["path"],
218
+ docset=self.identifier,
219
+ )
220
+ )
221
+ except Exception as e:
222
+ # Fallback for unexpected DB error
223
+ print(f"Error querying {self.name}: {e}", file=sys.stderr)
224
+
225
+ return results
226
+
227
+
228
+ class DocsetManager:
229
+ """Discovers and manages docsets across system and custom directories."""
230
+
231
+ def __init__(self, extra_paths: Optional[List[Path | str]] = None) -> None:
232
+ self.extra_paths = [Path(p).resolve() for p in (extra_paths or [])]
233
+ self.docsets: Dict[str, Docset] = {}
234
+ self.rescan()
235
+
236
+ @staticmethod
237
+ def get_default_storage_dir() -> Path:
238
+ """Returns the primary storage directory for zealpy docsets (~/.zealpy/docsets)."""
239
+ base = Path.home() / ".zealpy" / "docsets"
240
+ base.mkdir(parents=True, exist_ok=True)
241
+ return base
242
+
243
+ @classmethod
244
+ def get_search_paths(cls, extra_paths: Optional[List[Path]] = None) -> List[Path]:
245
+ """Returns all directories checked for docsets, including native Zeal paths."""
246
+ paths: List[Path] = []
247
+
248
+ if extra_paths:
249
+ paths.extend(extra_paths)
250
+
251
+ # Local workspace paths
252
+ paths.append(Path.cwd() / "docsets")
253
+ paths.append(Path.cwd())
254
+
255
+ # zealpy storage
256
+ paths.append(cls.get_default_storage_dir())
257
+
258
+ # Native Zeal paths for cross-compatibility
259
+ if sys.platform == "win32":
260
+ local_appdata = os.environ.get("LOCALAPPDATA")
261
+ if local_appdata:
262
+ paths.append(Path(local_appdata) / "Zeal" / "Zeal" / "docsets")
263
+ elif sys.platform == "darwin":
264
+ paths.append(Path.home() / "Library" / "Application Support" / "Zeal" / "Zeal" / "docsets")
265
+ else:
266
+ paths.append(Path.home() / ".local" / "share" / "Zeal" / "Zeal" / "docsets")
267
+
268
+ # Return existing unique directories
269
+ unique_paths: List[Path] = []
270
+ for p in paths:
271
+ if p.is_dir() and p not in unique_paths:
272
+ unique_paths.append(p)
273
+ return unique_paths
274
+
275
+ def rescan(self) -> None:
276
+ """Scans all search paths and registers found docsets."""
277
+ self.docsets.clear()
278
+ for folder in self.get_search_paths(self.extra_paths):
279
+ if folder.name.endswith(".docset") and folder.is_dir():
280
+ try:
281
+ ds = Docset(folder)
282
+ self.docsets[ds.identifier] = ds
283
+ except Exception:
284
+ pass
285
+ continue
286
+
287
+ # Check subdirectories
288
+ for entry in folder.iterdir():
289
+ if entry.is_dir() and entry.name.endswith(".docset"):
290
+ try:
291
+ ds = Docset(entry)
292
+ # Keep latest or first seen
293
+ if ds.identifier not in self.docsets:
294
+ self.docsets[ds.identifier] = ds
295
+ except Exception:
296
+ pass
297
+
298
+ def get(self, name: str) -> Optional[Docset]:
299
+ """Gets docset by identifier or name (case-insensitive)."""
300
+ if name in self.docsets:
301
+ return self.docsets[name]
302
+ lower_name = name.lower()
303
+ for k, v in self.docsets.items():
304
+ if k.lower() == lower_name or v.name.lower() == lower_name:
305
+ return v
306
+ return None
307
+
308
+ def search(
309
+ self,
310
+ query: str,
311
+ docset_name: Optional[str] = None,
312
+ entry_type: Optional[str] = None,
313
+ limit_per_docset: int = 25,
314
+ total_limit: int = 100,
315
+ ) -> List[DocsetEntry]:
316
+ """Searches across all docsets or a specific docset."""
317
+ results: List[DocsetEntry] = []
318
+ if docset_name:
319
+ ds = self.get(docset_name)
320
+ if ds:
321
+ return ds.search(query, entry_type=entry_type, limit=total_limit)
322
+ return []
323
+
324
+ for ds in self.docsets.values():
325
+ ds_results = ds.search(query, entry_type=entry_type, limit=limit_per_docset)
326
+ results.extend(ds_results)
327
+ if len(results) >= total_limit:
328
+ break
329
+
330
+ return results[:total_limit]