onetool-mcp 1.0.0b1__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.
Files changed (132) hide show
  1. bench/__init__.py +5 -0
  2. bench/cli.py +69 -0
  3. bench/harness/__init__.py +66 -0
  4. bench/harness/client.py +692 -0
  5. bench/harness/config.py +397 -0
  6. bench/harness/csv_writer.py +109 -0
  7. bench/harness/evaluate.py +512 -0
  8. bench/harness/metrics.py +283 -0
  9. bench/harness/runner.py +899 -0
  10. bench/py.typed +0 -0
  11. bench/reporter.py +629 -0
  12. bench/run.py +487 -0
  13. bench/secrets.py +101 -0
  14. bench/utils.py +16 -0
  15. onetool/__init__.py +4 -0
  16. onetool/cli.py +391 -0
  17. onetool/py.typed +0 -0
  18. onetool_mcp-1.0.0b1.dist-info/METADATA +163 -0
  19. onetool_mcp-1.0.0b1.dist-info/RECORD +132 -0
  20. onetool_mcp-1.0.0b1.dist-info/WHEEL +4 -0
  21. onetool_mcp-1.0.0b1.dist-info/entry_points.txt +3 -0
  22. onetool_mcp-1.0.0b1.dist-info/licenses/LICENSE.txt +687 -0
  23. onetool_mcp-1.0.0b1.dist-info/licenses/NOTICE.txt +64 -0
  24. ot/__init__.py +37 -0
  25. ot/__main__.py +6 -0
  26. ot/_cli.py +107 -0
  27. ot/_tui.py +53 -0
  28. ot/config/__init__.py +46 -0
  29. ot/config/defaults/bench.yaml +4 -0
  30. ot/config/defaults/diagram-templates/api-flow.mmd +33 -0
  31. ot/config/defaults/diagram-templates/c4-context.puml +30 -0
  32. ot/config/defaults/diagram-templates/class-diagram.mmd +87 -0
  33. ot/config/defaults/diagram-templates/feature-mindmap.mmd +70 -0
  34. ot/config/defaults/diagram-templates/microservices.d2 +81 -0
  35. ot/config/defaults/diagram-templates/project-gantt.mmd +37 -0
  36. ot/config/defaults/diagram-templates/state-machine.mmd +42 -0
  37. ot/config/defaults/onetool.yaml +25 -0
  38. ot/config/defaults/prompts.yaml +97 -0
  39. ot/config/defaults/servers.yaml +7 -0
  40. ot/config/defaults/snippets.yaml +4 -0
  41. ot/config/defaults/tool_templates/__init__.py +7 -0
  42. ot/config/defaults/tool_templates/extension.py +52 -0
  43. ot/config/defaults/tool_templates/isolated.py +61 -0
  44. ot/config/dynamic.py +121 -0
  45. ot/config/global_templates/__init__.py +2 -0
  46. ot/config/global_templates/bench-secrets-template.yaml +6 -0
  47. ot/config/global_templates/bench.yaml +9 -0
  48. ot/config/global_templates/onetool.yaml +27 -0
  49. ot/config/global_templates/secrets-template.yaml +44 -0
  50. ot/config/global_templates/servers.yaml +18 -0
  51. ot/config/global_templates/snippets.yaml +235 -0
  52. ot/config/loader.py +1087 -0
  53. ot/config/mcp.py +145 -0
  54. ot/config/secrets.py +190 -0
  55. ot/config/tool_config.py +125 -0
  56. ot/decorators.py +116 -0
  57. ot/executor/__init__.py +35 -0
  58. ot/executor/base.py +16 -0
  59. ot/executor/fence_processor.py +83 -0
  60. ot/executor/linter.py +142 -0
  61. ot/executor/pack_proxy.py +260 -0
  62. ot/executor/param_resolver.py +140 -0
  63. ot/executor/pep723.py +288 -0
  64. ot/executor/result_store.py +369 -0
  65. ot/executor/runner.py +496 -0
  66. ot/executor/simple.py +163 -0
  67. ot/executor/tool_loader.py +396 -0
  68. ot/executor/validator.py +398 -0
  69. ot/executor/worker_pool.py +388 -0
  70. ot/executor/worker_proxy.py +189 -0
  71. ot/http_client.py +145 -0
  72. ot/logging/__init__.py +37 -0
  73. ot/logging/config.py +315 -0
  74. ot/logging/entry.py +213 -0
  75. ot/logging/format.py +188 -0
  76. ot/logging/span.py +349 -0
  77. ot/meta.py +1555 -0
  78. ot/paths.py +453 -0
  79. ot/prompts.py +218 -0
  80. ot/proxy/__init__.py +21 -0
  81. ot/proxy/manager.py +396 -0
  82. ot/py.typed +0 -0
  83. ot/registry/__init__.py +189 -0
  84. ot/registry/models.py +57 -0
  85. ot/registry/parser.py +269 -0
  86. ot/registry/registry.py +413 -0
  87. ot/server.py +315 -0
  88. ot/shortcuts/__init__.py +15 -0
  89. ot/shortcuts/aliases.py +87 -0
  90. ot/shortcuts/snippets.py +258 -0
  91. ot/stats/__init__.py +35 -0
  92. ot/stats/html.py +250 -0
  93. ot/stats/jsonl_writer.py +283 -0
  94. ot/stats/reader.py +354 -0
  95. ot/stats/timing.py +57 -0
  96. ot/support.py +63 -0
  97. ot/tools.py +114 -0
  98. ot/utils/__init__.py +81 -0
  99. ot/utils/batch.py +161 -0
  100. ot/utils/cache.py +120 -0
  101. ot/utils/deps.py +403 -0
  102. ot/utils/exceptions.py +23 -0
  103. ot/utils/factory.py +179 -0
  104. ot/utils/format.py +65 -0
  105. ot/utils/http.py +202 -0
  106. ot/utils/platform.py +45 -0
  107. ot/utils/sanitize.py +130 -0
  108. ot/utils/truncate.py +69 -0
  109. ot_tools/__init__.py +4 -0
  110. ot_tools/_convert/__init__.py +12 -0
  111. ot_tools/_convert/excel.py +279 -0
  112. ot_tools/_convert/pdf.py +254 -0
  113. ot_tools/_convert/powerpoint.py +268 -0
  114. ot_tools/_convert/utils.py +358 -0
  115. ot_tools/_convert/word.py +283 -0
  116. ot_tools/brave_search.py +604 -0
  117. ot_tools/code_search.py +736 -0
  118. ot_tools/context7.py +495 -0
  119. ot_tools/convert.py +614 -0
  120. ot_tools/db.py +415 -0
  121. ot_tools/diagram.py +1604 -0
  122. ot_tools/diagram.yaml +167 -0
  123. ot_tools/excel.py +1372 -0
  124. ot_tools/file.py +1348 -0
  125. ot_tools/firecrawl.py +732 -0
  126. ot_tools/grounding_search.py +646 -0
  127. ot_tools/package.py +604 -0
  128. ot_tools/py.typed +0 -0
  129. ot_tools/ripgrep.py +544 -0
  130. ot_tools/scaffold.py +471 -0
  131. ot_tools/transform.py +213 -0
  132. ot_tools/web_fetch.py +384 -0
