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 ADDED
@@ -0,0 +1,5 @@
1
+ """Surfx - a fast, clean, terminal-first web search CLI."""
2
+
3
+ __version__ = "0.2.0"
4
+
5
+ __all__ = ["__version__"]
surfx/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from surfx.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
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."""