webget-cli 0.12.1__tar.gz → 0.14.0__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.
Files changed (62) hide show
  1. {webget_cli-0.12.1 → webget_cli-0.14.0}/PKG-INFO +132 -3
  2. {webget_cli-0.12.1 → webget_cli-0.14.0}/README.md +128 -1
  3. {webget_cli-0.12.1 → webget_cli-0.14.0}/pyproject.toml +24 -3
  4. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_adversarial_mcp.py +26 -9
  5. webget_cli-0.14.0/tests/test_cli_engine.py +62 -0
  6. webget_cli-0.14.0/tests/test_cli_provenance.py +98 -0
  7. webget_cli-0.14.0/tests/test_health.py +125 -0
  8. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_login_flow.py +2 -1
  9. webget_cli-0.14.0/tests/test_mcp_engine.py +66 -0
  10. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_mcp_leak_review.py +11 -3
  11. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_mcp_metadata.py +6 -3
  12. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_mcp_profile.py +4 -3
  13. webget_cli-0.14.0/tests/test_mcp_provenance.py +90 -0
  14. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_mcp_server.py +10 -8
  15. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_mcp_smoke.py +17 -4
  16. webget_cli-0.14.0/tests/test_search_engine.py +108 -0
  17. webget_cli-0.14.0/tests/test_search_failover.py +247 -0
  18. webget_cli-0.14.0/tests/test_search_provenance.py +159 -0
  19. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_webget.py +9 -8
  20. {webget_cli-0.12.1 → webget_cli-0.14.0}/webget/__init__.py +2 -1
  21. {webget_cli-0.12.1 → webget_cli-0.14.0}/webget/cli.py +77 -39
  22. webget_cli-0.14.0/webget/health.py +198 -0
  23. webget_cli-0.14.0/webget/search.py +352 -0
  24. {webget_cli-0.12.1 → webget_cli-0.14.0}/webget_cli.egg-info/PKG-INFO +132 -3
  25. {webget_cli-0.12.1 → webget_cli-0.14.0}/webget_cli.egg-info/SOURCES.txt +9 -0
  26. {webget_cli-0.12.1 → webget_cli-0.14.0}/webget_cli.egg-info/requires.txt +3 -1
  27. {webget_cli-0.12.1 → webget_cli-0.14.0}/webget_mcp.py +56 -11
  28. webget_cli-0.12.1/webget/search.py +0 -38
  29. {webget_cli-0.12.1 → webget_cli-0.14.0}/LICENSE +0 -0
  30. {webget_cli-0.12.1 → webget_cli-0.14.0}/setup.cfg +0 -0
  31. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_adversarial_auth.py +0 -0
  32. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_adversarial_cache.py +0 -0
  33. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_adversarial_concurrency.py +0 -0
  34. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_adversarial_http.py +0 -0
  35. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_adversarial_ssrf.py +0 -0
  36. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_auth_review.py +0 -0
  37. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_base64_strip.py +0 -0
  38. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_browser_ssrf.py +0 -0
  39. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_cache_review.py +0 -0
  40. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_concurrency_review.py +0 -0
  41. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_discovery_map.py +0 -0
  42. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_extraction_markdown.py +0 -0
  43. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_firecrawl_policy.py +0 -0
  44. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_integration_ladder.py +0 -0
  45. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_ladder_retry.py +0 -0
  46. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_mcp_map.py +0 -0
  47. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_metadata.py +0 -0
  48. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_nonhtml.py +0 -0
  49. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_security_review.py +0 -0
  50. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_size_review.py +0 -0
  51. {webget_cli-0.12.1 → webget_cli-0.14.0}/tests/test_ssrf_dual_dns.py +0 -0
  52. {webget_cli-0.12.1 → webget_cli-0.14.0}/webget/cache.py +0 -0
  53. {webget_cli-0.12.1 → webget_cli-0.14.0}/webget/discovery.py +0 -0
  54. {webget_cli-0.12.1 → webget_cli-0.14.0}/webget/firecrawl.py +0 -0
  55. {webget_cli-0.12.1 → webget_cli-0.14.0}/webget/http.py +0 -0
  56. {webget_cli-0.12.1 → webget_cli-0.14.0}/webget/ladder.py +0 -0
  57. {webget_cli-0.12.1 → webget_cli-0.14.0}/webget/profile.py +0 -0
  58. {webget_cli-0.12.1 → webget_cli-0.14.0}/webget/ssrf.py +0 -0
  59. {webget_cli-0.12.1 → webget_cli-0.14.0}/webget_cli.egg-info/dependency_links.txt +0 -0
  60. {webget_cli-0.12.1 → webget_cli-0.14.0}/webget_cli.egg-info/entry_points.txt +0 -0
  61. {webget_cli-0.12.1 → webget_cli-0.14.0}/webget_cli.egg-info/top_level.txt +0 -0
  62. {webget_cli-0.12.1 → webget_cli-0.14.0}/webget_cli.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webget-cli