@@ -0,0 +1,604 @@
1
+ """Brave Search API tools.
2
+
3
+ Provides web search, local search, news search, image search, and video search
4
+ via the Brave Search API. Requires BRAVE_API_KEY secret in secrets.yaml.
5
+
6
+ API docs: https://api-dashboard.search.brave.com/app/documentation
7
+ Reference: https://github.com/brave/brave-search-mcp-server
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ # Pack for dot notation: brave.search(), brave.news(), etc.
13
+ pack = "brave"
14
+
15
+ __all__ = ["image", "local", "news", "search", "search_batch", "video"]
16
+
17
+ # Dependency declarations for CLI validation
18
+ __ot_requires__ = {
19
+ "secrets": ["BRAVE_API_KEY"],
20
+ }
21
+
22
+ from typing import Any, Literal
23
+
24
+ import httpx
25
+ from pydantic import BaseModel, Field
26
+
27
+ from ot.config import get_secret, get_tool_config
28
+ from ot.logging import LogSpan
29
+ from ot.utils import batch_execute, format_batch_results, lazy_client, normalize_items
30
+
31
+
32
+ class Config(BaseModel):
33
+ """Pack configuration - discovered by registry."""
34
+
35
+ timeout: float = Field(
36
+ default=60.0,
37
+ ge=1.0,
38
+ le=300.0,
39
+ description="Request timeout in seconds",
40
+ )
41
+
42
+ BRAVE_API_BASE = "https://api.search.brave.com/res/v1"
43
+
44
+ # Truncation length for video descriptions (147 chars + "..." = 150 total)
45
+ VIDEO_DESC_MAX_LENGTH = 150
46
+
47
+ def _create_http_client() -> httpx.Client:
48
+ """Create HTTP client for Brave API requests."""
49
+ return httpx.Client(
50
+ base_url=BRAVE_API_BASE,
51
+ timeout=60.0,
52
+ headers={"Accept": "application/json", "Accept-Encoding": "gzip"},
53
+ )
54
+
55
+
56
+ # Thread-safe lazy client using SDK utility
57
+ _get_http_client = lazy_client(_create_http_client)
58
+
59
+
60
+ def _get_config() -> Config:
61
+ """Get brave pack configuration."""
62
+ return get_tool_config("brave", Config)
63
+
64
+
65
+ def _get_api_key() -> str:
66
+ """Get Brave API key from secrets."""
67
+ return get_secret("BRAVE_API_KEY") or ""
68
+
69
+
70
+ def _get_headers(api_key: str) -> dict[str, str]:
71
+ """Get headers for Brave API requests.
72
+
73
+ Args:
74
+ api_key: Pre-fetched Brave API key
75
+ """
76
+ return {
77
+ "X-Subscription-Token": api_key,
78
+ }
79
+
80
+
81
+ def _make_request(
82
+ endpoint: str,
83
+ params: dict[str, Any],
84
+ timeout: float | None = None,
85
+ ) -> tuple[bool, dict[str, Any] | str]:
86
+ """Make HTTP GET request to Brave API.
87
+
88
+ Args:
89
+ endpoint: API endpoint path (e.g., "/web/search")
90
+ params: Query parameters
91
+ timeout: Request timeout in seconds
92
+
93
+ Returns:
94
+ Tuple of (success, result). If success, result is parsed JSON dict.
95
+ If failure, result is error message string.
96
+ """
97
+ api_key = _get_api_key()
98
+ if not api_key:
99
+ return False, "Error: BRAVE_API_KEY secret not configured"
100
+
101
+ if timeout is None:
102
+ timeout = _get_config().timeout
103
+
104
+ with LogSpan(
105
+ span="brave.request", endpoint=endpoint, query=params.get("q", "")
106
+ ) as span:
107
+ try:
108
+ client = _get_http_client()
109
+ if client is None:
110
+ return False, "Error: HTTP client not initialized"
111
+ response = client.get(
112
+ endpoint,
113
+ params=params,
114
+ headers=_get_headers(api_key),
115
+ timeout=timeout,
116
+ )
117
+ response.raise_for_status()
118
+
119
+ result = response.json()
120
+ span.add(status=response.status_code)
121
+ return True, result
122
+
123
+ except Exception as e:
124
+ error_type = type(e).__name__
125
+ span.add(error=f"{error_type}: {e}")
126
+
127
+ if hasattr(e, "response"):
128
+ status = getattr(e.response, "status_code", "unknown")
129
+ text = getattr(e.response, "text", "")[:200]
130
+ return False, f"HTTP error ({status}): {text}"
131
+ return False, f"Request failed: {e}"
132
+
133
+
134
+ def _format_web_results(data: dict[str, Any]) -> str:
135
+ """Format web search results for display."""
136
+ lines: list[str] = []
137
+
138
+ web = data.get("web", {})
139
+ results = web.get("results", [])
140
+
141
+ if not results:
142
+ return "No results found."
143
+
144
+ for i, result in enumerate(results, 1):
145
+ title = result.get("title", "No title")
146
+ url = result.get("url", "")
147
+ description = result.get("description", "")
148
+
149
+ lines.append(f"{i}. {title}")
150
+ lines.append(f" {url}")
151
+ if description:
152
+ lines.append(f" {description}")
153
+ lines.append("")
154
+
155
+ return "\n".join(lines)
156
+
157
+
158
+ def _format_news_results(data: dict[str, Any]) -> str:
159
+ """Format news search results for display."""
160
+ lines: list[str] = []
161
+
162
+ results = data.get("results", [])
163
+
164
+ if not results:
165
+ return "No news results found."
166
+
167
+ for i, result in enumerate(results, 1):
168
+ title = result.get("title", "No title")
169
+ url = result.get("url", "")
170
+ source = result.get("meta_url", {}).get("hostname", "")
171
+ age = result.get("age", "")
172
+ breaking = result.get("breaking", False)
173
+
174
+ prefix = "[BREAKING] " if breaking else ""
175
+ lines.append(f"{i}. {prefix}{title}")
176
+ if source:
177
+ lines.append(f" Source: {source} ({age})")
178
+ lines.append(f" {url}")
179
+ lines.append("")
180
+
181
+ return "\n".join(lines)
182
+
183
+
184
+ def _format_local_results_from_web(data: dict[str, Any]) -> str:
185
+ """Format local results from web search with locations filter."""
186
+ lines: list[str] = []
187
+
188
+ locations = data.get("locations", {})
189
+ results = locations.get("results", [])
190
+
191
+ if not results:
192
+ # Inform user about fallback instead of silent switch
193
+ web_results = _format_web_results(data)
194
+ if web_results == "No results found.":
195
+ return web_results
196
+ return f"No local business data found. Showing web results:\n\n{web_results}"
197
+
198
+ for i, result in enumerate(results, 1):
199
+ name = result.get("title", "No name")
200
+
201
+ lines.append(f"{i}. {name}")
202
+
203
+ # Address
204
+ address = result.get("address", {})
205
+ if address:
206
+ addr_parts = [
207
+ address.get("streetAddress", ""),
208
+ address.get("addressLocality", ""),
209
+ address.get("addressRegion", ""),
210
+ ]
211
+ addr_str = ", ".join(p for p in addr_parts if p)
212
+ if addr_str:
213
+ lines.append(f" Address: {addr_str}")
214
+
215
+ # Rating
216
+ rating = result.get("rating", {})
217
+ if rating:
218
+ stars = rating.get("ratingValue", "")
219
+ count = rating.get("ratingCount", "")
220
+ if stars:
221
+ lines.append(f" Rating: {stars}/5 ({count} reviews)")
222
+
223
+ # Phone
224
+ phone = result.get("phone", "")
225
+ if phone:
226
+ lines.append(f" Phone: {phone}")
227
+
228
+ lines.append("")
229
+
230
+ return "\n".join(lines)
231
+
232
+
233
+ def _format_image_results(data: dict[str, Any]) -> str:
234
+ """Format image search results for display."""
235
+ lines: list[str] = []
236
+
237
+ results = data.get("results", [])
238
+
239
+ if not results:
240
+ return "No image results found."
241
+
242
+ for i, result in enumerate(results, 1):
243
+ title = result.get("title", "No title")
244
+ url = result.get("url", "")
245
+ source = result.get("source", "")
246
+ width = result.get("properties", {}).get("width", "")
247
+ height = result.get("properties", {}).get("height", "")
248
+
249
+ lines.append(f"{i}. {title}")
250
+ if width and height:
251
+ lines.append(f" Size: {width}x{height}")
252
+ if source:
253
+ lines.append(f" Source: {source}")
254
+ lines.append(f" {url}")
255
+ lines.append("")
256
+
257
+ return "\n".join(lines)
258
+
259
+
260
+ def _format_video_results(data: dict[str, Any]) -> str:
261
+ """Format video search results for display."""
262
+ lines: list[str] = []
263
+
264
+ results = data.get("results", [])
265
+
266
+ if not results:
267
+ return "No video results found."
268
+
269
+ for i, result in enumerate(results, 1):
270
+ title = result.get("title", "No title")
271
+ url = result.get("url", "")
272
+ description = result.get("description", "")
273
+ creator = result.get("meta_url", {}).get("hostname", "")
274
+ duration = result.get("video", {}).get("duration", "")
275
+ views = result.get("video", {}).get("views", "")
276
+
277
+ lines.append(f"{i}. {title}")
278
+ if creator:
279
+ lines.append(f" Channel: {creator}")
280
+ if duration:
281
+ lines.append(f" Duration: {duration}")
282
+ if views:
283
+ lines.append(f" Views: {views}")
284
+ if description:
285
+ if len(description) > VIDEO_DESC_MAX_LENGTH:
286
+ description = description[: VIDEO_DESC_MAX_LENGTH - 3] + "..."
287
+ lines.append(f" {description}")
288
+ lines.append(f" {url}")
289
+ lines.append("")
290
+
291
+ return "\n".join(lines)
292
+
293
+
294
+ def _clamp(value: int, min_val: int, max_val: int) -> int:
295
+ """Clamp a value between min and max."""
296
+ return min(max(value, min_val), max_val)
297
+
298
+
299
+ def _validate_query(query: str) -> str | None:
300
+ """Validate query against Brave API limits.
301
+
302
+ Returns None if valid, error message if invalid.
303
+ """
304
+ if not query or not query.strip():
305
+ return "Error: Query cannot be empty"
306
+ if len(query) > 400:
307
+ return f"Error: Query exceeds 400 character limit ({len(query)} chars)"
308
+ word_count = len(query.split())
309
+ if word_count > 50:
310
+ return f"Error: Query exceeds 50 word limit ({word_count} words)"
311
+ return None
312
+
313
+
314
+ def search(
315
+ *,
316
+ query: str,
317
+ count: int = 10,
318
+ offset: int = 0,
319
+ country: str = "US",
320
+ search_lang: str = "en",
321
+ safesearch: Literal["off", "moderate", "strict"] = "moderate",
322
+ freshness: Literal["pd", "pw", "pm", "py"] | None = None,
323
+ ) -> str:
324
+ """Search the web using Brave Search API.
325
+
326
+ Args:
327
+ query: Search query (max 400 chars, 50 words)
328
+ count: Number of results to return (1-20, default: 10)
329
+ offset: Pagination offset (0-9, default: 0)
330
+ country: 2-letter country code for results (default: "US")
331
+ search_lang: Language code for results (default: "en")
332
+ safesearch: Content filter - "off", "moderate", "strict" (default: "moderate")
333
+ freshness: Time filter - "pd" (day), "pw" (week), "pm" (month), "py" (year)
334
+
335
+ Returns:
336
+ YAML flow style search results or error message
337
+
338
+ Example:
339
+ # Basic search
340
+ brave.search(query="Python async best practices")
341
+
342
+ # Recent results only
343
+ brave.search(query="AI news", freshness="pw", count=5)
344
+ """
345
+ if error := _validate_query(query):
346
+ return error
347
+
348
+ params: dict[str, Any] = {
349
+ "q": query,
350
+ "count": _clamp(count, 1, 20),
351
+ "offset": _clamp(offset, 0, 9),
352
+ "country": country,
353
+ "search_lang": search_lang,
354
+ "safesearch": safesearch,
355
+ }
356
+
357
+ if freshness:
358
+ params["freshness"] = freshness
359
+
360
+ success, result = _make_request("/web/search", params)
361
+
362
+ if not success:
363
+ return str(result)
364
+
365
+ return _format_web_results(result) # type: ignore[arg-type]
366
+
367
+
368
+ def news(
369
+ *,
370
+ query: str,
371
+ count: int = 10,
372
+ offset: int = 0,
373
+ country: str = "US",
374
+ search_lang: str = "en",
375
+ freshness: Literal["pd", "pw", "pm"] | None = None,
376
+ ) -> str:
377
+ """Search news articles using Brave Search API.
378
+
379
+ Uses the dedicated /news/search endpoint for better news results.
380
+
381
+ Args:
382
+ query: Search query for news
383
+ count: Number of results (1-20, default: 10)
384
+ offset: Pagination offset (0-9, default: 0)
385
+ country: 2-letter country code (default: "US")
386
+ search_lang: Language code (default: "en")
387
+ freshness: Time filter - "pd" (day), "pw" (week), "pm" (month)
388
+
389
+ Returns:
390
+ Formatted news results or error message
391
+
392
+ Example:
393
+ # Today's tech news
394
+ brave.news(query="artificial intelligence", freshness="pd")
395
+
396
+ # UK news
397
+ brave.news(query="technology", country="GB", count=5)
398
+ """
399
+ if error := _validate_query(query):
400
+ return error
401
+
402
+ params: dict[str, Any] = {
403
+ "q": query,
404
+ "count": _clamp(count, 1, 20),
405
+ "offset": _clamp(offset, 0, 9),
406
+ "country": country,
407
+ "search_lang": search_lang,
408
+ }
409
+
410
+ if freshness:
411
+ params["freshness"] = freshness
412
+
413
+ success, result = _make_request("/news/search", params)
414
+
415
+ if not success:
416
+ return str(result)
417
+ return _format_news_results(result) # type: ignore[arg-type]
418
+
419
+
420
+ def local(
421
+ *,
422
+ query: str,
423
+ count: int = 5,
424
+ country: str = "US",
425
+ ) -> str:
426
+ """Search for local businesses and places using Brave Search API.
427
+
428
+ Performs web search optimized for local queries. Returns location results
429
+ if available, otherwise falls back to web results.
430
+
431
+ Args:
432
+ query: Local search query (e.g., "pizza near Central Park")
433
+ count: Number of results (1-20, default: 5)
434
+ country: 2-letter country code (default: "US")
435
+
436
+ Returns:
437
+ Formatted local results with addresses, ratings, phone numbers
438
+
439
+ Example:
440
+ brave.local(query="coffee shops near Times Square")
441
+ brave.local(query="restaurants in San Francisco", count=10)
442
+ """
443
+ if error := _validate_query(query):
444
+ return error
445
+
446
+ params: dict[str, Any] = {
447
+ "q": query,
448
+ "count": _clamp(count, 1, 20),
449
+ "country": country,
450
+ }
451
+
452
+ success, result = _make_request("/web/search", params)
453
+
454
+ if not success:
455
+ return str(result)
456
+ return _format_local_results_from_web(result) # type: ignore[arg-type]
457
+
458
+
459
+ def image(
460
+ *,
461
+ query: str,
462
+ count: int = 10,
463
+ country: str = "US",
464
+ search_lang: str = "en",
465
+ safesearch: Literal["off", "strict"] = "strict",
466
+ ) -> str:
467
+ """Search for images using Brave Search API.
468
+
469
+ Uses the dedicated /images/search endpoint.
470
+
471
+ Args:
472
+ query: Search query for images
473
+ count: Number of results (1-20, default: 10)
474
+ country: 2-letter country code (default: "US")
475
+ search_lang: Language code (default: "en")
476
+ safesearch: Content filter - "off" or "strict" (default: "strict")
477
+
478
+ Returns:
479
+ Formatted image results with URLs, sizes, and sources
480
+
481
+ Example:
482
+ brave.image(query="Python programming logo")
483
+ brave.image(query="architecture diagrams", count=5)
484
+ """
485
+ if error := _validate_query(query):
486
+ return error
487
+
488
+ params: dict[str, Any] = {
489
+ "q": query,
490
+ "count": _clamp(count, 1, 20),
491
+ "country": country,
492
+ "search_lang": search_lang,
493
+ "safesearch": safesearch,
494
+ }
495
+
496
+ success, result = _make_request("/images/search", params)
497
+
498
+ if not success:
499
+ return str(result)
500
+ return _format_image_results(result) # type: ignore[arg-type]
501
+
502
+
503
+ def video(
504
+ *,
505
+ query: str,
506
+ count: int = 10,
507
+ country: str = "US",
508
+ search_lang: str = "en",
509
+ freshness: Literal["pd", "pw", "pm", "py"] | None = None,
510
+ ) -> str:
511
+ """Search for videos using Brave Search API.
512
+
513
+ Uses the dedicated /videos/search endpoint.
514
+
515
+ Args:
516
+ query: Search query for videos
517
+ count: Number of results (1-20, default: 10)
518
+ country: 2-letter country code (default: "US")
519
+ search_lang: Language code (default: "en")
520
+ freshness: Time filter - "pd" (day), "pw" (week), "pm" (month), "py" (year)
521
+
522
+ Returns:
523
+ Formatted video results with titles, channels, durations, and URLs
524
+
525
+ Example:
526
+ brave.video(query="Python tutorial for beginners")
527
+ brave.video(query="tech conference keynote", freshness="pm", count=5)
528
+ """
529
+ if error := _validate_query(query):
530
+ return error
531
+
532
+ params: dict[str, Any] = {
533
+ "q": query,
534
+ "count": _clamp(count, 1, 20),
535
+ "country": country,
536
+ "search_lang": search_lang,
537
+ }
538
+
539
+ if freshness:
540
+ params["freshness"] = freshness
541
+
542
+ success, result = _make_request("/videos/search", params)
543
+
544
+ if not success:
545
+ return str(result)
546
+ return _format_video_results(result) # type: ignore[arg-type]
547
+
548
+
549
+ def search_batch(
550
+ *,
551
+ queries: list[tuple[str, str] | str],
552
+ count: int = 2,
553
+ country: str = "US",
554
+ search_lang: str = "en",
555
+ ) -> str:
556
+ """Execute multiple web searches concurrently and return combined results.
557
+
558
+ Queries are executed in parallel using threads for better performance.
559
+
560
+ Args:
561
+ queries: List of queries. Each item can be:
562
+ - A string (query text, used as both query and label)
563
+ - A tuple of (query, label) for custom labeling
564
+ count: Number of results per query (1-20, default: 2)
565
+ country: 2-letter country code for results (default: "US")
566
+ search_lang: Language code for results (default: "en")
567
+
568
+ Returns:
569
+ Combined formatted results with labels
570
+
571
+ Example:
572
+ # Simple list of queries
573
+ brave.search_batch(["scipy", "sqlalchemy", "jupyterlab"])
574
+
575
+ # With custom labels
576
+ brave.search_batch([
577
+ ("current Gold price USD/oz today", "Gold (USD/oz)"),
578
+ ("current Silver price USD/oz today", "Silver (USD/oz)"),
579
+ ("current Copper price USD/lb today", "Copper (USD/lb)"),
580
+ ])
581
+ """
582
+ normalized = normalize_items(queries)
583
+
584
+ if not normalized:
585
+ return "Error: No queries provided"
586
+
587
+ with LogSpan(span="brave.batch", query_count=len(normalized), count=count) as s:
588
+
589
+ def _search_one(query: str, label: str) -> tuple[str, str]:
590
+ """Execute a single search and return (label, result)."""
591
+ result = search(
592
+ query=query,
593
+ count=count,
594
+ country=country,
595
+ search_lang=search_lang,
596
+ )
597
+ return label, result
598
+
599
+ results = batch_execute(_search_one, normalized, max_workers=len(normalized))
600
+ output = format_batch_results(results, normalized)
601
+ s.add(outputLen=len(output))
602
+ return output
603
+
604
+