sharetop 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sharetop/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """ShareTop Python SDK - 高性能行情数据客户端。
2
+
3
+ 支持 A股的基础数据和行情数据查询,提供同步和异步两种接口。
4
+
5
+ """
6
+
7
+ from .__version__ import __version__
8
+ from ._exceptions import (
9
+ APIError,
10
+ AuthenticationError,
11
+ BadRequestError,
12
+ ConnectionError,
13
+ InternalServerError,
14
+ NotFoundError,
15
+ PermissionError,
16
+ RateLimitError,
17
+ ShareTopError,
18
+ TimeoutError,
19
+ )
20
+ from .client import ShareTop
21
+
22
+
23
+ __all__ = [
24
+ "__version__",
25
+ # Main clients
26
+ "ShareTop",
27
+ # Exceptions
28
+ "ShareTopError",
29
+ "APIError",
30
+ "AuthenticationError",
31
+ "PermissionError",
32
+ "NotFoundError",
33
+ "BadRequestError",
34
+ "RateLimitError",
35
+ "InternalServerError",
36
+ "ConnectionError",
37
+ "TimeoutError",
38
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,330 @@
1
+ """Base HTTP client implementation with retry support for sync and async operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import random
7
+ import time
8
+ from typing import Any, Optional, TypeVar, Union
9
+
10
+ import httpx
11
+
12
+ from . import __version__
13
+ from ._exceptions import (
14
+ APIError,
15
+ ConnectionError,
16
+ InternalServerError,
17
+ RateLimitError,
18
+ TimeoutError,
19
+ raise_for_status,
20
+ )
21
+ from ._types import NOT_GIVEN, Headers, NotGiven, Query, Timeout
22
+
23
+ __all__ = ["SyncAPIClient"]
24
+
25
+ DEFAULT_BASE_URL = "https://api.sharetop.top/api"
26
+ DEFAULT_TIMEOUT = 30.0
27
+ DEFAULT_MAX_RETRIES = 3
28
+
29
+ T = TypeVar("T")
30
+
31
+
32
+ def _should_retry(exception: Exception) -> bool:
33
+ """Determine if an exception is retryable.
34
+
35
+ Parameters
36
+ ----------
37
+ exception : Exception
38
+ The exception to check.
39
+
40
+ Returns
41
+ -------
42
+ bool
43
+ True if the request should be retried.
44
+ """
45
+ # Retry on connection errors and timeouts
46
+ if isinstance(exception, (ConnectionError, TimeoutError)):
47
+ return True
48
+
49
+ # Retry on server errors (5xx) and rate limits (429)
50
+ if isinstance(exception, (InternalServerError, RateLimitError)):
51
+ return True
52
+
53
+ return False
54
+
55
+
56
+ def _calculate_retry_delay(
57
+ attempt: int, base_delay: float = 1.0, max_delay: float = 30.0
58
+ ) -> float:
59
+ """Calculate exponential backoff delay with jitter.
60
+
61
+ Parameters
62
+ ----------
63
+ attempt : int
64
+ Current attempt number (0-indexed).
65
+ base_delay : float
66
+ Base delay in seconds.
67
+ max_delay : float
68
+ Maximum delay in seconds.
69
+
70
+ Returns
71
+ -------
72
+ float
73
+ Delay in seconds.
74
+ """
75
+ # Exponential backoff: 1s, 2s, 4s, 8s, ...
76
+ delay = base_delay * (2**attempt)
77
+ # Add jitter (±25%)
78
+ jitter = delay * 0.25 * (2 * random.random() - 1)
79
+ delay = delay + jitter
80
+ # Cap at max delay
81
+ return min(delay, max_delay)
82
+
83
+
84
+ class BaseClient:
85
+ """Base class with shared configuration for API clients."""
86
+
87
+ def __init__(
88
+ self,
89
+ api_key: Optional[str] = None,
90
+ base_url: Optional[str] = None,
91
+ timeout: Timeout = DEFAULT_TIMEOUT,
92
+ max_retries: int = DEFAULT_MAX_RETRIES,
93
+ default_headers: Optional[Headers] = None,
94
+ ) -> None:
95
+ # Allow None for free tier mode
96
+ if api_key is None or api_key == "":
97
+ self.api_key = os.environ.get("SHARETOP_API_KEY")
98
+ else:
99
+ self.api_key = api_key
100
+
101
+ # Only raise error if we're trying to use the default paid URL without a key
102
+ if not self.api_key:
103
+ effective_base_url = base_url or os.environ.get("SHARETOP_BASE_URL")
104
+ if effective_base_url is None or effective_base_url == DEFAULT_BASE_URL:
105
+ raise ValueError(
106
+ "Token is required for paid API. "
107
+ "无效Token,请关注公众号:“浔溯de小仓鼠”进行获取"
108
+ )
109
+
110
+ if self.api_key and len(self.api_key) != 50:
111
+ raise ValueError(
112
+ "Invalid API key."
113
+ "无效Token,请关注公众号:“浔溯de小仓鼠”进行获取"
114
+ )
115
+
116
+ self.base_url = (
117
+ base_url or os.environ.get("SHARETOP_BASE_URL") or DEFAULT_BASE_URL
118
+ ).rstrip("/")
119
+ self.timeout = timeout
120
+ self.max_retries = max_retries
121
+ self._default_headers = dict(default_headers) if default_headers else {}
122
+
123
+ def _build_headers(self, extra_headers: Optional[Headers] = None) -> dict[str, str]:
124
+ """Build request headers with authentication."""
125
+ headers = {
126
+ "Content-Type": "application/json",
127
+ "Accept": "application/json",
128
+ "User-Agent": f"sharetop-python-sdk/{__version__}",
129
+ **self._default_headers,
130
+ }
131
+ # Only add API key if present (free tier doesn't need it)
132
+ if self.api_key:
133
+ headers["Authorization"] = ' '.join(["Bearer", self.api_key])
134
+ if extra_headers:
135
+ headers.update(extra_headers)
136
+ # print("headers:", headers)
137
+ return headers
138
+
139
+ def _build_url(self, path: str) -> str:
140
+ """Build full URL from path."""
141
+ return f"{self.base_url}{path}"
142
+
143
+ @property
144
+ def default_headers(self):
145
+ return self._default_headers
146
+
147
+
148
+ class SyncAPIClient(BaseClient):
149
+ """Synchronous HTTP client for ShareTop API with automatic retry.
150
+
151
+ Parameters
152
+ ----------
153
+ api_key : str, optional
154
+ API key for authentication. If not provided, reads from SHARETOP_API_KEY
155
+ environment variable.
156
+ base_url : str, optional
157
+ Base URL for the API. Defaults to https://api.sharetop.com.
158
+ timeout : float, optional
159
+ Request timeout in seconds. Defaults to 30.0.
160
+ max_retries : int, optional
161
+ Maximum number of retry attempts for failed requests. Defaults to 3.
162
+ Retries occur on connection errors, timeouts, server errors (5xx),
163
+ and rate limits (429).
164
+ default_headers : dict, optional
165
+ Default headers to include in all requests.
166
+ """
167
+
168
+ def __init__(
169
+ self,
170
+ api_key: Optional[str] = None,
171
+ base_url: Optional[str] = None,
172
+ timeout: Timeout = DEFAULT_TIMEOUT,
173
+ max_retries: int = DEFAULT_MAX_RETRIES,
174
+ default_headers: Optional[Headers] = None,
175
+ ) -> None:
176
+ super().__init__(api_key, base_url, timeout, max_retries, default_headers)
177
+ self._client = httpx.Client(timeout=timeout, verify=False)
178
+
179
+ def __enter__(self) -> "SyncAPIClient":
180
+ return self
181
+
182
+ def __exit__(self, *args: Any) -> None:
183
+ self.close()
184
+
185
+ def close(self) -> None:
186
+ """Close the underlying HTTP client."""
187
+ self._client.close()
188
+
189
+ def _request(
190
+ self,
191
+ method: str,
192
+ path: str,
193
+ *,
194
+ params: Optional[Query] = None,
195
+ json: Optional[dict[str, Any]] = None,
196
+ extra_headers: Optional[Headers] = None,
197
+ timeout: Union[Timeout, NotGiven] = NOT_GIVEN,
198
+ max_retries: Union[int, NotGiven] = NOT_GIVEN,
199
+ ) -> Any:
200
+ """Make an HTTP request with automatic retry on failures.
201
+
202
+ Parameters
203
+ ----------
204
+ method : str
205
+ HTTP method (GET, POST, etc.).
206
+ path : str
207
+ API endpoint path.
208
+ params : dict, optional
209
+ Query parameters.
210
+ json : dict, optional
211
+ JSON request body.
212
+ extra_headers : dict, optional
213
+ Additional headers for this request.
214
+ timeout : float, optional
215
+ Override timeout for this request.
216
+ max_retries : int, optional
217
+ Override max retries for this request.
218
+
219
+ Returns
220
+ -------
221
+ Any
222
+ Parsed JSON response.
223
+
224
+ Raises
225
+ ------
226
+ APIError
227
+ If the API returns an error response after all retries.
228
+ ConnectionError
229
+ If there's a network connection issue after all retries.
230
+ TimeoutError
231
+ If the request times out after all retries.
232
+ """
233
+ url = self._build_url(path)
234
+ headers = self._build_headers(extra_headers)
235
+ request_timeout = timeout if not isinstance(timeout, NotGiven) else self.timeout
236
+ retries = (
237
+ max_retries if not isinstance(max_retries, NotGiven) else self.max_retries
238
+ )
239
+
240
+ # Filter out None values from params
241
+ if params:
242
+ params = {k: v for k, v in params.items() if v is not None}
243
+
244
+ last_exception: Optional[Exception] = None
245
+
246
+ for attempt in range(retries + 1):
247
+ try:
248
+ response = self._client.request(
249
+ method,
250
+ url,
251
+ params=params,
252
+ json=json,
253
+ headers=headers,
254
+ timeout=request_timeout,
255
+ )
256
+
257
+ # Parse response
258
+ try:
259
+ # print("url==:", url)
260
+ # print("json===========:", json)
261
+ response_body = response.json()
262
+ # print("response_body==========:", response_body)
263
+ except Exception:
264
+ response_body = {"message": response.text, "code": "PARSE_ERROR"}
265
+
266
+ # Check for errors (may raise retryable exceptions)
267
+ # raise_for_status(response.status_code, response_body)
268
+
269
+ return response_body
270
+
271
+ except httpx.ConnectError as e:
272
+ last_exception = ConnectionError(f"Failed to connect to {url}: {e}")
273
+ except httpx.TimeoutException as e:
274
+ last_exception = TimeoutError(f"Request to {url} timed out")
275
+ except APIError as e:
276
+ last_exception = e
277
+ if not _should_retry(e):
278
+ raise
279
+
280
+ # Check if we should retry
281
+ if attempt < retries and _should_retry(last_exception):
282
+ delay = _calculate_retry_delay(attempt)
283
+ time.sleep(delay)
284
+ else:
285
+ break
286
+
287
+ # All retries exhausted
288
+ if last_exception:
289
+ raise last_exception
290
+ raise RuntimeError("Unexpected state: no exception but request failed")
291
+
292
+ def get(
293
+ self,
294
+ path: str,
295
+ *,
296
+ params: Optional[Query] = None,
297
+ extra_headers: Optional[Headers] = None,
298
+ timeout: Union[Timeout, NotGiven] = NOT_GIVEN,
299
+ max_retries: Union[int, NotGiven] = NOT_GIVEN,
300
+ ) -> Any:
301
+ """Make a GET request with automatic retry."""
302
+ return self._request(
303
+ "GET",
304
+ path,
305
+ params=params,
306
+ extra_headers=extra_headers,
307
+ timeout=timeout,
308
+ max_retries=max_retries,
309
+ )
310
+
311
+ def post(
312
+ self,
313
+ path: str,
314
+ *,
315
+ json: Optional[dict[str, Any]] = None,
316
+ params: Optional[Query] = None,
317
+ extra_headers: Optional[Headers] = None,
318
+ timeout: Union[Timeout, NotGiven] = NOT_GIVEN,
319
+ max_retries: Union[int, NotGiven] = NOT_GIVEN,
320
+ ) -> Any:
321
+ """Make a POST request with automatic retry."""
322
+ return self._request(
323
+ "POST",
324
+ path,
325
+ json=json,
326
+ params=params,
327
+ extra_headers=extra_headers,
328
+ timeout=timeout,
329
+ max_retries=max_retries,
330
+ )
sharetop/_batch.py ADDED
@@ -0,0 +1,177 @@
1
+ """Generic batched GET utilities for splitting large symbol lists across requests.
2
+
3
+ Handles URL length limits by automatically chunking the symbol list and
4
+ merging ``response["data"]`` dicts from each chunk. Both synchronous
5
+ (ThreadPoolExecutor) and asynchronous (asyncio.Semaphore) variants are
6
+ provided so that any resource can reuse the same logic.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import concurrent.futures
12
+ from typing import Any, Callable, Dict, List, Optional, Union
13
+
14
+
15
+ from ._base_client import SyncAPIClient
16
+
17
+ DEFAULT_BATCH_SIZE = 100
18
+ DEFAULT_MAX_WORKERS = 5
19
+
20
+
21
+ def _chunk_list(lst: List[str], chunk_size: int) -> List[List[str]]:
22
+ return [lst[i : i + chunk_size] for i in range(0, len(lst), chunk_size)]
23
+
24
+
25
+ def _get_progress_bar(total: int, desc: str, show: bool):
26
+ if show:
27
+ try:
28
+ from tqdm.auto import tqdm
29
+
30
+ return tqdm(total=total, desc=desc, leave=False)
31
+ except ImportError:
32
+ pass
33
+ return None
34
+
35
+
36
+ def _default_merge(
37
+ acc: Dict[str, Any], chunk: Union[Dict[str, Any], List], symbols: List[str] = None
38
+ ) -> None:
39
+ """Default merge: if chunk is a dict, update directly; if it's a list, group by ts_code."""
40
+ if isinstance(chunk, dict):
41
+ acc.update(chunk)
42
+ elif isinstance(chunk, list) and symbols:
43
+ # Group list items by ts_code
44
+ for item in chunk:
45
+ ts_code = item.get("ts_code", "")
46
+ if ts_code:
47
+ # Match with provided symbols
48
+ for sym in symbols:
49
+ # Match by code prefix (e.g., "603435" matches "603435.SH")
50
+ code = ts_code.split(".")[0]
51
+ sym_code = sym.split(".")[0]
52
+ if code == sym_code:
53
+ acc.setdefault(sym, []).append(item)
54
+ break
55
+ else:
56
+ # No match, put in empty key
57
+ acc.setdefault("", []).append(item)
58
+ else:
59
+ acc.setdefault("", []).append(item)
60
+ elif isinstance(chunk, list):
61
+ acc.setdefault("", []).extend(chunk)
62
+
63
+
64
+ def batched_get_sync(
65
+ client: "SyncAPIClient",
66
+ endpoint: str,
67
+ symbols: List[str],
68
+ params: Dict[str, Any],
69
+ *,
70
+ symbols_param: str = "symbols",
71
+ batch_size: int = DEFAULT_BATCH_SIZE,
72
+ max_workers: int = DEFAULT_MAX_WORKERS,
73
+ show_progress: bool = False,
74
+ progress_desc: str = "Fetching data",
75
+ merge: Optional[
76
+ Callable[[Dict[str, Any], Union[Dict[str, Any], List], List[str]], None]
77
+ ] = None,
78
+ ) -> Dict[str, Any]:
79
+ """Fetch *endpoint* in chunks, merging responses.
80
+
81
+ Parameters
82
+ ----------
83
+ merge : callable, optional
84
+ ``merge(accumulated, chunk_data, symbols)`` – custom merge strategy.
85
+ Defaults to grouping list responses by ts_code.
86
+ """
87
+ # If no symbols provided, fetch all data without symbols_param
88
+ if not symbols:
89
+ return client.post(endpoint, json=params)["data"]
90
+
91
+ chunks = _chunk_list(symbols, batch_size)
92
+ _merge = merge or _default_merge
93
+
94
+ if len(chunks) == 1:
95
+ chunk_params = {**params, symbols_param: ",".join(chunks[0])}
96
+ data = client.post(endpoint, json=chunk_params)["data"]
97
+ # Use merge for single chunk too, to convert list to dict
98
+ result: Dict[str, Any] = {}
99
+ _merge(result, data, symbols)
100
+ return result
101
+
102
+ pbar = _get_progress_bar(len(chunks), progress_desc, show_progress)
103
+ all_data: Dict[str, Any] = {}
104
+
105
+ def _fetch(chunk: List[str]) -> Any:
106
+ chunk_params = {**params, symbols_param: ",".join(chunk)}
107
+ return client.post(endpoint, json=chunk_params)["data"]
108
+
109
+ try:
110
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool:
111
+ futures = {pool.submit(_fetch, c): c for c in chunks}
112
+ for future in concurrent.futures.as_completed(futures):
113
+ _merge(all_data, future.result(), symbols)
114
+ if pbar:
115
+ pbar.update(1)
116
+ finally:
117
+ if pbar:
118
+ pbar.close()
119
+
120
+ return all_data
121
+
122
+
123
+ # async def batched_get_async(
124
+ # client: "AsyncAPIClient",
125
+ # endpoint: str,
126
+ # symbols: List[str],
127
+ # params: Dict[str, Any],
128
+ # *,
129
+ # symbols_param: str = "symbols",
130
+ # batch_size: int = DEFAULT_BATCH_SIZE,
131
+ # max_concurrency: int = DEFAULT_MAX_WORKERS,
132
+ # show_progress: bool = False,
133
+ # progress_desc: str = "Fetching data",
134
+ # merge: Optional[
135
+ # Callable[[Dict[str, Any], Union[Dict[str, Any], List], List[str]], None]
136
+ # ] = None,
137
+ # ) -> Dict[str, Any]:
138
+ # """Async variant of :func:`batched_get_sync`."""
139
+ # # If no symbols provided, fetch all data without symbols_param
140
+ # if not symbols:
141
+ # return (await client.post(endpoint, json=params))["data"]
142
+ #
143
+ # chunks = _chunk_list(symbols, batch_size)
144
+ # _merge = merge or _default_merge
145
+ #
146
+ # if len(chunks) == 1:
147
+ # chunk_params = {**params, symbols_param: ",".join(chunks[0])}
148
+ # data = (await client.get(endpoint, params=chunk_params))["data"]
149
+ # result: Dict[str, Any] = {}
150
+ # _merge(result, data, symbols)
151
+ # return result
152
+ #
153
+ # pbar = _get_progress_bar(len(chunks), progress_desc, show_progress)
154
+ # sem = asyncio.Semaphore(max_concurrency)
155
+ # all_data: Dict[str, Any] = {}
156
+ #
157
+ # async def _fetch(chunk: List[str]) -> Any:
158
+ # async with sem:
159
+ # chunk_params = {**params, symbols_param: ",".join(chunk)}
160
+ # resp = await client.get(endpoint, params=chunk_params)
161
+ # if pbar:
162
+ # pbar.update(1)
163
+ # return resp["data"]
164
+ #
165
+ # try:
166
+ # results = await asyncio.gather(
167
+ # *[_fetch(c) for c in chunks], return_exceptions=True
168
+ # )
169
+ # for r in results:
170
+ # if isinstance(r, Exception):
171
+ # raise r
172
+ # _merge(all_data, r, symbols)
173
+ # finally:
174
+ # if pbar:
175
+ # pbar.close()
176
+ #
177
+ # return all_data
sharetop/_cache.py ADDED
@@ -0,0 +1,135 @@
1
+ """Local instrument name cache for ShareTop.
2
+
3
+ Caches symbol -> name mappings to avoid frequent API calls.
4
+ Cache directory is configurable via SHARETOP_CACHE_DIR environment variable.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+ import threading
13
+ import time
14
+ from pathlib import Path
15
+ from typing import Dict, List, Optional
16
+
17
+ from ._base_client import SyncAPIClient
18
+
19
+ logger = logging.getLogger("sharetop.cache")
20
+
21
+ DEFAULT_CACHE_DIR = os.path.join(Path.home(), ".sharetop", "cache")
22
+ CACHE_FILENAME = "instruments.json"
23
+ CACHE_TTL_SECONDS = 24 * 60 * 60 # 24 hours
24
+ MAX_BATCH_SIZE = 500
25
+
26
+
27
+ def _get_cache_dir() -> str:
28
+ return os.environ.get("SHARETOP_CACHE_DIR", DEFAULT_CACHE_DIR)
29
+
30
+
31
+ class InstrumentNameCache:
32
+ """Thread-safe local cache for instrument names.
33
+
34
+ Backed by an in-memory dict and a JSON file on disk.
35
+ Resolves missing names from the instruments API on demand.
36
+ """
37
+
38
+ def __init__(self, cache_dir: Optional[str] = None) -> None:
39
+ self._cache_dir = cache_dir or _get_cache_dir()
40
+ self._names: Dict[str, str] = {}
41
+ self._updated_at: float = 0.0
42
+ self._lock = threading.Lock()
43
+ self._load_from_disk()
44
+
45
+ @property
46
+ def _cache_path(self) -> str:
47
+ return os.path.join(self._cache_dir, CACHE_FILENAME)
48
+
49
+ # ---- public API ----
50
+
51
+ def get_name(self, symbol: str) -> Optional[str]:
52
+ with self._lock:
53
+ return self._names.get(symbol)
54
+
55
+ def get_names(self, symbols: List[str]) -> Dict[str, str]:
56
+ with self._lock:
57
+ return {s: self._names[s] for s in symbols if s in self._names}
58
+
59
+ def missing(self, symbols: List[str]) -> List[str]:
60
+ with self._lock:
61
+ return [s for s in symbols if s not in self._names]
62
+
63
+ def update(self, names: Dict[str, str]) -> None:
64
+ if not names:
65
+ return
66
+ with self._lock:
67
+ self._names.update(names)
68
+ self._updated_at = time.time()
69
+ self._save_to_disk()
70
+
71
+ def resolve_sync(
72
+ self, symbols: List[str], client: "SyncAPIClient"
73
+ ) -> Dict[str, str]:
74
+ """Resolve names for symbols, fetching missing ones via sync client."""
75
+ missing = self.missing(symbols)
76
+ if missing:
77
+ self._fetch_sync(missing, client)
78
+ return self.get_names(symbols)
79
+
80
+ # ---- internal: fetch from API ----
81
+
82
+ def _fetch_sync(self, symbols: List[str], client: "SyncAPIClient") -> None:
83
+ try:
84
+ for i in range(0, len(symbols), MAX_BATCH_SIZE):
85
+ chunk = symbols[i : i + MAX_BATCH_SIZE]
86
+ response = client.post("/v1/instruments", json={"symbols": chunk})
87
+ names = {
88
+ inst["symbol"]: inst["name"]
89
+ for inst in response.get("data", [])
90
+ if inst.get("name")
91
+ }
92
+ self.update(names)
93
+ except Exception as e:
94
+ logger.debug("Failed to fetch instrument names: %s", e)
95
+
96
+ # ---- disk persistence ----
97
+
98
+ def _load_from_disk(self) -> None:
99
+ path = self._cache_path
100
+ if not os.path.exists(path):
101
+ return
102
+
103
+ try:
104
+ with open(path, "r", encoding="utf-8") as f:
105
+ data = json.load(f)
106
+
107
+ meta = data.get("_meta", {})
108
+ saved_at = meta.get("updated_at", 0)
109
+
110
+ if time.time() - saved_at > CACHE_TTL_SECONDS:
111
+ logger.debug("Cache expired, ignoring disk cache")
112
+ return
113
+
114
+ names = data.get("data", {})
115
+ if isinstance(names, dict):
116
+ with self._lock:
117
+ self._names.update(names)
118
+ self._updated_at = saved_at
119
+ logger.debug("Loaded %d instrument names from cache", len(names))
120
+ except Exception as e:
121
+ logger.debug("Failed to load cache from %s: %s", path, e)
122
+
123
+ def _save_to_disk(self) -> None:
124
+ path = self._cache_path
125
+ try:
126
+ os.makedirs(os.path.dirname(path), exist_ok=True)
127
+ with self._lock:
128
+ payload = {
129
+ "_meta": {"updated_at": self._updated_at, "version": 1},
130
+ "data": dict(self._names),
131
+ }
132
+ with open(path, "w", encoding="utf-8") as f:
133
+ json.dump(payload, f, ensure_ascii=False)
134
+ except Exception as e:
135
+ logger.debug("Failed to save cache to %s: %s", path, e)