pytdxfeed 0.2.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.
pytdxfeed/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """Shared tdx-api client and macOS runtime."""
2
+
3
+ from pytdxfeed._version import __version__
4
+ from pytdxfeed.client import (
5
+ BASE_URL,
6
+ DEFAULT_PORT,
7
+ HOST,
8
+ TdxApiClient,
9
+ local_base_url,
10
+ validate_port,
11
+ )
12
+ from pytdxfeed.errors import (
13
+ TdxApiDeploymentError,
14
+ TdxApiError,
15
+ TdxApiResponseError,
16
+ TdxApiTransportError,
17
+ )
18
+ from pytdxfeed.runtime import ServiceInfo, ensure_service
19
+
20
+ __all__ = [
21
+ "BASE_URL",
22
+ "DEFAULT_PORT",
23
+ "HOST",
24
+ "ServiceInfo",
25
+ "TdxApiClient",
26
+ "TdxApiDeploymentError",
27
+ "TdxApiError",
28
+ "TdxApiResponseError",
29
+ "TdxApiTransportError",
30
+ "__version__",
31
+ "ensure_service",
32
+ "local_base_url",
33
+ "validate_port",
34
+ ]
pytdxfeed/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Package version shared by runtime metadata and public exports."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,27 @@
1
+ {
2
+ "api_major": 1,
3
+ "artifact_generation": 3,
4
+ "assets": {
5
+ "arm64": {
6
+ "filename": "tdx-api-ea07dccb67ae-darwin-arm64.tar.gz",
7
+ "sha256": "8b99f16639c248c3b634f613abfc9d8bd469f5428aa70b6f16ca38c700a7e8c5"
8
+ },
9
+ "x86_64": {
10
+ "filename": "tdx-api-ea07dccb67ae-darwin-x86_64.tar.gz",
11
+ "sha256": "9696b1332b826bb0d723da52cd3c558ae529ed31de6a6989f6d9e982659a4a31"
12
+ }
13
+ },
14
+ "patches": [
15
+ {
16
+ "filename": "tdx-api-ths-response-validation.patch",
17
+ "sha256": "6f27e36df68011cd60038b701b155788a7b90ad0cd7522c37144042cd6d17e20"
18
+ },
19
+ {
20
+ "filename": "tdx-api-configurable-port.patch",
21
+ "sha256": "f1c7d6824ce8f5c486d854a85ffab30ecef053e243b25b017c953b2b505e28a2"
22
+ }
23
+ ],
24
+ "schema_version": 1,
25
+ "upstream_sha": "ea07dccb67aeb92ebde851ac31503b4bf457a318",
26
+ "upstream_url": "https://github.com/oficcejo/tdx-api.git"
27
+ }
pytdxfeed/client.py ADDED
@@ -0,0 +1,172 @@
1
+ """Thread-safe HTTP client for the local tdx-api service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterable, Mapping
6
+ from threading import Lock, local
7
+ from typing import Any
8
+
9
+ import requests
10
+
11
+ from pytdxfeed.errors import TdxApiResponseError, TdxApiTransportError
12
+
13
+ HOST = "127.0.0.1"
14
+ DEFAULT_PORT = 8080
15
+
16
+
17
+ def validate_port(port: object) -> int:
18
+ """Require an explicit TCP port integer."""
19
+ if isinstance(port, bool) or not isinstance(port, int):
20
+ raise ValueError("port must be an integer between 1 and 65535")
21
+ if not 1 <= port <= 65535:
22
+ raise ValueError("port must be an integer between 1 and 65535")
23
+ return port
24
+
25
+
26
+ def local_base_url(port: int = DEFAULT_PORT) -> str:
27
+ """Build the fixed-host service URL for one validated port."""
28
+ return f"http://{HOST}:{validate_port(port)}"
29
+
30
+
31
+ BASE_URL = local_base_url()
32
+
33
+
34
+ class TdxApiClient:
35
+ """Access the raw tdx-api JSON contract with per-thread HTTP sessions."""
36
+
37
+ def __init__(
38
+ self,
39
+ timeout_sec: float = 10.0,
40
+ *,
41
+ port: int = DEFAULT_PORT,
42
+ session_factory: Callable[[], requests.Session] = requests.Session,
43
+ ) -> None:
44
+ timeout = float(timeout_sec)
45
+ if timeout <= 0:
46
+ raise ValueError("timeout_sec must be positive")
47
+ self.timeout_sec = timeout
48
+ self.port = validate_port(port)
49
+ self.base_url = local_base_url(self.port)
50
+ self._session_factory = session_factory
51
+ self._local = local()
52
+ self._sessions: list[requests.Session] = []
53
+ self._sessions_lock = Lock()
54
+ self._closed = False
55
+
56
+ def __enter__(self) -> TdxApiClient:
57
+ return self
58
+
59
+ def __exit__(self, *_args: object) -> None:
60
+ self.close()
61
+
62
+ def close(self) -> None:
63
+ """Close every session created by this client."""
64
+ with self._sessions_lock:
65
+ sessions = tuple(self._sessions)
66
+ self._sessions.clear()
67
+ self._closed = True
68
+ for session in sessions:
69
+ session.close()
70
+
71
+ def health(self) -> dict[str, Any]:
72
+ """Require the exact health response implemented by the pinned service."""
73
+ payload = self._request_json("GET", "/api/health")
74
+ if not isinstance(payload, dict):
75
+ raise TdxApiResponseError("[tdx-api] health response must be an object")
76
+ if str(payload.get("status") or "").lower() != "healthy":
77
+ raise TdxApiResponseError(
78
+ f"[tdx-api] health status is not healthy: {payload}"
79
+ )
80
+ return payload
81
+
82
+ def kline_all(
83
+ self,
84
+ code: str,
85
+ *,
86
+ source: str,
87
+ interval: str,
88
+ limit: int | None = None,
89
+ ) -> object:
90
+ """Return all stock K-lines from the explicit tdx or ths endpoint."""
91
+ if source not in {"tdx", "ths"}:
92
+ raise ValueError("source must be 'tdx' or 'ths'")
93
+ params: dict[str, object] = {"code": _required(code, "code"), "type": interval}
94
+ if limit is not None and limit > 0:
95
+ params["limit"] = int(limit)
96
+ return self._request_json("GET", f"/api/kline-all/{source}", params=params)
97
+
98
+ def index_all(
99
+ self, code: str, *, interval: str, limit: int | None = None
100
+ ) -> object:
101
+ """Return all index K-lines."""
102
+ params: dict[str, object] = {"code": _required(code, "code"), "type": interval}
103
+ if limit is not None and limit > 0:
104
+ params["limit"] = int(limit)
105
+ return self._request_json("GET", "/api/index/all", params=params)
106
+
107
+ def batch_quote(self, codes: Iterable[str]) -> object:
108
+ """Return a raw quote response for at most 50 explicit API codes."""
109
+ items = [_required(code, "code") for code in codes]
110
+ if not items:
111
+ raise ValueError("codes must not be empty")
112
+ if len(items) > 50:
113
+ raise ValueError("tdx-api accepts at most 50 quote codes per request")
114
+ return self._request_json("POST", "/api/batch-quote", json={"codes": items})
115
+
116
+ def trade(self, code: str) -> object:
117
+ """Return recent trades for one API code."""
118
+ return self._request_json(
119
+ "GET", "/api/trade", params={"code": _required(code, "code")}
120
+ )
121
+
122
+ def _session(self) -> requests.Session:
123
+ if self._closed:
124
+ raise RuntimeError("TdxApiClient is closed")
125
+ session = getattr(self._local, "session", None)
126
+ if session is None:
127
+ session = self._session_factory()
128
+ self._local.session = session
129
+ with self._sessions_lock:
130
+ self._sessions.append(session)
131
+ return session
132
+
133
+ def _request_json(
134
+ self,
135
+ method: str,
136
+ path: str,
137
+ *,
138
+ params: Mapping[str, object] | None = None,
139
+ json: object | None = None,
140
+ ) -> object:
141
+ url = self.base_url + path
142
+ try:
143
+ response = self._session().request(
144
+ method,
145
+ url,
146
+ params=params,
147
+ json=json,
148
+ timeout=self.timeout_sec,
149
+ )
150
+ response.raise_for_status()
151
+ except requests.RequestException as exc:
152
+ raise TdxApiTransportError(
153
+ f"[tdx-api] request failed {url}: {exc}"
154
+ ) from exc
155
+ try:
156
+ payload = response.json()
157
+ except ValueError as exc:
158
+ preview = str(getattr(response, "text", ""))[:200]
159
+ raise TdxApiTransportError(
160
+ f"[tdx-api] response is not JSON {url}: {preview}"
161
+ ) from exc
162
+ if isinstance(payload, dict) and payload.get("code") not in (None, 0):
163
+ message = payload.get("message") or payload.get("error") or payload
164
+ raise TdxApiResponseError(f"[tdx-api] {path} returned an error: {message}")
165
+ return payload
166
+
167
+
168
+ def _required(value: object, field: str) -> str:
169
+ text = str(value or "").strip()
170
+ if not text:
171
+ raise ValueError(f"{field} must not be empty")
172
+ return text
pytdxfeed/errors.py ADDED
@@ -0,0 +1,17 @@
1
+ """Public exceptions raised by pytdxfeed."""
2
+
3
+
4
+ class TdxApiError(RuntimeError):
5
+ """Base error for tdx-api client and runtime failures."""
6
+
7
+
8
+ class TdxApiTransportError(TdxApiError):
9
+ """Raised when an HTTP request cannot be completed or decoded."""
10
+
11
+
12
+ class TdxApiResponseError(TdxApiError):
13
+ """Raised when tdx-api returns an invalid or unsuccessful response."""
14
+
15
+
16
+ class TdxApiDeploymentError(TdxApiError):
17
+ """Raised when the shared macOS service cannot be deployed."""
pytdxfeed/runtime.py ADDED
@@ -0,0 +1,648 @@
1
+ """Shared macOS lifecycle manager for the bundled tdx-api service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fcntl
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import platform
10
+ import plistlib
11
+ import shutil
12
+ import socket
13
+ import subprocess
14
+ import tarfile
15
+ import tempfile
16
+ import time
17
+ from collections.abc import Mapping, Sequence
18
+ from dataclasses import dataclass
19
+ from importlib import resources
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from pytdxfeed._version import __version__
24
+ from pytdxfeed.client import (
25
+ DEFAULT_PORT,
26
+ HOST,
27
+ TdxApiClient,
28
+ local_base_url,
29
+ validate_port,
30
+ )
31
+ from pytdxfeed.errors import TdxApiDeploymentError, TdxApiError
32
+
33
+ PORT = DEFAULT_PORT
34
+ UPSTREAM_SHA = "ea07dccb67aeb92ebde851ac31503b4bf457a318"
35
+ LAUNCHD_LABEL = "com.hermanzhaozzzz.pytdxfeed.tdx-api"
36
+ LEGACY_VQT_LABEL = "com.hermanzhaozzzz.vqt.tdx-api"
37
+ RUNTIME_DIRNAME = ".pytdxfeed"
38
+ LEGACY_RUNTIME_DIRNAME = ".tdx-api"
39
+ ASSET_MANIFEST = "manifest.json"
40
+ STARTUP_TIMEOUT_SEC = 120.0
41
+ PORT_RELEASE_TIMEOUT_SEC = 5.0
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class ServiceInfo:
46
+ """Description of the healthy local service selected for this process."""
47
+
48
+ base_url: str
49
+ port: int
50
+ managed: bool
51
+ launchd_label: str | None
52
+ runtime_root: Path | None
53
+ upstream_sha: str | None
54
+ artifact_generation: int | None
55
+
56
+
57
+ def ensure_service(
58
+ *,
59
+ timeout_sec: float = 2.0,
60
+ startup_timeout_sec: float = STARTUP_TIMEOUT_SEC,
61
+ port: int = DEFAULT_PORT,
62
+ ) -> ServiceInfo:
63
+ """Return a healthy service, deploying the shared runtime on macOS if needed."""
64
+ resolved_port = validate_port(port)
65
+ client = TdxApiClient(timeout_sec=timeout_sec, port=resolved_port)
66
+ try:
67
+ if platform.system() != "Darwin":
68
+ try:
69
+ client.health()
70
+ except TdxApiError as exc:
71
+ raise TdxApiDeploymentError(
72
+ "[pytdxfeed] tdx-api is unavailable; automatic deployment "
73
+ "is supported only on macOS"
74
+ ) from exc
75
+ return _external_service_info(resolved_port)
76
+ return _RuntimeManager(
77
+ client=client,
78
+ home_dir=Path.home(),
79
+ startup_timeout_sec=startup_timeout_sec,
80
+ port=resolved_port,
81
+ ).ensure()
82
+ finally:
83
+ client.close()
84
+
85
+
86
+ class _RuntimeManager:
87
+ def __init__(
88
+ self,
89
+ *,
90
+ client: TdxApiClient,
91
+ home_dir: Path,
92
+ startup_timeout_sec: float,
93
+ port: int = DEFAULT_PORT,
94
+ ) -> None:
95
+ self.client = client
96
+ self.home_dir = home_dir.expanduser().resolve()
97
+ self.runtime_root = self.home_dir / RUNTIME_DIRNAME
98
+ self.binary_path = self.runtime_root / "bin" / "tdx-api"
99
+ self.static_path = self.runtime_root / "static"
100
+ self.data_path = self.runtime_root / "data" / "database"
101
+ self.logs_path = self.runtime_root / "logs"
102
+ self.manifest_path = self.runtime_root / "deployment.json"
103
+ self.lock_path = self.runtime_root / "deployment.lock"
104
+ self.stdout_log_path = self.logs_path / "tdx-api.stdout.log"
105
+ self.stderr_log_path = self.logs_path / "tdx-api.stderr.log"
106
+ self.install_log_path = self.logs_path / "deployment.log"
107
+ self.plist_path = (
108
+ self.home_dir / "Library" / "LaunchAgents" / f"{LAUNCHD_LABEL}.plist"
109
+ )
110
+ self.legacy_vqt_plist_path = (
111
+ self.home_dir / "Library" / "LaunchAgents" / f"{LEGACY_VQT_LABEL}.plist"
112
+ )
113
+ self.domain = f"gui/{os.getuid()}"
114
+ self.service_target = f"{self.domain}/{LAUNCHD_LABEL}"
115
+ self.legacy_vqt_target = f"{self.domain}/{LEGACY_VQT_LABEL}"
116
+ self.startup_timeout_sec = float(startup_timeout_sec)
117
+ self.port = validate_port(port)
118
+ self.base_url = local_base_url(self.port)
119
+ if self.startup_timeout_sec <= 0:
120
+ raise ValueError("startup_timeout_sec must be positive")
121
+
122
+ def ensure(self) -> ServiceInfo:
123
+ """Reconcile the single per-user service."""
124
+ new_loaded = self._job_loaded(self.service_target)
125
+ legacy_loaded = self._job_loaded(self.legacy_vqt_target)
126
+ if not new_loaded and not legacy_loaded and self._healthy():
127
+ return _external_service_info(self.port)
128
+
129
+ self._prepare_directories()
130
+ with self.lock_path.open("a+", encoding="utf-8") as lock_file:
131
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
132
+ try:
133
+ return self._ensure_locked()
134
+ finally:
135
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
136
+
137
+ def _ensure_locked(self) -> ServiceInfo:
138
+ new_loaded = self._job_loaded(self.service_target)
139
+ legacy_loaded = self._job_loaded(self.legacy_vqt_target)
140
+ healthy = self._healthy()
141
+ asset_manifest = _load_asset_manifest()
142
+ deployment = self._read_json(self.manifest_path)
143
+ deployed_port = self._deployed_port(deployment)
144
+
145
+ if legacy_loaded:
146
+ self._seed_legacy_data()
147
+ return self._deploy(
148
+ asset_manifest,
149
+ old_target=self.legacy_vqt_target,
150
+ old_plist=self.legacy_vqt_plist_path,
151
+ remove_legacy_plist=True,
152
+ old_port=DEFAULT_PORT,
153
+ )
154
+
155
+ if new_loaded:
156
+ acceptable = self._deployment_acceptable(deployment, asset_manifest)
157
+ if healthy and acceptable:
158
+ return self._managed_service_info(deployment)
159
+ if deployed_port != self.port:
160
+ if healthy:
161
+ self._retire_managed_service(deployed_port)
162
+ return _external_service_info(self.port)
163
+ if _port_open(self.port):
164
+ raise TdxApiDeploymentError(
165
+ f"[pytdxfeed] {HOST}:{self.port} is occupied by an "
166
+ "unhealthy unknown process; it will not be reused or terminated"
167
+ )
168
+ return self._deploy(
169
+ asset_manifest,
170
+ old_target=self.service_target,
171
+ old_plist=self.plist_path,
172
+ remove_legacy_plist=False,
173
+ old_port=deployed_port,
174
+ )
175
+
176
+ if healthy:
177
+ return _external_service_info(self.port)
178
+ if _port_open(self.port):
179
+ raise TdxApiDeploymentError(
180
+ f"[pytdxfeed] {HOST}:{self.port} is occupied by an unhealthy "
181
+ "unknown process; it will not be reused or terminated"
182
+ )
183
+
184
+ self._seed_legacy_data()
185
+ return self._deploy(
186
+ asset_manifest,
187
+ old_target=None,
188
+ old_plist=None,
189
+ remove_legacy_plist=False,
190
+ old_port=None,
191
+ )
192
+
193
+ def _deployment_acceptable(
194
+ self, deployment: Mapping[str, Any] | None, assets: Mapping[str, Any]
195
+ ) -> bool:
196
+ if deployment is None:
197
+ return False
198
+ deployed_api = int(deployment.get("api_major", 0) or 0)
199
+ expected_api = int(assets["api_major"])
200
+ deployed_generation = int(deployment.get("artifact_generation", 0) or 0)
201
+ expected_generation = int(assets["artifact_generation"])
202
+ if deployed_api > expected_api:
203
+ raise TdxApiDeploymentError(
204
+ "[pytdxfeed] the managed service API is newer than this client; "
205
+ "refusing to downgrade it"
206
+ )
207
+ return (
208
+ deployed_api == expected_api
209
+ and deployed_generation >= expected_generation
210
+ and int(deployment.get("service_port", 0) or 0) == self.port
211
+ and self.binary_path.is_file()
212
+ and self.static_path.is_dir()
213
+ )
214
+
215
+ def _deploy(
216
+ self,
217
+ asset_manifest: Mapping[str, Any],
218
+ *,
219
+ old_target: str | None,
220
+ old_plist: Path | None,
221
+ remove_legacy_plist: bool,
222
+ old_port: int | None,
223
+ ) -> ServiceInfo:
224
+ old_plist_bytes = _read_bytes(old_plist) if old_plist is not None else None
225
+ with tempfile.TemporaryDirectory(
226
+ prefix="rollback-", dir=self.runtime_root
227
+ ) as rollback_text:
228
+ rollback = Path(rollback_text)
229
+ self._snapshot_runtime(rollback)
230
+ try:
231
+ deployment = self._install_asset(asset_manifest)
232
+ desired_plist = plistlib.dumps(self._plist_payload(), sort_keys=True)
233
+ _write_bytes_atomic(self.plist_path, desired_plist, mode=0o644)
234
+ if old_target is not None and self._job_loaded(old_target):
235
+ self._launchctl(["bootout", old_target])
236
+ self._wait_for_port_release(old_port or self.port)
237
+ if _port_open(self.port):
238
+ raise TdxApiDeploymentError(
239
+ f"[pytdxfeed] port {self.port} became occupied before bootstrap"
240
+ )
241
+ self._launchctl(["bootstrap", self.domain, str(self.plist_path)])
242
+ self._launchctl(["kickstart", "-k", self.service_target])
243
+ self._wait_until_healthy()
244
+ except Exception as exc:
245
+ self._launchctl(["bootout", self.service_target], check=False)
246
+ self._restore_runtime(rollback)
247
+ if old_plist is not None and old_plist_bytes is not None:
248
+ _write_bytes_atomic(old_plist, old_plist_bytes, mode=0o644)
249
+ self._launchctl(
250
+ ["bootstrap", self.domain, str(old_plist)], check=False
251
+ )
252
+ if old_target is not None:
253
+ self._launchctl(["kickstart", "-k", old_target], check=False)
254
+ if isinstance(exc, TdxApiDeploymentError):
255
+ raise
256
+ raise TdxApiDeploymentError(
257
+ f"[pytdxfeed] deployment failed: {exc}"
258
+ ) from exc
259
+
260
+ if (
261
+ remove_legacy_plist
262
+ and old_plist is not None
263
+ and old_plist != self.plist_path
264
+ ):
265
+ old_plist.unlink(missing_ok=True)
266
+ return self._managed_service_info(deployment)
267
+
268
+ def _install_asset(self, asset_manifest: Mapping[str, Any]) -> dict[str, Any]:
269
+ architecture = _architecture()
270
+ asset = asset_manifest["assets"].get(architecture)
271
+ if not isinstance(asset, dict):
272
+ raise TdxApiDeploymentError(
273
+ f"[pytdxfeed] no bundled asset for architecture {architecture}"
274
+ )
275
+ filename = str(asset["filename"])
276
+ expected_checksum = str(asset["sha256"])
277
+ asset_ref = resources.files("pytdxfeed").joinpath("assets", filename)
278
+ with resources.as_file(asset_ref) as archive:
279
+ actual_checksum = _sha256(archive)
280
+ if actual_checksum != expected_checksum:
281
+ raise TdxApiDeploymentError(
282
+ "[pytdxfeed] bundled asset checksum mismatch: "
283
+ f"expected={expected_checksum} actual={actual_checksum}"
284
+ )
285
+ with tempfile.TemporaryDirectory(
286
+ prefix="install-", dir=self.runtime_root
287
+ ) as install_text:
288
+ install = Path(install_text)
289
+ _extract_archive_safe(archive, install)
290
+ source_binary = install / "bin" / "tdx-api"
291
+ source_static = install / "static"
292
+ source_data = install / "data" / "database"
293
+ if not source_binary.is_file() or not source_static.is_dir():
294
+ raise TdxApiDeploymentError(
295
+ "[pytdxfeed] bundled asset is missing binary or static files"
296
+ )
297
+ temp_binary = self.binary_path.with_name(
298
+ f".{self.binary_path.name}.{os.getpid()}.tmp"
299
+ )
300
+ shutil.copy2(source_binary, temp_binary)
301
+ temp_binary.chmod(0o755)
302
+ temp_binary.replace(self.binary_path)
303
+ self._replace_static(source_static)
304
+ _copy_missing_tree(source_data, self.data_path)
305
+
306
+ deployment = {
307
+ "schema_version": 2,
308
+ "api_major": int(asset_manifest["api_major"]),
309
+ "artifact_generation": int(asset_manifest["artifact_generation"]),
310
+ "package_version": __version__,
311
+ "upstream_sha": str(asset_manifest["upstream_sha"]),
312
+ "patches": list(asset_manifest.get("patches") or []),
313
+ "architecture": architecture,
314
+ "asset_filename": filename,
315
+ "asset_sha256": expected_checksum,
316
+ "binary_path": str(self.binary_path),
317
+ "service_port": self.port,
318
+ "installed_at": int(time.time()),
319
+ }
320
+ _write_json_atomic(self.manifest_path, deployment)
321
+ return deployment
322
+
323
+ def _replace_static(self, source: Path) -> None:
324
+ staged = self.runtime_root / f".static.{os.getpid()}.tmp"
325
+ previous = self.runtime_root / f".static.{os.getpid()}.old"
326
+ shutil.rmtree(staged, ignore_errors=True)
327
+ shutil.rmtree(previous, ignore_errors=True)
328
+ shutil.copytree(source, staged)
329
+ if self.static_path.exists():
330
+ self.static_path.replace(previous)
331
+ staged.replace(self.static_path)
332
+ shutil.rmtree(previous, ignore_errors=True)
333
+
334
+ def _seed_legacy_data(self) -> None:
335
+ sources: list[Path] = []
336
+ legacy_payload = _read_plist(self.legacy_vqt_plist_path)
337
+ if legacy_payload is not None:
338
+ working = str(legacy_payload.get("WorkingDirectory") or "").strip()
339
+ if working:
340
+ sources.append(Path(working).expanduser() / "data" / "database")
341
+
342
+ old_shared = self.home_dir / LEGACY_RUNTIME_DIRNAME
343
+ old_manifest = self._read_json(old_shared / "deployment.json")
344
+ if old_manifest is not None and old_manifest.get("upstream_sha"):
345
+ sources.append(old_shared / "data" / "database")
346
+
347
+ for source in sources:
348
+ _copy_missing_tree(source, self.data_path)
349
+
350
+ def _snapshot_runtime(self, target: Path) -> None:
351
+ if self.binary_path.is_file():
352
+ (target / "bin").mkdir(parents=True, exist_ok=True)
353
+ shutil.copy2(self.binary_path, target / "bin" / "tdx-api")
354
+ if self.static_path.is_dir():
355
+ shutil.copytree(self.static_path, target / "static")
356
+ if self.manifest_path.is_file():
357
+ shutil.copy2(self.manifest_path, target / "deployment.json")
358
+
359
+ def _restore_runtime(self, source: Path) -> None:
360
+ backup_binary = source / "bin" / "tdx-api"
361
+ if backup_binary.is_file():
362
+ shutil.copy2(backup_binary, self.binary_path)
363
+ self.binary_path.chmod(0o755)
364
+ backup_static = source / "static"
365
+ if backup_static.is_dir():
366
+ self._replace_static(backup_static)
367
+ backup_manifest = source / "deployment.json"
368
+ if backup_manifest.is_file():
369
+ shutil.copy2(backup_manifest, self.manifest_path)
370
+
371
+ def _prepare_directories(self) -> None:
372
+ for path in (
373
+ self.runtime_root,
374
+ self.binary_path.parent,
375
+ self.data_path,
376
+ self.logs_path,
377
+ self.plist_path.parent,
378
+ ):
379
+ path.mkdir(parents=True, exist_ok=True)
380
+ self.stdout_log_path.touch(exist_ok=True)
381
+ self.stderr_log_path.touch(exist_ok=True)
382
+ self.install_log_path.touch(exist_ok=True)
383
+
384
+ def _plist_payload(self) -> dict[str, object]:
385
+ return {
386
+ "Label": LAUNCHD_LABEL,
387
+ "ProgramArguments": [str(self.binary_path)],
388
+ "EnvironmentVariables": {"TDX_API_PORT": str(self.port)},
389
+ "WorkingDirectory": str(self.runtime_root),
390
+ "RunAtLoad": True,
391
+ "KeepAlive": True,
392
+ "ThrottleInterval": 30,
393
+ "ProcessType": "Standard",
394
+ "StandardOutPath": str(self.stdout_log_path),
395
+ "StandardErrorPath": str(self.stderr_log_path),
396
+ }
397
+
398
+ def _wait_for_port_release(self, port: int) -> None:
399
+ deadline = time.monotonic() + PORT_RELEASE_TIMEOUT_SEC
400
+ while _port_open(port) and time.monotonic() < deadline:
401
+ time.sleep(0.1)
402
+ if _port_open(port):
403
+ raise TdxApiDeploymentError(
404
+ f"[pytdxfeed] LaunchAgent stopped but port {port} is still occupied"
405
+ )
406
+
407
+ def _retire_managed_service(self, deployed_port: int) -> None:
408
+ """Stop a known managed job before reusing a healthy external service."""
409
+ self._launchctl(["bootout", self.service_target])
410
+ self._wait_for_port_release(deployed_port)
411
+ self.plist_path.unlink(missing_ok=True)
412
+
413
+ def _deployed_port(self, deployment: Mapping[str, Any] | None) -> int:
414
+ """Read the last managed port without guessing from an unknown process."""
415
+ if deployment is not None:
416
+ raw = deployment.get("service_port")
417
+ if isinstance(raw, int) and not isinstance(raw, bool):
418
+ try:
419
+ return validate_port(raw)
420
+ except ValueError:
421
+ pass
422
+ payload = _read_plist(self.plist_path)
423
+ environment = payload.get("EnvironmentVariables") if payload else None
424
+ if isinstance(environment, dict):
425
+ raw = environment.get("TDX_API_PORT")
426
+ try:
427
+ parsed = int(str(raw))
428
+ except (TypeError, ValueError):
429
+ pass
430
+ else:
431
+ try:
432
+ return validate_port(parsed)
433
+ except ValueError:
434
+ pass
435
+ return DEFAULT_PORT
436
+
437
+ def _wait_until_healthy(self) -> None:
438
+ deadline = time.monotonic() + self.startup_timeout_sec
439
+ last_error: Exception | None = None
440
+ while time.monotonic() < deadline:
441
+ try:
442
+ self.client.health()
443
+ return
444
+ except TdxApiError as exc:
445
+ last_error = exc
446
+ time.sleep(0.5)
447
+ launch_state = self._launchctl(
448
+ ["print", self.service_target], check=False
449
+ ).stdout
450
+ stderr_tail = _tail_file(self.stderr_log_path)
451
+ raise TdxApiDeploymentError(
452
+ "[pytdxfeed] service did not become healthy within "
453
+ f"{self.startup_timeout_sec:.0f}s; last_error={last_error}\n"
454
+ f"launchctl:\n{_tail_text(launch_state)}\n"
455
+ f"stderr:\n{stderr_tail}"
456
+ )
457
+
458
+ def _healthy(self) -> bool:
459
+ try:
460
+ self.client.health()
461
+ except TdxApiError:
462
+ return False
463
+ return True
464
+
465
+ def _job_loaded(self, target: str) -> bool:
466
+ return self._launchctl(["print", target], check=False).returncode == 0
467
+
468
+ def _launchctl(
469
+ self, args: Sequence[str], *, check: bool = True
470
+ ) -> subprocess.CompletedProcess[str]:
471
+ result = subprocess.run( # noqa: S603 - fixed executable with explicit argv
472
+ ["/bin/launchctl", *args],
473
+ check=False,
474
+ stdout=subprocess.PIPE,
475
+ stderr=subprocess.STDOUT,
476
+ text=True,
477
+ timeout=60,
478
+ )
479
+ if check and result.returncode != 0:
480
+ raise TdxApiDeploymentError(
481
+ f"[pytdxfeed] launchctl {' '.join(args)} failed "
482
+ f"with exit={result.returncode}:\n{_tail_text(result.stdout)}"
483
+ )
484
+ return result
485
+
486
+ @staticmethod
487
+ def _read_json(path: Path) -> dict[str, Any] | None:
488
+ try:
489
+ payload = json.loads(path.read_text(encoding="utf-8"))
490
+ except (OSError, ValueError):
491
+ return None
492
+ return payload if isinstance(payload, dict) else None
493
+
494
+ def _managed_service_info(self, deployment: Mapping[str, Any]) -> ServiceInfo:
495
+ return ServiceInfo(
496
+ base_url=self.base_url,
497
+ port=self.port,
498
+ managed=True,
499
+ launchd_label=LAUNCHD_LABEL,
500
+ runtime_root=self.runtime_root,
501
+ upstream_sha=str(deployment.get("upstream_sha") or UPSTREAM_SHA),
502
+ artifact_generation=int(deployment.get("artifact_generation", 0) or 0),
503
+ )
504
+
505
+
506
+ def _external_service_info(port: int) -> ServiceInfo:
507
+ return ServiceInfo(
508
+ base_url=local_base_url(port),
509
+ port=port,
510
+ managed=False,
511
+ launchd_label=None,
512
+ runtime_root=None,
513
+ upstream_sha=None,
514
+ artifact_generation=None,
515
+ )
516
+
517
+
518
+ def _load_asset_manifest() -> dict[str, Any]:
519
+ manifest_ref = resources.files("pytdxfeed").joinpath("assets", ASSET_MANIFEST)
520
+ try:
521
+ payload = json.loads(manifest_ref.read_text(encoding="utf-8"))
522
+ except (OSError, ValueError) as exc:
523
+ raise TdxApiDeploymentError(
524
+ f"[pytdxfeed] bundled asset manifest is invalid: {exc}"
525
+ ) from exc
526
+ if not isinstance(payload, dict) or payload.get("upstream_sha") != UPSTREAM_SHA:
527
+ raise TdxApiDeploymentError(
528
+ "[pytdxfeed] bundled asset manifest does not match the locked upstream"
529
+ )
530
+ return payload
531
+
532
+
533
+ def _architecture() -> str:
534
+ machine = platform.machine().lower()
535
+ aliases = {
536
+ "aarch64": "arm64",
537
+ "arm64": "arm64",
538
+ "amd64": "x86_64",
539
+ "x86_64": "x86_64",
540
+ }
541
+ try:
542
+ return aliases[machine]
543
+ except KeyError as exc:
544
+ raise TdxApiDeploymentError(
545
+ f"[pytdxfeed] unsupported macOS architecture: {machine}"
546
+ ) from exc
547
+
548
+
549
+ def _port_open(port: int = DEFAULT_PORT) -> bool:
550
+ resolved_port = validate_port(port)
551
+ try:
552
+ with socket.create_connection((HOST, resolved_port), timeout=0.25):
553
+ return True
554
+ except OSError:
555
+ return False
556
+
557
+
558
+ def _sha256(path: Path) -> str:
559
+ digest = hashlib.sha256()
560
+ with path.open("rb") as stream:
561
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
562
+ digest.update(chunk)
563
+ return digest.hexdigest()
564
+
565
+
566
+ def _extract_archive_safe(archive: Path, destination: Path) -> None:
567
+ with tarfile.open(archive, "r:gz") as tar:
568
+ for member in tar.getmembers():
569
+ relative = Path(member.name)
570
+ if relative.is_absolute() or ".." in relative.parts:
571
+ raise TdxApiDeploymentError(
572
+ f"[pytdxfeed] unsafe archive member: {member.name}"
573
+ )
574
+ target = destination / relative
575
+ if member.isdir():
576
+ target.mkdir(parents=True, exist_ok=True)
577
+ continue
578
+ if not member.isfile():
579
+ raise TdxApiDeploymentError(
580
+ f"[pytdxfeed] unsupported archive member: {member.name}"
581
+ )
582
+ target.parent.mkdir(parents=True, exist_ok=True)
583
+ source = tar.extractfile(member)
584
+ if source is None:
585
+ raise TdxApiDeploymentError(
586
+ f"[pytdxfeed] cannot read archive member: {member.name}"
587
+ )
588
+ with source, target.open("wb") as output:
589
+ shutil.copyfileobj(source, output)
590
+ target.chmod(member.mode & 0o777)
591
+
592
+
593
+ def _copy_missing_tree(source: Path, destination: Path) -> None:
594
+ if not source.is_dir():
595
+ return
596
+ destination.mkdir(parents=True, exist_ok=True)
597
+ for item in source.rglob("*"):
598
+ relative = item.relative_to(source)
599
+ target = destination / relative
600
+ if item.is_dir():
601
+ target.mkdir(parents=True, exist_ok=True)
602
+ elif item.is_file() and not target.exists():
603
+ target.parent.mkdir(parents=True, exist_ok=True)
604
+ shutil.copy2(item, target)
605
+
606
+
607
+ def _read_plist(path: Path) -> dict[str, Any] | None:
608
+ try:
609
+ payload = plistlib.loads(path.read_bytes())
610
+ except (OSError, ValueError):
611
+ return None
612
+ return payload if isinstance(payload, dict) else None
613
+
614
+
615
+ def _read_bytes(path: Path | None) -> bytes | None:
616
+ if path is None:
617
+ return None
618
+ try:
619
+ return path.read_bytes()
620
+ except OSError:
621
+ return None
622
+
623
+
624
+ def _write_json_atomic(path: Path, payload: Mapping[str, Any]) -> None:
625
+ encoded = (json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode()
626
+ _write_bytes_atomic(path, encoded, mode=0o644)
627
+
628
+
629
+ def _write_bytes_atomic(path: Path, payload: bytes, *, mode: int) -> None:
630
+ path.parent.mkdir(parents=True, exist_ok=True)
631
+ temp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
632
+ try:
633
+ temp.write_bytes(payload)
634
+ temp.chmod(mode)
635
+ temp.replace(path)
636
+ finally:
637
+ temp.unlink(missing_ok=True)
638
+
639
+
640
+ def _tail_text(value: str, *, lines: int = 80) -> str:
641
+ return "\n".join(str(value or "").splitlines()[-lines:])
642
+
643
+
644
+ def _tail_file(path: Path) -> str:
645
+ try:
646
+ return _tail_text(path.read_text(encoding="utf-8", errors="replace"))
647
+ except OSError as exc:
648
+ return f"<cannot read {path}: {exc}>"
@@ -0,0 +1,72 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytdxfeed
3
+ Version: 0.2.0
4
+ Summary: Private macOS runtime and HTTP client for oficcejo/tdx-api.
5
+ Author-email: Huanan Herman Zhao <hermanzhaozzzz@gmail.com>
6
+ Requires-Python: <3.14,>=3.11
7
+ Requires-Dist: requests<3.0.0,>=2.32.0
8
+ Description-Content-Type: text/markdown
9
+
10
+ # pytdxfeed
11
+
12
+ `pytdxfeed` is a private Python wrapper around the local
13
+ [`oficcejo/tdx-api`](https://github.com/oficcejo/tdx-api) service used by VQT
14
+ and `vnpy_ths`. It provides one macOS LaunchAgent and one HTTP client contract,
15
+ so both consumers share the same process instead of deploying competing copies.
16
+
17
+ The package is installed only from the private Git repository. It is not the
18
+ unrelated package formerly published under similar names on PyPI.
19
+
20
+ ## Runtime
21
+
22
+ - Host: `127.0.0.1`
23
+ - Port: `8080` by default; consumers may select another explicit port
24
+ - Runtime root: `~/.pytdxfeed`
25
+ - LaunchAgent: `com.hermanzhaozzzz.pytdxfeed.tdx-api`
26
+ - Pinned upstream commit: `ea07dccb67aeb92ebde851ac31503b4bf457a318`
27
+
28
+ On macOS, `ensure_service()` reuses an already healthy service or installs the
29
+ bundled binary for the current architecture. The persistent upstream database
30
+ is kept in `~/.pytdxfeed/data/database`. On other systems the function only
31
+ checks an existing local service.
32
+
33
+ ```python
34
+ from pytdxfeed import TdxApiClient, ensure_service
35
+
36
+ service = ensure_service(port=18080)
37
+ with TdxApiClient(timeout_sec=3.0, port=18080) as client:
38
+ health = client.health()
39
+ quotes = client.batch_quote(["sh600000", "sz159915"])
40
+ ```
41
+
42
+ The host and LaunchAgent identity stay fixed. Port selection is explicit: the
43
+ runtime never scans for a free port, and an unhealthy unknown process occupying
44
+ the selected port causes startup to fail without terminating that process. The
45
+ managed macOS service receives the selected port through `TDX_API_PORT`.
46
+
47
+ ## Development
48
+
49
+ ```bash
50
+ uv sync --locked --all-groups
51
+ uv run ruff check .
52
+ uv run ruff format --check .
53
+ uv run pytest
54
+ uv build
55
+ ```
56
+
57
+ Regenerate the private macOS assets when the locked upstream commit or reviewed
58
+ private patch set changes:
59
+
60
+ ```bash
61
+ uv run python scripts/build_assets.py --source /path/to/tdx-api
62
+ ```
63
+
64
+ The build verifies the exact clean upstream checkout, applies the patches in
65
+ `patches/`, runs their Go tests, and records every patch checksum in the asset
66
+ manifest. Artifact generation 3 retains explicit THS JSONP validation and adds
67
+ strict `TDX_API_PORT` handling to the Go service. It does not retry or switch
68
+ market-data sources.
69
+
70
+ The upstream repository currently has no declared license. This repository and
71
+ its derived binaries must remain private/internal until redistribution terms are
72
+ clear.
@@ -0,0 +1,11 @@
1
+ pytdxfeed/__init__.py,sha256=6gCCdJT1txxDcnlzOKSChWGwqLO1YOrKyxaAmjNUxYY,698
2
+ pytdxfeed/_version.py,sha256=mUzatm0zy0kkeuqzaWFobubGADb9Z4JAnkzlgBlhe9Y,92
3
+ pytdxfeed/client.py,sha256=hIBf23B2wkDHgIrYlmRjkggS0mmouLRPKS7TcOC8Odc,6042
4
+ pytdxfeed/errors.py,sha256=sEyDGLb4FP1mVPJdvDWI_bM8TxLdgSBW0d14Pn1CeQ8,483
5
+ pytdxfeed/runtime.py,sha256=q4ccVWgGn6x8pLb_6pCiCIpw0yqFt6yw_mGCqLRKXlw,24823
6
+ pytdxfeed/assets/manifest.json,sha256=P6lAnRdwF6uA_DZslmxUnEQi9TBFx8m-ybVQ1U4DOEs,872
7
+ pytdxfeed/assets/tdx-api-ea07dccb67ae-darwin-arm64.tar.gz,sha256=i5nxZjnCSMO2NPYTq_ydi9Rp9UKKpwtvFso4xwCn6MU,6357661
8
+ pytdxfeed/assets/tdx-api-ea07dccb67ae-darwin-x86_64.tar.gz,sha256=lpaxMyuCa7DXI9pSzTxViuUp7THeammJ9tnpgmWaSjE,6791327
9
+ pytdxfeed-0.2.0.dist-info/METADATA,sha256=cm9rymQGQC528ZyueVd9rIdjR8eve79gukGRHGnFCm8,2648
10
+ pytdxfeed-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
11
+ pytdxfeed-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any