3
- Version: 0.12.1
3
+ Version: 0.14.0
4
4
  Summary: Local search + scrape CLI with an HTTP fast path, optional browser fallback, and authenticated session profiles. Zero API keys.
5
5
  Author-email: David Tarigan <tarigansdavid@gmail.com>
6
6
  License: Apache-2.0
@@ -27,10 +27,12 @@ Requires-Dist: pypdf
27
27
  Provides-Extra: browser
28
28
  Requires-Dist: crawl4ai>=0.9; extra == "browser"
29
29
  Provides-Extra: mcp
30
- Requires-Dist: fastmcp>=2; extra == "mcp"
30
+ Requires-Dist: fastmcp>=4; extra == "mcp"
31
31
  Provides-Extra: dev
32
32
  Requires-Dist: pytest>=8; extra == "dev"
33
33
  Requires-Dist: ruff>=0.6; extra == "dev"
34
+ Requires-Dist: pytest-timeout>=2; extra == "dev"
35
+ Requires-Dist: pytest-socket>=0.7; extra == "dev"
34
36
  Dynamic: license-file
35
37
 
36
38
  <div align="center">
@@ -124,7 +126,9 @@ uv pip install -e ".[dev,browser]"
124
126
  ## Usage
125
127
 
126
128
  ```bash
127
- webget s "rust async runtime" # search DuckDuckGo (top 5)
129
+ webget s "rust async runtime" # search (ddgs metasearch, top 5)
130
+ webget s "rust async runtime" --engine brave,duckduckgo # subset: faster than auto
131
+ webget s "rust async runtime" --json # machine-readable, includes engine provenance
128
132
  webget u https://example.com # scrape (auto: http -> crawl4ai)
129
133
  webget su "llm inference" 5 # search + scrape top 5, parallel
130
134
  cat urls.txt | webget u - # batch scrape, one browser instance
@@ -133,6 +137,86 @@ webget fetch https://example.com --json # machine-readable result
133
137
 
134
138
  Long aliases: `search` = `s`, `fetch` = `u`, `search-fetch` = `su`.
135
139
 
140
+ ## Search engines
141
+
142
+ webget searches through `ddgs`, a metasearch library that aggregates several
143
+ keyless engines. The default `auto` queries all of them; naming a subset is
144
+ faster and skips engines that are having a bad hour.
145
+
146
+ ```bash
147
+ webget s "query" --engine brave,duckduckgo
148
+ ```
149
+
150
+ **Do not hardcode engine names.** The set ddgs offers changes between its
151
+ releases, and webget validates against the registry at runtime rather than
152
+ a built-in list. Pass a deliberately bogus name to see what your installed
153
+ version supports:
154
+
155
+ ```bash
156
+ $ webget s "query" --engine bogus
157
+ warning: unknown search engine(s): bogus - using auto
158
+ (known: brave, duckduckgo, google, grokipedia, mojeek, startpage, wikipedia, yahoo)
159
+ ```
160
+
161
+ Unknown names degrade to `auto` with a warning instead of failing, so a
162
+ typo never kills a search.
163
+
164
+ **Failover is automatic.** If the engine you named fails, or returns zero
165
+ results, webget tries the remaining engines until a time budget is spent
166
+ (15s by default, `WEBGET_FAILOVER_BUDGET_S` to override) and returns the
167
+ first non-empty set. The budget replaced a fixed "3 engines" cap: a count is
168
+ either too many (3 x 20s timeout = a minute of dead air) or too few (a live
169
+ engine sitting fifth in line never got reached). At least two alternates are
170
+ always tried, however fast the budget expires. This is not paranoia: engine
171
+ reachability depends on where you are, not just whether a service is up. A
172
+ benchmark from one residential connection found only 2 of 9 engines
173
+ reachable, and `auto` survived purely because they did.
174
+
175
+ Every substitution is announced, never silent:
176
+
177
+ ```bash
178
+ $ webget s "rust programming language book" --engine google
179
+ warning: engine 'google' failed (No results found.); fell back to 'brave'
180
+ 1. The Rust Programming Language - doc.rust-lang.org/book/
181
+ ```
182
+
183
+ **Results carry provenance.** Because failover can answer from an engine you
184
+ did not ask for, `--json` reports which one actually did:
185
+
186
+ ```bash
187
+ $ webget s "linux kernel" --engine google --json
188
+ {
189
+ "results": [ ... ],
190
+ "engine": "grokipedia", # who actually answered
191
+ "requested_engine": "google",
192
+ "failed_over": true
193
+ }
194
+ ```
195
+
196
+ This matters most over MCP, where a stderr warning is invisible to the
197
+ agent: the payload is the only signal that the results came from elsewhere.
198
+
199
+ **Failover order is learned, not hardcoded.** webget keeps a small health
200
+ ledger per install (`~/.local/state/webget/engine_health.json`) recording
201
+ whether each engine answered and how fast. When failover kicks in, engines
202
+ are tried best-first by that record instead of in registry order. The ledger
203
+ is advisory - it reorders candidates, it never removes one - and it decays:
204
+ an engine marked dead last week is retried with no penalty this week, because
205
+ the blocking that made it dead is exactly what changes. There is no baked-in
206
+ ranking anywhere in the source; the same webget learns a different order on
207
+ your machine than on the author's, which is the point.
208
+
209
+ Diagnostics: `python -c "from webget import health; print(health.health_snapshot())"`
210
+
211
+ > **MCP note (breaking in 0.13.0):** the `search` and `search_fetch` tools
212
+ > now return an object `{results, engine, requested_engine, failed_over}`
213
+ > instead of a bare list, so provenance has somewhere to live. Errors are
214
+ > `{error, results: []}`.
215
+
216
+ Provenance is per-call, not per-result: `ddgs` merges every engine's hits
217
+ into one list and discards which engine supplied each hit, so per-hit
218
+ attribution is not obtainable through its public API.
219
+
136
220
  ### Options
137
221
 
138
222
  | Flag | Meaning |
@@ -260,6 +344,51 @@ Register as a local MCP server in opencode:
260
344
  Then prompt with `use webget` for search and scrape tasks. Run the server
261
345
  standalone with `webget-mcp` (stdio transport) or `python webget_mcp.py`.
262
346
 
347
+ ### Breaking change in 0.13.0: search returns an object, not a list
348
+
349
+ `search` and `search_fetch` used to return a bare JSON array of results.
350
+ They now return an object, because a list has nowhere to carry provenance:
351
+
352
+ ```jsonc
353
+ // <= 0.12.1 -> a list
354
+ [ { "title": "...", "href": "...", "body": "..." }, ... ]
355
+
356
+ // >= 0.13.0 -> an object
357
+ {
358
+ "results": [ { "title": "...", "href": "...", "body": "..." }, ... ],
359
+ "engine": "brave", // the engine that actually answered
360
+ "requested_engine": "google", // what was asked for
361
+ "failed_over": true // an alternative was attempted
362
+ }
363
+ ```
364
+
365
+ Error responses changed shape too:
366
+
367
+ ```jsonc
368
+ // <= 0.12.1 -> a plain error string / empty list
369
+ // >= 0.13.0
370
+ {
371
+ "error": "SearchError: RequestError(...)",
372
+ "results": []
373
+ }
374
+ ```
375
+
376
+ **How to migrate.** If you read the tool result as an array, read
377
+ `.results` instead. In JavaScript that is `result.results` rather than
378
+ `result`; in Python `data["results"]` rather than `data`. If you only
379
+ iterate the hits, the change is mechanical.
380
+
381
+ **Why it had to break.** Failover means the engine that answers is not
382
+ always the engine that was asked for, and over MCP there is no stderr for a
383
+ warning to land on, so the payload is the only place that fact can live.
384
+ Keeping the list shape would have meant silently returning results from an
385
+ unexpected engine with no way for a client to notice. An additive field was
386
+ not possible: a JSON array cannot carry sibling keys.
387
+
388
+ `fetch` is unchanged and still returns its string payload. The Python API is
389
+ unchanged: `search()` still returns a list, and `search_with_provenance()`
390
+ is the new opt-in that returns `(results, provenance)`.
391
+
263
392
  ### Authenticated sessions (profiles)
264
393
 
265
394
  MCP tools can use locally stored login sessions. Create one first with the
@@ -89,7 +89,9 @@ uv pip install -e ".[dev,browser]"
89
89
  ## Usage
90
90
 
91
91
  ```bash
