surfx 0.2.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.
- surfx/__init__.py +5 -0
- surfx/__main__.py +4 -0
- surfx/browser.py +25 -0
- surfx/cli.py +337 -0
- surfx/config.py +213 -0
- surfx/errors.py +64 -0
- surfx/models.py +63 -0
- surfx/providers/__init__.py +16 -0
- surfx/providers/base.py +28 -0
- surfx/providers/searxng.py +191 -0
- surfx/services/__init__.py +1 -0
- surfx/services/cache.py +111 -0
- surfx/services/search.py +70 -0
- surfx/terminal.py +60 -0
- surfx-0.2.0.dist-info/METADATA +290 -0
- surfx-0.2.0.dist-info/RECORD +19 -0
- surfx-0.2.0.dist-info/WHEEL +4 -0
- surfx-0.2.0.dist-info/entry_points.txt +2 -0
- surfx-0.2.0.dist-info/licenses/LICENSE +21 -0
surfx/__init__.py
ADDED
surfx/__main__.py
ADDED
surfx/browser.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Cross-platform browser opening.
|
|
2
|
+
|
|
3
|
+
Surfx never downloads or executes the content of a search result. Opening a
|
|
4
|
+
result simply hands its URL to the operating system's default browser via
|
|
5
|
+
the standard library ``webbrowser`` module.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import webbrowser
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def open_url(url: str) -> bool:
|
|
14
|
+
"""Open ``url`` in the user's default browser.
|
|
15
|
+
|
|
16
|
+
Returns ``True`` if the browser was successfully launched, ``False``
|
|
17
|
+
otherwise. Never raises: environments without a usable browser (e.g.
|
|
18
|
+
headless CI) should degrade gracefully.
|
|
19
|
+
"""
|
|
20
|
+
if not (url.startswith("http://") or url.startswith("https://")):
|
|
21
|
+
return False
|
|
22
|
+
try:
|
|
23
|
+
return webbrowser.open(url, new=2)
|
|
24
|
+
except Exception:
|
|
25
|
+
return False
|
surfx/cli.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""The Surfx command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import platform
|
|
7
|
+
import socket
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
import typer
|
|
13
|
+
from rich.prompt import Confirm
|
|
14
|
+
from typer.core import TyperGroup
|
|
15
|
+
|
|
16
|
+
from surfx import __version__
|
|
17
|
+
from surfx import terminal
|
|
18
|
+
from surfx.browser import open_url
|
|
19
|
+
from surfx.config import Settings, load_settings, write_file_setting
|
|
20
|
+
from surfx.errors import SurfxError
|
|
21
|
+
from surfx.models import SearchResponse
|
|
22
|
+
from surfx.providers import PROVIDERS
|
|
23
|
+
from surfx.services.cache import ResultCache
|
|
24
|
+
from surfx.services.search import build_provider, run_search
|
|
25
|
+
|
|
26
|
+
DEFAULT_COMMAND = "search"
|
|
27
|
+
|
|
28
|
+
INTERACTIVE_HELP = """\
|
|
29
|
+
Commands:
|
|
30
|
+
/help Show this help
|
|
31
|
+
/clear Clear the screen
|
|
32
|
+
/exit Leave interactive mode
|
|
33
|
+
/quit Leave interactive mode (alias for /exit)
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DefaultCommandGroup(TyperGroup):
|
|
38
|
+
"""A Click/Typer group that falls back to the ``search`` command.
|
|
39
|
+
|
|
40
|
+
This is what lets ``surfx "some query"`` work without typing
|
|
41
|
+
``surfx search "some query"``, while still supporting real subcommands
|
|
42
|
+
like ``surfx config`` and ``surfx doctor``.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def resolve_command(self, ctx, args): # type: ignore[override]
|
|
46
|
+
if not args or (args[0] not in self.commands and not args[0].startswith("-")):
|
|
47
|
+
args = [DEFAULT_COMMAND, *args]
|
|
48
|
+
return super().resolve_command(ctx, args)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
app = typer.Typer(
|
|
52
|
+
cls=DefaultCommandGroup,
|
|
53
|
+
add_completion=True,
|
|
54
|
+
no_args_is_help=False,
|
|
55
|
+
help="Surfx - search the web from your terminal.",
|
|
56
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
config_app = typer.Typer(help="Inspect or change Surfx configuration.")
|
|
60
|
+
app.add_typer(config_app, name="config")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _version_callback(value: bool) -> None:
|
|
64
|
+
if value:
|
|
65
|
+
typer.echo(f"surfx {__version__}")
|
|
66
|
+
raise typer.Exit()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.callback()
|
|
70
|
+
def main(
|
|
71
|
+
version: bool = typer.Option(
|
|
72
|
+
False, "--version", callback=_version_callback, is_eager=True, help="Show the version and exit."
|
|
73
|
+
),
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Surfx - search the web from your terminal."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _resolve_settings(
|
|
79
|
+
*, provider: str | None, limit: int | None, no_color: bool | None
|
|
80
|
+
) -> Settings:
|
|
81
|
+
settings = load_settings()
|
|
82
|
+
return settings.with_overrides(provider=provider, limit=limit, no_color=no_color)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _handle_error(console, error: Exception, *, debug: bool) -> None:
|
|
86
|
+
if debug:
|
|
87
|
+
raise error
|
|
88
|
+
if isinstance(error, SurfxError):
|
|
89
|
+
terminal.print_error(console, error.message)
|
|
90
|
+
else:
|
|
91
|
+
terminal.print_error(console, str(error) or error.__class__.__name__)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@app.command(DEFAULT_COMMAND, help="Search the web. This is Surfx's default command.")
|
|
95
|
+
def search_command(
|
|
96
|
+
query: Optional[str] = typer.Argument(
|
|
97
|
+
None, help='The search query, e.g. surfx "python asyncio tutorial"'
|
|
98
|
+
),
|
|
99
|
+
limit: Optional[int] = typer.Option(
|
|
100
|
+
None, "--limit", "-n", help="Number of results to return (1-50)."
|
|
101
|
+
),
|
|
102
|
+
provider: Optional[str] = typer.Option(
|
|
103
|
+
None, "--provider", help="Search provider to use.", show_default=False
|
|
104
|
+
),
|
|
105
|
+
as_json: bool = typer.Option(
|
|
106
|
+
False, "--json", help="Output machine-readable JSON on stdout only."
|
|
107
|
+
),
|
|
108
|
+
no_color: bool = typer.Option(False, "--no-color", help="Disable colored/styled output."),
|
|
109
|
+
open_rank: Optional[int] = typer.Option(
|
|
110
|
+
None, "--open", help="Open the result with this rank in your browser.", metavar="N"
|
|
111
|
+
),
|
|
112
|
+
yes: bool = typer.Option(
|
|
113
|
+
False, "--yes", "-y", help="Skip the confirmation prompt when using --open."
|
|
114
|
+
),
|
|
115
|
+
debug: bool = typer.Option(False, "--debug", help="Show full tracebacks on error."),
|
|
116
|
+
) -> None:
|
|
117
|
+
out_console = terminal.make_console(no_color=no_color)
|
|
118
|
+
err_console = terminal.make_console(no_color=no_color, stderr=True)
|
|
119
|
+
|
|
120
|
+
settings = _resolve_settings(provider=provider, limit=limit, no_color=no_color)
|
|
121
|
+
|
|
122
|
+
if not query:
|
|
123
|
+
_interactive_loop(settings, debug=debug)
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
_run_single_search(
|
|
127
|
+
query,
|
|
128
|
+
settings,
|
|
129
|
+
as_json=as_json,
|
|
130
|
+
open_rank=open_rank,
|
|
131
|
+
auto_confirm=yes or as_json,
|
|
132
|
+
debug=debug,
|
|
133
|
+
out_console=out_console,
|
|
134
|
+
err_console=err_console,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _run_single_search(
|
|
139
|
+
query: str,
|
|
140
|
+
settings: Settings,
|
|
141
|
+
*,
|
|
142
|
+
as_json: bool,
|
|
143
|
+
open_rank: int | None,
|
|
144
|
+
auto_confirm: bool,
|
|
145
|
+
debug: bool,
|
|
146
|
+
out_console,
|
|
147
|
+
err_console,
|
|
148
|
+
) -> None:
|
|
149
|
+
cache = ResultCache(ttl_seconds=settings.cache_ttl_seconds)
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
if as_json:
|
|
153
|
+
response = run_search(query, settings, cache=cache, allow_empty=True)
|
|
154
|
+
else:
|
|
155
|
+
with terminal.loading_status(out_console):
|
|
156
|
+
response = run_search(query, settings, cache=cache)
|
|
157
|
+
except SurfxError as error:
|
|
158
|
+
_handle_error(err_console, error, debug=debug)
|
|
159
|
+
raise typer.Exit(code=1) from None
|
|
160
|
+
except httpx.HTTPError as error:
|
|
161
|
+
_handle_error(err_console, error, debug=debug)
|
|
162
|
+
raise typer.Exit(code=1) from None
|
|
163
|
+
|
|
164
|
+
if as_json:
|
|
165
|
+
_print_json(response)
|
|
166
|
+
else:
|
|
167
|
+
terminal.print_results(out_console, response)
|
|
168
|
+
|
|
169
|
+
if open_rank is not None:
|
|
170
|
+
_maybe_open(response, open_rank, auto_confirm=auto_confirm, console=out_console)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _print_json(response: SearchResponse) -> None:
|
|
174
|
+
sys.stdout.write(json.dumps(response.to_dict(), indent=2))
|
|
175
|
+
sys.stdout.write("\n")
|
|
176
|
+
sys.stdout.flush()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _maybe_open(
|
|
180
|
+
response: SearchResponse, rank: int, *, auto_confirm: bool, console
|
|
181
|
+
) -> None:
|
|
182
|
+
if rank < 1 or rank > len(response.results):
|
|
183
|
+
terminal.print_error(console, f"No result with rank {rank}.")
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
result = response.results[rank - 1]
|
|
187
|
+
|
|
188
|
+
if not auto_confirm:
|
|
189
|
+
should_open = Confirm.ask(f"Open result [{rank}] {result.title!r}?", default=False)
|
|
190
|
+
if not should_open:
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
if open_url(result.url):
|
|
194
|
+
terminal.print_success(console, f"Opened {result.url}")
|
|
195
|
+
else:
|
|
196
|
+
terminal.print_error(console, f"Could not open a browser for {result.url}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _interactive_loop(settings: Settings, *, debug: bool) -> None:
|
|
200
|
+
console = terminal.make_console(no_color=settings.no_color)
|
|
201
|
+
err_console = terminal.make_console(no_color=settings.no_color, stderr=True)
|
|
202
|
+
cache = ResultCache(ttl_seconds=settings.cache_ttl_seconds)
|
|
203
|
+
|
|
204
|
+
terminal.print_banner(console)
|
|
205
|
+
console.print()
|
|
206
|
+
|
|
207
|
+
while True:
|
|
208
|
+
try:
|
|
209
|
+
raw = console.input("[bold cyan]Search >[/bold cyan] ")
|
|
210
|
+
except (EOFError, KeyboardInterrupt):
|
|
211
|
+
console.print()
|
|
212
|
+
break
|
|
213
|
+
|
|
214
|
+
query = raw.strip()
|
|
215
|
+
if not query:
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
if query in {"/exit", "/quit"}:
|
|
219
|
+
break
|
|
220
|
+
if query == "/help":
|
|
221
|
+
console.print(INTERACTIVE_HELP)
|
|
222
|
+
continue
|
|
223
|
+
if query == "/clear":
|
|
224
|
+
console.clear()
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
with terminal.loading_status(console):
|
|
229
|
+
response = run_search(query, settings, cache=cache)
|
|
230
|
+
terminal.print_results(console, response)
|
|
231
|
+
except SurfxError as error:
|
|
232
|
+
_handle_error(err_console, error, debug=debug)
|
|
233
|
+
except httpx.HTTPError as error:
|
|
234
|
+
_handle_error(err_console, error, debug=debug)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@config_app.callback(invoke_without_command=True)
|
|
238
|
+
def config_show(ctx: typer.Context) -> None:
|
|
239
|
+
"""Show the current configuration (secrets are never printed)."""
|
|
240
|
+
if ctx.invoked_subcommand is not None:
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
console = terminal.make_console()
|
|
244
|
+
settings = load_settings()
|
|
245
|
+
data = settings.redacted_dict()
|
|
246
|
+
|
|
247
|
+
terminal.print_banner(console, "Configuration")
|
|
248
|
+
console.print()
|
|
249
|
+
for key, value in data.items():
|
|
250
|
+
console.print(f" [bold]{key}[/bold]: {value}")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@config_app.command("set")
|
|
254
|
+
def config_set(key: str, value: str) -> None:
|
|
255
|
+
"""Set a non-secret configuration value, e.g. 'surfx config set searxng_url https://...'."""
|
|
256
|
+
console = terminal.make_console()
|
|
257
|
+
try:
|
|
258
|
+
write_file_setting(key, value)
|
|
259
|
+
except SurfxError as error:
|
|
260
|
+
terminal.print_error(console, error.message)
|
|
261
|
+
raise typer.Exit(code=1) from None
|
|
262
|
+
|
|
263
|
+
terminal.print_success(console, f"Set {key} = {value}")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@app.command()
|
|
267
|
+
def doctor() -> None:
|
|
268
|
+
"""Check that Surfx is installed and configured correctly."""
|
|
269
|
+
console = terminal.make_console()
|
|
270
|
+
settings = load_settings()
|
|
271
|
+
|
|
272
|
+
terminal.print_banner(console, "Doctor")
|
|
273
|
+
console.print()
|
|
274
|
+
|
|
275
|
+
checks: list[tuple[bool, str]] = [
|
|
276
|
+
(True, f"Python {platform.python_version()}"),
|
|
277
|
+
(True, "Configuration loaded"),
|
|
278
|
+
(_check_network(), "Internet connectivity available"),
|
|
279
|
+
]
|
|
280
|
+
|
|
281
|
+
provider_ok = settings.provider in PROVIDERS
|
|
282
|
+
checks.append(
|
|
283
|
+
(
|
|
284
|
+
provider_ok,
|
|
285
|
+
f"Provider '{settings.provider}' is {'known' if provider_ok else 'NOT recognized'}",
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
if provider_ok:
|
|
290
|
+
checks.extend(_check_provider(settings))
|
|
291
|
+
else:
|
|
292
|
+
available = ", ".join(sorted(PROVIDERS))
|
|
293
|
+
checks.append((False, f"Available providers: {available}"))
|
|
294
|
+
|
|
295
|
+
for ok, label in checks:
|
|
296
|
+
mark = "[green]\u2713[/green]" if ok else "[red]\u2717[/red]"
|
|
297
|
+
console.print(f"{mark} {label}")
|
|
298
|
+
|
|
299
|
+
console.print()
|
|
300
|
+
if all(ok for ok, _ in checks):
|
|
301
|
+
terminal.print_success(console, "Surfx is ready.")
|
|
302
|
+
else:
|
|
303
|
+
terminal.print_error(console, "Surfx is not fully configured. See above.")
|
|
304
|
+
raise typer.Exit(code=1)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _check_provider(settings: Settings) -> list[tuple[bool, str]]:
|
|
308
|
+
"""Validate configuration and, where supported, check live connectivity."""
|
|
309
|
+
results: list[tuple[bool, str]] = []
|
|
310
|
+
|
|
311
|
+
try:
|
|
312
|
+
provider = build_provider(settings.provider, settings)
|
|
313
|
+
provider.validate_config()
|
|
314
|
+
except SurfxError as error:
|
|
315
|
+
results.append((False, error.message))
|
|
316
|
+
return results
|
|
317
|
+
|
|
318
|
+
results.append((True, f"'{settings.provider}' provider is configured"))
|
|
319
|
+
|
|
320
|
+
check_connectivity = getattr(provider, "check_connectivity", None)
|
|
321
|
+
if callable(check_connectivity):
|
|
322
|
+
ok, message = check_connectivity()
|
|
323
|
+
results.append((ok, message))
|
|
324
|
+
|
|
325
|
+
return results
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _check_network(host: str = "8.8.8.8", port: int = 53, timeout: float = 2.0) -> bool:
|
|
329
|
+
try:
|
|
330
|
+
with socket.create_connection((host, port), timeout=timeout):
|
|
331
|
+
return True
|
|
332
|
+
except OSError:
|
|
333
|
+
return False
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
if __name__ == "__main__":
|
|
337
|
+
app()
|
surfx/config.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Configuration handling for Surfx.
|
|
2
|
+
|
|
3
|
+
Precedence (highest to lowest):
|
|
4
|
+
|
|
5
|
+
1. CLI arguments (applied by the caller, not here)
|
|
6
|
+
2. Environment variables
|
|
7
|
+
3. Config file (``~/.config/surfx/config.toml``)
|
|
8
|
+
4. Built-in defaults
|
|
9
|
+
|
|
10
|
+
Secret-shaped values (API keys, tokens, etc.) are intentionally *not* read
|
|
11
|
+
from the config file - they must come from environment variables. This
|
|
12
|
+
keeps ``config.toml`` safe to version-control or share by accident. Plain
|
|
13
|
+
configuration such as the SearXNG instance URL is not a secret and may live
|
|
14
|
+
in either place.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from dataclasses import dataclass, replace
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
if sys.version_info >= (3, 11):
|
|
26
|
+
import tomllib
|
|
27
|
+
else: # pragma: no cover - repo targets 3.11+
|
|
28
|
+
import tomli as tomllib
|
|
29
|
+
|
|
30
|
+
import tomli_w
|
|
31
|
+
|
|
32
|
+
from surfx.errors import ConfigurationError
|
|
33
|
+
|
|
34
|
+
DEFAULT_PROVIDER = "searxng"
|
|
35
|
+
DEFAULT_LIMIT = 10
|
|
36
|
+
MIN_LIMIT = 1
|
|
37
|
+
MAX_LIMIT = 50
|
|
38
|
+
|
|
39
|
+
ENV_PROVIDER = "SURFX_PROVIDER"
|
|
40
|
+
ENV_LIMIT = "SURFX_LIMIT"
|
|
41
|
+
ENV_SEARXNG_URL = "SURFX_SEARXNG_URL"
|
|
42
|
+
ENV_NO_COLOR = "SURFX_NO_COLOR"
|
|
43
|
+
ENV_CACHE_ENABLED = "SURFX_CACHE_ENABLED"
|
|
44
|
+
ENV_CACHE_TTL = "SURFX_CACHE_TTL_SECONDS"
|
|
45
|
+
|
|
46
|
+
# Config file keys that are allowed to live on disk. Deliberately excludes
|
|
47
|
+
# anything credential-shaped - "searxng_url" is allowed because it's a
|
|
48
|
+
# plain endpoint address, not a secret.
|
|
49
|
+
_ALLOWED_FILE_KEYS = {
|
|
50
|
+
"provider",
|
|
51
|
+
"limit",
|
|
52
|
+
"no_color",
|
|
53
|
+
"cache_enabled",
|
|
54
|
+
"cache_ttl_seconds",
|
|
55
|
+
"searxng_url",
|
|
56
|
+
}
|
|
57
|
+
_SECRET_LOOKING_SUFFIXES = ("key", "secret", "token", "password", "cx")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def default_config_path() -> Path:
|
|
61
|
+
xdg_config = os.environ.get("XDG_CONFIG_HOME")
|
|
62
|
+
base = Path(xdg_config) if xdg_config else Path.home() / ".config"
|
|
63
|
+
return base / "surfx" / "config.toml"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class Settings:
|
|
68
|
+
"""Fully resolved Surfx settings."""
|
|
69
|
+
|
|
70
|
+
provider: str = DEFAULT_PROVIDER
|
|
71
|
+
limit: int = DEFAULT_LIMIT
|
|
72
|
+
no_color: bool = False
|
|
73
|
+
cache_enabled: bool = False
|
|
74
|
+
cache_ttl_seconds: int = 300
|
|
75
|
+
searxng_url: str | None = None
|
|
76
|
+
config_path: Path | None = None
|
|
77
|
+
|
|
78
|
+
def with_overrides(self, **kwargs: Any) -> Settings:
|
|
79
|
+
"""Return a copy with only the non-``None`` overrides applied."""
|
|
80
|
+
overrides = {k: v for k, v in kwargs.items() if v is not None}
|
|
81
|
+
return replace(self, **overrides)
|
|
82
|
+
|
|
83
|
+
def redacted_dict(self) -> dict[str, Any]:
|
|
84
|
+
"""Settings as a dict safe to print.
|
|
85
|
+
|
|
86
|
+
``searxng_url`` is not a secret, so its value is shown directly;
|
|
87
|
+
there is nothing else in the current settings worth redacting.
|
|
88
|
+
"""
|
|
89
|
+
return {
|
|
90
|
+
"provider": self.provider,
|
|
91
|
+
"limit": self.limit,
|
|
92
|
+
"no_color": self.no_color,
|
|
93
|
+
"cache_enabled": self.cache_enabled,
|
|
94
|
+
"cache_ttl_seconds": self.cache_ttl_seconds,
|
|
95
|
+
"searxng_url": self.searxng_url or "(not set)",
|
|
96
|
+
"config_path": str(self.config_path) if self.config_path else None,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _read_config_file(path: Path) -> dict[str, Any]:
|
|
101
|
+
if not path.exists():
|
|
102
|
+
return {}
|
|
103
|
+
try:
|
|
104
|
+
raw = path.read_bytes()
|
|
105
|
+
data = tomllib.loads(raw.decode("utf-8"))
|
|
106
|
+
except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as exc:
|
|
107
|
+
raise ConfigurationError(
|
|
108
|
+
f"Could not read config file at {path}: {exc}"
|
|
109
|
+
) from exc
|
|
110
|
+
|
|
111
|
+
if not isinstance(data, dict):
|
|
112
|
+
raise ConfigurationError(f"Config file at {path} must contain a table of settings.")
|
|
113
|
+
|
|
114
|
+
# Silently ignore (never surface) anything that looks like a secret, and
|
|
115
|
+
# ignore unknown keys rather than failing hard on forward-compatible files.
|
|
116
|
+
return {k: v for k, v in data.items() if k in _ALLOWED_FILE_KEYS}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _env_bool(name: str) -> bool | None:
|
|
120
|
+
value = os.environ.get(name)
|
|
121
|
+
if value is None:
|
|
122
|
+
return None
|
|
123
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _env_int(name: str) -> int | None:
|
|
127
|
+
value = os.environ.get(name)
|
|
128
|
+
if value is None:
|
|
129
|
+
return None
|
|
130
|
+
try:
|
|
131
|
+
return int(value)
|
|
132
|
+
except ValueError as exc:
|
|
133
|
+
raise ConfigurationError(f"{name} must be an integer, got {value!r}.") from exc
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def load_settings(config_path: Path | None = None) -> Settings:
|
|
137
|
+
"""Load settings from the config file and environment, defaults included.
|
|
138
|
+
|
|
139
|
+
CLI-level overrides are intentionally applied afterwards by the caller
|
|
140
|
+
(see ``cli.py``) via :meth:`Settings.with_overrides`.
|
|
141
|
+
"""
|
|
142
|
+
path = config_path or default_config_path()
|
|
143
|
+
file_values = _read_config_file(path)
|
|
144
|
+
|
|
145
|
+
settings = Settings(config_path=path)
|
|
146
|
+
settings = settings.with_overrides(
|
|
147
|
+
provider=file_values.get("provider"),
|
|
148
|
+
limit=file_values.get("limit"),
|
|
149
|
+
no_color=file_values.get("no_color"),
|
|
150
|
+
cache_enabled=file_values.get("cache_enabled"),
|
|
151
|
+
cache_ttl_seconds=file_values.get("cache_ttl_seconds"),
|
|
152
|
+
searxng_url=file_values.get("searxng_url"),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
settings = settings.with_overrides(
|
|
156
|
+
provider=os.environ.get(ENV_PROVIDER),
|
|
157
|
+
limit=_env_int(ENV_LIMIT),
|
|
158
|
+
no_color=_env_bool(ENV_NO_COLOR),
|
|
159
|
+
cache_enabled=_env_bool(ENV_CACHE_ENABLED),
|
|
160
|
+
cache_ttl_seconds=_env_int(ENV_CACHE_TTL),
|
|
161
|
+
searxng_url=os.environ.get(ENV_SEARXNG_URL),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return settings
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def write_file_setting(key: str, value: str, config_path: Path | None = None) -> Settings:
|
|
168
|
+
"""Persist a single non-secret setting to the config file and return the
|
|
169
|
+
reloaded settings.
|
|
170
|
+
"""
|
|
171
|
+
if key not in _ALLOWED_FILE_KEYS:
|
|
172
|
+
raise ConfigurationError(
|
|
173
|
+
f"'{key}' cannot be stored in the config file. "
|
|
174
|
+
"Secrets must be set via environment variables instead."
|
|
175
|
+
)
|
|
176
|
+
if any(key.endswith(suffix) for suffix in _SECRET_LOOKING_SUFFIXES):
|
|
177
|
+
raise ConfigurationError(
|
|
178
|
+
f"Refusing to store '{key}' in the config file because it looks like a secret. "
|
|
179
|
+
"Use an environment variable instead."
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
path = config_path or default_config_path()
|
|
183
|
+
existing = _read_config_file(path)
|
|
184
|
+
|
|
185
|
+
coerced: Any = value
|
|
186
|
+
if key == "limit":
|
|
187
|
+
try:
|
|
188
|
+
coerced = int(value)
|
|
189
|
+
except ValueError as exc:
|
|
190
|
+
raise ConfigurationError(f"'limit' must be an integer, got {value!r}.") from exc
|
|
191
|
+
if not (MIN_LIMIT <= coerced <= MAX_LIMIT):
|
|
192
|
+
raise ConfigurationError(f"'limit' must be between {MIN_LIMIT} and {MAX_LIMIT}.")
|
|
193
|
+
elif key in {"no_color", "cache_enabled"}:
|
|
194
|
+
coerced = value.strip().lower() in {"1", "true", "yes", "on"}
|
|
195
|
+
elif key == "cache_ttl_seconds":
|
|
196
|
+
try:
|
|
197
|
+
coerced = int(value)
|
|
198
|
+
except ValueError as exc:
|
|
199
|
+
raise ConfigurationError(
|
|
200
|
+
f"'cache_ttl_seconds' must be an integer, got {value!r}."
|
|
201
|
+
) from exc
|
|
202
|
+
|
|
203
|
+
existing[key] = coerced
|
|
204
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
205
|
+
path.write_bytes(tomli_w.dumps(existing).encode("utf-8"))
|
|
206
|
+
|
|
207
|
+
return load_settings(path)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def validate_limit(limit: int) -> int:
|
|
211
|
+
if not (MIN_LIMIT <= limit <= MAX_LIMIT):
|
|
212
|
+
raise ConfigurationError(f"limit must be between {MIN_LIMIT} and {MAX_LIMIT}, got {limit}.")
|
|
213
|
+
return limit
|
surfx/errors.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Custom exceptions used across Surfx.
|
|
2
|
+
|
|
3
|
+
All exceptions carry a short, user-facing ``message`` that is safe to print
|
|
4
|
+
directly to the terminal without leaking internals such as stack traces or
|
|
5
|
+
credential values.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SurfxError(Exception):
|
|
12
|
+
"""Base class for all Surfx errors."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, message: str) -> None:
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.message = message
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConfigurationError(SurfxError):
|
|
20
|
+
"""Raised when Surfx or a provider is misconfigured."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProviderNotFoundError(SurfxError):
|
|
24
|
+
"""Raised when the requested provider name is unknown."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ProviderNotConfiguredError(ConfigurationError):
|
|
28
|
+
"""Raised when a provider is missing required configuration (a URL, key, etc.)."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AuthenticationError(SurfxError):
|
|
32
|
+
"""Raised when a provider rejects the supplied credentials."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class QuotaExceededError(SurfxError):
|
|
36
|
+
"""Raised when a provider's usage quota has been exhausted."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RateLimitedError(SurfxError):
|
|
40
|
+
"""Raised when a provider is throttling requests."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class NetworkError(SurfxError):
|
|
44
|
+
"""Raised for connectivity problems: DNS failures, unreachable hosts, etc."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TimeoutError_(SurfxError): # noqa: N818 - avoid shadowing builtins.TimeoutError
|
|
48
|
+
"""Raised when a request exceeds its configured timeout."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class MalformedResponseError(SurfxError):
|
|
52
|
+
"""Raised when a provider returns a response Surfx cannot parse."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class EmptyResultsError(SurfxError):
|
|
56
|
+
"""Raised when a search completes successfully but returns no results."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class InvalidLimitError(SurfxError):
|
|
60
|
+
"""Raised when a requested result limit is out of the allowed range."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class CacheError(SurfxError):
|
|
64
|
+
"""Raised for cache read/write problems. Never fatal to the CLI."""
|