fix(HEL-494): 网站市场客户端改为纯中枢 Facade,并迁移 iFinD 凭据到中枢
生产 gateway 不再读取 Tushare token 或实例化 TushareProvider/TushareClient;问财凭据经带鉴权的中枢接口加密入库,避免发版后 iFinD 未配置。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
co-authored by
Cursor
multica-agent
parent
100752f43c
commit
8a7d1f3698
@@ -125,6 +125,10 @@ class DashboardService(
|
|||||||
)
|
)
|
||||||
self.data_gateway = self.container.data_gateway
|
self.data_gateway = self.container.data_gateway
|
||||||
self.ifind = self.container.ifind
|
self.ifind = self.container.ifind
|
||||||
|
refresh = str(self._system_credentials.get("ifind_refresh_token") or "")
|
||||||
|
access = str(self._system_credentials.get("ifind_access_token") or "")
|
||||||
|
if refresh or access:
|
||||||
|
self.ifind.set_credentials(refresh, access)
|
||||||
self.screener = self.container.screener
|
self.screener = self.container.screener
|
||||||
self.strategy_tracking = self.container.strategy_tracking
|
self.strategy_tracking = self.container.strategy_tracking
|
||||||
self.alert_service = self.container.alert_service
|
self.alert_service = self.container.alert_service
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from typing import Any, Callable
|
from threading import Lock
|
||||||
|
from typing import Any, Callable, ClassVar
|
||||||
|
|
||||||
from backend.data.datahub.client import DatahubClient, DatahubResponse
|
from backend.data.datahub.client import DatahubClient, DatahubResponse
|
||||||
from backend.data.datahub.compare import compare_rows
|
from backend.data.datahub.compare import compare_rows
|
||||||
@@ -18,7 +19,13 @@ from backend.data.datahub.native import (
|
|||||||
from backend.data.datahub.redact import redact_text, redact_value
|
from backend.data.datahub.redact import redact_text, redact_value
|
||||||
from backend.data.datahub.route_state import LEDGER
|
from backend.data.datahub.route_state import LEDGER
|
||||||
from backend.data.datahub.settings import DatahubSettings
|
from backend.data.datahub.settings import DatahubSettings
|
||||||
from backend.data.providers.tushare_client import TushareClient
|
from backend.data.providers.tushare_daily import DailyMarketMixin
|
||||||
|
from backend.data.providers.tushare_dashboard import DashboardMixin
|
||||||
|
from backend.data.providers.tushare_dragon_tiger import DragonTigerMixin
|
||||||
|
from backend.data.providers.tushare_indices import IndexMixin
|
||||||
|
from backend.data.providers.tushare_industries import ShenwanIndustryMixin
|
||||||
|
from backend.data.providers.tushare_sectors import SectorMixin
|
||||||
|
from backend.data.providers.tushare_stocks import StockMixin
|
||||||
from backend.data.providers.tushare_transport import TushareError
|
from backend.data.providers.tushare_transport import TushareError
|
||||||
|
|
||||||
LOGGER = logging.getLogger("xiaobai.datahub")
|
LOGGER = logging.getLogger("xiaobai.datahub")
|
||||||
@@ -304,11 +311,9 @@ class DatahubBridge:
|
|||||||
def query(
|
def query(
|
||||||
self,
|
self,
|
||||||
api_name: str,
|
api_name: str,
|
||||||
params: dict[str, Any] | None,
|
params: dict[str, Any] | None = None,
|
||||||
fields: str,
|
fields: str = "",
|
||||||
legacy_query: Callable[..., list[dict[str, Any]]],
|
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
del legacy_query # 主网站不再直连 Tushare;调度全部由数据中枢完成。
|
|
||||||
if api_name == "rt_sw_k":
|
if api_name == "rt_sw_k":
|
||||||
raise TushareError("rt_sw_k is disabled; use published sw_daily or free Shenwan realtime")
|
raise TushareError("rt_sw_k is disabled; use published sw_daily or free Shenwan realtime")
|
||||||
dataset = API_TO_DATASET.get(api_name)
|
dataset = API_TO_DATASET.get(api_name)
|
||||||
@@ -542,21 +547,36 @@ def _finite(value: Any) -> float:
|
|||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
class DatahubAwareTushareClient:
|
class DatahubAwareTushareClient(
|
||||||
def __init__(self, legacy: TushareClient, bridge: DatahubBridge) -> None:
|
DashboardMixin,
|
||||||
self._legacy = legacy
|
IndexMixin,
|
||||||
self._bridge = bridge
|
ShenwanIndustryMixin,
|
||||||
# Mixins run as methods on the inner instance (dashboard / indices /
|
SectorMixin,
|
||||||
# getattr). Bind hub hooks and query onto that instance so real
|
DragonTigerMixin,
|
||||||
# assembly cannot skip 8766.
|
StockMixin,
|
||||||
self._legacy_query = legacy.query
|
DailyMarketMixin,
|
||||||
legacy.query = self.query
|
):
|
||||||
legacy.try_market_quotes = self.try_market_quotes
|
"""Website market facade. Mixins call query(); query talks only to the hub."""
|
||||||
legacy.try_quotes = self.try_quotes
|
|
||||||
legacy.try_index_quotes = self.try_index_quotes
|
_realtime_reference_cache: ClassVar[dict[str, dict[str, Any]]] = {}
|
||||||
legacy.try_sector_quote = self.try_sector_quote
|
_realtime_reference_lock: ClassVar[Lock] = Lock()
|
||||||
legacy.try_limit_pool = self.try_limit_pool
|
_capital_cache: ClassVar[dict[str, dict[str, Any]]] = {}
|
||||||
legacy.record_datahub_legacy = self.record_datahub_legacy
|
_latest_realtime_market: ClassVar[dict[str, dict[str, Any]]] = {}
|
||||||
|
_stock_activity_cache: ClassVar[dict[str, dict[str, Any]]] = {}
|
||||||
|
_stock_listing_cache: ClassVar[dict[str, Any]] = {}
|
||||||
|
_stock_listing_lock: ClassVar[Lock] = Lock()
|
||||||
|
_suspension_cache: ClassVar[dict[str, dict[str, str] | None]] = {}
|
||||||
|
_suspension_lock: ClassVar[Lock] = Lock()
|
||||||
|
_sw_member_cache: ClassVar[dict[str, Any]] = {}
|
||||||
|
_sw_member_lock: ClassVar[Lock] = Lock()
|
||||||
|
|
||||||
|
def __init__(self, first: Any, second: Any | None = None) -> None:
|
||||||
|
# Production: DatahubAwareTushareClient(bridge)
|
||||||
|
# Older tests: DatahubAwareTushareClient(unused_legacy, bridge)
|
||||||
|
self._bridge = second if second is not None else first
|
||||||
|
self.token = "datahub"
|
||||||
|
self.timeout = 30
|
||||||
|
self.realtime_aggregator = None
|
||||||
|
|
||||||
def query(
|
def query(
|
||||||
self,
|
self,
|
||||||
@@ -564,7 +584,7 @@ class DatahubAwareTushareClient:
|
|||||||
params: dict[str, Any] | None = None,
|
params: dict[str, Any] | None = None,
|
||||||
fields: str = "",
|
fields: str = "",
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
return self._bridge.query(api_name, params, fields, self._legacy_query)
|
return self._bridge.query(api_name, params, fields)
|
||||||
|
|
||||||
def try_market_quotes(self, trade_date: str = "") -> list[dict[str, Any]] | None:
|
def try_market_quotes(self, trade_date: str = "") -> list[dict[str, Any]] | None:
|
||||||
return self._bridge.try_market_quotes(trade_date)
|
return self._bridge.try_market_quotes(trade_date)
|
||||||
@@ -583,6 +603,3 @@ class DatahubAwareTushareClient:
|
|||||||
|
|
||||||
def record_datahub_legacy(self, dataset: str, source: str = "", error: str = "") -> None:
|
def record_datahub_legacy(self, dataset: str, source: str = "", error: str = "") -> None:
|
||||||
self._bridge.record_legacy(dataset, source, error)
|
self._bridge.record_legacy(dataset, source, error)
|
||||||
|
|
||||||
def __getattr__(self, name: str) -> Any:
|
|
||||||
return getattr(self._legacy, name)
|
|
||||||
|
|||||||
@@ -96,6 +96,15 @@ class DatahubClient:
|
|||||||
{"api_name": api_name, "params": params or {}, "fields": fields},
|
{"api_name": api_name, "params": params or {}, "fields": fields},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def put_ifind_credentials(self, refresh_token: str, access_token: str = "") -> DatahubResponse:
|
||||||
|
return self.post(
|
||||||
|
"/v1/credentials/ifind",
|
||||||
|
{
|
||||||
|
"ifind_refresh_token": refresh_token,
|
||||||
|
"ifind_access_token": access_token,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def sector_quote(self, code: str, date: str = "") -> DatahubResponse:
|
def sector_quote(self, code: str, date: str = "") -> DatahubResponse:
|
||||||
payload: dict[str, Any] = {"code": code}
|
payload: dict[str, Any] = {"code": code}
|
||||||
if date:
|
if date:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -7,6 +8,8 @@ from backend.data.datahub.bridge import DatahubBridge
|
|||||||
from backend.data.datahub.errors import DatahubError
|
from backend.data.datahub.errors import DatahubError
|
||||||
from backend.data.providers.ifind_client import IfindError
|
from backend.data.providers.ifind_client import IfindError
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("xiaobai.datahub")
|
||||||
|
|
||||||
|
|
||||||
class HubIfindProxy:
|
class HubIfindProxy:
|
||||||
"""Website-facing iFinD facade. Talks only to xiaobai-datahub."""
|
"""Website-facing iFinD facade. Talks only to xiaobai-datahub."""
|
||||||
@@ -15,16 +18,20 @@ class HubIfindProxy:
|
|||||||
self._datahub = datahub
|
self._datahub = datahub
|
||||||
self._status: dict[str, Any] | None = None
|
self._status: dict[str, Any] | None = None
|
||||||
self._status_at = 0.0
|
self._status_at = 0.0
|
||||||
|
self._pending: tuple[str, str] | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def configured(self) -> bool:
|
def configured(self) -> bool:
|
||||||
return bool(self.status().get("configured"))
|
return bool(self.status().get("configured"))
|
||||||
|
|
||||||
def set_credentials(self, refresh_token: str, access_token: str = "") -> None:
|
def set_credentials(self, refresh_token: str, access_token: str = "") -> None:
|
||||||
|
self._pending = (str(refresh_token or ""), str(access_token or ""))
|
||||||
self._status = None
|
self._status = None
|
||||||
self._status_at = 0.0
|
self._status_at = 0.0
|
||||||
|
self._flush_credentials()
|
||||||
|
|
||||||
def status(self) -> dict[str, Any]:
|
def status(self) -> dict[str, Any]:
|
||||||
|
self._flush_credentials()
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if self._status is not None and now - self._status_at < 30:
|
if self._status is not None and now - self._status_at < 30:
|
||||||
return dict(self._status)
|
return dict(self._status)
|
||||||
@@ -132,7 +139,18 @@ class HubIfindProxy:
|
|||||||
"sample_time": str(payload[0].get("time") or "") if payload else "",
|
"sample_time": str(payload[0].get("time") or "") if payload else "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _flush_credentials(self) -> None:
|
||||||
|
pending = self._pending
|
||||||
|
if pending is None or not self._datahub.settings.token:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._datahub.client.put_ifind_credentials(pending[0], pending[1])
|
||||||
|
self._pending = None
|
||||||
|
except DatahubError:
|
||||||
|
LOGGER.warning("datahub ifind credential push failed; will retry")
|
||||||
|
|
||||||
def _rows(self, api_name: str, params: dict[str, Any]) -> list[dict[str, Any]]:
|
def _rows(self, api_name: str, params: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
self._flush_credentials()
|
||||||
try:
|
try:
|
||||||
response = self._datahub.client.query_api(api_name, params)
|
response = self._datahub.client.query_api(api_name, params)
|
||||||
except DatahubError as exc:
|
except DatahubError as exc:
|
||||||
|
|||||||
+5
-11
@@ -10,9 +10,8 @@ from backend.data.datahub import DatahubAwareTushareClient, DatahubBridge, Datah
|
|||||||
from backend.data.datahub.ifind_proxy import HubIfindProxy
|
from backend.data.datahub.ifind_proxy import HubIfindProxy
|
||||||
from backend.data.datahub.realtime_proxy import HubRealtimeProxy
|
from backend.data.datahub.realtime_proxy import HubRealtimeProxy
|
||||||
from backend.data.policy import DataSourcePolicy
|
from backend.data.policy import DataSourcePolicy
|
||||||
from backend.data.providers import IfindProvider, TushareProvider
|
from backend.data.providers import IfindProvider
|
||||||
from backend.data.quality import DataQualityGate, QualityEvidence, QualityReport
|
from backend.data.quality import DataQualityGate, QualityEvidence, QualityReport
|
||||||
from backend.data.providers.tushare_client import TushareClient
|
|
||||||
from backend.features.market.charts import MarketChartClient
|
from backend.features.market.charts import MarketChartClient
|
||||||
|
|
||||||
|
|
||||||
@@ -20,7 +19,6 @@ from backend.features.market.charts import MarketChartClient
|
|||||||
class DataGateway:
|
class DataGateway:
|
||||||
policy: DataSourcePolicy
|
policy: DataSourcePolicy
|
||||||
quality: DataQualityGate
|
quality: DataQualityGate
|
||||||
tushare_provider: TushareProvider
|
|
||||||
ifind_provider: IfindProvider
|
ifind_provider: IfindProvider
|
||||||
chart_data: MarketChartClient
|
chart_data: MarketChartClient
|
||||||
realtime_observer: HubRealtimeProxy
|
realtime_observer: HubRealtimeProxy
|
||||||
@@ -34,12 +32,10 @@ class DataGateway:
|
|||||||
self,
|
self,
|
||||||
dataset_id: str = "",
|
dataset_id: str = "",
|
||||||
usage: DataUsage = "calculation",
|
usage: DataUsage = "calculation",
|
||||||
) -> TushareClient:
|
) -> DatahubAwareTushareClient:
|
||||||
if dataset_id:
|
if dataset_id:
|
||||||
self.policy.assert_allowed(dataset_id, "tushare", usage)
|
self.policy.assert_allowed(dataset_id, "tushare", usage)
|
||||||
legacy = self.tushare_provider.client()
|
return DatahubAwareTushareClient(self.datahub)
|
||||||
legacy.realtime_aggregator = None
|
|
||||||
return DatahubAwareTushareClient(legacy, self.datahub)
|
|
||||||
|
|
||||||
def dataset_status(self, trade_date: str) -> list[dict[str, Any]] | None:
|
def dataset_status(self, trade_date: str) -> list[dict[str, Any]] | None:
|
||||||
return self.datahub.dataset_status(trade_date)
|
return self.datahub.dataset_status(trade_date)
|
||||||
@@ -102,9 +98,8 @@ def build_data_gateway(
|
|||||||
tushare_token_supplier: Callable[[], str] | None = None,
|
tushare_token_supplier: Callable[[], str] | None = None,
|
||||||
datahub_settings: DatahubSettings | None = None,
|
datahub_settings: DatahubSettings | None = None,
|
||||||
) -> DataGateway:
|
) -> DataGateway:
|
||||||
token_supplier = tushare_token_supplier or (
|
# Website Tushare tokens are not used to assemble market clients.
|
||||||
lambda: str(credentials.get("tushare_token") or "")
|
del tushare_token_supplier
|
||||||
)
|
|
||||||
policy = DataSourcePolicy.load()
|
policy = DataSourcePolicy.load()
|
||||||
settings = datahub_settings or DatahubSettings.load(credentials=credentials)
|
settings = datahub_settings or DatahubSettings.load(credentials=credentials)
|
||||||
datahub_client = DatahubClient(settings)
|
datahub_client = DatahubClient(settings)
|
||||||
@@ -113,7 +108,6 @@ def build_data_gateway(
|
|||||||
return DataGateway(
|
return DataGateway(
|
||||||
policy=policy,
|
policy=policy,
|
||||||
quality=DataQualityGate.load(policy),
|
quality=DataQualityGate.load(policy),
|
||||||
tushare_provider=TushareProvider(token_supplier),
|
|
||||||
ifind_provider=IfindProvider(ifind),
|
ifind_provider=IfindProvider(ifind),
|
||||||
chart_data=MarketChartClient(datahub),
|
chart_data=MarketChartClient(datahub),
|
||||||
realtime_observer=HubRealtimeProxy(datahub),
|
realtime_observer=HubRealtimeProxy(datahub),
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ services:
|
|||||||
DATAHUB_TOKEN: "${DATAHUB_TOKEN:?DATAHUB_TOKEN must be set}"
|
DATAHUB_TOKEN: "${DATAHUB_TOKEN:?DATAHUB_TOKEN must be set}"
|
||||||
DATAHUB_ADMIN_PASSWORD: "${DATAHUB_ADMIN_PASSWORD:?DATAHUB_ADMIN_PASSWORD must be set}"
|
DATAHUB_ADMIN_PASSWORD: "${DATAHUB_ADMIN_PASSWORD:?DATAHUB_ADMIN_PASSWORD must be set}"
|
||||||
TUSHARE_TOKEN: "${TUSHARE_TOKEN:-}"
|
TUSHARE_TOKEN: "${TUSHARE_TOKEN:-}"
|
||||||
|
IFIND_REFRESH_TOKEN: "${IFIND_REFRESH_TOKEN:-}"
|
||||||
|
IFIND_ACCESS_TOKEN: "${IFIND_ACCESS_TOKEN:-}"
|
||||||
DATAHUB_DB_PATH: /app/data/datahub.db
|
DATAHUB_DB_PATH: /app/data/datahub.db
|
||||||
DATAHUB_BACKUP_DIR: /app/data/backups
|
DATAHUB_BACKUP_DIR: /app/data/backups
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
|
|||||||
@@ -636,16 +636,16 @@
|
|||||||
"bytes": 8357,
|
"bytes": 8357,
|
||||||
"lines": 116
|
"lines": 116
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/application.py",
|
||||||
|
"bytes": 6997,
|
||||||
|
"lines": 182
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/screener/formula.py",
|
"path": "backend/features/screener/formula.py",
|
||||||
"bytes": 6983,
|
"bytes": 6983,
|
||||||
"lines": 146
|
"lines": 146
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "backend/application.py",
|
|
||||||
"bytes": 6751,
|
|
||||||
"lines": 178
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "backend/features/market/insights_popularity.py",
|
"path": "backend/features/market/insights_popularity.py",
|
||||||
"bytes": 6739,
|
"bytes": 6739,
|
||||||
|
|||||||
@@ -35,21 +35,23 @@ class DataGatewayTests(unittest.TestCase):
|
|||||||
with self.assertRaises(DataPolicyError):
|
with self.assertRaises(DataPolicyError):
|
||||||
policy.assert_allowed("market.level2", "unresolved", "display")
|
policy.assert_allowed("market.level2", "unresolved", "display")
|
||||||
|
|
||||||
def test_gateway_uses_live_token_supplier_and_hub_proxies(self) -> None:
|
def test_gateway_uses_hub_facade_and_proxies(self) -> None:
|
||||||
token = {"value": "first"}
|
|
||||||
gateway = build_data_gateway(
|
gateway = build_data_gateway(
|
||||||
{"ifind_refresh_token": "refresh", "ifind_access_token": "access"},
|
{"ifind_refresh_token": "refresh", "ifind_access_token": "access"},
|
||||||
lambda: token["value"],
|
lambda: "must-not-be-used",
|
||||||
)
|
)
|
||||||
self.assertEqual(gateway.tushare().token, "first")
|
client = gateway.tushare()
|
||||||
token["value"] = "second"
|
self.assertEqual(client.token, "datahub")
|
||||||
self.assertEqual(gateway.tushare().token, "second")
|
self.assertIsNone(client.realtime_aggregator)
|
||||||
|
self.assertFalse(hasattr(client, "_legacy"))
|
||||||
self.assertIs(gateway.ifind, gateway.ifind_provider.client)
|
self.assertIs(gateway.ifind, gateway.ifind_provider.client)
|
||||||
self.assertIs(gateway.chart_data.datahub, gateway.datahub)
|
self.assertIs(gateway.chart_data.datahub, gateway.datahub)
|
||||||
self.assertIsNone(gateway.chart_data.ifind)
|
self.assertIsNone(gateway.chart_data.ifind)
|
||||||
|
from backend.data.datahub.bridge import DatahubAwareTushareClient
|
||||||
from backend.data.datahub.ifind_proxy import HubIfindProxy
|
from backend.data.datahub.ifind_proxy import HubIfindProxy
|
||||||
from backend.data.datahub.realtime_proxy import HubRealtimeProxy
|
from backend.data.datahub.realtime_proxy import HubRealtimeProxy
|
||||||
|
|
||||||
|
self.assertIsInstance(client, DatahubAwareTushareClient)
|
||||||
self.assertIsInstance(gateway.ifind, HubIfindProxy)
|
self.assertIsInstance(gateway.ifind, HubIfindProxy)
|
||||||
self.assertIsInstance(gateway.realtime_observer, HubRealtimeProxy)
|
self.assertIsInstance(gateway.realtime_observer, HubRealtimeProxy)
|
||||||
|
|
||||||
@@ -70,7 +72,6 @@ class DataGatewayTests(unittest.TestCase):
|
|||||||
"IfindProvider": {"backend/data/gateway.py"},
|
"IfindProvider": {"backend/data/gateway.py"},
|
||||||
"MarketChartClient": {"backend/data/gateway.py"},
|
"MarketChartClient": {"backend/data/gateway.py"},
|
||||||
"TushareClient": {"backend/features/market/service.py"},
|
"TushareClient": {"backend/features/market/service.py"},
|
||||||
"TushareProvider": {"backend/data/gateway.py"},
|
|
||||||
"DatahubClient": {"backend/data/gateway.py"},
|
"DatahubClient": {"backend/data/gateway.py"},
|
||||||
"DatahubAwareTushareClient": {"backend/data/gateway.py"},
|
"DatahubAwareTushareClient": {"backend/data/gateway.py"},
|
||||||
"DatahubBridge": {"backend/data/gateway.py"},
|
"DatahubBridge": {"backend/data/gateway.py"},
|
||||||
@@ -82,6 +83,7 @@ class DataGatewayTests(unittest.TestCase):
|
|||||||
"IfindHttpClient": set(),
|
"IfindHttpClient": set(),
|
||||||
"EastmoneyChartClient": set(),
|
"EastmoneyChartClient": set(),
|
||||||
"WebRealtimeAggregator": set(),
|
"WebRealtimeAggregator": set(),
|
||||||
|
"TushareProvider": set(),
|
||||||
}
|
}
|
||||||
found_forbidden = {name: set() for name in forbidden}
|
found_forbidden = {name: set() for name in forbidden}
|
||||||
for path in (root / "backend").rglob("*.py"):
|
for path in (root / "backend").rglob("*.py"):
|
||||||
|
|||||||
@@ -546,7 +546,7 @@ class DatahubBridgeTests(unittest.TestCase):
|
|||||||
self.assertEqual(chart[-1]["trade_date"], "2024-09-02")
|
self.assertEqual(chart[-1]["trade_date"], "2024-09-02")
|
||||||
self.assertEqual(chart[-1]["close"], 10.4)
|
self.assertEqual(chart[-1]["close"], 10.4)
|
||||||
|
|
||||||
def test_gateway_tushare_assembly_binds_hooks_on_inner_client(self) -> None:
|
def test_gateway_tushare_facade_has_no_legacy_client(self) -> None:
|
||||||
quotes = [
|
quotes = [
|
||||||
{
|
{
|
||||||
"ts_code": f"{index:06d}.SZ",
|
"ts_code": f"{index:06d}.SZ",
|
||||||
@@ -574,21 +574,20 @@ class DatahubBridgeTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
gateway.datahub.client = hub_client
|
gateway.datahub.client = hub_client
|
||||||
wrapped = gateway.tushare()
|
wrapped = gateway.tushare()
|
||||||
inner = wrapped._legacy
|
self.assertFalse(hasattr(wrapped, "_legacy"))
|
||||||
self.assertTrue(callable(getattr(inner, "try_market_quotes", None)))
|
self.assertIsNone(getattr(type(wrapped), "__getattr__", None))
|
||||||
self.assertTrue(callable(getattr(inner, "try_index_quotes", None)))
|
self.assertTrue(callable(getattr(type(wrapped), "try_market_quotes", None)))
|
||||||
self.assertTrue(callable(getattr(inner, "record_datahub_legacy", None)))
|
self.assertTrue(callable(getattr(type(wrapped), "try_index_quotes", None)))
|
||||||
self.assertIs(inner.query.__self__, wrapped)
|
self.assertTrue(callable(getattr(type(wrapped), "record_datahub_legacy", None)))
|
||||||
self.assertEqual(inner.query.__func__, wrapped.query.__func__)
|
self.assertTrue(callable(getattr(type(wrapped), "dashboard", None)))
|
||||||
self.assertFalse(hasattr(type(inner), "try_market_quotes"))
|
rows = wrapped.try_market_quotes("20240902")
|
||||||
rows = inner.try_market_quotes("20240902")
|
|
||||||
self.assertGreaterEqual(len(rows or []), 200)
|
self.assertGreaterEqual(len(rows or []), 200)
|
||||||
self.assertIn("/v1/quotes/latest", hub_client.paths)
|
self.assertIn("/v1/quotes/latest", hub_client.paths)
|
||||||
hub_client.response = DatahubResponse(
|
hub_client.response = DatahubResponse(
|
||||||
data=[dict(HUB_DAILY)],
|
data=[dict(HUB_DAILY)],
|
||||||
meta={"stale": False, "staleness_seconds": 0, "source": "tushare:daily"},
|
meta={"stale": False, "staleness_seconds": 0, "source": "tushare:daily"},
|
||||||
)
|
)
|
||||||
daily = inner.query("daily", {"trade_date": "20240902"}, "ts_code,amount")
|
daily = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,amount")
|
||||||
self.assertEqual(daily[0]["amount"], 2000.0)
|
self.assertEqual(daily[0]["amount"], 2000.0)
|
||||||
self.assertIn("/v1/bars/daily", hub_client.paths)
|
self.assertIn("/v1/bars/daily", hub_client.paths)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import ast
|
import ast
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -143,6 +144,26 @@ def hub_payload(request) -> dict:
|
|||||||
],
|
],
|
||||||
"meta": {"stale": False, "staleness_seconds": 0, "source": "tencent"},
|
"meta": {"stale": False, "staleness_seconds": 0, "source": "tencent"},
|
||||||
}
|
}
|
||||||
|
if path == "/v1/auction":
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"ts_code": "600000.SH",
|
||||||
|
"trade_date": "20240902",
|
||||||
|
"close": 10.2,
|
||||||
|
"vol": 1000.0,
|
||||||
|
"amount": 2000.0,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta": {"stale": False, "staleness_seconds": 0, "source": "datahub"},
|
||||||
|
}
|
||||||
|
if path == "/v1/credentials/ifind":
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"data": {"configured": True, "access_ready": True, "access_expires_at": ""},
|
||||||
|
"meta": {"source": "ifind"},
|
||||||
|
}
|
||||||
if path == "/v1/intraday/points":
|
if path == "/v1/intraday/points":
|
||||||
return {
|
return {
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
@@ -184,7 +205,7 @@ def hub_payload(request) -> dict:
|
|||||||
],
|
],
|
||||||
"meta": {"source": "ifind"},
|
"meta": {"source": "ifind"},
|
||||||
}
|
}
|
||||||
if api_name in {"daily", "rt_k"}:
|
if api_name in {"daily", "rt_k", "stk_auction"}:
|
||||||
return {
|
return {
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"data": [{"ts_code": "600000.SH", "trade_date": "20240902", "close": 10.2, "amount": 2000.0}],
|
"data": [{"ts_code": "600000.SH", "trade_date": "20240902", "close": 10.2, "amount": 2000.0}],
|
||||||
@@ -254,10 +275,22 @@ class HubExclusiveWebsiteTests(unittest.TestCase):
|
|||||||
self.assertNotIn("IfindHttpClient", source)
|
self.assertNotIn("IfindHttpClient", source)
|
||||||
self.assertNotIn("EastmoneyChartClient", source)
|
self.assertNotIn("EastmoneyChartClient", source)
|
||||||
self.assertNotIn("WebRealtimeAggregator", source)
|
self.assertNotIn("WebRealtimeAggregator", source)
|
||||||
|
self.assertNotIn("TushareProvider", source)
|
||||||
|
self.assertIsNone(re.search(r"(?<![A-Za-z])TushareClient\(", source))
|
||||||
self.assertIn("HubIfindProxy", source)
|
self.assertIn("HubIfindProxy", source)
|
||||||
self.assertIn("HubRealtimeProxy", source)
|
self.assertIn("HubRealtimeProxy", source)
|
||||||
self.assertIn("legacy.realtime_aggregator = None", source)
|
|
||||||
self.assertIn("DatahubAwareTushareClient", source)
|
self.assertIn("DatahubAwareTushareClient", source)
|
||||||
|
facade = (ROOT / "backend" / "data" / "datahub" / "bridge.py").read_text(encoding="utf-8")
|
||||||
|
tree = ast.parse(facade)
|
||||||
|
cls = next(
|
||||||
|
node
|
||||||
|
for node in tree.body
|
||||||
|
if isinstance(node, ast.ClassDef) and node.name == "DatahubAwareTushareClient"
|
||||||
|
)
|
||||||
|
methods = {item.name for item in cls.body if isinstance(item, ast.FunctionDef)}
|
||||||
|
self.assertNotIn("__getattr__", methods)
|
||||||
|
self.assertIn("query", methods)
|
||||||
|
self.assertTrue(any(base.id == "DashboardMixin" for base in cls.bases if isinstance(base, ast.Name)))
|
||||||
|
|
||||||
def test_production_python_does_not_embed_blocked_hosts(self) -> None:
|
def test_production_python_does_not_embed_blocked_hosts(self) -> None:
|
||||||
violations = []
|
violations = []
|
||||||
@@ -273,7 +306,8 @@ class HubExclusiveWebsiteTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_website_runtime_does_not_call_blocked_hosts_from_gateway(self) -> None:
|
def test_website_runtime_does_not_call_blocked_hosts_from_gateway(self) -> None:
|
||||||
gateway_src = (ROOT / "backend" / "data" / "gateway.py").read_text(encoding="utf-8")
|
gateway_src = (ROOT / "backend" / "data" / "gateway.py").read_text(encoding="utf-8")
|
||||||
self.assertIn("legacy.realtime_aggregator = None", gateway_src)
|
self.assertNotIn("TushareProvider", gateway_src)
|
||||||
|
self.assertIsNone(re.search(r"(?<![A-Za-z])TushareClient\(", gateway_src))
|
||||||
self.assertIn("DatahubAwareTushareClient", gateway_src)
|
self.assertIn("DatahubAwareTushareClient", gateway_src)
|
||||||
|
|
||||||
def test_bridge_query_has_no_legacy_call(self) -> None:
|
def test_bridge_query_has_no_legacy_call(self) -> None:
|
||||||
@@ -298,13 +332,47 @@ class HubExclusiveWebsiteTests(unittest.TestCase):
|
|||||||
settings = _enabled_settings()
|
settings = _enabled_settings()
|
||||||
with patch("urllib.request.urlopen", blocked_urlopen):
|
with patch("urllib.request.urlopen", blocked_urlopen):
|
||||||
gateway = build_data_gateway({"tushare_token": "tok"}, datahub_settings=settings)
|
gateway = build_data_gateway({"tushare_token": "tok"}, datahub_settings=settings)
|
||||||
gateway.datahub.client = DatahubClient(settings, urlopen=blocked_urlopen)
|
hub_client = DatahubClient(settings, urlopen=blocked_urlopen)
|
||||||
|
gateway.datahub.client = hub_client
|
||||||
rows = gateway.ifind.wencai("涨停")
|
rows = gateway.ifind.wencai("涨停")
|
||||||
quotes = gateway.realtime_observer.tencent_indices()
|
quotes = gateway.realtime_observer.tencent_indices()
|
||||||
chart = gateway.chart_data.stock_daily("600000", "20240902")
|
chart = gateway.chart_data.stock_daily("600000", "20240902")
|
||||||
|
market = gateway.tushare()
|
||||||
|
market_quotes = market.try_quotes(["600000.SH"])
|
||||||
|
auction = market.query("stk_auction", {"trade_date": "20240902"}, "")
|
||||||
|
gateway.ifind.set_credentials("refresh-token", "access-token")
|
||||||
self.assertEqual(rows[0]["涨停原因"], "重组")
|
self.assertEqual(rows[0]["涨停原因"], "重组")
|
||||||
self.assertEqual(len(quotes), 3)
|
self.assertEqual(len(quotes), 3)
|
||||||
self.assertEqual(chart[-1]["close"], 10.2)
|
self.assertEqual(chart[-1]["close"], 10.2)
|
||||||
|
self.assertEqual(market_quotes[0]["close"], 10.2)
|
||||||
|
self.assertEqual(auction[0]["close"], 10.2)
|
||||||
|
self.assertIsNone(market.realtime_aggregator)
|
||||||
|
self.assertEqual(market.token, "datahub")
|
||||||
|
|
||||||
|
def test_set_credentials_posts_to_hub_not_ifind(self) -> None:
|
||||||
|
seen: list[str] = []
|
||||||
|
|
||||||
|
def urlopen(request, timeout=None):
|
||||||
|
url = str(getattr(request, "full_url", None) or request)
|
||||||
|
seen.append(url)
|
||||||
|
if any(host in url for host in BLOCKED_HOSTS):
|
||||||
|
raise AssertionError(f"website opened blocked host: {url}")
|
||||||
|
return _Resp(hub_payload(request))
|
||||||
|
|
||||||
|
settings = _enabled_settings()
|
||||||
|
hub_client = DatahubClient(settings, urlopen=urlopen)
|
||||||
|
proxy = HubIfindProxy(DatahubBridge(settings, hub_client))
|
||||||
|
proxy.set_credentials("refresh-token", "access-token")
|
||||||
|
self.assertTrue(any("/v1/credentials/ifind" in url for url in seen))
|
||||||
|
self.assertFalse(any("51ifind.com" in url for url in seen))
|
||||||
|
self.assertFalse(any("quantapi" in url for url in seen))
|
||||||
|
|
||||||
|
def test_compose_passes_ifind_env_to_hub(self) -> None:
|
||||||
|
overlay = (ROOT / "compose.datahub.yaml").read_text(encoding="utf-8")
|
||||||
|
standalone = (ROOT / "xiaobai-datahub" / "compose.yaml").read_text(encoding="utf-8")
|
||||||
|
for text in (overlay, standalone):
|
||||||
|
self.assertIn('IFIND_REFRESH_TOKEN: "${IFIND_REFRESH_TOKEN:-}"', text)
|
||||||
|
self.assertIn('IFIND_ACCESS_TOKEN: "${IFIND_ACCESS_TOKEN:-}"', text)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -380,10 +380,9 @@ class RealtimeDashboardTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_gateway_dashboard_uses_bound_market_quotes(self) -> None:
|
def test_gateway_dashboard_uses_bound_market_quotes(self) -> None:
|
||||||
from backend.data import build_data_gateway
|
from backend.data import build_data_gateway
|
||||||
|
from backend.data.datahub.bridge import DatahubAwareTushareClient
|
||||||
from backend.data.datahub.client import DatahubResponse
|
from backend.data.datahub.client import DatahubResponse
|
||||||
from backend.data.datahub.settings import DATASETS, DatahubSettings, DatasetFlags
|
from backend.data.datahub.settings import DATASETS, DatahubSettings, DatasetFlags
|
||||||
from backend.data.gateway import DataGateway
|
|
||||||
from backend.data.providers.tushare import TushareProvider
|
|
||||||
|
|
||||||
quotes = [
|
quotes = [
|
||||||
{
|
{
|
||||||
@@ -442,30 +441,17 @@ class RealtimeDashboardTests(unittest.TestCase):
|
|||||||
datasets = {name: DatasetFlags(name) for name in DATASETS}
|
datasets = {name: DatasetFlags(name) for name in DATASETS}
|
||||||
datasets["quotes"] = DatasetFlags("quotes", read=True, shadow=False)
|
datasets["quotes"] = DatasetFlags("quotes", read=True, shadow=False)
|
||||||
settings = DatahubSettings(base_url="http://127.0.0.1:9", token="tok", datasets=datasets)
|
settings = DatahubSettings(base_url="http://127.0.0.1:9", token="tok", datasets=datasets)
|
||||||
base = build_data_gateway({"tushare_token": "tok"}, datahub_settings=settings)
|
gateway = build_data_gateway({"tushare_token": "tok"}, datahub_settings=settings)
|
||||||
gateway = DataGateway(
|
|
||||||
policy=base.policy,
|
|
||||||
quality=base.quality,
|
|
||||||
tushare_provider=TushareProvider(
|
|
||||||
lambda: "tok",
|
|
||||||
client_factory=lambda token: FakeRealtimeClient(token),
|
|
||||||
),
|
|
||||||
ifind_provider=base.ifind_provider,
|
|
||||||
chart_data=base.chart_data,
|
|
||||||
realtime_observer=base.realtime_observer,
|
|
||||||
datahub=base.datahub,
|
|
||||||
)
|
|
||||||
gateway.datahub.client = QuoteHub()
|
gateway.datahub.client = QuoteHub()
|
||||||
wrapped = gateway.tushare()
|
wrapped = gateway.tushare()
|
||||||
inner = wrapped._legacy
|
wrapped.clock = lambda: datetime(2026, 7, 20, 10, 30, tzinfo=timezone(timedelta(hours=8)))
|
||||||
inner.clock = lambda: datetime(2026, 7, 20, 10, 30, tzinfo=timezone(timedelta(hours=8)))
|
wrapped.realtime_aggregator = FakeFreeAggregator(fail=True)
|
||||||
inner.realtime_aggregator = FakeFreeAggregator(fail=True)
|
DatahubAwareTushareClient._realtime_reference_cache.clear()
|
||||||
TushareClient._realtime_reference_cache.clear()
|
|
||||||
dashboard = wrapped.dashboard("20260720")
|
dashboard = wrapped.dashboard("20260720")
|
||||||
self.assertEqual(dashboard["meta"]["quote_source"], "datahub")
|
self.assertEqual(dashboard["meta"]["quote_source"], "datahub")
|
||||||
self.assertIn("/v1/quotes/latest", gateway.datahub.client.calls)
|
self.assertIn("/v1/quotes/latest", gateway.datahub.client.calls)
|
||||||
self.assertTrue(callable(getattr(inner, "try_market_quotes", None)))
|
self.assertTrue(callable(getattr(type(wrapped), "try_market_quotes", None)))
|
||||||
self.assertFalse(hasattr(type(inner), "try_market_quotes"))
|
self.assertFalse(hasattr(wrapped, "_legacy"))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ services:
|
|||||||
DATAHUB_TOKEN: "${DATAHUB_TOKEN:?DATAHUB_TOKEN must be set}"
|
DATAHUB_TOKEN: "${DATAHUB_TOKEN:?DATAHUB_TOKEN must be set}"
|
||||||
DATAHUB_ADMIN_PASSWORD: "${DATAHUB_ADMIN_PASSWORD:?DATAHUB_ADMIN_PASSWORD must be set}"
|
DATAHUB_ADMIN_PASSWORD: "${DATAHUB_ADMIN_PASSWORD:?DATAHUB_ADMIN_PASSWORD must be set}"
|
||||||
TUSHARE_TOKEN: "${TUSHARE_TOKEN:-}"
|
TUSHARE_TOKEN: "${TUSHARE_TOKEN:-}"
|
||||||
|
IFIND_REFRESH_TOKEN: "${IFIND_REFRESH_TOKEN:-}"
|
||||||
|
IFIND_ACCESS_TOKEN: "${IFIND_ACCESS_TOKEN:-}"
|
||||||
DATAHUB_DB_PATH: /app/data/datahub.db
|
DATAHUB_DB_PATH: /app/data/datahub.db
|
||||||
DATAHUB_BACKUP_DIR: /app/data/backups
|
DATAHUB_BACKUP_DIR: /app/data/backups
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ class HubRequestHandler(BaseHTTPRequestHandler):
|
|||||||
payload = self.hub.api.query_api(body)
|
payload = self.hub.api.query_api(body)
|
||||||
self._json(payload, HTTPStatus.OK)
|
self._json(payload, HTTPStatus.OK)
|
||||||
return
|
return
|
||||||
|
if path == "/v1/credentials/ifind" and method == "POST":
|
||||||
|
body = self._read_json()
|
||||||
|
payload = self.hub.put_ifind_credentials(body)
|
||||||
|
self._json(payload, HTTPStatus.OK)
|
||||||
|
return
|
||||||
payload = self.hub.api.handle(path, parse_query(query))
|
payload = self.hub.api.handle(path, parse_query(query))
|
||||||
self._json(payload, HTTPStatus.OK)
|
self._json(payload, HTTPStatus.OK)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from datahub.adapters.ifind import IfindAdapter
|
from datahub.adapters.ifind import IfindAdapter
|
||||||
from datahub.adapters.tushare import TushareAdapter
|
from datahub.adapters.tushare import TushareAdapter
|
||||||
@@ -52,6 +53,17 @@ class Hub:
|
|||||||
self.admin = AdminAPI(self.db, self.pipeline, self.scheduler, self.auth, ifind=self.ifind)
|
self.admin = AdminAPI(self.db, self.pipeline, self.scheduler, self.auth, ifind=self.ifind)
|
||||||
self.static_dir = Path(__file__).resolve().parents[1] / "admin"
|
self.static_dir = Path(__file__).resolve().parents[1] / "admin"
|
||||||
|
|
||||||
|
def put_ifind_credentials(self, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
from datahub.serving import envelope
|
||||||
|
|
||||||
|
payload = dict(body or {})
|
||||||
|
refresh = str(payload.get("ifind_refresh_token") or "").strip()
|
||||||
|
access = str(payload.get("ifind_access_token") or "").strip()
|
||||||
|
self.auth.store_credential("ifind_refresh_token", refresh)
|
||||||
|
self.auth.store_credential("ifind_access_token", access)
|
||||||
|
self.ifind.set_credentials(refresh, access)
|
||||||
|
return envelope(self.ifind.status(), {"source": "ifind"})
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
if self.settings.scheduler_enabled:
|
if self.settings.scheduler_enabled:
|
||||||
self.scheduler.start()
|
self.scheduler.start()
|
||||||
|
|||||||
@@ -61,6 +61,18 @@ class ApiContractTests(unittest.TestCase):
|
|||||||
self.hub.stop()
|
self.hub.stop()
|
||||||
self.tmp.cleanup()
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def _post(self, path: str, body: dict, token: str | None = None) -> tuple[int, dict]:
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if token is not None:
|
||||||
|
headers["X-Datahub-Token"] = token
|
||||||
|
raw = json.dumps(body).encode("utf-8")
|
||||||
|
req = Request(self.base + path, data=raw, headers=headers, method="POST")
|
||||||
|
try:
|
||||||
|
with urlopen(req, timeout=5) as resp:
|
||||||
|
return resp.status, json.loads(resp.read().decode())
|
||||||
|
except HTTPError as exc:
|
||||||
|
return exc.code, json.loads(exc.read().decode())
|
||||||
|
|
||||||
def _get(self, path: str, token: str | None = None) -> tuple[int, dict]:
|
def _get(self, path: str, token: str | None = None) -> tuple[int, dict]:
|
||||||
headers = {}
|
headers = {}
|
||||||
if token is not None:
|
if token is not None:
|
||||||
@@ -145,6 +157,28 @@ class ApiContractTests(unittest.TestCase):
|
|||||||
self.assertNotIn("tushare-secret-token-xyz", blob)
|
self.assertNotIn("tushare-secret-token-xyz", blob)
|
||||||
self.assertNotIn(self.token, blob)
|
self.assertNotIn(self.token, blob)
|
||||||
|
|
||||||
|
def test_ifind_credentials_require_token_and_update_adapter(self) -> None:
|
||||||
|
status, body = self._post(
|
||||||
|
"/v1/credentials/ifind",
|
||||||
|
{"ifind_refresh_token": "refresh-secret", "ifind_access_token": "access-secret"},
|
||||||
|
token=None,
|
||||||
|
)
|
||||||
|
self.assertEqual(status, 401)
|
||||||
|
self.assertEqual(body["error"]["code"], "UNAUTHORIZED")
|
||||||
|
status, body = self._post(
|
||||||
|
"/v1/credentials/ifind",
|
||||||
|
{"ifind_refresh_token": "refresh-secret", "ifind_access_token": "access-secret"},
|
||||||
|
token=self.token,
|
||||||
|
)
|
||||||
|
self.assertEqual(status, 200, body)
|
||||||
|
self.assertTrue(body["data"]["configured"])
|
||||||
|
self.assertTrue(body["data"]["access_ready"])
|
||||||
|
self.assertTrue(self.hub.ifind.configured)
|
||||||
|
self.assertEqual(self.hub.auth.load_credential("ifind_refresh_token"), "refresh-secret")
|
||||||
|
blob = json.dumps(body)
|
||||||
|
self.assertNotIn("refresh-secret", blob)
|
||||||
|
self.assertNotIn("access-secret", blob)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user