92
- webget s "rust async runtime" # search DuckDuckGo (top 5)
92
+ webget s "rust async runtime" # search (ddgs metasearch, top 5)
93
+ webget s "rust async runtime" --engine brave,duckduckgo # subset: faster than auto
94
+ webget s "rust async runtime" --json # machine-readable, includes engine provenance
93
95
  webget u https://example.com # scrape (auto: http -> crawl4ai)
94
96
  webget su "llm inference" 5 # search + scrape top 5, parallel
95
97
  cat urls.txt | webget u - # batch scrape, one browser instance
@@ -98,6 +100,86 @@ webget fetch https://example.com --json # machine-readable result
98
100
 
99
101
  Long aliases: `search` = `s`, `fetch` = `u`, `search-fetch` = `su`.
100
102
 
103
+ ## Search engines
104
+
105
+ webget searches through `ddgs`, a metasearch library that aggregates several
106
+ keyless engines. The default `auto` queries all of them; naming a subset is
107
+ faster and skips engines that are having a bad hour.
108
+
109
+ ```bash
110
+ webget s "query" --engine brave,duckduckgo
111
+ ```
112
+
113
+ **Do not hardcode engine names.** The set ddgs offers changes between its
114
+ releases, and webget validates against the registry at runtime rather than
115
+ a built-in list. Pass a deliberately bogus name to see what your installed
116
+ version supports:
117
+
118
+ ```bash
119
+ $ webget s "query" --engine bogus
120
+ warning: unknown search engine(s): bogus - using auto
121
+ (known: brave, duckduckgo, google, grokipedia, mojeek, startpage, wikipedia, yahoo)
122
+ ```
123
+
124
+ Unknown names degrade to `auto` with a warning instead of failing, so a
125
+ typo never kills a search.
126
+
127
+ **Failover is automatic.** If the engine you named fails, or returns zero
128
+ results, webget tries the remaining engines until a time budget is spent
129
+ (15s by default, `WEBGET_FAILOVER_BUDGET_S` to override) and returns the
130
+ first non-empty set. The budget replaced a fixed "3 engines" cap: a count is
131
+ either too many (3 x 20s timeout = a minute of dead air) or too few (a live
132
+ engine sitting fifth in line never got reached). At least two alternates are
133
+ always tried, however fast the budget expires. This is not paranoia: engine
134
+ reachability depends on where you are, not just whether a service is up. A
135
+ benchmark from one residential connection found only 2 of 9 engines
136
+ reachable, and `auto` survived purely because they did.
137
+
138
+ Every substitution is announced, never silent:
139
+
140
+ ```bash
141
+ $ webget s "rust programming language book" --engine google
142
+ warning: engine 'google' failed (No results found.); fell back to 'brave'
143
+ 1. The Rust Programming Language - doc.rust-lang.org/book/
144
+ ```
145
+
146
+ **Results carry provenance.** Because failover can answer from an engine you
147
+ did not ask for, `--json` reports which one actually did:
148
+
149
+ ```bash
150
+ $ webget s "linux kernel" --engine google --json
151
+ {
152
+ "results": [ ... ],
153
+ "engine": "grokipedia", # who actually answered
154
+ "requested_engine": "google",
155
+ "failed_over": true
156
+ }
157
+ ```
158
+
159
+ This matters most over MCP, where a stderr warning is invisible to the
160
+ agent: the payload is the only signal that the results came from elsewhere.
161
+
162
+ **Failover order is learned, not hardcoded.** webget keeps a small health
163
+ ledger per install (`~/.local/state/webget/engine_health.json`) recording
164
+ whether each engine answered and how fast. When failover kicks in, engines
165
+ are tried best-first by that record instead of in registry order. The ledger
166
+ is advisory - it reorders candidates, it never removes one - and it decays:
167
+ an engine marked dead last week is retried with no penalty this week, because
168
+ the blocking that made it dead is exactly what changes. There is no baked-in
169
+ ranking anywhere in the source; the same webget learns a different order on
170
+ your machine than on the author's, which is the point.
171
+
172
+ Diagnostics: `python -c "from webget import health; print(health.health_snapshot())"`
173
+
174
+ > **MCP note (breaking in 0.13.0):** the `search` and `search_fetch` tools
175
+ > now return an object `{results, engine, requested_engine, failed_over}`
176
+ > instead of a bare list, so provenance has somewhere to live. Errors are
177
+ > `{error, results: []}`.
178
+
179
+ Provenance is per-call, not per-result: `ddgs` merges every engine's hits
180
+ into one list and discards which engine supplied each hit, so per-hit
181
+ attribution is not obtainable through its public API.
182
+
101
183
  ### Options
