From d8eebbfc3792c87aeaa1d91954aea15a5e8dfe73 Mon Sep 17 00:00:00 2001 From: Tidelight Mirror Bot Date: Wed, 11 Nov 2026 05:14:28 +0000 Subject: [PATCH] chore(sync): mirror python from f75aa3468baaa7a5aab75aa1ccfec00befe24cbc --- CHANGELOG.md | 6 ++ README.md | 15 ++++- README.zh-CN.md | 5 +- src/tidelight/__init__.py | 4 +- src/tidelight/client.py | 124 +++++++++++++++++++++++++++++++++++++- tests/test_client.py | 107 +++++++++++++++++++++++++++++++- 6 files changed, 255 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8e8527..5cd7c6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- Add unauthenticated public Decision Capsule retrieval and local SHA-256 verification. +- Add a read-only public capsule method to the authenticated client. +- Retry interrupted public capsule response downloads and normalize string error responses. + ## 0.1.2 - Publish the current tested Python client with aligned installation and API availability guidance. diff --git a/README.md b/README.md index 8cdb1a4..3dfa7af 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ --- -The official Python client supports tokenized-equity market listings, asynchronous company research jobs, and research webhooks. It preserves provider provenance, stable error codes, and request IDs while applying bounded timeouts and retries. +The official Python client supports tokenized-equity market listings, asynchronous company research jobs, research webhooks, and read-only public Decision Capsule verification. It preserves provider provenance, stable error codes, and request IDs while applying bounded timeouts and retries. ## Install @@ -38,6 +38,7 @@ for instrument in page.data: | Tokenized-equity spot listings and pagination | `market:read` | `list_tokenized_equities`, `tokenized_equities` | | Company research snapshots | `research:run` | `start_company_research`, `get_research_job`, `cancel_research_job`, `wait_for_research_job` | | Research webhooks and delivery history | `alerts:manage` | `create_webhook`, `update_webhook`, `list_webhooks`, `list_webhook_deliveries` | +| Public Decision Capsule copies | Public bearer link | `get_public_decision_capsule`, `verify_public_decision_capsule` | A tokenized-equity spot instrument is not a direct share or a stock perpetual. When provider terms block a market response, the SDK surfaces `provider_terms_unconfirmed` without retrying or substituting data. @@ -45,6 +46,18 @@ A tokenized-equity spot instrument is not a direct share or a stock perpetual. W The default request timeout is 10 seconds. Network errors, `429`, and transient `5xx` responses receive at most two bounded retries. Webhook mutations are not automatically retried because the API does not accept webhook idempotency keys. The default transport rejects redirects and restricts bearer credentials to `api.tidelight.app`. +Public capsule links are fetched from `tidelight.app` without an API key and are verified locally against the disclosed snapshot's SHA-256 digest. `public_base_url` can be overridden only for a trusted HTTPS origin or local development. Digest verification detects changed bytes relative to the supplied digest; it does not prove authorship, source authenticity, or research quality. + +Use the standalone helper to retrieve a shared capsule without creating an authenticated client: + +```python +import os +from tidelight import get_public_decision_capsule + +capsule = get_public_decision_capsule(os.environ["TIDELIGHT_PUBLIC_CAPSULE_TOKEN"]) +print(capsule["integrityHash"], capsule["snapshot"]) +``` + Webhook creation and secret rotation return a one-time signing secret. **TODO: Persist** it in your approved secret manager immediately. Do not print it; it cannot be recovered by listing the endpoint. ## Build and test from source diff --git a/README.zh-CN.md b/README.zh-CN.md index 3a31dcb..5276c9d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -7,7 +7,7 @@ --- -官方 Python 客户端支持代币化股票行情列表、异步公司研究任务和研究 Webhook。客户端保留数据提供方来源、稳定错误代码和请求 ID,并使用有界超时与重试。 +官方 Python 客户端支持代币化股票行情列表、异步公司研究任务、研究 Webhook 和只读公开决策胶囊验证。客户端保留数据提供方来源、稳定错误代码和请求 ID,并使用有界超时与重试。 ## 安装 @@ -37,6 +37,7 @@ for instrument in page.data: | 代币化股票现货列表与分页 | `market:read` | `list_tokenized_equities`、`tokenized_equities` | | 公司研究快照 | `research:run` | `start_company_research`、`get_research_job`、`cancel_research_job`、`wait_for_research_job` | | 研究 Webhook 与投递记录 | `alerts:manage` | `create_webhook`、`update_webhook`、`list_webhooks`、`list_webhook_deliveries` | +| 公开决策胶囊副本 | 公开持有者链接 | `get_public_decision_capsule`、`verify_public_decision_capsule` | 代币化股票现货交易工具并非直接持有的股票,也不是股票永续合约。当数据提供方条款阻止行情响应时,SDK 会返回 `provider_terms_unconfirmed`,不会自动改用其他数据源。 @@ -46,6 +47,8 @@ for instrument in page.data: 创建 Webhook 或轮换密钥时会返回一次性签名密钥。**请立即将其保存到获批的密钥管理器中。** 不要打印该密钥;之后无法通过列出端点找回。 +公开胶囊链接从 `tidelight.app` 获取,不发送 API 密钥;客户端会在本地根据已公开快照的 SHA-256 摘要进行验证。摘要验证只能检测相对于给定摘要的字节变化,不能证明作者身份、来源真实性或研究质量。 + ## 从源代码构建与测试 ```sh diff --git a/src/tidelight/__init__.py b/src/tidelight/__init__.py index 46ad5db..4a54d1a 100644 --- a/src/tidelight/__init__.py +++ b/src/tidelight/__init__.py @@ -1,5 +1,5 @@ """Tidelight Intelligence Cloud Python client.""" -from .client import ApiError, Client, Instrument, InstrumentPage, ResearchJob +from .client import ApiError, Client, Instrument, InstrumentPage, ResearchJob, get_public_decision_capsule, verify_public_decision_capsule -__all__ = ["ApiError", "Client", "Instrument", "InstrumentPage", "ResearchJob"] +__all__ = ["ApiError", "Client", "Instrument", "InstrumentPage", "ResearchJob", "get_public_decision_capsule", "verify_public_decision_capsule"] diff --git a/src/tidelight/client.py b/src/tidelight/client.py index b6ab29d..067961b 100644 --- a/src/tidelight/client.py +++ b/src/tidelight/client.py @@ -3,9 +3,13 @@ from __future__ import annotations import json +import hashlib +from http.client import IncompleteRead +import math import re import time from dataclasses import dataclass +from decimal import Decimal from typing import Any, Callable, Iterator from urllib.error import HTTPError, URLError from urllib.parse import urlencode, urlparse @@ -57,6 +61,72 @@ class ResearchJob: request_id: str +def _canonical_json(value: Any) -> str: + if value is None or isinstance(value, (str, bool)): + return _canonical_json_string(value) + if isinstance(value, int): + return str(value) + if isinstance(value, float): + if not math.isfinite(value): + raise ValueError("Public capsule snapshots must contain finite numbers.") + if value == 0: + return "0" + raw = repr(value).lower() + magnitude = abs(value) + if 1e-6 <= magnitude < 1e21: + fixed = format(Decimal(raw), "f") + return fixed.rstrip("0").rstrip(".") if "." in fixed else fixed + mantissa, _, exponent = raw.partition("e") + if mantissa.endswith(".0"): + mantissa = mantissa[:-2] + exponent_value = int(exponent or "0") + return f"{mantissa}e{'+' if exponent_value >= 0 else ''}{exponent_value}" + if isinstance(value, list): + return "[" + ",".join(_canonical_json(item) for item in value) + "]" + if isinstance(value, dict) and all(isinstance(key, str) for key in value): + return "{" + ",".join( + _canonical_json_string(key) + ":" + _canonical_json(value[key]) + for key in sorted(value) + ) + "}" + raise ValueError("Public capsule snapshots must contain JSON values only.") + + +def _canonical_json_string(value: str | bool | None) -> str: + rendered = json.dumps(value, ensure_ascii=False, separators=(",", ":")) + result: list[str] = [] + index = 0 + while index < len(rendered): + codepoint = ord(rendered[index]) + if 0xD800 <= codepoint <= 0xDBFF and index + 1 < len(rendered): + low = ord(rendered[index + 1]) + if 0xDC00 <= low <= 0xDFFF: + result.append(chr(0x10000 + ((codepoint - 0xD800) << 10) + low - 0xDC00)) + index += 2 + continue + if 0xD800 <= codepoint <= 0xDFFF: + result.append(f"\\u{codepoint:04x}") + else: + result.append(rendered[index]) + index += 1 + return "".join(result) + + +def verify_public_decision_capsule(publication: dict[str, Any]) -> bool: + """Check the disclosed snapshot digest; this does not establish source truth or authorship.""" + if not isinstance(publication, dict) or not isinstance(publication.get("snapshot"), dict): + return False + if publication.get("hashAlgorithm") != "sha256": + return False + expected = publication.get("integrityHash") + if not isinstance(expected, str) or not re.fullmatch(r"[a-f0-9]{64}", expected): + return False + try: + actual = hashlib.sha256(_canonical_json(publication.get("snapshot")).encode("utf-8")).hexdigest() + except (TypeError, ValueError, OverflowError): + return False + return actual == expected + + class ApiError(Exception): def __init__(self, status: int, code: str, message: str, request_id: str | None = None): super().__init__(message) @@ -65,8 +135,52 @@ def __init__(self, status: int, code: str, message: str, request_id: str | None self.request_id = request_id +def get_public_decision_capsule(token: str, *, public_base_url: str = "https://tidelight.app", timeout: float = 10, max_retries: int = 2, opener: Callable[..., Any] | None = None, sleeper: Callable[[float], None] = time.sleep) -> dict[str, Any]: + if not isinstance(token, str) or not re.fullmatch(r"[A-Za-z0-9_-]{43}", token): + raise ValueError("Invalid public capsule token.") + public = urlparse(public_base_url) + local_public = public.scheme == "http" and public.hostname in {"localhost", "127.0.0.1"} + if (public.scheme != "https" and not local_public) or (public.scheme == "https" and not public.hostname) or (public.scheme == "https" and public.port not in (None, 443)) or public.path not in ("", "/") or public.username or public.password or public.query or public.fragment: + raise ValueError("The public capsule URL must use HTTPS or a local development origin.") + if not 0.1 <= timeout <= 120 or not isinstance(max_retries, int) or not 0 <= max_retries <= 5: + raise ValueError("Public capsule timeout or retry limit is out of range.") + transport = opener or build_opener(_NoRedirect()).open + pause = sleeper + request = Request(f"{public_base_url.rstrip('/')}/api/decision-capsules/public/{token}", headers={"Accept": "application/json"}) + for attempt in range(max_retries + 1): + try: + with transport(request, timeout=timeout) as response: + try: + payload = json.load(response) + except ValueError: + raise ApiError(getattr(response, "status", 0), "invalid_response", "The public capsule response is invalid.") from None + except HTTPError as error: + try: + body = json.load(error) + details = body.get("error", {}) if isinstance(body, dict) else {} + if isinstance(details, str): + details = {"message": details} + if not isinstance(details, dict): + details = {} + except (ValueError, AttributeError, OSError, IncompleteRead): + details = {} + if error.code in {429, 502, 503, 504} and attempt < max_retries: + pause(min(5.0, float(error.headers.get("Retry-After", 0) or 0)) if str(error.headers.get("Retry-After", "")).isdigit() else min(2.0, 0.25 * 2 ** attempt)) + continue + raise ApiError(error.code, details.get("code", "public_capsule_error"), details.get("message", "Public capsule request failed."), details.get("request_id") or error.headers.get("X-Request-Id")) from None + except (URLError, TimeoutError, OSError, IncompleteRead): + if attempt >= max_retries: + raise ApiError(0, "network_error", "The public capsule could not be reached.") from None + pause(min(2.0, 0.25 * 2 ** attempt)) + continue + if not isinstance(payload, dict) or payload.get("schemaVersion") != 1 or not isinstance(payload.get("snapshot"), dict) or payload.get("integrity") != "valid" or not verify_public_decision_capsule(payload): + raise ApiError(409, "integrity_verification_failed", "Public capsule integrity verification failed.") + return payload + raise RuntimeError("Unreachable retry state") + + class Client: - def __init__(self, api_key: str, *, base_url: str = "https://api.tidelight.app/v1", timeout: float = 10, max_retries: int = 2, opener: Callable[..., Any] | None = None, sleeper: Callable[[float], None] = time.sleep): + def __init__(self, api_key: str, *, base_url: str = "https://api.tidelight.app/v1", public_base_url: str = "https://tidelight.app", timeout: float = 10, max_retries: int = 2, opener: Callable[..., Any] | None = None, sleeper: Callable[[float], None] = time.sleep): if not re.fullmatch(r"tl_(test|live)_[A-Za-z0-9_-]{43}", api_key): raise ValueError("A valid Tidelight API key is required.") parsed = urlparse(base_url) @@ -76,17 +190,25 @@ def __init__(self, api_key: str, *, base_url: str = "https://api.tidelight.app/v raise ValueError("The API URL must use api.tidelight.app over HTTPS.") if parsed.username or parsed.password or parsed.query or parsed.fragment: raise ValueError("The API URL cannot contain credentials, query, or fragment.") + public = urlparse(public_base_url) + local_public = public.scheme == "http" and public.hostname in {"localhost", "127.0.0.1"} + if (public.scheme != "https" and not local_public) or (public.scheme == "https" and not public.hostname) or (public.scheme == "https" and public.port not in (None, 443)) or public.path not in ("", "/") or public.username or public.password or public.query or public.fragment: + raise ValueError("The public capsule URL must use HTTPS or a local development origin.") if not 0.1 <= timeout <= 120: raise ValueError("timeout must be 0.1 to 120 seconds.") if not isinstance(max_retries, int) or not 0 <= max_retries <= 5: raise ValueError("max_retries must be 0 to 5.") self._api_key = api_key self._base_url = base_url.rstrip("/") + self._public_base_url = public_base_url.rstrip("/") self._timeout = timeout self._max_retries = max_retries self._opener = opener or build_opener(_NoRedirect()).open self._sleeper = sleeper + def get_public_decision_capsule(self, token: str) -> dict[str, Any]: + return get_public_decision_capsule(token, public_base_url=self._public_base_url, timeout=self._timeout, max_retries=self._max_retries, opener=self._opener, sleeper=self._sleeper) + def list_tokenized_equities(self, *, limit: int = 20, cursor: str | None = None, underlying_ticker: str | None = None) -> InstrumentPage: if not isinstance(limit, int) or not 1 <= limit <= 100: raise ValueError("limit must be 1 to 100.") diff --git a/tests/test_client.py b/tests/test_client.py index 1a3ba2d..01f76a0 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,12 +1,14 @@ import io +import hashlib import json import threading import unittest +from http.client import IncompleteRead from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.error import HTTPError from urllib.parse import parse_qs, urlparse -from tidelight import ApiError, Client +from tidelight import ApiError, Client, get_public_decision_capsule, verify_public_decision_capsule KEY = "tl_test_" + "a" * 43 INSTRUMENT = {"symbol": "RNVDAUSDT", "instrument_type": "tokenized_equity_spot", "underlying_ticker": "NVDA", "issuer_name": "NVIDIA", "quote_currency": "USDT", "last_price": 1.0, "change_24h": None, "provider_timestamp": None, "market_session_status": "available", "source": "Bitget Reality spot"} @@ -20,6 +22,109 @@ def __init__(self, data): class ClientTests(unittest.TestCase): + def test_public_capsule_fetch_verifies_digest_without_api_credentials(self): + from tidelight.client import _canonical_json + + snapshot = {"schemaVersion": 1, "integrityScope": "disclosed_public_projection", "research": {"question": "NVDA?", "createdAt": "2026-11-01T00:00:00Z"}} + digest = hashlib.sha256(_canonical_json(snapshot).encode()).hexdigest() + payload = {"schemaVersion": 1, "publishedAt": "2026-11-01T00:00:00Z", "snapshot": snapshot, "integrityHash": digest, "hashAlgorithm": "sha256", "integrity": "valid"} + calls = [] + + def opener(request, timeout): + calls.append((request.full_url, request.get_header("Authorization"))) + return FakeResponse(payload) + + client = Client(KEY, opener=opener) + result = client.get_public_decision_capsule("a" * 43) + self.assertEqual(result["snapshot"]["research"]["question"], "NVDA?") + self.assertEqual(calls[0][0], "https://tidelight.app/api/decision-capsules/public/" + "a" * 43) + self.assertIsNone(calls[0][1]) + self.assertTrue(verify_public_decision_capsule(payload)) + tampered = {**payload, "integrityHash": "0" * 64} + self.assertFalse(verify_public_decision_capsule(tampered)) + null_digest = hashlib.sha256(b"null").hexdigest() + self.assertFalse(verify_public_decision_capsule({"hashAlgorithm": "sha256", "integrityHash": null_digest})) + with self.assertRaises(ValueError): + client.get_public_decision_capsule("bad/token") + public_only = get_public_decision_capsule("a" * 43, opener=opener) + self.assertEqual(public_only["integrity"], "valid") + + local_client = Client(KEY, public_base_url="http://localhost:3000", opener=opener) + local_client.get_public_decision_capsule("a" * 43) + self.assertTrue(calls[-1][0].startswith("http://localhost:3000/")) + preview_client = Client(KEY, public_base_url="https://preview.tidelight.example", opener=opener) + preview_client.get_public_decision_capsule("a" * 43) + self.assertTrue(calls[-1][0].startswith("https://preview.tidelight.example/")) + + shared = {"z": -0.0, "a": {"unicode": "NVIDIA ™", "tiny": 1e-7, "micro": 1e-6, "near": 1.23e20, "prob": 0.7, "whole": 1.0}, "items": [True, None, 3]} + self.assertEqual(_canonical_json(shared), '{"a":{"micro":0.000001,"near":123000000000000000000,"prob":0.7,"tiny":1e-7,"unicode":"NVIDIA ™","whole":1},"items":[true,null,3],"z":0}') + self.assertTrue(verify_public_decision_capsule({"snapshot": shared, "hashAlgorithm": "sha256", "integrityHash": "d9e3e417a7e61eaeeb90ce493e07e959ee65d4801282f69c559aa8f9bf55dc98"})) + + def test_python_public_capsule_canonical_json_matches_javascript_surrogate_escaping(self): + from tidelight.client import _canonical_json + + value = {"split": "a" * 3999 + "\ud83d", "emoji": "😀", "pair": "\ud83d\ude00", "literal": r"\ud83d"} + expected = '{"emoji":"😀","literal":"\\\\ud83d","pair":"😀","split":"' + "a" * 3999 + r"\ud83d" + '"}' + canonical = _canonical_json(value) + self.assertEqual(canonical, expected) + publication = {"snapshot": value, "hashAlgorithm": "sha256", "integrityHash": hashlib.sha256(canonical.encode("utf-8")).hexdigest()} + self.assertTrue(verify_public_decision_capsule(publication)) + + def test_public_capsule_http_string_error_becomes_api_error(self): + def opener(request, timeout): + raise HTTPError(request.full_url, 404, "Not found", {}, io.BytesIO(b'{"error":"Public capsule not found."}')) + + with self.assertRaises(ApiError) as caught: + get_public_decision_capsule("a" * 43, opener=opener, max_retries=0) + self.assertEqual(caught.exception.status, 404) + self.assertEqual(str(caught.exception), "Public capsule not found.") + + def test_public_capsule_truncated_error_body_remains_an_api_error(self): + class TruncatedErrorBody(io.BytesIO): + def read(self, *args, **kwargs): + raise IncompleteRead(b'{"error":', 20) + + def opener(request, timeout): + raise HTTPError(request.full_url, 503, "Unavailable", {}, TruncatedErrorBody(b"")) + + with self.assertRaises(ApiError) as caught: + get_public_decision_capsule("a" * 43, opener=opener, max_retries=0) + self.assertEqual(caught.exception.status, 503) + self.assertEqual(caught.exception.code, "public_capsule_error") + + def test_public_capsule_malformed_success_body_becomes_api_error(self): + class MalformedResponse(io.BytesIO): + status = 200 + + with self.assertRaises(ApiError) as caught: + get_public_decision_capsule("a" * 43, opener=lambda *_args, **_kwargs: MalformedResponse(b"proxy error"), max_retries=0) + self.assertEqual(caught.exception.status, 200) + self.assertEqual(caught.exception.code, "invalid_response") + + def test_public_capsule_retries_truncated_response_body(self): + from tidelight.client import _canonical_json + + snapshot = {"schemaVersion": 1, "research": {"question": "NVDA?"}} + digest = hashlib.sha256(_canonical_json(snapshot).encode()).hexdigest() + payload = {"schemaVersion": 1, "publishedAt": "2026-11-01T00:00:00Z", "snapshot": snapshot, "integrityHash": digest, "hashAlgorithm": "sha256", "integrity": "valid"} + calls = [] + + class TruncatedResponse(io.BytesIO): + status = 200 + + def read(self, *args, **kwargs): + raise IncompleteRead(b"{\"schema", 20) + + def opener(request, timeout): + calls.append(request.full_url) + if len(calls) == 1: + return TruncatedResponse(b"") + return FakeResponse(payload) + + result = get_public_decision_capsule("a" * 43, opener=opener, sleeper=lambda _seconds: None, max_retries=1) + self.assertEqual(result["integrity"], "valid") + self.assertEqual(len(calls), 2) + def test_research_job_creation_status_and_cancellation(self): job_id = "01234567-89ab-4cde-8fab-0123456789ab" calls = []