kestrel-data-sdk 0.3.2__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.
- kestrel_data_sdk/__init__.py +5 -0
- kestrel_data_sdk/cache.py +318 -0
- kestrel_data_sdk/certs/kestrel-root-ca.crt +32 -0
- kestrel_data_sdk/client.py +283 -0
- kestrel_data_sdk/endpoint.py +66 -0
- kestrel_data_sdk-0.3.2.dist-info/METADATA +52 -0
- kestrel_data_sdk-0.3.2.dist-info/RECORD +9 -0
- kestrel_data_sdk-0.3.2.dist-info/WHEEL +5 -0
- kestrel_data_sdk-0.3.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import sqlite3
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Iterator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class CacheInfo:
|
|
14
|
+
path: Path
|
|
15
|
+
files: int
|
|
16
|
+
partitions: int
|
|
17
|
+
total_bytes: int
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class CacheCheckResult:
|
|
22
|
+
path: Path
|
|
23
|
+
checked: int
|
|
24
|
+
valid: int
|
|
25
|
+
missing: int
|
|
26
|
+
changed: int
|
|
27
|
+
corrupt: int
|
|
28
|
+
invalidated: int
|
|
29
|
+
deep: bool
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class CachedFile:
|
|
34
|
+
product: str
|
|
35
|
+
partition_key: str
|
|
36
|
+
version: int
|
|
37
|
+
relative_path: str
|
|
38
|
+
path: Path
|
|
39
|
+
file_size: int
|
|
40
|
+
sha256: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LocalCache:
|
|
44
|
+
"""Per-output-root SQLite index for immutable SDK release files."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, output: str | Path):
|
|
47
|
+
self.root = Path(output).expanduser().resolve()
|
|
48
|
+
metadata = self.root / ".kestrel"
|
|
49
|
+
metadata.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
self.path = metadata / "sdk-cache.sqlite3"
|
|
51
|
+
self._initialize()
|
|
52
|
+
|
|
53
|
+
def completed_partition(self, product: str, partition_key: str,
|
|
54
|
+
version: int) -> list[CachedFile] | None:
|
|
55
|
+
with self._connect() as db:
|
|
56
|
+
marker = db.execute("""
|
|
57
|
+
SELECT file_count FROM partition_cache
|
|
58
|
+
WHERE product_id=? AND partition_key=? AND version=?
|
|
59
|
+
""", (product, partition_key, version)).fetchone()
|
|
60
|
+
if marker is None:
|
|
61
|
+
return None
|
|
62
|
+
expected_files = int(marker[0])
|
|
63
|
+
rows = self._rows(db, product, partition_key, version)
|
|
64
|
+
if len(rows) != expected_files:
|
|
65
|
+
self._invalidate_partition(db, product, partition_key, version)
|
|
66
|
+
return None
|
|
67
|
+
return [self._trusted_file(row) for row in rows]
|
|
68
|
+
|
|
69
|
+
def cached_file(self, product: str, partition_key: str, version: int,
|
|
70
|
+
relative_path: str) -> CachedFile | None:
|
|
71
|
+
with self._connect() as db:
|
|
72
|
+
row = db.execute("""
|
|
73
|
+
SELECT relative_path,local_path,file_size,sha256,mtime_ns,product_id,partition_key,version
|
|
74
|
+
FROM file_cache WHERE product_id=? AND partition_key=? AND version=? AND relative_path=?
|
|
75
|
+
""", (product, partition_key, version, relative_path)).fetchone()
|
|
76
|
+
if row is None:
|
|
77
|
+
return None
|
|
78
|
+
return self._trusted_file(row)
|
|
79
|
+
|
|
80
|
+
def record(self, product: str, partition_key: str, version: int,
|
|
81
|
+
relative_path: str, target: Path, file_size: int, sha256: str) -> None:
|
|
82
|
+
stat = target.stat()
|
|
83
|
+
local_path = target.resolve().relative_to(self.root).as_posix()
|
|
84
|
+
now = _now()
|
|
85
|
+
with self._connect() as db:
|
|
86
|
+
db.execute("""
|
|
87
|
+
INSERT INTO file_cache(product_id,partition_key,version,relative_path,local_path,
|
|
88
|
+
file_size,sha256,mtime_ns,verified_at)
|
|
89
|
+
VALUES(?,?,?,?,?,?,?,?,?)
|
|
90
|
+
ON CONFLICT(product_id,partition_key,version,relative_path) DO UPDATE SET
|
|
91
|
+
local_path=excluded.local_path,file_size=excluded.file_size,sha256=excluded.sha256,
|
|
92
|
+
mtime_ns=excluded.mtime_ns,verified_at=excluded.verified_at
|
|
93
|
+
""", (product, partition_key, version, relative_path, local_path, file_size,
|
|
94
|
+
sha256, stat.st_mtime_ns, now))
|
|
95
|
+
|
|
96
|
+
def mark_complete(self, product: str, partition_key: str, version: int, file_count: int) -> None:
|
|
97
|
+
with self._connect() as db:
|
|
98
|
+
actual = db.execute("""
|
|
99
|
+
SELECT count(*) FROM file_cache
|
|
100
|
+
WHERE product_id=? AND partition_key=? AND version=?
|
|
101
|
+
""", (product, partition_key, version)).fetchone()[0]
|
|
102
|
+
if int(actual) != file_count:
|
|
103
|
+
self._invalidate_partition(db, product, partition_key, version)
|
|
104
|
+
return
|
|
105
|
+
db.execute("""
|
|
106
|
+
INSERT INTO partition_cache(product_id,partition_key,version,file_count,completed_at)
|
|
107
|
+
VALUES(?,?,?,?,?)
|
|
108
|
+
ON CONFLICT(product_id,partition_key,version) DO UPDATE SET
|
|
109
|
+
file_count=excluded.file_count,completed_at=excluded.completed_at
|
|
110
|
+
""", (product, partition_key, version, file_count, _now()))
|
|
111
|
+
db.execute("""
|
|
112
|
+
DELETE FROM partition_cache
|
|
113
|
+
WHERE product_id=? AND partition_key=? AND version<>?
|
|
114
|
+
""", (product, partition_key, version))
|
|
115
|
+
|
|
116
|
+
def retain_files(self, product: str, partition_key: str, version: int,
|
|
117
|
+
relative_paths: list[str]) -> None:
|
|
118
|
+
keep = set(relative_paths)
|
|
119
|
+
with self._connect() as db:
|
|
120
|
+
rows = db.execute("""
|
|
121
|
+
SELECT relative_path,local_path FROM file_cache
|
|
122
|
+
WHERE product_id=? AND partition_key=? AND version=?
|
|
123
|
+
""", (product, partition_key, version)).fetchall()
|
|
124
|
+
for row in rows:
|
|
125
|
+
if str(row[0]) not in keep:
|
|
126
|
+
self._unlink_relative(str(row[1]))
|
|
127
|
+
self._invalidate_file(db, product, partition_key, version, str(row[0]))
|
|
128
|
+
current_paths = {str(row[0]) for row in db.execute("""
|
|
129
|
+
SELECT local_path FROM file_cache
|
|
130
|
+
WHERE product_id=? AND partition_key=? AND version=?
|
|
131
|
+
""", (product, partition_key, version)).fetchall()}
|
|
132
|
+
old_paths = {str(row[0]) for row in db.execute("""
|
|
133
|
+
SELECT local_path FROM file_cache
|
|
134
|
+
WHERE product_id=? AND partition_key=? AND version<>?
|
|
135
|
+
""", (product, partition_key, version)).fetchall()}
|
|
136
|
+
for local_path in old_paths-current_paths:
|
|
137
|
+
self._unlink_relative(local_path)
|
|
138
|
+
db.execute("""
|
|
139
|
+
DELETE FROM file_cache
|
|
140
|
+
WHERE product_id=? AND partition_key=? AND version<>?
|
|
141
|
+
""", (product, partition_key, version))
|
|
142
|
+
|
|
143
|
+
def remove_partition(self, product: str, partition_key: str) -> int:
|
|
144
|
+
"""Apply a server tombstone by deleting only files tracked by this cache."""
|
|
145
|
+
removed = 0
|
|
146
|
+
with self._connect() as db:
|
|
147
|
+
rows = db.execute("""
|
|
148
|
+
SELECT DISTINCT local_path FROM file_cache
|
|
149
|
+
WHERE product_id=? AND partition_key=?
|
|
150
|
+
""", (product, partition_key)).fetchall()
|
|
151
|
+
for row in rows:
|
|
152
|
+
if self._unlink_relative(str(row[0])):
|
|
153
|
+
removed += 1
|
|
154
|
+
db.execute("DELETE FROM file_cache WHERE product_id=? AND partition_key=?",
|
|
155
|
+
(product, partition_key))
|
|
156
|
+
db.execute("DELETE FROM partition_cache WHERE product_id=? AND partition_key=?",
|
|
157
|
+
(product, partition_key))
|
|
158
|
+
return removed
|
|
159
|
+
|
|
160
|
+
def _unlink_relative(self, local_path: str) -> bool:
|
|
161
|
+
target = (self.root / local_path).resolve()
|
|
162
|
+
if not target.is_relative_to(self.root):
|
|
163
|
+
raise ValueError("缓存路径越界")
|
|
164
|
+
try:
|
|
165
|
+
target.unlink()
|
|
166
|
+
return True
|
|
167
|
+
except FileNotFoundError:
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
def info(self, product: str | None = None) -> CacheInfo:
|
|
171
|
+
where = " WHERE product_id=?" if product else ""
|
|
172
|
+
args = (product,) if product else ()
|
|
173
|
+
with self._connect() as db:
|
|
174
|
+
files, total = db.execute(
|
|
175
|
+
"SELECT count(*),COALESCE(sum(file_size),0) FROM file_cache" + where, args).fetchone()
|
|
176
|
+
partitions = db.execute(
|
|
177
|
+
"SELECT count(*) FROM partition_cache" + where, args).fetchone()[0]
|
|
178
|
+
return CacheInfo(self.path, int(files), int(partitions), int(total))
|
|
179
|
+
|
|
180
|
+
def revisions(self, product: str) -> dict[str, int]:
|
|
181
|
+
"""Return the trusted local revision vector without touching product files."""
|
|
182
|
+
with self._connect() as db:
|
|
183
|
+
rows=db.execute("""
|
|
184
|
+
SELECT partition_key,version FROM partition_cache
|
|
185
|
+
WHERE product_id=? ORDER BY partition_key
|
|
186
|
+
""",(product,)).fetchall()
|
|
187
|
+
return {str(row[0]):int(row[1]) for row in rows}
|
|
188
|
+
|
|
189
|
+
def check(self, product: str | None = None, *, deep: bool = False,
|
|
190
|
+
repair: bool = True) -> CacheCheckResult:
|
|
191
|
+
where = " WHERE product_id=?" if product else ""
|
|
192
|
+
args = (product,) if product else ()
|
|
193
|
+
checked = valid = missing = changed = corrupt = invalidated = 0
|
|
194
|
+
with self._connect() as db:
|
|
195
|
+
rows = db.execute("""
|
|
196
|
+
SELECT relative_path,local_path,file_size,sha256,mtime_ns,product_id,partition_key,version
|
|
197
|
+
FROM file_cache
|
|
198
|
+
""" + where + " ORDER BY product_id,partition_key,relative_path", args).fetchall()
|
|
199
|
+
for row in rows:
|
|
200
|
+
checked += 1
|
|
201
|
+
target = self.root / str(row[1])
|
|
202
|
+
reason: str | None = None
|
|
203
|
+
try:
|
|
204
|
+
stat = target.stat()
|
|
205
|
+
if not target.is_file():
|
|
206
|
+
reason = "missing"
|
|
207
|
+
elif stat.st_size != int(row[2]):
|
|
208
|
+
reason = "changed"
|
|
209
|
+
elif deep:
|
|
210
|
+
if _sha256(target) != str(row[3]):
|
|
211
|
+
reason = "corrupt"
|
|
212
|
+
elif stat.st_mtime_ns != int(row[4]):
|
|
213
|
+
db.execute("""
|
|
214
|
+
UPDATE file_cache SET mtime_ns=?,verified_at=?
|
|
215
|
+
WHERE product_id=? AND partition_key=? AND version=? AND relative_path=?
|
|
216
|
+
""", (stat.st_mtime_ns, _now(), row[5], row[6], row[7], row[0]))
|
|
217
|
+
elif stat.st_mtime_ns != int(row[4]):
|
|
218
|
+
reason = "changed"
|
|
219
|
+
except FileNotFoundError:
|
|
220
|
+
reason = "missing"
|
|
221
|
+
if reason is None:
|
|
222
|
+
valid += 1
|
|
223
|
+
continue
|
|
224
|
+
if reason == "missing": missing += 1
|
|
225
|
+
elif reason == "corrupt": corrupt += 1
|
|
226
|
+
else: changed += 1
|
|
227
|
+
if repair:
|
|
228
|
+
self._invalidate_file(db, str(row[5]), str(row[6]), int(row[7]), str(row[0]))
|
|
229
|
+
invalidated += 1
|
|
230
|
+
return CacheCheckResult(self.path, checked, valid, missing, changed, corrupt, invalidated, deep)
|
|
231
|
+
|
|
232
|
+
def _trusted_file(self, row: sqlite3.Row) -> CachedFile:
|
|
233
|
+
"""Return SQLite state without touching the filesystem; check() is explicit reconciliation."""
|
|
234
|
+
return CachedFile(str(row[5]), str(row[6]), int(row[7]), str(row[0]),
|
|
235
|
+
self.root / str(row[1]),
|
|
236
|
+
int(row[2]), str(row[3]))
|
|
237
|
+
|
|
238
|
+
@staticmethod
|
|
239
|
+
def _rows(db: sqlite3.Connection, product: str, partition_key: str,
|
|
240
|
+
version: int) -> list[sqlite3.Row]:
|
|
241
|
+
return db.execute("""
|
|
242
|
+
SELECT relative_path,local_path,file_size,sha256,mtime_ns,product_id,partition_key,version
|
|
243
|
+
FROM file_cache WHERE product_id=? AND partition_key=? AND version=?
|
|
244
|
+
ORDER BY relative_path
|
|
245
|
+
""", (product, partition_key, version)).fetchall()
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def _invalidate_partition(db: sqlite3.Connection, product: str,
|
|
249
|
+
partition_key: str, version: int) -> None:
|
|
250
|
+
db.execute("""
|
|
251
|
+
DELETE FROM partition_cache WHERE product_id=? AND partition_key=? AND version=?
|
|
252
|
+
""", (product, partition_key, version))
|
|
253
|
+
|
|
254
|
+
@classmethod
|
|
255
|
+
def _invalidate_file(cls, db: sqlite3.Connection, product: str, partition_key: str,
|
|
256
|
+
version: int, relative_path: str) -> None:
|
|
257
|
+
db.execute("""
|
|
258
|
+
DELETE FROM file_cache
|
|
259
|
+
WHERE product_id=? AND partition_key=? AND version=? AND relative_path=?
|
|
260
|
+
""", (product, partition_key, version, relative_path))
|
|
261
|
+
cls._invalidate_partition(db, product, partition_key, version)
|
|
262
|
+
|
|
263
|
+
@contextmanager
|
|
264
|
+
def _connect(self) -> Iterator[sqlite3.Connection]:
|
|
265
|
+
db = sqlite3.connect(self.path, timeout=30)
|
|
266
|
+
db.row_factory = sqlite3.Row
|
|
267
|
+
db.execute("PRAGMA foreign_keys=ON")
|
|
268
|
+
db.execute("PRAGMA busy_timeout=30000")
|
|
269
|
+
try:
|
|
270
|
+
yield db
|
|
271
|
+
db.commit()
|
|
272
|
+
except Exception:
|
|
273
|
+
db.rollback()
|
|
274
|
+
raise
|
|
275
|
+
finally:
|
|
276
|
+
db.close()
|
|
277
|
+
|
|
278
|
+
def _initialize(self) -> None:
|
|
279
|
+
with self._connect() as db:
|
|
280
|
+
db.execute("PRAGMA journal_mode=WAL")
|
|
281
|
+
db.execute("""
|
|
282
|
+
CREATE TABLE IF NOT EXISTS file_cache (
|
|
283
|
+
product_id TEXT NOT NULL,
|
|
284
|
+
partition_key TEXT NOT NULL,
|
|
285
|
+
version INTEGER NOT NULL CHECK(version>0),
|
|
286
|
+
relative_path TEXT NOT NULL,
|
|
287
|
+
local_path TEXT NOT NULL,
|
|
288
|
+
file_size INTEGER NOT NULL CHECK(file_size>=0),
|
|
289
|
+
sha256 TEXT NOT NULL CHECK(length(sha256)=64),
|
|
290
|
+
mtime_ns INTEGER NOT NULL,
|
|
291
|
+
verified_at TEXT NOT NULL,
|
|
292
|
+
PRIMARY KEY(product_id,partition_key,version,relative_path)
|
|
293
|
+
)
|
|
294
|
+
""")
|
|
295
|
+
db.execute("""
|
|
296
|
+
CREATE TABLE IF NOT EXISTS partition_cache (
|
|
297
|
+
product_id TEXT NOT NULL,
|
|
298
|
+
partition_key TEXT NOT NULL,
|
|
299
|
+
version INTEGER NOT NULL CHECK(version>0),
|
|
300
|
+
file_count INTEGER NOT NULL CHECK(file_count>=0),
|
|
301
|
+
completed_at TEXT NOT NULL,
|
|
302
|
+
PRIMARY KEY(product_id,partition_key,version)
|
|
303
|
+
)
|
|
304
|
+
""")
|
|
305
|
+
db.execute("CREATE INDEX IF NOT EXISTS idx_sdk_cache_product ON file_cache(product_id)")
|
|
306
|
+
db.execute("PRAGMA user_version=1")
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _sha256(path: Path) -> str:
|
|
310
|
+
digest = hashlib.sha256()
|
|
311
|
+
with path.open("rb") as source:
|
|
312
|
+
while block := source.read(4 * 1024 * 1024):
|
|
313
|
+
digest.update(block)
|
|
314
|
+
return digest.hexdigest()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _now() -> str:
|
|
318
|
+
return datetime.now(timezone.utc).isoformat()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
-----BEGIN CERTIFICATE-----
|
|
2
|
+
MIIFhjCCA26gAwIBAgIUBUFoV1/TVqWxP2z+IYI1AHxR0tAwDQYJKoZIhvcNAQEL
|
|
3
|
+
BQAwSTELMAkGA1UEBhMCQ04xFTATBgNVBAoMDEtlc3RyZWwgRGF0YTEjMCEGA1UE
|
|
4
|
+
AwwaS2VzdHJlbCBQcml2YXRlIFJvb3QgQ0EgVjEwHhcNMjYwODEwMTAzOTU0WhcN
|
|
5
|
+
MzYwODEwMTAzOTU0WjBJMQswCQYDVQQGEwJDTjEVMBMGA1UECgwMS2VzdHJlbCBE
|
|
6
|
+
YXRhMSMwIQYDVQQDDBpLZXN0cmVsIFByaXZhdGUgUm9vdCBDQSBWMTCCAiIwDQYJ
|
|
7
|
+
KoZIhvcNAQEBBQADggIPADCCAgoCggIBAMRyHZUWclK+nUE6i/gkHTynk30tBWjc
|
|
8
|
+
AYXNJ7jp6iA1vlobr2hHNG6gyPLefmXGl+GFLYL4eTA3foOZzuk1flJrlIFM18hH
|
|
9
|
+
tELrSkrTTIRUgfI+n9JR3XKKuJHAN0ZeD5vcgmg8F7ECkHE20/etth8Qw0IulwoP
|
|
10
|
+
g0qOP4qyM+pZtjrF+2Kq9i0AJYzt8oV/xIjYSCikv4FeuijSkxejEQ+9hfwGfyBv
|
|
11
|
+
/NKietIXn29aurzUbY3TzoFz6c9ItHZxiEz3r/cHgW0IRODrracpsppGKhWAVSqN
|
|
12
|
+
nwdw9KlY0ZI6H3sV+rTjDNjDJcdQmyoRH9fTWAYENz4kzx9ohnThgDCnnhgUDdXX
|
|
13
|
+
RgtBEyVy226pmJ6m/sdFM9baHFyddo4+V67XQHAwKGWxnniGdWjeoVpkWgeuF/cd
|
|
14
|
+
jHlFlXQlq2xlXKWPbYDOkiPUbJHXRTRZSxQFTkARdTQZct8qGClLQbN7b6bRZ1tA
|
|
15
|
+
IelfvyOwg0kBpouzYEQyJK+FOja+QK7bfdZhhDcvwRHUiPkSikK7Ik7T+3x7J59s
|
|
16
|
+
fTJysdrHD8c1USiCCK1RjVT85Zp00Oyz29LGHzOjtGC0pXaSQQ+bxY1i0I7mwuD2
|
|
17
|
+
uT5knczW5Do2r73uLbaZHvZ5RwxaN376uTiIGy9/mA+54E041Y49hi3efVxBqCHX
|
|
18
|
+
PcisurOujvKZAgMBAAGjZjBkMB8GA1UdIwQYMBaAFEMu8xhrgmAyJmsu3TkND3I/
|
|
19
|
+
05fnMBIGA1UdEwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQW
|
|
20
|
+
BBRDLvMYa4JgMiZrLt05DQ9yP9OX5zANBgkqhkiG9w0BAQsFAAOCAgEAjqVG659Z
|
|
21
|
+
MJFuIDanwVj17J0REf9wo7dqUJRjQJS8w/p1yLT3r93kzsuAGZ+XjwPWfTqJrPCQ
|
|
22
|
+
rHMUSyrblwXibu3MAEbzIqgyWurWAMQFldcar4366HolC1u4WocVlEO+Ic1Yms1e
|
|
23
|
+
OCNHLngzdkLKZheZHUzBhAxQIQh05oooew0kLRIiyzZx+z5WwhxptLFV8c9WV00t
|
|
24
|
+
VeHoflAmDsNyOftsY1unOjv4PY2Pi8xgTV248ZXspNvjf42NkGYu7pTRKKCd+ryc
|
|
25
|
+
ydzZElB50xNAMf+Ru+PE8otox1GmdO5tQ7DH3/Bi1FzUPirIZ+EFO+t+XOzoIBGM
|
|
26
|
+
MxScCXkoi0EMV1iz+djANQk61M4a77W2lp45e8Z4P6qZMgP/fSvDCt5pcHluTm4U
|
|
27
|
+
R41v4fclEgz3v1KpeVsDREGXFoCfCPy2S/qnmW9rll/FlG1eyEdenVTM0oTXu6uo
|
|
28
|
+
XlR24HqZaJFZmjJVWiDUt4DvNlZRm/0iTSkt6HfFShoCGQPUE5EHeH5Xd6MdoUFu
|
|
29
|
+
pt8fkifjnq23wvb2q9XhVRGxyUYJSAwFq+XRm3uKty0/SUmQC2QIxtW1hsZNybOi
|
|
30
|
+
9zLycutC1nZMJJ4F0QGhyBqaAmut96+7GVO/6Bh5F1E8ULZRr5rtbTXZmJc0HOtt
|
|
31
|
+
avJDshZQdZcY5HWrRSmd/8kkYPpk6Ep5/RI=
|
|
32
|
+
-----END CERTIFICATE-----
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import calendar
|
|
4
|
+
import hashlib
|
|
5
|
+
import http.client
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import socket
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import date
|
|
13
|
+
from pathlib import Path, PurePosixPath
|
|
14
|
+
from urllib.parse import quote, urlencode
|
|
15
|
+
|
|
16
|
+
from .cache import CacheCheckResult, CacheInfo, LocalCache
|
|
17
|
+
from .endpoint import GatewayEndpoint, discover_endpoint, ssl_context
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class DownloadResult:
|
|
21
|
+
path: Path
|
|
22
|
+
file_size: int
|
|
23
|
+
sha256: str
|
|
24
|
+
resumed_from: int
|
|
25
|
+
reused: bool
|
|
26
|
+
|
|
27
|
+
class _Connection(http.client.HTTPSConnection):
|
|
28
|
+
def __init__(self,endpoint: GatewayEndpoint,timeout: float):
|
|
29
|
+
super().__init__(endpoint.logical_host,endpoint.port,timeout=timeout,context=ssl_context())
|
|
30
|
+
self.endpoint_ip=endpoint.ip
|
|
31
|
+
def connect(self) -> None:
|
|
32
|
+
raw=socket.create_connection((self.endpoint_ip,self.port),self.timeout)
|
|
33
|
+
try: self.sock=self._context.wrap_socket(raw,server_hostname=self.host)
|
|
34
|
+
except Exception: raw.close();raise
|
|
35
|
+
|
|
36
|
+
class Kestrel:
|
|
37
|
+
def __init__(self,username: str,password: str,*,timeout: float=120.0,retries: int=5,
|
|
38
|
+
endpoint: GatewayEndpoint|None=None,cache: bool=True):
|
|
39
|
+
if not username or not password: raise ValueError("用户名和密码必填")
|
|
40
|
+
if retries<1: raise ValueError("retries 必须大于等于 1")
|
|
41
|
+
self.username=username;self.password=password;self.timeout=timeout
|
|
42
|
+
self.retries=retries;self.cache_enabled=cache
|
|
43
|
+
self.endpoint=endpoint or discover_endpoint(timeout=min(timeout,8));self.token: str|None=None
|
|
44
|
+
|
|
45
|
+
def login(self) -> dict:
|
|
46
|
+
body=self._json("POST","/api/auth/login",{"username":self.username,"password":self.password},auth=False)
|
|
47
|
+
data=self._data(body)
|
|
48
|
+
if data.get("accountType")!="api": raise PermissionError("该账户不是 API 工具账户")
|
|
49
|
+
self.token=str(data["token"]);return {k:v for k,v in data.items() if k!="token"}
|
|
50
|
+
|
|
51
|
+
def products(self) -> list[dict]: return list(self._data(self._json("GET","/api/sdk/products")))
|
|
52
|
+
|
|
53
|
+
def partitions(self,product: str,*,start: str|date|None=None,end: str|date|None=None) -> list[dict]:
|
|
54
|
+
query={};start=_date_value(start,False);end=_date_value(end,True)
|
|
55
|
+
if start: query["from"]=start
|
|
56
|
+
if end: query["to"]=end
|
|
57
|
+
path=f"/api/sdk/products/{quote(product,safe='')}/partitions"
|
|
58
|
+
if query: path+="?"+urlencode(query)
|
|
59
|
+
return list(self._data(self._json("GET",path)))
|
|
60
|
+
|
|
61
|
+
def files(self,product: str,partition_key: str,version: int,*,plan_id: str|None=None) -> list[dict]:
|
|
62
|
+
args={"partitionKey":partition_key,"version":version}
|
|
63
|
+
if plan_id is not None:args["planId"]=plan_id
|
|
64
|
+
query=urlencode(args)
|
|
65
|
+
return list(self._data(self._json("GET",f"/api/sdk/products/{quote(product,safe='')}/files?{query}")))
|
|
66
|
+
|
|
67
|
+
def sync_plan(self,product: str,*,start: str|date|None=None,end: str|date|None=None,
|
|
68
|
+
local_versions: dict[str,int]|None=None) -> dict:
|
|
69
|
+
payload={"productId":product,"localVersions":local_versions or {}}
|
|
70
|
+
start=_date_value(start,False);end=_date_value(end,True)
|
|
71
|
+
if start: payload["from"]=start
|
|
72
|
+
if end: payload["to"]=end
|
|
73
|
+
return dict(self._data(self._json("POST","/api/sdk/sync-plans",payload)))
|
|
74
|
+
|
|
75
|
+
def download(self,product: str,output: str|Path,*,start: str|date|None=None,
|
|
76
|
+
end: str|date|None=None,force: bool=False,progress: bool=True) -> list[DownloadResult]:
|
|
77
|
+
results=[];local_cache=LocalCache(output) if self.cache_enabled else None
|
|
78
|
+
local_versions={} if force or local_cache is None else local_cache.revisions(product)
|
|
79
|
+
if progress:
|
|
80
|
+
sys.stderr.write(f"[检查更新] {product} 本地已有 {len(local_versions)} 个分区\n");sys.stderr.flush()
|
|
81
|
+
plan=self.sync_plan(product,start=start,end=end,local_versions=local_versions)
|
|
82
|
+
plan_id=str(plan["planId"])
|
|
83
|
+
actions=list(plan.get("actions",[]))
|
|
84
|
+
if progress:
|
|
85
|
+
total=sum(int(x.get("parquetBytes",0)) for x in actions if x.get("action")=="add_replace")
|
|
86
|
+
sys.stderr.write(f"[同步计划] 更新 {sum(x.get('action')=='add_replace' for x in actions)} 个分区"
|
|
87
|
+
f",删除 {sum(x.get('action')=='delete' for x in actions)} 个分区"
|
|
88
|
+
f",预计 {_size(total)}\n");sys.stderr.flush()
|
|
89
|
+
for index,action in enumerate(actions,1):
|
|
90
|
+
partition_key=str(action["partitionKey"]);version=int(action["version"])
|
|
91
|
+
if action["action"]=="delete":
|
|
92
|
+
if local_cache is not None:
|
|
93
|
+
removed=local_cache.remove_partition(product,partition_key)
|
|
94
|
+
if progress: sys.stderr.write(f"[服务端删除] {product} {partition_key} 清理 {removed} 个本地文件\n")
|
|
95
|
+
continue
|
|
96
|
+
if progress:sys.stderr.write(f"[准备分区 {index}/{len(actions)}] {partition_key}\n");sys.stderr.flush()
|
|
97
|
+
plan_files=list(action.get("files",[]))
|
|
98
|
+
results.extend(self.download_partition(product,partition_key,version,output,force=force,
|
|
99
|
+
progress=progress,_cache=local_cache,
|
|
100
|
+
_items=plan_files or None,_plan_id=plan_id))
|
|
101
|
+
return results
|
|
102
|
+
|
|
103
|
+
def download_partition(self,product: str,partition_key: str,version: int,
|
|
104
|
+
output: str|Path,*,force: bool=False,progress: bool=True,
|
|
105
|
+
_cache: LocalCache|None=None,_items: list[dict]|None=None,
|
|
106
|
+
_plan_id: str|None=None) -> list[DownloadResult]:
|
|
107
|
+
output_root=Path(output).expanduser().resolve();root=output_root/product
|
|
108
|
+
local_cache=_cache if _cache is not None else (LocalCache(output_root) if self.cache_enabled else None)
|
|
109
|
+
results=[];items=self.files(product,partition_key,version,plan_id=_plan_id) if _items is None else _items;manifest_paths=[]
|
|
110
|
+
for item in items:
|
|
111
|
+
manifest_path=str(item["path"]);manifest_paths.append(manifest_path)
|
|
112
|
+
relative=_safe_relative(f"{partition_key}/{item['path']}")
|
|
113
|
+
target=root.joinpath(*relative.parts)
|
|
114
|
+
size=int(item["fileSize"]);sha=str(item["sha256"])
|
|
115
|
+
if local_cache is not None and not force:
|
|
116
|
+
cached=local_cache.cached_file(product,partition_key,version,manifest_path)
|
|
117
|
+
if cached is not None and cached.path==target and cached.file_size==size and cached.sha256==sha:
|
|
118
|
+
if progress: _ProgressBar(f"{product} {partition_key}/{item['path']}",size,size).finish(reused=True)
|
|
119
|
+
results.append(DownloadResult(target,size,sha,size,True));continue
|
|
120
|
+
query_args={"partitionKey":partition_key,"version":version,"path":item["path"]}
|
|
121
|
+
if _plan_id is not None:query_args["planId"]=_plan_id
|
|
122
|
+
query=urlencode(query_args)
|
|
123
|
+
url=f"/api/sdk/products/{quote(product,safe='')}/download?{query}"
|
|
124
|
+
label=f"{product} {partition_key}/{item['path']}"
|
|
125
|
+
result=self._download(url,target,size,sha,force=force,progress=progress,label=label)
|
|
126
|
+
results.append(result)
|
|
127
|
+
if local_cache is not None:
|
|
128
|
+
local_cache.record(product,partition_key,version,manifest_path,target,size,sha)
|
|
129
|
+
if local_cache is not None:
|
|
130
|
+
local_cache.retain_files(product,partition_key,version,manifest_paths)
|
|
131
|
+
local_cache.mark_complete(product,partition_key,version,len(items))
|
|
132
|
+
return results
|
|
133
|
+
|
|
134
|
+
def cache_info(self,output: str|Path,*,product: str|None=None) -> CacheInfo:
|
|
135
|
+
return LocalCache(output).info(product)
|
|
136
|
+
|
|
137
|
+
def check_cache(self,output: str|Path,*,product: str|None=None,deep: bool=False,
|
|
138
|
+
repair: bool=True) -> CacheCheckResult:
|
|
139
|
+
"""Validate local cache; deep=True recalculates SHA-256, repair removes invalid entries."""
|
|
140
|
+
return LocalCache(output).check(product,deep=deep,repair=repair)
|
|
141
|
+
|
|
142
|
+
def _download(self,url: str,target: Path,size: int,sha: str,*,force: bool=False,
|
|
143
|
+
progress: bool=False,label: str="") -> DownloadResult:
|
|
144
|
+
target.parent.mkdir(parents=True,exist_ok=True);partial=target.with_name(target.name+".kestrel.part")
|
|
145
|
+
if not force and target.is_file() and target.stat().st_size==size and _sha256(target)==sha:
|
|
146
|
+
if progress: _ProgressBar(label or target.name,size,size).finish(reused=True)
|
|
147
|
+
return DownloadResult(target,size,sha,size,True)
|
|
148
|
+
if force: partial.unlink(missing_ok=True)
|
|
149
|
+
offset=partial.stat().st_size if partial.is_file() else 0
|
|
150
|
+
if offset>size: partial.unlink();offset=0
|
|
151
|
+
resumed=offset;bar=_ProgressBar(label or target.name,size,offset) if progress else None
|
|
152
|
+
if bar: bar.update(offset)
|
|
153
|
+
last_error: Exception|None=None
|
|
154
|
+
for attempt in range(1,self.retries+1):
|
|
155
|
+
offset=partial.stat().st_size if partial.is_file() else 0
|
|
156
|
+
headers=self._headers();headers["Accept"]="application/octet-stream"
|
|
157
|
+
if offset: headers["Range"]=f"bytes={offset}-"
|
|
158
|
+
connection=_Connection(self.endpoint,self.timeout)
|
|
159
|
+
try:
|
|
160
|
+
connection.request("GET",url,headers=headers);response=connection.getresponse()
|
|
161
|
+
if offset and response.status==200:
|
|
162
|
+
offset=0;resumed=0
|
|
163
|
+
if bar: bar=_ProgressBar(label or target.name,size,0);bar.update(0)
|
|
164
|
+
elif offset and response.status!=206:
|
|
165
|
+
error=f"断点续传失败: HTTP {response.status}"
|
|
166
|
+
if response.status in (408,429,500,502,503,504): raise _RetryableDownloadError(error)
|
|
167
|
+
raise _PermanentDownloadError(error)
|
|
168
|
+
elif not offset and response.status not in (200,206):
|
|
169
|
+
error=f"下载失败: HTTP {response.status}"
|
|
170
|
+
if response.status not in (408,429,500,502,503,504): raise _PermanentDownloadError(error)
|
|
171
|
+
raise _RetryableDownloadError(error)
|
|
172
|
+
with partial.open("ab" if offset else "wb") as sink:
|
|
173
|
+
while block:=response.read(1024*1024):
|
|
174
|
+
sink.write(block);offset+=len(block)
|
|
175
|
+
if bar: bar.update(offset)
|
|
176
|
+
sink.flush();os.fsync(sink.fileno())
|
|
177
|
+
actual=partial.stat().st_size
|
|
178
|
+
if actual!=size:
|
|
179
|
+
raise _RetryableDownloadError(f"文件未下载完整: {actual}/{size} B")
|
|
180
|
+
if _sha256(partial)!=sha:
|
|
181
|
+
partial.unlink(missing_ok=True)
|
|
182
|
+
raise _RetryableDownloadError("文件 SHA-256 校验失败,已从头重试")
|
|
183
|
+
last_error=None
|
|
184
|
+
break
|
|
185
|
+
except _PermanentDownloadError:
|
|
186
|
+
raise
|
|
187
|
+
except (OSError,http.client.HTTPException,_RetryableDownloadError) as error:
|
|
188
|
+
last_error=error
|
|
189
|
+
if attempt>=self.retries: break
|
|
190
|
+
if bar: bar.retry(attempt,self.retries,error)
|
|
191
|
+
time.sleep(min(2**(attempt-1),8))
|
|
192
|
+
finally: connection.close()
|
|
193
|
+
if last_error is not None:
|
|
194
|
+
raise RuntimeError(f"下载失败,已断点重试 {self.retries} 次: {target}: {last_error}") from last_error
|
|
195
|
+
os.replace(partial,target)
|
|
196
|
+
if bar: bar.finish()
|
|
197
|
+
return DownloadResult(target,size,sha,resumed,False)
|
|
198
|
+
|
|
199
|
+
def _json(self,method: str,path: str,payload: dict|None=None,*,auth: bool=True) -> object:
|
|
200
|
+
if auth and not self.token: self.login()
|
|
201
|
+
data=None if payload is None else json.dumps(payload,separators=(",",":"),ensure_ascii=False).encode()
|
|
202
|
+
headers=self._headers() if auth else {"Accept":"application/json"}
|
|
203
|
+
if data is not None: headers["Content-Type"]="application/json"
|
|
204
|
+
connection=_Connection(self.endpoint,self.timeout)
|
|
205
|
+
try:
|
|
206
|
+
connection.request(method,path,body=data,headers=headers);response=connection.getresponse()
|
|
207
|
+
raw=response.read(4*1024*1024)
|
|
208
|
+
if response.status==401 and auth:
|
|
209
|
+
self.token=None;connection.close();self.login();return self._json(method,path,payload)
|
|
210
|
+
if response.status not in (200,201): raise RuntimeError(f"API 请求失败: HTTP {response.status}")
|
|
211
|
+
return json.loads(raw.decode("utf-8"))
|
|
212
|
+
finally: connection.close()
|
|
213
|
+
|
|
214
|
+
def _headers(self) -> dict[str,str]:
|
|
215
|
+
return {"Accept":"application/json","Authorization":f"Bearer {self.token}"}
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def _data(body: object):
|
|
219
|
+
if not isinstance(body,dict) or body.get("code")!=0: raise RuntimeError(str(body.get("msg") if isinstance(body,dict) else "API 响应无效"))
|
|
220
|
+
return body.get("data")
|
|
221
|
+
|
|
222
|
+
def _date_value(value: str|date|None,end: bool) -> str|None:
|
|
223
|
+
if value is None: return None
|
|
224
|
+
if isinstance(value,date): return value.isoformat()
|
|
225
|
+
text=str(value).strip().replace("-","")
|
|
226
|
+
if len(text)==6 and text.isdigit():
|
|
227
|
+
year,month=int(text[:4]),int(text[4:]);day=calendar.monthrange(year,month)[1] if end else 1
|
|
228
|
+
return date(year,month,day).isoformat()
|
|
229
|
+
if len(text)==8 and text.isdigit(): return date(int(text[:4]),int(text[4:6]),int(text[6:])).isoformat()
|
|
230
|
+
raise ValueError("日期必须是 YYYYMM、YYYYMMDD 或 date")
|
|
231
|
+
|
|
232
|
+
def _safe_relative(value: str) -> PurePosixPath:
|
|
233
|
+
path=PurePosixPath(value)
|
|
234
|
+
if path.is_absolute() or ".." in path.parts or path.suffix!=".parquet": raise ValueError("服务端文件路径无效")
|
|
235
|
+
return path
|
|
236
|
+
|
|
237
|
+
def _sha256(path: Path) -> str:
|
|
238
|
+
digest=hashlib.sha256()
|
|
239
|
+
with path.open("rb") as source:
|
|
240
|
+
while block:=source.read(4*1024*1024): digest.update(block)
|
|
241
|
+
return digest.hexdigest()
|
|
242
|
+
|
|
243
|
+
class _ProgressBar:
|
|
244
|
+
def __init__(self,label: str,total: int,initial: int=0):
|
|
245
|
+
self.label=label;self.total=max(0,total);self.initial=max(0,initial)
|
|
246
|
+
self.started=time.monotonic();self.last=0.0;self.tty=sys.stderr.isatty()
|
|
247
|
+
def update(self,current: int,*,final: bool=False) -> None:
|
|
248
|
+
now=time.monotonic()
|
|
249
|
+
if not final and now-self.last<0.15:return
|
|
250
|
+
self.last=now;current=min(max(0,current),self.total)
|
|
251
|
+
ratio=current/self.total if self.total else 1.0;filled=min(24,int(ratio*24))
|
|
252
|
+
elapsed=max(.001,now-self.started);speed=max(0,(current-self.initial)/elapsed)
|
|
253
|
+
eta=(self.total-current)/speed if speed>0 else None
|
|
254
|
+
line=(f"[{('='*filled)+(' '*(24-filled))}] {ratio*100:6.2f}% "
|
|
255
|
+
f"{_size(current)} / {_size(self.total)} {_size(speed)}/s ETA {_eta(eta)} {self.label}")
|
|
256
|
+
if self.tty: sys.stderr.write("\r\033[2K"+line)
|
|
257
|
+
elif final: sys.stderr.write(line)
|
|
258
|
+
sys.stderr.flush()
|
|
259
|
+
def finish(self,*,reused: bool=False) -> None:
|
|
260
|
+
if reused:
|
|
261
|
+
line=f"[本地复用] {_size(self.total)} {self.label}"
|
|
262
|
+
if self.tty:sys.stderr.write("\r\033[2K"+line+"\n")
|
|
263
|
+
else:sys.stderr.write(line+"\n")
|
|
264
|
+
sys.stderr.flush();return
|
|
265
|
+
self.update(self.total,final=True);sys.stderr.write("\n");sys.stderr.flush()
|
|
266
|
+
def retry(self,attempt: int,total: int,error: Exception) -> None:
|
|
267
|
+
message=f"\n[连接中断,自动续传 {attempt}/{total}] {self.label}: {error}\n"
|
|
268
|
+
sys.stderr.write(message);sys.stderr.flush()
|
|
269
|
+
|
|
270
|
+
class _RetryableDownloadError(Exception): pass
|
|
271
|
+
class _PermanentDownloadError(Exception): pass
|
|
272
|
+
|
|
273
|
+
def _size(value: float|int) -> str:
|
|
274
|
+
number=float(value)
|
|
275
|
+
for unit in ("B","KiB","MiB","GiB","TiB"):
|
|
276
|
+
if abs(number)<1024 or unit=="TiB":return f"{number:.1f} {unit}"
|
|
277
|
+
number/=1024
|
|
278
|
+
return f"{number:.1f} TiB"
|
|
279
|
+
|
|
280
|
+
def _eta(value: float|None) -> str:
|
|
281
|
+
if value is None:return "--:--"
|
|
282
|
+
seconds=max(0,int(value));hours,seconds=divmod(seconds,3600);minutes,seconds=divmod(seconds,60)
|
|
283
|
+
return f"{hours:02d}:{minutes:02d}:{seconds:02d}" if hours else f"{minutes:02d}:{seconds:02d}"
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import socket
|
|
7
|
+
import ssl
|
|
8
|
+
import urllib.request
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from importlib.resources import files
|
|
11
|
+
|
|
12
|
+
DEFAULT_CLOUD_ENDPOINT = "https://47.116.211.215:15688/ip/endpoint"
|
|
13
|
+
DEFAULT_LOGICAL_HOST = "data.kestrel.internal"
|
|
14
|
+
DEFAULT_GATEWAY_PORT = 15688
|
|
15
|
+
DEFAULT_INTERNAL_GATEWAY_IP = "192.168.100.118"
|
|
16
|
+
DEFAULT_INTERNAL_CIDRS = "192.168.100.0/24"
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class GatewayEndpoint:
|
|
20
|
+
ip: str
|
|
21
|
+
logical_host: str = DEFAULT_LOGICAL_HOST
|
|
22
|
+
port: int = DEFAULT_GATEWAY_PORT
|
|
23
|
+
|
|
24
|
+
def ca_certificate() -> str:
|
|
25
|
+
override=os.environ.get("KESTREL_CA_FILE")
|
|
26
|
+
if override:
|
|
27
|
+
return open(override,"r",encoding="ascii").read()
|
|
28
|
+
return files("kestrel_data_sdk").joinpath("certs/kestrel-root-ca.crt").read_text(encoding="ascii")
|
|
29
|
+
|
|
30
|
+
def ssl_context() -> ssl.SSLContext:
|
|
31
|
+
context=ssl.create_default_context(cadata=ca_certificate())
|
|
32
|
+
context.minimum_version=ssl.TLSVersion.TLSv1_2
|
|
33
|
+
return context
|
|
34
|
+
|
|
35
|
+
def discover_endpoint(timeout: float=8.0) -> GatewayEndpoint:
|
|
36
|
+
override=os.environ.get("KESTREL_GATEWAY_IP")
|
|
37
|
+
if override: return _endpoint(override)
|
|
38
|
+
internal=_internal_endpoint(min(timeout,1.0))
|
|
39
|
+
if internal: return internal
|
|
40
|
+
request=urllib.request.Request(os.environ.get("KESTREL_CLOUD_ENDPOINT",DEFAULT_CLOUD_ENDPOINT),
|
|
41
|
+
headers={"Accept":"application/json","User-Agent":"kestrel-data-sdk"})
|
|
42
|
+
try:
|
|
43
|
+
with urllib.request.urlopen(request,timeout=timeout,context=ssl_context()) as response:
|
|
44
|
+
body=json.loads(response.read(16*1024).decode("utf-8"))
|
|
45
|
+
except Exception as error:
|
|
46
|
+
raise RuntimeError(f"无法获取 Kestrel Data 公网入口: {error}") from error
|
|
47
|
+
if not body.get("ok") or body.get("stale"): raise RuntimeError("公网入口尚未就绪或已过期")
|
|
48
|
+
return _endpoint(str(body.get("ip","")))
|
|
49
|
+
|
|
50
|
+
def _internal_endpoint(timeout: float) -> GatewayEndpoint|None:
|
|
51
|
+
endpoint=_endpoint(os.environ.get("KESTREL_INTERNAL_GATEWAY_IP",DEFAULT_INTERNAL_GATEWAY_IP))
|
|
52
|
+
try:
|
|
53
|
+
with socket.socket(socket.AF_INET,socket.SOCK_DGRAM) as probe:
|
|
54
|
+
probe.connect((endpoint.ip,endpoint.port));local=ipaddress.ip_address(probe.getsockname()[0])
|
|
55
|
+
cidrs=os.environ.get("KESTREL_INTERNAL_CIDRS",DEFAULT_INTERNAL_CIDRS).split(",")
|
|
56
|
+
if not any(local in ipaddress.ip_network(x.strip()) for x in cidrs if x.strip()): return None
|
|
57
|
+
with socket.create_connection((endpoint.ip,endpoint.port),timeout=max(.2,timeout)) as raw:
|
|
58
|
+
with ssl_context().wrap_socket(raw,server_hostname=endpoint.logical_host): return endpoint
|
|
59
|
+
except (OSError,ValueError,ssl.SSLError): return None
|
|
60
|
+
|
|
61
|
+
def _endpoint(value: str) -> GatewayEndpoint:
|
|
62
|
+
try: ip=str(ipaddress.ip_address(value.strip()))
|
|
63
|
+
except ValueError as error: raise RuntimeError("入口服务返回了无效 IP") from error
|
|
64
|
+
port=int(os.environ.get("KESTREL_GATEWAY_PORT",str(DEFAULT_GATEWAY_PORT)))
|
|
65
|
+
if not 1<=port<=65535: raise RuntimeError("网关端口无效")
|
|
66
|
+
return GatewayEndpoint(ip,os.environ.get("KESTREL_GATEWAY_HOST",DEFAULT_LOGICAL_HOST),port)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: kestrel-data-sdk
|
|
3
|
+
Version: 0.3.2
|
|
4
|
+
Summary: Kestrel Data on-demand download SDK
|
|
5
|
+
Author: Kestrel Data
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# kestrel-data-sdk
|
|
11
|
+
|
|
12
|
+
Kestrel Data 按需数据下载 SDK。适用于已获授权的 API 账户,无 Web UI、无常驻服务、
|
|
13
|
+
无自动下发,支持 Linux、macOS 和 Windows 的 Python 3.10+。
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from kestrel_data_sdk import Kestrel
|
|
17
|
+
|
|
18
|
+
kd = Kestrel("api-user", "password")
|
|
19
|
+
print(kd.products())
|
|
20
|
+
kd.download("ods.wind.ashareeodprices", "/data/kestrel", start="202601", end="202608")
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
授权范围内没有下载次数配额。特定 API 账户还可由管理员开启“全部数据权限”。默认在输出
|
|
24
|
+
目录的 `.kestrel/sdk-cache.sqlite3` 记录已完成的发布版本。每次同步只把本地版本号集合提交给
|
|
25
|
+
服务端,由服务端立即返回不可变的 24 小时同步计划;只有需要更新的分区才逐个获取文件清单,
|
|
26
|
+
避免在首次下载前等待整批历史文件扫描。
|
|
27
|
+
需要主动重新下载时使用:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
kd.download("ods.wind.ashareeodprices", "/data/kestrel", start="202608", end="202608", force=True)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
下载默认在终端显示文件进度、已下载/总大小、速度和 ETA。非交互脚本可使用 `progress=False`
|
|
34
|
+
关闭。网络中断时会保留 `.kestrel.part` 临时文件并自动断点续传,默认最多尝试 5 次。
|
|
35
|
+
|
|
36
|
+
缓存查询和校验:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
print(kd.cache_info("/data/kestrel"))
|
|
40
|
+
|
|
41
|
+
# 显式检查存在性、大小和修改时间;失效索引会被清除,下次下载自动补齐
|
|
42
|
+
print(kd.check_cache("/data/kestrel"))
|
|
43
|
+
|
|
44
|
+
# 精确检查会读取全部已缓存文件并重算 SHA-256
|
|
45
|
+
print(kd.check_cache("/data/kestrel", deep=True))
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`check_cache(..., repair=True)` 只修复缓存索引,不会删除本地文件。特殊脚本可以使用
|
|
49
|
+
`Kestrel(..., cache=False)` 禁用持久缓存。
|
|
50
|
+
|
|
51
|
+
账户必须由管理员设为 `API 工具`。普通 API 账户受产品授权、数据范围和授权有效期约束;
|
|
52
|
+
开启“全部数据权限”的账户不受这些约束。
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
kestrel_data_sdk/__init__.py,sha256=CUdSZ-s5JGg_AGoSEPUI9JCafQII5mvGoWDyIznRcWY,187
|
|
2
|
+
kestrel_data_sdk/cache.py,sha256=swRCCnm5L_jjacnR2J1Yh3_L4oxfUGrQDUfh2x2ORcg,14026
|
|
3
|
+
kestrel_data_sdk/client.py,sha256=yo3EDkTu8MPcdeRLUFDS9fl4zhMO4Q8IdvQeHOJLFqI,15966
|
|
4
|
+
kestrel_data_sdk/endpoint.py,sha256=OS2hz0WXCBMPGLYV8HmhATCVgaREEJbpHHhcXFxG9s8,3131
|
|
5
|
+
kestrel_data_sdk/certs/kestrel-root-ca.crt,sha256=EK1VVsoqkrrkiRC89_SeizQ40lT4Fozhm2B6wItTrUI,1976
|
|
6
|
+
kestrel_data_sdk-0.3.2.dist-info/METADATA,sha256=A4A3E5PNG9Bz5y4-N8EQ2ZXLOfGBByG3aqCCcQjVsFk,2110
|
|
7
|
+
kestrel_data_sdk-0.3.2.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
|
|
8
|
+
kestrel_data_sdk-0.3.2.dist-info/top_level.txt,sha256=gLsKiu_Dql888wgJnkcys1zlXG_EZPZseotpX9NMYds,17
|
|
9
|
+
kestrel_data_sdk-0.3.2.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
kestrel_data_sdk
|