gfa-sdk 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.
gfa/__init__.py ADDED
@@ -0,0 +1,82 @@
1
+ """gfa — Opinionated Python SDK for gfa (Git for Agents).
2
+
3
+ Public surface re-exports. Power users can reach into submodules
4
+ (``gfa.routing``, ``gfa.cache``) to inspect internals.
5
+
6
+ See ``design/smart-client-sdk.md`` in the gfa repo for the design spec.
7
+ """
8
+
9
+ from gfa._version import __version__
10
+ from gfa.async_client import AsyncClient
11
+ from gfa.cache import BlobCache, ProfileCache, RefCache
12
+ from gfa.client import Client
13
+ from gfa.defaults import ClientConfig
14
+ from gfa.errors import (
15
+ BadRequestError,
16
+ ConflictError,
17
+ FileNotFoundError,
18
+ ForbiddenError,
19
+ GfaError,
20
+ GitBinaryMissingError,
21
+ RefNotFoundError,
22
+ RepoNotFoundError,
23
+ ServerError,
24
+ SuggestPartialCloneError,
25
+ TransportError,
26
+ UnauthorizedError,
27
+ UnavailableError,
28
+ UnprocessableError,
29
+ )
30
+ from gfa.hints import Hint, HintHandler, default_hint_handler
31
+ from gfa.mint_token import FileKeyTokenProvider, TokenProvider
32
+ from gfa.models import (
33
+ Author,
34
+ ClientStats,
35
+ CommitInfo,
36
+ ConflictSurface,
37
+ DivergeResult,
38
+ FileChange,
39
+ RepoProfile,
40
+ TreeEntry,
41
+ )
42
+ from gfa.partial_clone import PartialClone
43
+ from gfa.workspace import Workspace
44
+
45
+ __all__ = [
46
+ "__version__",
47
+ "AsyncClient",
48
+ "Author",
49
+ "BadRequestError",
50
+ "BlobCache",
51
+ "Client",
52
+ "ClientConfig",
53
+ "ClientStats",
54
+ "CommitInfo",
55
+ "ConflictError",
56
+ "ConflictSurface",
57
+ "DivergeResult",
58
+ "FileChange",
59
+ "FileKeyTokenProvider",
60
+ "FileNotFoundError",
61
+ "ForbiddenError",
62
+ "GfaError",
63
+ "GitBinaryMissingError",
64
+ "Hint",
65
+ "HintHandler",
66
+ "PartialClone",
67
+ "ProfileCache",
68
+ "RefCache",
69
+ "RefNotFoundError",
70
+ "RepoNotFoundError",
71
+ "RepoProfile",
72
+ "ServerError",
73
+ "SuggestPartialCloneError",
74
+ "TokenProvider",
75
+ "TransportError",
76
+ "TreeEntry",
77
+ "UnauthorizedError",
78
+ "UnavailableError",
79
+ "UnprocessableError",
80
+ "Workspace",
81
+ "default_hint_handler",
82
+ ]
gfa/_transport.py ADDED
@@ -0,0 +1,383 @@
1
+ """HTTP transport for the SDK.
2
+
3
+ Wraps an ``httpx.Client`` with:
4
+
5
+ - Bearer-token-via-Basic-Auth header injection (gfa uses HTTP Basic with
6
+ username='t', password=<JWT>)
7
+ - Exponential backoff retry on 5xx + network errors, with jitter
8
+ - Atomic telemetry counters (request count, byte counts, per-endpoint hits)
9
+ - Error envelope -> typed exception mapping
10
+
11
+ This module is intentionally small. The high-level routing/cache logic
12
+ lives in :mod:`gfa.client`; we just deliver well-formed HTTP/JSON.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import json
19
+ import random
20
+ import threading
21
+ import time
22
+ from dataclasses import dataclass
23
+ from typing import Any, Mapping
24
+
25
+ import httpx
26
+
27
+ from gfa.defaults import ClientConfig
28
+ from gfa.errors import (
29
+ GfaError,
30
+ TransportError,
31
+ map_status_to_error,
32
+ )
33
+ from gfa.mint_token import TokenProvider
34
+ from gfa._version import __version__
35
+
36
+
37
+ @dataclass
38
+ class _Counters:
39
+ request_count: int = 0
40
+ byte_count_in: int = 0
41
+ byte_count_out: int = 0
42
+ cache_hit_count: int = 0
43
+ cache_miss_count: int = 0
44
+ hints_emitted: int = 0
45
+
46
+
47
+ class Transport:
48
+ """HTTP transport + retry + auth + telemetry.
49
+
50
+ One ``Transport`` instance per ``Client``. Thread-safe (httpx.Client is
51
+ thread-safe by design; the counters use a Lock).
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ *,
57
+ endpoint: str,
58
+ token: str | TokenProvider,
59
+ config: ClientConfig,
60
+ transport: httpx.BaseTransport | None = None,
61
+ ) -> None:
62
+ if not endpoint:
63
+ raise ValueError("endpoint must be non-empty")
64
+ # Normalize: strip trailing slash.
65
+ self.endpoint = endpoint.rstrip("/")
66
+ self._token: str | TokenProvider = token
67
+ self._config = config
68
+ self._counters = _Counters()
69
+ self._per_endpoint: dict[str, int] = {}
70
+ self._lock = threading.Lock()
71
+
72
+ user_agent = config.user_agent or f"gfa-sdk-python/{__version__}"
73
+
74
+ # httpx.Client kwargs. ``transport`` is for test injection
75
+ # (httpx.MockTransport). Don't pass http2 when a mock transport is
76
+ # used — MockTransport doesn't speak HTTP/2.
77
+ kwargs: dict[str, Any] = {
78
+ "base_url": self.endpoint,
79
+ "timeout": httpx.Timeout(
80
+ config.timeout_seconds,
81
+ connect=config.connect_timeout_seconds,
82
+ ),
83
+ "limits": httpx.Limits(max_connections=config.pool_max_connections),
84
+ "headers": {"User-Agent": user_agent},
85
+ "follow_redirects": False,
86
+ }
87
+ if transport is not None:
88
+ kwargs["transport"] = transport
89
+ else:
90
+ kwargs["http2"] = config.http2
91
+ self._http = httpx.Client(**kwargs)
92
+
93
+ # -------------------------------------------------------------------
94
+ # Lifecycle
95
+ # -------------------------------------------------------------------
96
+
97
+ def close(self) -> None:
98
+ self._http.close()
99
+
100
+ def __enter__(self) -> "Transport":
101
+ return self
102
+
103
+ def __exit__(self, *exc: object) -> None:
104
+ self.close()
105
+
106
+ # -------------------------------------------------------------------
107
+ # Counters / telemetry
108
+ # -------------------------------------------------------------------
109
+
110
+ @property
111
+ def counters(self) -> _Counters:
112
+ with self._lock:
113
+ return _Counters(
114
+ request_count=self._counters.request_count,
115
+ byte_count_in=self._counters.byte_count_in,
116
+ byte_count_out=self._counters.byte_count_out,
117
+ cache_hit_count=self._counters.cache_hit_count,
118
+ cache_miss_count=self._counters.cache_miss_count,
119
+ hints_emitted=self._counters.hints_emitted,
120
+ )
121
+
122
+ @property
123
+ def per_endpoint(self) -> dict[str, int]:
124
+ with self._lock:
125
+ return dict(self._per_endpoint)
126
+
127
+ def record_cache_hit(self) -> None:
128
+ with self._lock:
129
+ self._counters.cache_hit_count += 1
130
+
131
+ def record_cache_miss(self) -> None:
132
+ with self._lock:
133
+ self._counters.cache_miss_count += 1
134
+
135
+ def record_hint_emitted(self) -> None:
136
+ with self._lock:
137
+ self._counters.hints_emitted += 1
138
+
139
+ # -------------------------------------------------------------------
140
+ # Request entry points
141
+ # -------------------------------------------------------------------
142
+
143
+ def request_json(
144
+ self,
145
+ method: str,
146
+ path: str,
147
+ *,
148
+ op_id: str,
149
+ repo_for_token: str | None = None,
150
+ params: Mapping[str, Any] | None = None,
151
+ json_body: Any | None = None,
152
+ expected_status: tuple[int, ...] = (200, 201),
153
+ ) -> tuple[int, dict[str, Any], httpx.Headers]:
154
+ """Send a request, parse the JSON body, return (status, parsed, headers).
155
+
156
+ Raises a typed ``GfaError`` subclass on non-expected status. Errors
157
+ from ``ErrorEnvelope`` payloads carry the machine ``error`` code
158
+ through to disambiguate 404 cases. Network failures raise
159
+ :class:`TransportError`.
160
+
161
+ ``expected_status`` is the set of statuses that are considered
162
+ success for this call. 204 (no content) returns an empty dict.
163
+ """
164
+ response = self._send(
165
+ method, path, op_id=op_id,
166
+ repo_for_token=repo_for_token,
167
+ params=params, json_body=json_body, content=None,
168
+ accept="application/json",
169
+ )
170
+ status = response.status_code
171
+ body = response.content
172
+ if status == 204 or not body:
173
+ parsed: dict[str, Any] = {}
174
+ else:
175
+ try:
176
+ parsed = response.json()
177
+ if not isinstance(parsed, (dict, list)):
178
+ # Some endpoints (listTree) return arrays — wrap.
179
+ parsed = {"_array": parsed}
180
+ except json.JSONDecodeError as e:
181
+ # Non-JSON body from a JSON endpoint -> server error.
182
+ raise GfaError(
183
+ f"invalid JSON in response from {path}: {e}",
184
+ status=status,
185
+ body=response.text,
186
+ ) from e
187
+ if status not in expected_status:
188
+ self._raise_from_response(response, op_id=op_id)
189
+ return status, parsed, response.headers
190
+
191
+ def request_array(
192
+ self,
193
+ method: str,
194
+ path: str,
195
+ *,
196
+ op_id: str,
197
+ repo_for_token: str | None = None,
198
+ params: Mapping[str, Any] | None = None,
199
+ json_body: Any | None = None,
200
+ expected_status: tuple[int, ...] = (200, 201),
201
+ ) -> tuple[int, list[Any], httpx.Headers]:
202
+ """Same as ``request_json`` but expects an array body."""
203
+ response = self._send(
204
+ method, path, op_id=op_id,
205
+ repo_for_token=repo_for_token,
206
+ params=params, json_body=json_body, content=None,
207
+ accept="application/json",
208
+ )
209
+ status = response.status_code
210
+ if status not in expected_status:
211
+ self._raise_from_response(response, op_id=op_id)
212
+ if not response.content:
213
+ return status, [], response.headers
214
+ try:
215
+ parsed = response.json()
216
+ except json.JSONDecodeError as e:
217
+ raise GfaError(
218
+ f"invalid JSON in response from {path}: {e}",
219
+ status=status,
220
+ body=response.text,
221
+ ) from e
222
+ if isinstance(parsed, list):
223
+ return status, parsed, response.headers
224
+ # Server returned an object where we expected an array; surface as error.
225
+ raise GfaError(
226
+ f"expected JSON array from {path}, got {type(parsed).__name__}",
227
+ status=status,
228
+ body=response.text,
229
+ )
230
+
231
+ def request_bytes(
232
+ self,
233
+ method: str,
234
+ path: str,
235
+ *,
236
+ op_id: str,
237
+ repo_for_token: str | None = None,
238
+ params: Mapping[str, Any] | None = None,
239
+ expected_status: tuple[int, ...] = (200,),
240
+ accept: str = "application/octet-stream",
241
+ ) -> tuple[bytes, httpx.Headers]:
242
+ """For raw blob reads (``GET /file``). Returns the raw bytes."""
243
+ response = self._send(
244
+ method, path, op_id=op_id,
245
+ repo_for_token=repo_for_token,
246
+ params=params, json_body=None, content=None,
247
+ accept=accept,
248
+ )
249
+ if response.status_code not in expected_status:
250
+ self._raise_from_response(response, op_id=op_id)
251
+ return response.content, response.headers
252
+
253
+ # -------------------------------------------------------------------
254
+ # Internal: send w/ retry, auth, telemetry
255
+ # -------------------------------------------------------------------
256
+
257
+ def _send(
258
+ self,
259
+ method: str,
260
+ path: str,
261
+ *,
262
+ op_id: str,
263
+ repo_for_token: str | None,
264
+ params: Mapping[str, Any] | None,
265
+ json_body: Any | None,
266
+ content: bytes | None,
267
+ accept: str,
268
+ ) -> httpx.Response:
269
+ # Prepare body
270
+ body_bytes: bytes | None = None
271
+ headers = {"Accept": accept}
272
+ if json_body is not None:
273
+ body_bytes = json.dumps(json_body).encode("utf-8")
274
+ headers["Content-Type"] = "application/json"
275
+ elif content is not None:
276
+ body_bytes = content
277
+ # Auth header
278
+ headers["Authorization"] = self._auth_header(repo_for_token)
279
+
280
+ retry_attempts = self._config.max_retries
281
+ backoff = self._config.retry_backoff_initial_seconds
282
+
283
+ last_exc: Exception | None = None
284
+ for attempt in range(retry_attempts + 1):
285
+ try:
286
+ self._tick_request(op_id, request_size=len(body_bytes or b""))
287
+ response = self._http.request(
288
+ method,
289
+ path,
290
+ params=params,
291
+ content=body_bytes,
292
+ headers=headers,
293
+ )
294
+ # tick byte_count_in
295
+ self._tick_response(len(response.content or b""))
296
+ if (
297
+ response.status_code in self._config.retry_on_status
298
+ and attempt < retry_attempts
299
+ ):
300
+ self._sleep_backoff(backoff)
301
+ backoff = min(
302
+ backoff * 2,
303
+ self._config.retry_backoff_max_seconds,
304
+ )
305
+ continue
306
+ return response
307
+ except (httpx.ConnectError, httpx.ReadTimeout,
308
+ httpx.WriteTimeout, httpx.PoolTimeout,
309
+ httpx.RemoteProtocolError, httpx.NetworkError) as e:
310
+ last_exc = e
311
+ if attempt < retry_attempts:
312
+ self._sleep_backoff(backoff)
313
+ backoff = min(
314
+ backoff * 2,
315
+ self._config.retry_backoff_max_seconds,
316
+ )
317
+ continue
318
+ raise TransportError(
319
+ f"network error after {attempt + 1} attempts: {e}"
320
+ ) from e
321
+ # Should be unreachable (the loop either returns or raises).
322
+ raise TransportError(
323
+ f"exhausted retries; last error: {last_exc}"
324
+ ) from last_exc
325
+
326
+ def _tick_request(self, op_id: str, request_size: int) -> None:
327
+ with self._lock:
328
+ self._counters.request_count += 1
329
+ self._counters.byte_count_out += request_size
330
+ self._per_endpoint[op_id] = self._per_endpoint.get(op_id, 0) + 1
331
+
332
+ def _tick_response(self, response_size: int) -> None:
333
+ with self._lock:
334
+ self._counters.byte_count_in += response_size
335
+
336
+ def _sleep_backoff(self, base: float) -> None:
337
+ # +/- jitter%
338
+ jitter = self._config.retry_backoff_jitter
339
+ if jitter > 0:
340
+ base = base * (1.0 + random.uniform(-jitter, jitter))
341
+ time.sleep(max(0.0, base))
342
+
343
+ def _auth_header(self, repo: str | None) -> str:
344
+ """HTTP Basic with username='t', password=<JWT>.
345
+
346
+ This matches the code.storage-style scheme that gfa uses on the
347
+ wire (see ``CLAUDE.md`` -> Auth Model). The username 't' is
348
+ always literal — only the password (JWT) varies.
349
+ """
350
+ token = self._token
351
+ if isinstance(token, str):
352
+ jwt_str = token
353
+ else:
354
+ jwt_str = token.token_for(repo)
355
+ raw = f"t:{jwt_str}".encode("utf-8")
356
+ return "Basic " + base64.b64encode(raw).decode("ascii")
357
+
358
+ def _raise_from_response(self, response: httpx.Response, *, op_id: str) -> None:
359
+ """Translate an HTTP error response into a typed exception."""
360
+ status = response.status_code
361
+ error_code = ""
362
+ message = ""
363
+ body_text = ""
364
+ try:
365
+ body_text = response.text
366
+ except Exception:
367
+ body_text = ""
368
+ try:
369
+ parsed = response.json()
370
+ if isinstance(parsed, dict):
371
+ error_code = str(parsed.get("error", "") or "")
372
+ message = str(parsed.get("message", "") or "")
373
+ except (json.JSONDecodeError, UnicodeDecodeError, ValueError):
374
+ pass
375
+ request_id = response.headers.get("X-Gfa-Request-Id", "") or ""
376
+ raise map_status_to_error(
377
+ status,
378
+ url=str(response.request.url) if response.request else "",
379
+ error_code=error_code,
380
+ message=message or f"{op_id} failed with status {status}",
381
+ request_id=request_id,
382
+ body=body_text,
383
+ )
gfa/_version.py ADDED
@@ -0,0 +1,8 @@
1
+ """Single source of truth for the gfa-sdk version string.
2
+
3
+ Keep in sync with ``pyproject.toml``'s ``[project].version``. The package
4
+ re-exports ``__version__`` from here so callers can do
5
+ ``gfa.__version__`` for diagnostics.
6
+ """
7
+
8
+ __version__ = "0.1.0"
gfa/async_client.py ADDED
@@ -0,0 +1,31 @@
1
+ """``gfa.AsyncClient`` — stub.
2
+
3
+ The async port is filed as M-055-SDK-ASYNC follow-on. v1 ships the sync
4
+ client only. The class is importable so callers can detect availability
5
+ via ``hasattr(gfa, "AsyncClient")``; every method raises
6
+ ``NotImplementedError`` with a pointer to the follow-on item.
7
+
8
+ When the async client lands, the underlying ``_transport.py`` will gain
9
+ an ``async_request_*`` family using ``httpx.AsyncClient``; the routing,
10
+ cache, and hint layers reuse the same logic.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+
18
+ _MSG = (
19
+ "AsyncClient is not implemented in gfa-sdk 0.1 — "
20
+ "use `gfa.Client` (sync). Async lands in M-055-SDK-ASYNC follow-on."
21
+ )
22
+
23
+
24
+ class AsyncClient:
25
+ """Stub async client. All methods raise :class:`NotImplementedError`."""
26
+
27
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
28
+ raise NotImplementedError(_MSG)
29
+
30
+ def __getattr__(self, name: str) -> Any: # pragma: no cover - never reached
31
+ raise NotImplementedError(_MSG)
gfa/cache.py ADDED
@@ -0,0 +1,181 @@
1
+ """Session-level caches.
2
+
3
+ Three independent caches with independent TTL policies:
4
+
5
+ - :class:`ProfileCache` — per-repo ``RepoProfile``; default TTL is the
6
+ session lifetime (never expires unless ``profile_ttl_seconds`` is finite).
7
+ - :class:`RefCache` — ``(repo, ref-name)`` -> resolved SHA; 60s TTL by default.
8
+ - :class:`BlobCache` — blob-SHA -> bytes; LRU bounded by max-bytes; off by
9
+ default.
10
+
11
+ All caches are thread-safe. The Client holds one instance of each.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ import threading
18
+ import time
19
+ from collections import OrderedDict
20
+ from typing import Generic, TypeVar
21
+
22
+ from gfa.models import RepoProfile
23
+
24
+ K = TypeVar("K")
25
+ V = TypeVar("V")
26
+
27
+
28
+ class _TTLEntry(Generic[V]):
29
+ __slots__ = ("value", "expires_at")
30
+
31
+ def __init__(self, value: V, expires_at: float) -> None:
32
+ self.value = value
33
+ self.expires_at = expires_at
34
+
35
+
36
+ class ProfileCache:
37
+ """Per-repo ``RepoProfile`` cache.
38
+
39
+ Unbounded — profiles are ~1 KB and a session typically touches a small
40
+ number of repos. TTL defaults to infinity (session lifetime).
41
+ """
42
+
43
+ def __init__(self, ttl_seconds: float = math.inf) -> None:
44
+ self._ttl = ttl_seconds
45
+ self._entries: dict[str, _TTLEntry[RepoProfile]] = {}
46
+ self._lock = threading.Lock()
47
+
48
+ def get(self, repo: str) -> RepoProfile | None:
49
+ with self._lock:
50
+ entry = self._entries.get(repo)
51
+ if entry is None:
52
+ return None
53
+ if entry.expires_at < time.monotonic():
54
+ del self._entries[repo]
55
+ return None
56
+ return entry.value
57
+
58
+ def put(self, repo: str, profile: RepoProfile) -> None:
59
+ with self._lock:
60
+ expires = (
61
+ math.inf if math.isinf(self._ttl) else time.monotonic() + self._ttl
62
+ )
63
+ self._entries[repo] = _TTLEntry(profile, expires)
64
+
65
+ def invalidate(self, repo: str | None = None) -> None:
66
+ with self._lock:
67
+ if repo is None:
68
+ self._entries.clear()
69
+ else:
70
+ self._entries.pop(repo, None)
71
+
72
+
73
+ class RefCache:
74
+ """Ref-name -> resolved-SHA cache.
75
+
76
+ Keyed by ``(repo, ref)``. SHA-form refs (40-hex) are not cached — they
77
+ don't move and lookups are pointless.
78
+ """
79
+
80
+ def __init__(self, ttl_seconds: float = 60.0) -> None:
81
+ self._ttl = ttl_seconds
82
+ self._entries: dict[tuple[str, str], _TTLEntry[str]] = {}
83
+ self._lock = threading.Lock()
84
+
85
+ @staticmethod
86
+ def is_sha_like(ref: str) -> bool:
87
+ if len(ref) != 40:
88
+ return False
89
+ return all(c in "0123456789abcdef" for c in ref.lower())
90
+
91
+ def get(self, repo: str, ref: str) -> str | None:
92
+ if self.is_sha_like(ref):
93
+ return ref
94
+ key = (repo, ref)
95
+ with self._lock:
96
+ entry = self._entries.get(key)
97
+ if entry is None:
98
+ return None
99
+ if entry.expires_at < time.monotonic():
100
+ del self._entries[key]
101
+ return None
102
+ return entry.value
103
+
104
+ def put(self, repo: str, ref: str, sha: str) -> None:
105
+ if self.is_sha_like(ref):
106
+ return
107
+ with self._lock:
108
+ self._entries[(repo, ref)] = _TTLEntry(
109
+ sha, time.monotonic() + self._ttl
110
+ )
111
+
112
+ def invalidate_repo(self, repo: str) -> None:
113
+ """Drop every cached entry for ``repo`` (called after commit/merge)."""
114
+ with self._lock:
115
+ for k in list(self._entries.keys()):
116
+ if k[0] == repo:
117
+ del self._entries[k]
118
+
119
+ def invalidate_all(self) -> None:
120
+ with self._lock:
121
+ self._entries.clear()
122
+
123
+
124
+ class BlobCache:
125
+ """LRU cache of blob bytes keyed by blob SHA.
126
+
127
+ Bounded by total byte size (not entry count). Eviction is strict-LRU:
128
+ on insert that would exceed capacity, oldest entries are dropped until
129
+ the new entry fits. Disabled (always misses) when ``max_bytes <= 0``.
130
+
131
+ Off by default — turn on for benchmarks or read-heavy agents that
132
+ revisit blobs.
133
+ """
134
+
135
+ def __init__(self, max_bytes: int, enabled: bool = False) -> None:
136
+ self._max_bytes = max(0, max_bytes)
137
+ self._enabled = enabled and self._max_bytes > 0
138
+ self._entries: OrderedDict[str, bytes] = OrderedDict()
139
+ self._size = 0
140
+ self._lock = threading.Lock()
141
+
142
+ @property
143
+ def enabled(self) -> bool:
144
+ return self._enabled
145
+
146
+ @property
147
+ def size_bytes(self) -> int:
148
+ with self._lock:
149
+ return self._size
150
+
151
+ def get(self, sha: str) -> bytes | None:
152
+ if not self._enabled or not sha:
153
+ return None
154
+ with self._lock:
155
+ value = self._entries.get(sha)
156
+ if value is None:
157
+ return None
158
+ self._entries.move_to_end(sha)
159
+ return value
160
+
161
+ def put(self, sha: str, data: bytes) -> None:
162
+ if not self._enabled or not sha:
163
+ return
164
+ n = len(data)
165
+ if n > self._max_bytes:
166
+ # Item is bigger than the entire cache; do not store.
167
+ return
168
+ with self._lock:
169
+ if sha in self._entries:
170
+ old = self._entries.pop(sha)
171
+ self._size -= len(old)
172
+ while self._size + n > self._max_bytes and self._entries:
173
+ _, evicted = self._entries.popitem(last=False)
174
+ self._size -= len(evicted)
175
+ self._entries[sha] = data
176
+ self._size += n
177
+
178
+ def clear(self) -> None:
179
+ with self._lock:
180
+ self._entries.clear()
181
+ self._size = 0