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.
@@ -0,0 +1,5 @@
1
+ from .client import DownloadResult, Kestrel
2
+ from .cache import CacheCheckResult, CacheInfo
3
+
4
+ __all__ = ["CacheCheckResult", "CacheInfo", "DownloadResult", "Kestrel"]
5
+ __version__ = "0.3.1"
@@ -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
+ 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
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.42.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ kestrel_data_sdk