102
184
 
103
185
  | Flag | Meaning |
@@ -225,6 +307,51 @@ Register as a local MCP server in opencode:
225
307
  Then prompt with `use webget` for search and scrape tasks. Run the server
226
308
  standalone with `webget-mcp` (stdio transport) or `python webget_mcp.py`.
227
309
 
310
+ ### Breaking change in 0.13.0: search returns an object, not a list
311
+
312
+ `search` and `search_fetch` used to return a bare JSON array of results.
313
+ They now return an object, because a list has nowhere to carry provenance:
314
+
315
+ ```jsonc
316
+ // <= 0.12.1 -> a list
317
+ [ { "title": "...", "href": "...", "body": "..." }, ... ]
318
+
319
+ // >= 0.13.0 -> an object
320
+ {
321
+ "results": [ { "title": "...", "href": "...", "body": "..." }, ... ],
322
+ "engine": "brave", // the engine that actually answered
323
+ "requested_engine": "google", // what was asked for
324
+ "failed_over": true // an alternative was attempted
325
+ }
326
+ ```
327
+
328
+ Error responses changed shape too:
329
+
330
+ ```jsonc
331
+ // <= 0.12.1 -> a plain error string / empty list
332
+ // >= 0.13.0
333
+ {
334
+ "error": "SearchError: RequestError(...)",
335
+ "results": []
336
+ }
337
+ ```
338
+
339
+ **How to migrate.** If you read the tool result as an array, read
340
+ `.results` instead. In JavaScript that is `result.results` rather than
341
+ `result`; in Python `data["results"]` rather than `data`. If you only
342
+ iterate the hits, the change is mechanical.
343
+
344
+ **Why it had to break.** Failover means the engine that answers is not
345
+ always the engine that was asked for, and over MCP there is no stderr for a
346
+ warning to land on, so the payload is the only place that fact can live.
347
+ Keeping the list shape would have meant silently returning results from an
348
+ unexpected engine with no way for a client to notice. An additive field was
349
+ not possible: a JSON array cannot carry sibling keys.
350
+
351
+ `fetch` is unchanged and still returns its string payload. The Python API is
352
+ unchanged: `search()` still returns a list, and `search_with_provenance()`
353
+ is the new opt-in that returns `(results, provenance)`.
354
+
228
355
  ### Authenticated sessions (profiles)
