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 +34 -0
- pytdxfeed/_version.py +3 -0
- pytdxfeed/assets/manifest.json +27 -0
- pytdxfeed/assets/tdx-api-ea07dccb67ae-darwin-arm64.tar.gz +0 -0
- pytdxfeed/assets/tdx-api-ea07dccb67ae-darwin-x86_64.tar.gz +0 -0
- pytdxfeed/client.py +172 -0
- pytdxfeed/errors.py +17 -0
- pytdxfeed/runtime.py +648 -0
- pytdxfeed-0.2.0.dist-info/METADATA +72 -0
- pytdxfeed-0.2.0.dist-info/RECORD +11 -0
- pytdxfeed-0.2.0.dist-info/WHEEL +4 -0
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,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
|
+
}
|
|
Binary file
|
|
Binary file
|
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,,
|