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 +82 -0
- gfa/_transport.py +383 -0
- gfa/_version.py +8 -0
- gfa/async_client.py +31 -0
- gfa/cache.py +181 -0
- gfa/client.py +535 -0
- gfa/defaults.py +97 -0
- gfa/errors.py +219 -0
- gfa/hints.py +102 -0
- gfa/mint_token.py +107 -0
- gfa/models.py +262 -0
- gfa/partial_clone.py +214 -0
- gfa/routing.py +111 -0
- gfa/workspace.py +198 -0
- gfa_sdk-0.1.0.dist-info/METADATA +159 -0
- gfa_sdk-0.1.0.dist-info/RECORD +18 -0
- gfa_sdk-0.1.0.dist-info/WHEEL +5 -0
- gfa_sdk-0.1.0.dist-info/top_level.txt +1 -0
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
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
|