229
356
 
230
357
  MCP tools can use locally stored login sessions. Create one first with the
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "webget-cli"
7
- version = "0.12.1"
7
+ version = "0.14.0"
8
8
  description = "Local search + scrape CLI with an HTTP fast path, optional browser fallback, and authenticated session profiles. Zero API keys."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -36,8 +36,14 @@ Changelog = "https://github.com/DavidPandleton/webget/blob/main/CHANGELOG.md"
36
36
 
37
37
  [project.optional-dependencies]
38
38
  browser = ["crawl4ai>=0.9"]
39
- mcp = ["fastmcp>=2"]
40
- dev = ["pytest>=8", "ruff>=0.6"]
39
+ # fastmcp pulls in the `mcp` SDK. The MCP SDK renamed CallToolResult.isError to
40
+ # is_error in the 2.x line, and fastmcp 4.x ships that newer SDK. The code and
41
+ # tests here use is_error, so the floor is the version that has it.
42
+ # Without a floor, an install could resolve to a fastmcp whose result objects
43
+ # expose isError only, and the MCP surface would look broken for a reason that
44
+ # has nothing to do with webget.
45
+ mcp = ["fastmcp>=4"]
46
+ dev = ["pytest>=8", "ruff>=0.6", "pytest-timeout>=2", "pytest-socket>=0.7"]
41
47
 
