ap-client 0.5.1.dev1__tar.gz → 0.5.1.dev3__tar.gz
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.
- {ap_client-0.5.1.dev1 → ap_client-0.5.1.dev3}/PKG-INFO +3 -3
- {ap_client-0.5.1.dev1 → ap_client-0.5.1.dev3}/ap_client/irepo_sdk.py +574 -2
- {ap_client-0.5.1.dev1 → ap_client-0.5.1.dev3}/pyproject.toml +3 -3
- {ap_client-0.5.1.dev1 → ap_client-0.5.1.dev3}/ap_client/__init__.py +0 -0
- {ap_client-0.5.1.dev1 → ap_client-0.5.1.dev3}/ap_client/akpool_commands.py +0 -0
- {ap_client-0.5.1.dev1 → ap_client-0.5.1.dev3}/ap_client/api.py +0 -0
- {ap_client-0.5.1.dev1 → ap_client-0.5.1.dev3}/ap_client/cli.py +0 -0
- {ap_client-0.5.1.dev1 → ap_client-0.5.1.dev3}/ap_client/config.py +0 -0
- {ap_client-0.5.1.dev1 → ap_client-0.5.1.dev3}/ap_client/dataset_commands.py +0 -0
- {ap_client-0.5.1.dev1 → ap_client-0.5.1.dev3}/ap_client/dataset_register.py +0 -0
- {ap_client-0.5.1.dev1 → ap_client-0.5.1.dev3}/ap_client/exporter.py +0 -0
- {ap_client-0.5.1.dev1 → ap_client-0.5.1.dev3}/ap_client/fs_commands.py +0 -0
- {ap_client-0.5.1.dev1 → ap_client-0.5.1.dev3}/ap_client/instance_commands.py +0 -0
- {ap_client-0.5.1.dev1 → ap_client-0.5.1.dev3}/ap_client/managed_ak.py +0 -0
- {ap_client-0.5.1.dev1 → ap_client-0.5.1.dev3}/ap_client/profile_commands.py +0 -0
- {ap_client-0.5.1.dev1 → ap_client-0.5.1.dev3}/ap_client/split_publish.py +0 -0
- {ap_client-0.5.1.dev1 → ap_client-0.5.1.dev3}/ap_client/tbb.py +0 -0
- {ap_client-0.5.1.dev1 → ap_client-0.5.1.dev3}/ap_client/waiter.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ap-client
|
|
3
|
-
Version: 0.5.1.
|
|
3
|
+
Version: 0.5.1.dev3
|
|
4
4
|
Summary: Agent Platform API Client & CLI
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Requires-Dist: pyyaml>=6.0
|
|
@@ -9,9 +9,9 @@ Requires-Dist: rich>=13.0.0
|
|
|
9
9
|
Requires-Dist: typer>=0.9.0
|
|
10
10
|
Requires-Dist: websockets>=13.0
|
|
11
11
|
Provides-Extra: all
|
|
12
|
-
Requires-Dist: instance-repo[oss]==1.3.2.
|
|
12
|
+
Requires-Dist: instance-repo[oss]==1.3.2.dev3; extra == 'all'
|
|
13
13
|
Provides-Extra: dataset
|
|
14
|
-
Requires-Dist: instance-repo[oss]==1.3.2.
|
|
14
|
+
Requires-Dist: instance-repo[oss]==1.3.2.dev3; extra == 'dataset'
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
16
16
|
|
|
17
17
|
A lightweight Python SDK and command line interface for Agent Platform. It provides helpers for configuring API access and managing templates, datasets, jobs, and groups.
|
|
@@ -29,11 +29,22 @@
|
|
|
29
29
|
|
|
30
30
|
from __future__ import annotations
|
|
31
31
|
|
|
32
|
+
import hashlib
|
|
32
33
|
import inspect
|
|
33
34
|
import json
|
|
35
|
+
import math
|
|
34
36
|
import os
|
|
37
|
+
import platform
|
|
38
|
+
import re
|
|
39
|
+
import stat
|
|
40
|
+
import subprocess
|
|
41
|
+
import tempfile
|
|
42
|
+
import threading
|
|
35
43
|
import time
|
|
36
|
-
from
|
|
44
|
+
from contextlib import contextmanager
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import TYPE_CHECKING, Any, Callable, Iterator, NoReturn
|
|
47
|
+
from urllib.parse import urlsplit
|
|
37
48
|
|
|
38
49
|
import typer
|
|
39
50
|
|
|
@@ -82,6 +93,565 @@ class SdkMissing(Exception):
|
|
|
82
93
|
super().__init__(message)
|
|
83
94
|
|
|
84
95
|
|
|
96
|
+
_OSS_PROBE_CACHE_VERSION = 1
|
|
97
|
+
_OSS_PROBE_CACHE_TTL = 300.0
|
|
98
|
+
_OSS_PROBE_CACHE_MAX_BYTES = 64 * 1024
|
|
99
|
+
_OSS_PROBE_CACHE_MAX_ENTRIES = 128
|
|
100
|
+
_NETWORK_FINGERPRINT_UNSET = object()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _oss_probe_cache_path() -> Path:
|
|
104
|
+
return Path.home() / ".ap" / ".cache" / "oss-probe.json"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _run_network_command(command: tuple[str, ...]) -> bytes | None:
|
|
108
|
+
if not command or not os.path.isabs(command[0]):
|
|
109
|
+
return None
|
|
110
|
+
environment = {
|
|
111
|
+
key: value
|
|
112
|
+
for key, value in os.environ.items()
|
|
113
|
+
if key.upper() in {"LANG", "LC_ALL", "SYSTEMROOT", "WINDIR"}
|
|
114
|
+
}
|
|
115
|
+
environment.update({"LANG": "C", "LC_ALL": "C"})
|
|
116
|
+
try:
|
|
117
|
+
result = subprocess.run(
|
|
118
|
+
command,
|
|
119
|
+
check=False,
|
|
120
|
+
capture_output=True,
|
|
121
|
+
env=environment,
|
|
122
|
+
timeout=1.0,
|
|
123
|
+
)
|
|
124
|
+
except (OSError, subprocess.SubprocessError):
|
|
125
|
+
return None
|
|
126
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
127
|
+
return None
|
|
128
|
+
return result.stdout[:32768]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _read_network_file(path: str) -> bytes | None:
|
|
132
|
+
try:
|
|
133
|
+
with open(path, "rb") as handle:
|
|
134
|
+
value = handle.read(32769)
|
|
135
|
+
except OSError:
|
|
136
|
+
return None
|
|
137
|
+
if not value or len(value) > 32768:
|
|
138
|
+
return None
|
|
139
|
+
return value
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _network_context_parts(system: str) -> list[bytes]:
|
|
143
|
+
if system == "darwin":
|
|
144
|
+
primary = _run_network_command(("/usr/sbin/scutil", "--nwi"))
|
|
145
|
+
if primary is None:
|
|
146
|
+
return []
|
|
147
|
+
route = _run_network_command(("/sbin/route", "-n", "get", "default"))
|
|
148
|
+
return [primary, *([route] if route is not None else [])]
|
|
149
|
+
if system == "linux":
|
|
150
|
+
route = _read_network_file("/proc/net/route")
|
|
151
|
+
if route is None:
|
|
152
|
+
return []
|
|
153
|
+
parts = [route]
|
|
154
|
+
for path in ("/proc/net/ipv6_route", "/etc/resolv.conf"):
|
|
155
|
+
value = _read_network_file(path)
|
|
156
|
+
if value is not None:
|
|
157
|
+
parts.append(value)
|
|
158
|
+
try:
|
|
159
|
+
parts.append(f"netns:{os.stat('/proc/self/ns/net').st_ino}".encode("ascii"))
|
|
160
|
+
except OSError:
|
|
161
|
+
pass
|
|
162
|
+
try:
|
|
163
|
+
interfaces = sorted(Path("/sys/class/net").glob("*/address"))
|
|
164
|
+
except OSError:
|
|
165
|
+
interfaces = []
|
|
166
|
+
for address_path in interfaces:
|
|
167
|
+
value = _read_network_file(str(address_path))
|
|
168
|
+
if value is not None:
|
|
169
|
+
parts.append(address_path.parent.name.encode("utf-8") + b"=" + value)
|
|
170
|
+
return parts
|
|
171
|
+
return []
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _network_fingerprint() -> str | None:
|
|
175
|
+
system = platform.system().strip().lower()
|
|
176
|
+
parts = _network_context_parts(system)
|
|
177
|
+
if not parts:
|
|
178
|
+
return None
|
|
179
|
+
digest = hashlib.sha256()
|
|
180
|
+
digest.update(system.encode("ascii", errors="replace"))
|
|
181
|
+
for part in parts:
|
|
182
|
+
digest.update(b"\0")
|
|
183
|
+
digest.update(part)
|
|
184
|
+
return f"{system}:{digest.hexdigest()}"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _cache_endpoint(endpoint: str) -> str | None:
|
|
188
|
+
value = str(endpoint or "").strip()
|
|
189
|
+
if not value or len(value) > 2048:
|
|
190
|
+
return None
|
|
191
|
+
try:
|
|
192
|
+
parsed = urlsplit(value if "://" in value else "//" + value)
|
|
193
|
+
hostname = parsed.hostname
|
|
194
|
+
port = parsed.port
|
|
195
|
+
except ValueError:
|
|
196
|
+
return None
|
|
197
|
+
if not hostname:
|
|
198
|
+
return None
|
|
199
|
+
host = hostname.lower()
|
|
200
|
+
if ":" in host:
|
|
201
|
+
host = f"[{host}]"
|
|
202
|
+
scheme = parsed.scheme.lower()
|
|
203
|
+
authority = f"{host}:{port}" if port is not None else host
|
|
204
|
+
return f"{scheme}://{authority}" if scheme else authority
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _validate_oss_probe_cache(value: Any) -> list[dict[str, Any]]:
|
|
208
|
+
if (
|
|
209
|
+
not isinstance(value, dict)
|
|
210
|
+
or set(value) != {"version", "entries"}
|
|
211
|
+
or type(value.get("version")) is not int
|
|
212
|
+
or value["version"] != _OSS_PROBE_CACHE_VERSION
|
|
213
|
+
):
|
|
214
|
+
raise ValueError("invalid OSS probe cache version")
|
|
215
|
+
entries = value.get("entries")
|
|
216
|
+
if not isinstance(entries, list) or len(entries) > _OSS_PROBE_CACHE_MAX_ENTRIES:
|
|
217
|
+
raise ValueError("invalid OSS probe cache entries")
|
|
218
|
+
validated = []
|
|
219
|
+
required = {"endpoint", "network", "reachable", "timestamp"}
|
|
220
|
+
for entry in entries:
|
|
221
|
+
if not isinstance(entry, dict) or set(entry) != required:
|
|
222
|
+
raise ValueError("invalid OSS probe cache entry")
|
|
223
|
+
endpoint = entry["endpoint"]
|
|
224
|
+
network = entry["network"]
|
|
225
|
+
reachable = entry["reachable"]
|
|
226
|
+
timestamp = entry["timestamp"]
|
|
227
|
+
if (
|
|
228
|
+
not isinstance(endpoint, str)
|
|
229
|
+
or not endpoint
|
|
230
|
+
or len(endpoint) > 2048
|
|
231
|
+
or _cache_endpoint(endpoint) != endpoint
|
|
232
|
+
):
|
|
233
|
+
raise ValueError("invalid OSS probe cache endpoint")
|
|
234
|
+
if (
|
|
235
|
+
not isinstance(network, str)
|
|
236
|
+
or len(network) > 128
|
|
237
|
+
or re.fullmatch(r"[a-z0-9_-]+:[0-9a-f]{64}", network) is None
|
|
238
|
+
):
|
|
239
|
+
raise ValueError("invalid OSS probe cache network")
|
|
240
|
+
if not isinstance(reachable, bool):
|
|
241
|
+
raise ValueError("invalid OSS probe cache result")
|
|
242
|
+
if (
|
|
243
|
+
isinstance(timestamp, bool)
|
|
244
|
+
or not isinstance(timestamp, (int, float))
|
|
245
|
+
or not math.isfinite(timestamp)
|
|
246
|
+
or timestamp < 0
|
|
247
|
+
):
|
|
248
|
+
raise ValueError("invalid OSS probe cache timestamp")
|
|
249
|
+
validated.append(entry)
|
|
250
|
+
return validated
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _private_path_stat(path: Path, *, directory: bool) -> os.stat_result:
|
|
254
|
+
path_stat = path.lstat()
|
|
255
|
+
expected = stat.S_ISDIR if directory else stat.S_ISREG
|
|
256
|
+
if not expected(path_stat.st_mode):
|
|
257
|
+
raise PermissionError(f"unsafe OSS probe cache path: {path}")
|
|
258
|
+
getuid = getattr(os, "getuid", None)
|
|
259
|
+
if callable(getuid) and path_stat.st_uid != getuid():
|
|
260
|
+
raise PermissionError(f"OSS probe cache is not owned by current user: {path}")
|
|
261
|
+
if stat.S_IMODE(path_stat.st_mode) & 0o077:
|
|
262
|
+
raise PermissionError(f"OSS probe cache permissions are too broad: {path}")
|
|
263
|
+
return path_stat
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _prepare_oss_probe_cache_dir(path: Path) -> None:
|
|
267
|
+
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
268
|
+
path_stat = path.lstat()
|
|
269
|
+
if not stat.S_ISDIR(path_stat.st_mode):
|
|
270
|
+
raise PermissionError(f"unsafe OSS probe cache directory: {path}")
|
|
271
|
+
getuid = getattr(os, "getuid", None)
|
|
272
|
+
if callable(getuid) and path_stat.st_uid != getuid():
|
|
273
|
+
raise PermissionError(f"OSS probe cache directory is not owned by current user: {path}")
|
|
274
|
+
os.chmod(path, 0o700)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@contextmanager
|
|
278
|
+
def _oss_probe_cache_lock(
|
|
279
|
+
path: Path,
|
|
280
|
+
*,
|
|
281
|
+
name: str = ".oss-probe.lock",
|
|
282
|
+
timeout: float = 1.0,
|
|
283
|
+
) -> Iterator[bool]:
|
|
284
|
+
try:
|
|
285
|
+
import fcntl
|
|
286
|
+
|
|
287
|
+
_prepare_oss_probe_cache_dir(path.parent)
|
|
288
|
+
flags = os.O_CREAT | os.O_RDWR
|
|
289
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
290
|
+
flags |= os.O_NOFOLLOW
|
|
291
|
+
fd = os.open(path.parent / name, flags, 0o600)
|
|
292
|
+
lock_stat = os.fstat(fd)
|
|
293
|
+
getuid = getattr(os, "getuid", None)
|
|
294
|
+
if not stat.S_ISREG(lock_stat.st_mode) or (
|
|
295
|
+
callable(getuid) and lock_stat.st_uid != getuid()
|
|
296
|
+
):
|
|
297
|
+
raise PermissionError("unsafe OSS probe cache lock")
|
|
298
|
+
os.fchmod(fd, 0o600)
|
|
299
|
+
deadline = time.monotonic() + timeout
|
|
300
|
+
while True:
|
|
301
|
+
try:
|
|
302
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
303
|
+
break
|
|
304
|
+
except BlockingIOError:
|
|
305
|
+
if time.monotonic() >= deadline:
|
|
306
|
+
raise TimeoutError("timed out waiting for OSS probe cache lock")
|
|
307
|
+
time.sleep(0.01)
|
|
308
|
+
except Exception:
|
|
309
|
+
if "fd" in locals():
|
|
310
|
+
os.close(fd)
|
|
311
|
+
yield False
|
|
312
|
+
return
|
|
313
|
+
try:
|
|
314
|
+
yield True
|
|
315
|
+
finally:
|
|
316
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
317
|
+
os.close(fd)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _oss_probe_endpoint_lock_name(endpoint: str) -> str:
|
|
321
|
+
digest = hashlib.sha256(endpoint.encode("utf-8")).hexdigest()[:24]
|
|
322
|
+
return f".oss-probe-{digest}.lock"
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _read_oss_probe_cache(path: Path) -> list[dict[str, Any]]:
|
|
326
|
+
file_stat = _private_path_stat(path, directory=False)
|
|
327
|
+
if file_stat.st_size > _OSS_PROBE_CACHE_MAX_BYTES:
|
|
328
|
+
raise ValueError("invalid OSS probe cache file")
|
|
329
|
+
flags = os.O_RDONLY
|
|
330
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
331
|
+
flags |= os.O_NOFOLLOW
|
|
332
|
+
fd = os.open(path, flags)
|
|
333
|
+
try:
|
|
334
|
+
rendered = os.read(fd, _OSS_PROBE_CACHE_MAX_BYTES + 1)
|
|
335
|
+
finally:
|
|
336
|
+
os.close(fd)
|
|
337
|
+
if len(rendered) > _OSS_PROBE_CACHE_MAX_BYTES:
|
|
338
|
+
raise ValueError("invalid OSS probe cache file")
|
|
339
|
+
return _validate_oss_probe_cache(json.loads(rendered.decode("utf-8")))
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _write_oss_probe_cache(path: Path, entries: list[dict[str, Any]]) -> None:
|
|
343
|
+
cache_dir = path.parent
|
|
344
|
+
_prepare_oss_probe_cache_dir(cache_dir)
|
|
345
|
+
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=cache_dir)
|
|
346
|
+
temporary_path = Path(temporary)
|
|
347
|
+
try:
|
|
348
|
+
os.fchmod(fd, 0o600)
|
|
349
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
350
|
+
fd = -1
|
|
351
|
+
json.dump(
|
|
352
|
+
{"version": _OSS_PROBE_CACHE_VERSION, "entries": entries},
|
|
353
|
+
handle,
|
|
354
|
+
separators=(",", ":"),
|
|
355
|
+
sort_keys=True,
|
|
356
|
+
)
|
|
357
|
+
handle.flush()
|
|
358
|
+
os.fsync(handle.fileno())
|
|
359
|
+
os.replace(temporary_path, path)
|
|
360
|
+
os.chmod(path, 0o600)
|
|
361
|
+
finally:
|
|
362
|
+
if fd >= 0:
|
|
363
|
+
os.close(fd)
|
|
364
|
+
try:
|
|
365
|
+
temporary_path.unlink()
|
|
366
|
+
except FileNotFoundError:
|
|
367
|
+
pass
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _probe_cache_log(verbose: bool, status: str, endpoint: str) -> None:
|
|
371
|
+
if verbose:
|
|
372
|
+
typer.echo(f"OSS probe cache {status}: {endpoint}", err=True)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _cached_oss_probe(
|
|
376
|
+
endpoint: str,
|
|
377
|
+
probe: Any,
|
|
378
|
+
*,
|
|
379
|
+
verbose: bool,
|
|
380
|
+
fingerprints: dict[str, str],
|
|
381
|
+
state_lock: Any,
|
|
382
|
+
fingerprint: Any = _NETWORK_FINGERPRINT_UNSET,
|
|
383
|
+
checked_at: Callable[[float], None] | None = None,
|
|
384
|
+
) -> bool:
|
|
385
|
+
def run_probe() -> bool:
|
|
386
|
+
reachable = bool(probe(endpoint))
|
|
387
|
+
if checked_at is not None:
|
|
388
|
+
checked_at(time.time())
|
|
389
|
+
return reachable
|
|
390
|
+
|
|
391
|
+
cache_endpoint = _cache_endpoint(endpoint)
|
|
392
|
+
if fingerprint is _NETWORK_FINGERPRINT_UNSET:
|
|
393
|
+
fingerprint = _network_fingerprint()
|
|
394
|
+
if cache_endpoint is None or fingerprint is None:
|
|
395
|
+
status = "bypass (network context unavailable)" if fingerprint is None else "bypass"
|
|
396
|
+
_probe_cache_log(verbose, status, cache_endpoint or "invalid endpoint")
|
|
397
|
+
return run_probe()
|
|
398
|
+
|
|
399
|
+
with state_lock:
|
|
400
|
+
fingerprints[cache_endpoint] = fingerprint
|
|
401
|
+
path = _oss_probe_cache_path()
|
|
402
|
+
endpoint_lock_name = _oss_probe_endpoint_lock_name(cache_endpoint)
|
|
403
|
+
with _oss_probe_cache_lock(path, name=endpoint_lock_name, timeout=4.0) as endpoint_locked:
|
|
404
|
+
if not endpoint_locked:
|
|
405
|
+
_probe_cache_log(verbose, "bypass (cache busy)", cache_endpoint)
|
|
406
|
+
return run_probe()
|
|
407
|
+
|
|
408
|
+
now = time.time()
|
|
409
|
+
status = "miss"
|
|
410
|
+
with _oss_probe_cache_lock(path) as read_locked:
|
|
411
|
+
if not read_locked:
|
|
412
|
+
_probe_cache_log(verbose, "bypass (cache unavailable)", cache_endpoint)
|
|
413
|
+
return run_probe()
|
|
414
|
+
try:
|
|
415
|
+
entries = _read_oss_probe_cache(path)
|
|
416
|
+
except FileNotFoundError:
|
|
417
|
+
entries = []
|
|
418
|
+
except Exception:
|
|
419
|
+
entries = []
|
|
420
|
+
status = "invalid"
|
|
421
|
+
|
|
422
|
+
for entry in entries:
|
|
423
|
+
if entry["endpoint"] != cache_endpoint or entry["network"] != fingerprint:
|
|
424
|
+
continue
|
|
425
|
+
age = now - entry["timestamp"]
|
|
426
|
+
if 0 <= age < _OSS_PROBE_CACHE_TTL:
|
|
427
|
+
_probe_cache_log(verbose, "hit", cache_endpoint)
|
|
428
|
+
if checked_at is not None:
|
|
429
|
+
checked_at(entry["timestamp"])
|
|
430
|
+
return entry["reachable"]
|
|
431
|
+
status = "stale"
|
|
432
|
+
break
|
|
433
|
+
|
|
434
|
+
_probe_cache_log(verbose, status, cache_endpoint)
|
|
435
|
+
reachable = run_probe()
|
|
436
|
+
now = time.time()
|
|
437
|
+
with _oss_probe_cache_lock(path) as write_locked:
|
|
438
|
+
if not write_locked:
|
|
439
|
+
_probe_cache_log(verbose, "write skipped (cache busy)", cache_endpoint)
|
|
440
|
+
return reachable
|
|
441
|
+
try:
|
|
442
|
+
latest_entries = _read_oss_probe_cache(path)
|
|
443
|
+
except Exception:
|
|
444
|
+
latest_entries = []
|
|
445
|
+
fresh_entries = [
|
|
446
|
+
entry
|
|
447
|
+
for entry in latest_entries
|
|
448
|
+
if 0 <= now - entry["timestamp"] < _OSS_PROBE_CACHE_TTL
|
|
449
|
+
and not (entry["endpoint"] == cache_endpoint and entry["network"] == fingerprint)
|
|
450
|
+
]
|
|
451
|
+
fresh_entries.append(
|
|
452
|
+
{
|
|
453
|
+
"endpoint": cache_endpoint,
|
|
454
|
+
"network": fingerprint,
|
|
455
|
+
"reachable": reachable,
|
|
456
|
+
"timestamp": now,
|
|
457
|
+
}
|
|
458
|
+
)
|
|
459
|
+
try:
|
|
460
|
+
_write_oss_probe_cache(path, fresh_entries[-_OSS_PROBE_CACHE_MAX_ENTRIES:])
|
|
461
|
+
except Exception:
|
|
462
|
+
_probe_cache_log(verbose, "write failed", cache_endpoint)
|
|
463
|
+
return reachable
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _invalidate_oss_probe_cache(
|
|
467
|
+
endpoint: str,
|
|
468
|
+
fingerprint: str | None,
|
|
469
|
+
*,
|
|
470
|
+
verbose: bool,
|
|
471
|
+
) -> None:
|
|
472
|
+
cache_endpoint = _cache_endpoint(endpoint)
|
|
473
|
+
if cache_endpoint is None:
|
|
474
|
+
return
|
|
475
|
+
path = _oss_probe_cache_path()
|
|
476
|
+
endpoint_lock_name = _oss_probe_endpoint_lock_name(cache_endpoint)
|
|
477
|
+
with _oss_probe_cache_lock(path, name=endpoint_lock_name, timeout=4.0) as endpoint_locked:
|
|
478
|
+
if not endpoint_locked:
|
|
479
|
+
return
|
|
480
|
+
with _oss_probe_cache_lock(path) as locked:
|
|
481
|
+
if not locked:
|
|
482
|
+
return
|
|
483
|
+
try:
|
|
484
|
+
entries = _read_oss_probe_cache(path)
|
|
485
|
+
except Exception:
|
|
486
|
+
return
|
|
487
|
+
remaining = [
|
|
488
|
+
entry
|
|
489
|
+
for entry in entries
|
|
490
|
+
if not (
|
|
491
|
+
entry["endpoint"] == cache_endpoint
|
|
492
|
+
and (fingerprint is None or entry["network"] == fingerprint)
|
|
493
|
+
)
|
|
494
|
+
]
|
|
495
|
+
if len(remaining) == len(entries):
|
|
496
|
+
return
|
|
497
|
+
try:
|
|
498
|
+
_write_oss_probe_cache(path, remaining)
|
|
499
|
+
except Exception:
|
|
500
|
+
return
|
|
501
|
+
_probe_cache_log(verbose, "invalidated after connection error", cache_endpoint)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _sdk_reachability_classifier() -> Callable[[BaseException], bool] | None:
|
|
505
|
+
try:
|
|
506
|
+
from instance_repo._routing import _is_reachability_error
|
|
507
|
+
except (ImportError, AttributeError):
|
|
508
|
+
return None
|
|
509
|
+
return _is_reachability_error
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _install_connection_error_invalidation(
|
|
513
|
+
content: Any,
|
|
514
|
+
fingerprints: dict[str, str],
|
|
515
|
+
*,
|
|
516
|
+
verbose: bool,
|
|
517
|
+
classifier: Callable[[BaseException], bool],
|
|
518
|
+
state_lock: Any,
|
|
519
|
+
) -> bool:
|
|
520
|
+
open_bucket = getattr(content, "_open_bucket", None)
|
|
521
|
+
execute = getattr(content, "_do", None)
|
|
522
|
+
if not callable(open_bucket) or not callable(execute):
|
|
523
|
+
return False
|
|
524
|
+
|
|
525
|
+
active = threading.local()
|
|
526
|
+
|
|
527
|
+
def tracked_open_bucket(*args: Any, **kwargs: Any) -> Any:
|
|
528
|
+
endpoint = kwargs.get("endpoint")
|
|
529
|
+
if endpoint is None and len(args) >= 3:
|
|
530
|
+
endpoint = args[2]
|
|
531
|
+
active.endpoint = endpoint if isinstance(endpoint, str) else None
|
|
532
|
+
try:
|
|
533
|
+
return open_bucket(*args, **kwargs)
|
|
534
|
+
except Exception:
|
|
535
|
+
active.endpoint = None
|
|
536
|
+
raise
|
|
537
|
+
|
|
538
|
+
def tracked_execute(*args: Any, **kwargs: Any) -> Any:
|
|
539
|
+
try:
|
|
540
|
+
return execute(*args, **kwargs)
|
|
541
|
+
except Exception as exc:
|
|
542
|
+
endpoint = getattr(active, "endpoint", None)
|
|
543
|
+
if endpoint and classifier(exc):
|
|
544
|
+
cache_endpoint = _cache_endpoint(endpoint)
|
|
545
|
+
with state_lock:
|
|
546
|
+
fingerprint = fingerprints.get(cache_endpoint or "")
|
|
547
|
+
probe_cache = getattr(content, "_probe_cache", None)
|
|
548
|
+
if isinstance(probe_cache, dict):
|
|
549
|
+
probe_cache.pop(endpoint, None)
|
|
550
|
+
_invalidate_oss_probe_cache(endpoint, fingerprint, verbose=verbose)
|
|
551
|
+
raise
|
|
552
|
+
finally:
|
|
553
|
+
active.endpoint = None
|
|
554
|
+
|
|
555
|
+
try:
|
|
556
|
+
content._open_bucket = tracked_open_bucket
|
|
557
|
+
content._do = tracked_execute
|
|
558
|
+
except (AttributeError, TypeError):
|
|
559
|
+
try:
|
|
560
|
+
content._open_bucket = open_bucket
|
|
561
|
+
content._do = execute
|
|
562
|
+
except (AttributeError, TypeError):
|
|
563
|
+
pass
|
|
564
|
+
return False
|
|
565
|
+
return True
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _install_oss_probe_cache(repo: Any, *, verbose: bool) -> bool:
|
|
569
|
+
content = getattr(repo, "_content", None)
|
|
570
|
+
probe = getattr(content, "_probe_private_endpoint", None)
|
|
571
|
+
sdk_cached_probe = getattr(content, "_probe_cached", None)
|
|
572
|
+
probe_cache = getattr(content, "_probe_cache", None)
|
|
573
|
+
open_bucket = getattr(content, "_open_bucket", None)
|
|
574
|
+
execute = getattr(content, "_do", None)
|
|
575
|
+
classifier = _sdk_reachability_classifier()
|
|
576
|
+
if (
|
|
577
|
+
getattr(content, "_ap_probe_cache_installed", False)
|
|
578
|
+
or not callable(probe)
|
|
579
|
+
or not callable(sdk_cached_probe)
|
|
580
|
+
or not isinstance(probe_cache, dict)
|
|
581
|
+
or not callable(open_bucket)
|
|
582
|
+
or not callable(execute)
|
|
583
|
+
or classifier is None
|
|
584
|
+
):
|
|
585
|
+
return False
|
|
586
|
+
|
|
587
|
+
fingerprints: dict[str, str] = {}
|
|
588
|
+
fingerprint_context = threading.local()
|
|
589
|
+
state_lock = threading.RLock()
|
|
590
|
+
|
|
591
|
+
def cached_probe(endpoint: str) -> bool:
|
|
592
|
+
fingerprint = getattr(fingerprint_context, "value", _NETWORK_FINGERPRINT_UNSET)
|
|
593
|
+
return _cached_oss_probe(
|
|
594
|
+
endpoint,
|
|
595
|
+
probe,
|
|
596
|
+
verbose=verbose,
|
|
597
|
+
fingerprints=fingerprints,
|
|
598
|
+
state_lock=state_lock,
|
|
599
|
+
fingerprint=fingerprint,
|
|
600
|
+
checked_at=lambda timestamp: setattr(fingerprint_context, "checked_at", timestamp),
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
memory_networks: dict[str, str] = {}
|
|
604
|
+
|
|
605
|
+
def network_aware_cached_probe(endpoint: str) -> bool:
|
|
606
|
+
with state_lock:
|
|
607
|
+
fingerprint = _network_fingerprint()
|
|
608
|
+
if fingerprint is not None and memory_networks.get(endpoint) != fingerprint:
|
|
609
|
+
probe_cache.pop(endpoint, None)
|
|
610
|
+
if fingerprint is not None:
|
|
611
|
+
memory_networks[endpoint] = fingerprint
|
|
612
|
+
fingerprint_context.value = fingerprint
|
|
613
|
+
fingerprint_context.checked_at = None
|
|
614
|
+
try:
|
|
615
|
+
reachable = bool(sdk_cached_probe(endpoint))
|
|
616
|
+
# SDK timestamps newly acquired results. A disk-cache hit must
|
|
617
|
+
# retain its original age instead of receiving another 300s.
|
|
618
|
+
if fingerprint_context.checked_at is not None and endpoint in probe_cache:
|
|
619
|
+
probe_cache[endpoint] = (reachable, fingerprint_context.checked_at)
|
|
620
|
+
return reachable
|
|
621
|
+
finally:
|
|
622
|
+
del fingerprint_context.value
|
|
623
|
+
del fingerprint_context.checked_at
|
|
624
|
+
|
|
625
|
+
try:
|
|
626
|
+
content._probe_private_endpoint = cached_probe
|
|
627
|
+
content._probe_cached = network_aware_cached_probe
|
|
628
|
+
content._ap_probe_cache_installed = True
|
|
629
|
+
except (AttributeError, TypeError):
|
|
630
|
+
try:
|
|
631
|
+
content._probe_private_endpoint = probe
|
|
632
|
+
content._probe_cached = sdk_cached_probe
|
|
633
|
+
if hasattr(content, "_ap_probe_cache_installed"):
|
|
634
|
+
del content._ap_probe_cache_installed
|
|
635
|
+
except (AttributeError, TypeError):
|
|
636
|
+
pass
|
|
637
|
+
return False
|
|
638
|
+
if not _install_connection_error_invalidation(
|
|
639
|
+
content,
|
|
640
|
+
fingerprints,
|
|
641
|
+
verbose=verbose,
|
|
642
|
+
classifier=classifier,
|
|
643
|
+
state_lock=state_lock,
|
|
644
|
+
):
|
|
645
|
+
try:
|
|
646
|
+
content._probe_private_endpoint = probe
|
|
647
|
+
content._probe_cached = sdk_cached_probe
|
|
648
|
+
del content._ap_probe_cache_installed
|
|
649
|
+
except (AttributeError, TypeError):
|
|
650
|
+
pass
|
|
651
|
+
return False
|
|
652
|
+
return True
|
|
653
|
+
|
|
654
|
+
|
|
85
655
|
# ---------------------------------------------------------------------------
|
|
86
656
|
# Repo 构造
|
|
87
657
|
# ---------------------------------------------------------------------------
|
|
@@ -284,7 +854,9 @@ def load_repo(
|
|
|
284
854
|
_setdefault_env(_LEGACY_TOKEN_VAR, cfg.token_key or "")
|
|
285
855
|
_setdefault_env(_LEGACY_STORAGE_ENV_VAR, effective_storage_env)
|
|
286
856
|
|
|
287
|
-
|
|
857
|
+
repo = _Repo(**kwargs)
|
|
858
|
+
_install_oss_probe_cache(repo, verbose=cfg.verbose)
|
|
859
|
+
return repo
|
|
288
860
|
|
|
289
861
|
|
|
290
862
|
def _init_parameters(repo_cls: type) -> frozenset[str]:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ap-client"
|
|
7
|
-
version = "0.5.1.
|
|
7
|
+
version = "0.5.1.dev3"
|
|
8
8
|
description = "Agent Platform API Client & CLI"
|
|
9
9
|
readme = { text = "A lightweight Python SDK and command line interface for Agent Platform. It provides helpers for configuring API access and managing templates, datasets, jobs, and groups.", content-type = "text/markdown" }
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -18,10 +18,10 @@ dependencies = [
|
|
|
18
18
|
|
|
19
19
|
[project.optional-dependencies]
|
|
20
20
|
dataset = [
|
|
21
|
-
"instance-repo[oss]==1.3.2.
|
|
21
|
+
"instance-repo[oss]==1.3.2.dev3",
|
|
22
22
|
]
|
|
23
23
|
all = [
|
|
24
|
-
"instance-repo[oss]==1.3.2.
|
|
24
|
+
"instance-repo[oss]==1.3.2.dev3",
|
|
25
25
|
]
|
|
26
26
|
|
|
27
27
|
[project.scripts]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|