webfetch-llm 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- webfetch/__init__.py +48 -0
- webfetch/cache.py +347 -0
- webfetch/compress.py +360 -0
- webfetch/config.py +154 -0
- webfetch/data/volatility_centroids.json +1 -0
- webfetch/extract/__init__.py +35 -0
- webfetch/extract/base.py +180 -0
- webfetch/extract/claude.py +71 -0
- webfetch/extract/gemini.py +89 -0
- webfetch/extract/gpt.py +70 -0
- webfetch/extract/groq.py +83 -0
- webfetch/fetch/__init__.py +89 -0
- webfetch/fetch/base.py +42 -0
- webfetch/fetch/html.py +251 -0
- webfetch/fetch/pdf.py +246 -0
- webfetch/mcp.py +68 -0
- webfetch/pipeline.py +245 -0
- webfetch/rank/__init__.py +98 -0
- webfetch/rank/base.py +52 -0
- webfetch/rank/biencoder.py +91 -0
- webfetch/rank/bm25.py +70 -0
- webfetch/rank/chunker.py +78 -0
- webfetch/rank/crossencoder.py +73 -0
- webfetch/rank/hybrid.py +88 -0
- webfetch/rank/rrf.py +64 -0
- webfetch/receipts.py +123 -0
- webfetch/search/__init__.py +104 -0
- webfetch/search/base.py +56 -0
- webfetch/search/brave.py +91 -0
- webfetch/search/ddg.py +67 -0
- webfetch/search/multi.py +145 -0
- webfetch/search/resilience.py +153 -0
- webfetch/search/serper.py +90 -0
- webfetch/search/tavily.py +74 -0
- webfetch/semcache.py +270 -0
- webfetch/tool.py +190 -0
- webfetch/volatility.py +101 -0
- webfetch_llm-0.1.0.dist-info/METADATA +288 -0
- webfetch_llm-0.1.0.dist-info/RECORD +42 -0
- webfetch_llm-0.1.0.dist-info/WHEEL +4 -0
- webfetch_llm-0.1.0.dist-info/entry_points.txt +3 -0
- webfetch_llm-0.1.0.dist-info/licenses/LICENSE +21 -0
webfetch/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
webfetch: replicate LLM web-search tool calls cheaply by owning the full
|
|
3
|
+
search -> fetch -> rank pipeline.
|
|
4
|
+
|
|
5
|
+
Quick start (tool mode - the calling model is the extractor):
|
|
6
|
+
|
|
7
|
+
from webfetch import Pipeline, SqliteCache, WEB_SEARCH_TOOL, handle_web_search
|
|
8
|
+
|
|
9
|
+
pipeline = Pipeline(cache=SqliteCache())
|
|
10
|
+
text = handle_web_search({"query": "Fluke 87V DC voltage accuracy"},
|
|
11
|
+
pipeline=pipeline)
|
|
12
|
+
|
|
13
|
+
Structured extraction mode (webfetch calls a cheap LLM for JSON output):
|
|
14
|
+
|
|
15
|
+
from webfetch.extract import ClaudeExtractor
|
|
16
|
+
specs = pipeline.run(query, keys={"accuracy": "..."}, extractor=ClaudeExtractor())
|
|
17
|
+
|
|
18
|
+
The extract package is intentionally NOT imported here - the root import
|
|
19
|
+
must work with core dependencies only.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from webfetch.cache import AbstractCache, CacheMatch, SqliteCache
|
|
23
|
+
from webfetch.pipeline import Pipeline, SearchChunksResult
|
|
24
|
+
from webfetch.rank import Chunk
|
|
25
|
+
from webfetch.receipts import get_counters, savings_report
|
|
26
|
+
from webfetch.search import SearchResult, get_search_adapter
|
|
27
|
+
from webfetch.semcache import SemanticSqliteCache
|
|
28
|
+
from webfetch.tool import WEB_SEARCH_TOOL, get_default_pipeline, handle_web_search
|
|
29
|
+
|
|
30
|
+
__version__ = "0.1.0"
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"get_counters",
|
|
34
|
+
"savings_report",
|
|
35
|
+
"Pipeline",
|
|
36
|
+
"SearchChunksResult",
|
|
37
|
+
"AbstractCache",
|
|
38
|
+
"CacheMatch",
|
|
39
|
+
"SqliteCache",
|
|
40
|
+
"SemanticSqliteCache",
|
|
41
|
+
"Chunk",
|
|
42
|
+
"SearchResult",
|
|
43
|
+
"get_search_adapter",
|
|
44
|
+
"WEB_SEARCH_TOOL",
|
|
45
|
+
"handle_web_search",
|
|
46
|
+
"get_default_pipeline",
|
|
47
|
+
"__version__",
|
|
48
|
+
]
|
webfetch/cache.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Transparent cache layer for the webfetch pipeline.
|
|
3
|
+
|
|
4
|
+
Two cache layers, both in one sqlite database:
|
|
5
|
+
|
|
6
|
+
- pages: url -> extracted page text. The big win - fetching is the slowest
|
|
7
|
+
pipeline stage and page text is reusable across different queries
|
|
8
|
+
that hit the same URL.
|
|
9
|
+
- queries: query key -> final ranked chunks. Makes repeated identical queries
|
|
10
|
+
(same query + provider + n_results) near-instant.
|
|
11
|
+
|
|
12
|
+
The pipeline works identically with or without a cache (pass cache=None), so
|
|
13
|
+
this layer is purely an optimization - never a behavior change. Failed
|
|
14
|
+
fetches are NOT cached: a dead URL today might work tomorrow, and caching
|
|
15
|
+
None would hide recoverable failures for the whole TTL.
|
|
16
|
+
|
|
17
|
+
Backend is stdlib sqlite3 rather than diskcache - one less dependency, and
|
|
18
|
+
the access pattern (key lookup, small rows, low write volume) needs nothing
|
|
19
|
+
more. WAL mode + a single lock keeps it safe under the concurrent fetch pool.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import sqlite3
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
from abc import ABC, abstractmethod
|
|
31
|
+
from dataclasses import asdict, dataclass
|
|
32
|
+
|
|
33
|
+
from webfetch.config import (
|
|
34
|
+
DEFAULT_CACHE_DB,
|
|
35
|
+
DEFAULT_CACHE_TTL_DAYS,
|
|
36
|
+
DEFAULT_FRESHNESS,
|
|
37
|
+
TTL_BY_FRESHNESS,
|
|
38
|
+
)
|
|
39
|
+
from webfetch.rank.base import Chunk
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class CacheMatch:
|
|
44
|
+
"""A successful query-cache lookup with provenance.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
chunks: The cached ranked chunks.
|
|
48
|
+
kind: "exact" (same normalized query) or "semantic" (paraphrase
|
|
49
|
+
match via the semantic cache).
|
|
50
|
+
matched_query: The cached query text that matched. None for exact
|
|
51
|
+
matches (it is the incoming query itself).
|
|
52
|
+
similarity: Bi-encoder cosine to the matched query (semantic only).
|
|
53
|
+
age_secs: Seconds since the cached entry was stored, if known.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
chunks: list[Chunk]
|
|
57
|
+
kind: str
|
|
58
|
+
matched_query: str | None = None
|
|
59
|
+
similarity: float | None = None
|
|
60
|
+
age_secs: float | None = None
|
|
61
|
+
freshness: str | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def make_query_key(query: str, provider: str, n_results: int) -> str:
|
|
65
|
+
"""Build a deterministic cache key for a search query.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
query: The search query string (whitespace-normalized, lowercased -
|
|
69
|
+
"Fluke 87V" and "fluke 87v " should hit the same entry).
|
|
70
|
+
provider: Search provider name (different providers return different
|
|
71
|
+
URLs, so results are not interchangeable).
|
|
72
|
+
n_results: Number of search results requested.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
A sha256 hex digest usable as a primary key.
|
|
76
|
+
"""
|
|
77
|
+
normalized = " ".join(query.lower().split())
|
|
78
|
+
raw = f"{normalized}|{provider}|{n_results}"
|
|
79
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class AbstractCache(ABC):
|
|
83
|
+
"""Interface for pipeline caches.
|
|
84
|
+
|
|
85
|
+
Two layers: page text (keyed by URL) and final ranked chunks (keyed by
|
|
86
|
+
a query key from `make_query_key`). Implementations decide storage and
|
|
87
|
+
expiry; the pipeline only calls these four methods.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
@abstractmethod
|
|
91
|
+
def get_page(self, url: str) -> str | None:
|
|
92
|
+
"""Return cached extracted text for a URL, or None on miss/expiry."""
|
|
93
|
+
...
|
|
94
|
+
|
|
95
|
+
@abstractmethod
|
|
96
|
+
def set_page(self, url: str, text: str) -> None:
|
|
97
|
+
"""Cache extracted text for a URL."""
|
|
98
|
+
...
|
|
99
|
+
|
|
100
|
+
@abstractmethod
|
|
101
|
+
def get_chunks(self, key: str) -> list[Chunk] | None:
|
|
102
|
+
"""Return cached ranked chunks for a query key, or None on miss/expiry."""
|
|
103
|
+
...
|
|
104
|
+
|
|
105
|
+
@abstractmethod
|
|
106
|
+
def set_chunks(self, key: str, chunks: list[Chunk]) -> None:
|
|
107
|
+
"""Cache the final ranked chunks for a query key."""
|
|
108
|
+
...
|
|
109
|
+
|
|
110
|
+
def lookup(self, query: str, provider: str, n_results: int,
|
|
111
|
+
freshness: str | None = None) -> CacheMatch | None:
|
|
112
|
+
"""Look up cached chunks for a query, with provenance.
|
|
113
|
+
|
|
114
|
+
Default implementation is exact-match only with flat TTL (the
|
|
115
|
+
freshness hint is ignored). SqliteCache and semantic caches override
|
|
116
|
+
this with per-class TTLs - the pipeline calls only this method, so
|
|
117
|
+
cache implementations stay swappable.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
query: The incoming search query.
|
|
121
|
+
provider: Search provider name (part of the cache key).
|
|
122
|
+
n_results: Requested result count (part of the cache key).
|
|
123
|
+
freshness: Optional caller-side volatility class; implementations
|
|
124
|
+
may use it to tighten expiry (min of stored and hinted TTL).
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
A CacheMatch on hit, None on miss.
|
|
128
|
+
"""
|
|
129
|
+
chunks = self.get_chunks(make_query_key(query, provider, n_results))
|
|
130
|
+
if chunks is None:
|
|
131
|
+
return None
|
|
132
|
+
return CacheMatch(chunks=chunks, kind="exact")
|
|
133
|
+
|
|
134
|
+
def store(self, query: str, provider: str, n_results: int,
|
|
135
|
+
chunks: list[Chunk], freshness: str | None = None) -> None:
|
|
136
|
+
"""Store the final ranked chunks for a query.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
query: The search query that produced these chunks.
|
|
140
|
+
provider: Search provider name.
|
|
141
|
+
n_results: Requested result count.
|
|
142
|
+
chunks: Final ranked chunks to cache.
|
|
143
|
+
freshness: Volatility class to store with the row (drives its
|
|
144
|
+
TTL on later reads). Ignored by the default implementation.
|
|
145
|
+
"""
|
|
146
|
+
self.set_chunks(make_query_key(query, provider, n_results), chunks)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class SqliteCache(AbstractCache):
|
|
150
|
+
"""Sqlite-backed cache with lazy TTL expiry.
|
|
151
|
+
|
|
152
|
+
Expired rows are deleted on read rather than by a background sweeper -
|
|
153
|
+
simpler, and stale rows that are never read again cost only disk space.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
db_path: Path to the sqlite file. "~" is expanded; parent dirs are
|
|
157
|
+
created. Defaults to config.DEFAULT_CACHE_DB.
|
|
158
|
+
ttl_days: Rows older than this are treated as misses and deleted.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
def __init__(
|
|
162
|
+
self,
|
|
163
|
+
db_path: str = DEFAULT_CACHE_DB,
|
|
164
|
+
ttl_days: int = DEFAULT_CACHE_TTL_DAYS,
|
|
165
|
+
) -> None:
|
|
166
|
+
path = os.path.expanduser(db_path)
|
|
167
|
+
parent = os.path.dirname(path)
|
|
168
|
+
if parent:
|
|
169
|
+
os.makedirs(parent, exist_ok=True)
|
|
170
|
+
|
|
171
|
+
self._ttl_secs = ttl_days * 86400
|
|
172
|
+
# check_same_thread=False + one lock: fetch workers write pages from
|
|
173
|
+
# pool threads. A single connection behind a lock is plenty for this
|
|
174
|
+
# write volume and avoids per-thread connection management.
|
|
175
|
+
self._conn = sqlite3.connect(path, check_same_thread=False)
|
|
176
|
+
self._lock = threading.Lock()
|
|
177
|
+
with self._lock:
|
|
178
|
+
# WAL lets readers proceed during writes and is more crash-safe.
|
|
179
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
180
|
+
self._conn.execute(
|
|
181
|
+
"CREATE TABLE IF NOT EXISTS pages ("
|
|
182
|
+
"url TEXT PRIMARY KEY, text TEXT NOT NULL, created_at REAL NOT NULL)"
|
|
183
|
+
)
|
|
184
|
+
self._conn.execute(
|
|
185
|
+
"CREATE TABLE IF NOT EXISTS queries ("
|
|
186
|
+
"key TEXT PRIMARY KEY, chunks_json TEXT NOT NULL, "
|
|
187
|
+
"created_at REAL NOT NULL, freshness TEXT)"
|
|
188
|
+
)
|
|
189
|
+
# Migration for cache dbs created before volatility-aware TTLs:
|
|
190
|
+
# NULL freshness rows are treated as DEFAULT_FRESHNESS on read.
|
|
191
|
+
cols = {r[1] for r in self._conn.execute("PRAGMA table_info(queries)")}
|
|
192
|
+
if "freshness" not in cols:
|
|
193
|
+
self._conn.execute("ALTER TABLE queries ADD COLUMN freshness TEXT")
|
|
194
|
+
# Lifetime usage counters (cost receipts) - live with the cache
|
|
195
|
+
# because it is the one durable file webfetch owns.
|
|
196
|
+
self._conn.execute(
|
|
197
|
+
"CREATE TABLE IF NOT EXISTS stats ("
|
|
198
|
+
"key TEXT PRIMARY KEY, value REAL NOT NULL)"
|
|
199
|
+
)
|
|
200
|
+
self._conn.commit()
|
|
201
|
+
|
|
202
|
+
def bump_stats(self, **deltas: float) -> None:
|
|
203
|
+
"""Accumulate usage counters (see webfetch.receipts).
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
**deltas: Counter name -> increment, e.g.
|
|
207
|
+
bump_stats(searches_total=1, cache_hits_exact=1).
|
|
208
|
+
"""
|
|
209
|
+
with self._lock:
|
|
210
|
+
for key, delta in deltas.items():
|
|
211
|
+
self._conn.execute(
|
|
212
|
+
"INSERT INTO stats (key, value) VALUES (?, ?) "
|
|
213
|
+
"ON CONFLICT(key) DO UPDATE SET value = value + ?",
|
|
214
|
+
(key, float(delta), float(delta)),
|
|
215
|
+
)
|
|
216
|
+
self._conn.commit()
|
|
217
|
+
|
|
218
|
+
def get_stats(self) -> dict[str, float]:
|
|
219
|
+
"""Return all lifetime usage counters."""
|
|
220
|
+
with self._lock:
|
|
221
|
+
return dict(self._conn.execute("SELECT key, value FROM stats"))
|
|
222
|
+
|
|
223
|
+
def _get_with_age(self, table: str, key_col: str, value_col: str,
|
|
224
|
+
key: str) -> tuple[str, float] | None:
|
|
225
|
+
"""Shared read path: (value, age_secs) or None, deleting expired rows."""
|
|
226
|
+
with self._lock:
|
|
227
|
+
row = self._conn.execute(
|
|
228
|
+
f"SELECT {value_col}, created_at FROM {table} WHERE {key_col} = ?",
|
|
229
|
+
(key,),
|
|
230
|
+
).fetchone()
|
|
231
|
+
if row is None:
|
|
232
|
+
return None
|
|
233
|
+
value, created_at = row
|
|
234
|
+
age = time.time() - created_at
|
|
235
|
+
if age > self._ttl_secs:
|
|
236
|
+
self._conn.execute(f"DELETE FROM {table} WHERE {key_col} = ?", (key,))
|
|
237
|
+
self._conn.commit()
|
|
238
|
+
return None
|
|
239
|
+
return value, age
|
|
240
|
+
|
|
241
|
+
def _get(self, table: str, key_col: str, value_col: str, key: str) -> str | None:
|
|
242
|
+
"""Shared read path: return the value or None, deleting expired rows."""
|
|
243
|
+
hit = self._get_with_age(table, key_col, value_col, key)
|
|
244
|
+
return hit[0] if hit is not None else None
|
|
245
|
+
|
|
246
|
+
def _effective_ttl_secs(self, stored: str | None, hint: str | None) -> float:
|
|
247
|
+
"""Per-class TTL: min(stored class, caller hint, flat ceiling).
|
|
248
|
+
|
|
249
|
+
Resolved at read time so retuning TTL_BY_FRESHNESS applies to rows
|
|
250
|
+
already cached. Legacy rows (NULL freshness) use DEFAULT_FRESHNESS.
|
|
251
|
+
"""
|
|
252
|
+
ttl = float(TTL_BY_FRESHNESS.get(stored or DEFAULT_FRESHNESS, self._ttl_secs))
|
|
253
|
+
if hint in TTL_BY_FRESHNESS:
|
|
254
|
+
ttl = min(ttl, TTL_BY_FRESHNESS[hint])
|
|
255
|
+
return min(ttl, self._ttl_secs)
|
|
256
|
+
|
|
257
|
+
def _get_query_row(self, key: str, hint: str | None = None,
|
|
258
|
+
) -> tuple[str, float, str | None] | None:
|
|
259
|
+
"""Read a queries row with per-class expiry.
|
|
260
|
+
|
|
261
|
+
A row is DELETED only when expired for its OWN stored class - a
|
|
262
|
+
stricter caller hint produces a miss but leaves the row intact,
|
|
263
|
+
since other callers may still be served by it.
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
(chunks_json, age_secs, stored_freshness) or None on miss/expiry.
|
|
267
|
+
"""
|
|
268
|
+
with self._lock:
|
|
269
|
+
row = self._conn.execute(
|
|
270
|
+
"SELECT chunks_json, created_at, freshness FROM queries WHERE key = ?",
|
|
271
|
+
(key,),
|
|
272
|
+
).fetchone()
|
|
273
|
+
if row is None:
|
|
274
|
+
return None
|
|
275
|
+
value, created_at, stored = row
|
|
276
|
+
age = time.time() - created_at
|
|
277
|
+
if age > self._effective_ttl_secs(stored, None):
|
|
278
|
+
self._conn.execute("DELETE FROM queries WHERE key = ?", (key,))
|
|
279
|
+
self._conn.commit()
|
|
280
|
+
return None
|
|
281
|
+
if age > self._effective_ttl_secs(stored, hint):
|
|
282
|
+
return None
|
|
283
|
+
return value, age, stored
|
|
284
|
+
|
|
285
|
+
def _set(self, table: str, key_col: str, value_col: str, key: str,
|
|
286
|
+
value: str) -> None:
|
|
287
|
+
"""Shared write path: upsert with a fresh timestamp."""
|
|
288
|
+
with self._lock:
|
|
289
|
+
self._conn.execute(
|
|
290
|
+
f"INSERT OR REPLACE INTO {table} ({key_col}, {value_col}, created_at) "
|
|
291
|
+
"VALUES (?, ?, ?)",
|
|
292
|
+
(key, value, time.time()),
|
|
293
|
+
)
|
|
294
|
+
self._conn.commit()
|
|
295
|
+
|
|
296
|
+
def get_page(self, url: str) -> str | None:
|
|
297
|
+
"""Return cached extracted text for a URL, or None on miss/expiry."""
|
|
298
|
+
return self._get("pages", "url", "text", url)
|
|
299
|
+
|
|
300
|
+
def set_page(self, url: str, text: str) -> None:
|
|
301
|
+
"""Cache extracted text for a URL."""
|
|
302
|
+
self._set("pages", "url", "text", url, text)
|
|
303
|
+
|
|
304
|
+
def get_chunks(self, key: str) -> list[Chunk] | None:
|
|
305
|
+
"""Return cached ranked chunks for a query key, or None on miss/expiry."""
|
|
306
|
+
hit = self._get_query_row(key)
|
|
307
|
+
if hit is None:
|
|
308
|
+
return None
|
|
309
|
+
return [Chunk(**d) for d in json.loads(hit[0])]
|
|
310
|
+
|
|
311
|
+
def lookup(self, query: str, provider: str, n_results: int,
|
|
312
|
+
freshness: str | None = None) -> CacheMatch | None:
|
|
313
|
+
"""Exact-match lookup with per-class TTL and age provenance."""
|
|
314
|
+
hit = self._get_query_row(
|
|
315
|
+
make_query_key(query, provider, n_results), hint=freshness)
|
|
316
|
+
if hit is None:
|
|
317
|
+
return None
|
|
318
|
+
raw, age, stored = hit
|
|
319
|
+
return CacheMatch(
|
|
320
|
+
chunks=[Chunk(**d) for d in json.loads(raw)],
|
|
321
|
+
kind="exact", age_secs=age, freshness=stored,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def store(self, query: str, provider: str, n_results: int,
|
|
325
|
+
chunks: list[Chunk], freshness: str | None = None) -> None:
|
|
326
|
+
"""Store chunks with their volatility class."""
|
|
327
|
+
with self._lock:
|
|
328
|
+
self._conn.execute(
|
|
329
|
+
"INSERT OR REPLACE INTO queries "
|
|
330
|
+
"(key, chunks_json, created_at, freshness) VALUES (?, ?, ?, ?)",
|
|
331
|
+
(make_query_key(query, provider, n_results),
|
|
332
|
+
json.dumps([asdict(c) for c in chunks]), time.time(), freshness),
|
|
333
|
+
)
|
|
334
|
+
self._conn.commit()
|
|
335
|
+
|
|
336
|
+
def set_chunks(self, key: str, chunks: list[Chunk]) -> None:
|
|
337
|
+
"""Cache the final ranked chunks for a query key."""
|
|
338
|
+
self._set("queries", "key", "chunks_json", key,
|
|
339
|
+
json.dumps([asdict(c) for c in chunks]))
|
|
340
|
+
|
|
341
|
+
def close(self) -> None:
|
|
342
|
+
"""Close the underlying sqlite connection."""
|
|
343
|
+
with self._lock:
|
|
344
|
+
self._conn.close()
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
__all__ = ["AbstractCache", "CacheMatch", "SqliteCache", "make_query_key"]
|