42
48
  [project.scripts]
43
49
  webget = "webget.cli:main"
@@ -50,7 +56,22 @@ packages = ["webget"]
50
56
  [tool.pytest.ini_options]
51
57
  testpaths = ["tests"]
52
58
  addopts = "-q"
59
+ markers = [
60
+ # Tests that reach the PUBLIC internet (not the local test server). They are
61
+ # excluded from offline jobs with -m "not live_network" and run separately,
62
+ # because engine reachability is a property of the runner's network: during
63
+ # 0.13.0, DuckDuckGo was unreachable from the author's ISP while Google,
64
+ # Brave and Yandex answered in under a second. A red test here does not mean
65
+ # the release is broken.
66
+ "live_network: test reaches the public internet; excluded from offline CI",
67
+ ]
53
68
 
54
69
  [tool.ruff]
55
70
  line-length = 100
56
71
  target-version = "py311"
72
+
73
+ [tool.ruff.lint.per-file-ignores]
74
+ # Ops scripts drive subprocesses and inspect returncode manually; requiring
75
+ # check=True everywhere would force try/except noise where a plain if already
76
+ # handles the failure.
77
+ "scripts/*" = ["PLW1510"]
@@ -10,6 +10,8 @@ import pytest
10
10
  from mcp import ClientSession, StdioServerParameters
11
11
  from mcp.client.stdio import stdio_client
12
12
 
13
+ from tests.conftest import tool_failed
14
+
13
15
  ROOT = Path(__file__).resolve().parent.parent
14
16
  MCP = ROOT / "webget_mcp.py"
15
17
 
@@ -58,7 +60,7 @@ class TestMalformedArguments:
58
60
  return res
59
61
 
60
62
  res = _run(run())
61
- assert res.isError
63
+ assert tool_failed(res)
62
64
 
63
65
 
64
66
  class TestInvalidURLs:
@@ -72,7 +74,7 @@ class TestInvalidURLs:
72
74
  return res
73
75
 
74
76
  res = _run(run())
75
- assert "error" in res.content[0].text or res.isError
77
+ assert "error" in res.content[0].text or tool_failed(res)
76
78
 
77
79
 
78
80
  class TestRepeatedCalls:
@@ -86,7 +88,7 @@ class TestRepeatedCalls:
86
88
  "fetch",
87
89
  {"url": "https://example.com", "strategy": "http", "no_cache": True},
88
90
  )
89
- if res.isError:
91
+ if tool_failed(res):
90
92
  return "ERROR"
91
93
  return "OK"
92
94
 
@@ -106,7 +108,7 @@ class TestRepeatedCalls:
106
108
  for _ in range(5)
107
109
  ]
108
110
  )
109
- return [r.isError for r in results]
111
+ return [tool_failed(r) for r in results]
110
112
 
111
113
  assert _run(run()) == [False] * 5
112
114
 
@@ -133,7 +135,7 @@ class TestInputCaps:
133
135
  return res
134
136
 
135
137
  res = _run(run())
136
- assert "must be between" in res.content[0].text or res.isError
138
+ assert "must be between" in res.content[0].text or tool_failed(res)
137
139
 
138
140
 
139
141
  class TestToolFailureIsolation:
@@ -171,8 +173,18 @@ class TestSSRFViaMCP:
171
173
  res = _run(run())
172
174
  assert "private" in res.content[0].text.lower()
173
175
 
176
+ @pytest.mark.live_network
174
177
  def test_search_output_shape(self):
175
- """search must return list of dicts with title/url/snippet."""
178
+ """search must return {results: [...], engine: ...} with provenance.
179
+
180
+ Marked live_network deliberately: this is the ONLY test in the suite
181
+ that talks to the public internet (verified by running the whole MCP
182
+ suite with sockets blocked except localhost - everything passes except
183
+ this one, which fails exactly because the network is unavailable). It
184
+ exercises the real engine path end-to-end through a real server
185
+ process, which no mock can replace, so it stays but is excluded from
186
+ the offline jobs.
187
+ """
176
188
 
177
189
  async def run():
178
190
  params = StdioServerParameters(command=sys.executable, args=[str(MCP)])
@@ -183,6 +195,11 @@ class TestSSRFViaMCP:
183
195
 
184
196
  res = _run(run())
185
197
  payload = json.loads(res.content[0].text)
186
- assert isinstance(payload, list)
187
- if payload:
188
- assert {"title", "url", "snippet"} <= set(payload[0])
198
+ assert isinstance(payload, dict)
199
+ # Provenance keys are part of the contract: `engine` is the one that
200
+ # actually answered, which can differ from `requested_engine`.
201
+ assert {"results", "engine", "requested_engine", "failed_over"} <= set(payload)
202
+ results = payload["results"]
203
+ assert isinstance(results, list)
204
+ if results:
205
+ assert {"title", "url", "snippet"} <= set(results[0])
@@ -0,0 +1,62 @@
1
+ """Tests for the CLI surface of multi-engine search."""
2
+
3
+ from webget import cli
4
+
5
+
6
+ def _opts(*args):
7
+ return cli.parse_opts(list(args))
8
+
9
+
10
+ class TestParseOptsEngine:
11
+ def test_engine_flag_long(self):
12
+ r = _opts("s", "q", "--engine", "brave")
13
+ assert r[-1] == "brave"
14
+
15
+ def test_engine_flag_short(self):
16
+ r = _opts("s", "q", "-e", "brave,duckduckgo")
17
+ assert r[-1] == "brave,duckduckgo"
18
+
19
+ def test_engine_defaults_to_none(self):
20
+ r = _opts("s", "q")
21
+ assert r[-1] is None
22
+
23
+
24
+ class TestCmdSearchEngine:
25
+ def test_engine_reaches_search_call(self, monkeypatch, capsys):
26
+ captured = {}
27
+
28
+ def fake_prov(query, n=5, engine=None):
29
+ captured["query"] = query
30
+ captured["n"] = n
31
+ captured["engine"] = engine
32
+ return (
33
+ [{"title": "T", "url": "https://x.example", "snippet": "s"}],
34
+ {"requested": engine or "auto", "engine": engine or "auto", "failed_over": False},
35
+ )
36
+
37
+ # patch where it is USED (webget.cli namespace), not where defined.
38
+ # The CLI prefers search_with_provenance, so patching only `search`
39
+ # would leave the real network path live.
40
+ monkeypatch.setattr(cli, "search_with_provenance", fake_prov)
41
+ monkeypatch.setattr("sys.argv", ["webget", "s", "query", "--engine", "brave"])
42
+ try:
43
+ cli.main()
44
+ except SystemExit as e:
45
+ assert e.code in (0, None)
46
+ assert captured["engine"] == "brave"
47
+ assert captured["query"] == "query"
48
+
49
+ def test_invalid_engine_does_not_crash_cli(self, monkeypatch, capsys):
50
+ monkeypatch.setattr(
51
+ cli,
52
+ "search_with_provenance",
53
+ lambda query, n=5, engine=None: (
54
+ [{"title": "T", "url": "https://y.example", "snippet": "s"}],
55
+ {"requested": engine or "auto", "engine": engine or "auto", "failed_over": False},
56
+ ),
57
+ )
58
+ monkeypatch.setattr("sys.argv", ["webget", "s", "q", "--engine", "totally-bogus"])
59
+ try:
60
+ cli.main()
61
+ except SystemExit as e:
62
+ assert e.code in (0, None)
@@ -0,0 +1,98 @@
1
+ """Tests for engine provenance surfacing through the CLI.
2
+
3
+ The point of provenance is that it must be REACHABLE. A stderr warning is
4
+ lost when output is piped, so `webget s q --json` has to carry it in the
5
+ payload. Note the bare `webget s q --json` path did not previously exist at
6
+ all (cmd == "s" had no json branch), so this adds it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+
13
+ from webget import cli
14
+
15
+
16
+ def _run(argv, monkeypatch, capsys, search_impl=None, prov_impl=None):
17
+ """Run the CLI with search stubbed.
18
+
19
+ Both hooks are patched: the CLI prefers `search_with_provenance`, so
20
+ patching only `search` would leave the real network path live. Pass
21
+ `prov_impl` to control provenance explicitly.
22
+ """
23
+ impl = search_impl or (lambda *a, **k: [])
24
+
25
+ if prov_impl is None:
26
+
27
+ def _default_prov(query, n=5, engine=None):
28
+ return impl(query, n, engine), {
29
+ "requested": engine or "auto",
30
+ "engine": engine or "auto",
31
+ "failed_over": False,
32
+ }
33
+
34
+ prov_impl = _default_prov
35
+
36
+ monkeypatch.setattr(cli, "search", lambda *a, **k: impl(*a, **k))
37
+ monkeypatch.setattr(cli, "search_with_provenance", prov_impl)
38
+ monkeypatch.setattr("sys.argv", ["webget", *argv])
39
+ try:
40
+ cli.main()
41
+ except SystemExit as e:
42
+ assert e.code in (0, None)
43
+ return capsys.readouterr()
44
+
45
+
46
+ class TestProvenanceIsWiredUp:
47
+ """Guard against the fallback silently swallowing real provenance.
48
+
49
+ `_search_with_prov` falls back to a best-effort dict when
50
+ `search_with_provenance` is missing from the module namespace. That
51
+ fallback exists for older tests, but if the real import is ever
52
+ dropped the CLI would report `failed_over: False` forever while
53
+ looking perfectly green. This asserts the real function is reachable.
54
+ """
55
+
56
+ def test_cli_namespace_exposes_the_real_function(self):
57
+ assert hasattr(cli, "search_with_provenance")
58
+
59
+
60
+ class TestSearchJsonOutput:
61
+ def test_json_flag_emits_valid_json(self, monkeypatch, capsys):
62
+ out = _run(
63
+ ["s", "q", "--json"],
64
+ monkeypatch,
65
+ capsys,
66
+ lambda *a, **k: [{"title": "T", "url": "https://x.example/", "snippet": "s"}],
67
+ )
68
+ payload = json.loads(out.out)
69
+ assert payload["results"][0]["url"] == "https://x.example/"
70
+
71
+ def test_json_includes_engine_provenance_when_available(self, monkeypatch, capsys):
72
+ def fake_prov(query, n=5, engine=None):
73
+ return (
74
+ [{"title": "T", "url": "https://x.example/", "snippet": "s"}],
75
+ {
76
+ "requested": "google",
77
+ "engine": "brave",
78
+ "failed_over": True,
79
+ "tried": ["google", "brave"],
80
+ "first_error": "No results found.",
81
+ },
82
+ )
83
+
84
+ out = _run(["s", "q", "--json"], monkeypatch, capsys, prov_impl=fake_prov)
85
+ payload = json.loads(out.out)
86
+ assert payload["engine"] == "brave"
87
+ assert payload["requested_engine"] == "google"
88
+ assert payload["failed_over"] is True
89
+
90
+ def test_plain_output_still_prints_results(self, monkeypatch, capsys):
91
+ out = _run(
92
+ ["s", "q"],
93
+ monkeypatch,
94
+ capsys,
95
+ lambda *a, **k: [{"title": "T", "url": "https://x.example/", "snippet": "snip"}],
96
+ )
97
+ assert "https://x.example/" in out.out
98
+ assert "1." in out.out