Files
daily_stock_analysis/tests/test_tushare_fetcher_http_client.py
Nicholas-Xiong f2bfa0210f feat: 支持 TUSHARE_HTTP_URL 自定义 Tushare Pro 接入地址 (#2048)
* feat: support custom Tushare Pro endpoint via TUSHARE_HTTP_URL

Add TUSHARE_HTTP_URL so the Tushare data source can point at a self-hosted
or third-party compatible endpoint when the official api.tushare.pro is not
reachable. Defaults to the official host when unset, so behavior is
unchanged for existing users.

- data_provider/tushare_fetcher.py: add _resolve_tushare_http_url() helper
  (strip + http(s):// schema validation) and forward the resolved URL into
  _TushareHttpClient, with an info log when a custom endpoint is in use
- .env.example + .github/workflows/00-daily-analysis.yml: document and map
  TUSHARE_HTTP_URL so the new option is wired into the daily job without
  leaving a half-configured state
- tests: cover env parsing (empty/whitespace/http/https/missing schema),
  fetcher fall-through to the official host, and end-to-end POST target
- docs/CHANGELOG.md: flat [Unreleased] entries

Fixes #1985

* refactor: drop unnecessary string-literal type hint in _build_api_client

TushareHttpClient is already defined above TushareFetcher in the module
scope, so a string-literal type hint is not needed for forward reference.
Restore the bare type to match the surrounding code style and reduce the
diff against main.

* docs(tushare): 补 TUSHARE_HTTP_URL 在 full-guide 中英版本的用途/默认行为/workflow 映射说明

按 PR #2048 review 反馈补齐:
- 表格内新增 TUSHARE_HTTP_URL 行(中英文版本同步),明确默认 https://api.tushare.pro 与 http(s):// 前缀要求
- 在 GitHub Actions 段落后补充 TUSHARE_HTTP_URL 的 vars/Secrets 优先级与每日 workflow 0映射说明,与现有非敏感配置(TICKFLOW_PRIORITY)一致
- 完整环境变量列表(中英文版本)补 TUSHARE_HTTP_URL 行,默认值列填 https://api.tushare.pro
- 与代码实现一致:00-daily-analysis.yml 已用 vars.TUSHARE_HTTP_URL || secrets.TUSHARE_HTTP_URL 映射

* docs(tushare): align TUSHARE_HTTP_URL default with runtime and clarify vars/secrets precedence

Per review feedback on PR #2048: the per-repo config contract must stay
consistent across runtime, .env.example, workflow priority, tests and
both zh/en guides. Two fixes applied as a single contract update:

1. Default endpoint alignment. data_provider/tushare_fetcher.py:100,
   .env.example, and tests/test_tushare_fetcher_http_client.py all keep
   the existing official endpoint http://api.tushare.pro, but the zh/en
   full-guide rows had drifted to https://api.tushare.pro. Switching the
   documented default to HTTPS would silently change the runtime contract
   that the feat commit explicitly preserved. Revert both zh and en
   guide rows to http://api.tushare.pro so docs match runtime, .env.example
   and the test assertions.

2. vars/secrets precedence wording. The workflow uses
   'vars.TUSHARE_HTTP_URL || secrets.TUSHARE_HTTP_URL', which means a
   non-empty vars entry always wins and Secrets cannot override it. The
   zh/en notes previously suggested 'Secrets as a tamper fallback' which
   is incorrect under this precedence and can mislead users into thinking
   Secrets has override power. Replace with explicit description of the
   real semantics: vars wins when non-empty; Secrets is only selected
   when the Variable is empty; for a tamper-resistant deployment put the
   value only in Secrets and leave Variables empty.

Both zh and en guides are updated together; the same 6 contract surfaces
(runtime / .env.example / workflow priority / tests / zh guide / en guide)
now describe one consistent contract.

* docs(tushare): remove false Secret-as-tamper-guard claim, document real vars/secrets write-permission model
2026-07-22 20:54:40 +08:00

176 lines
6.5 KiB
Python

# -*- coding: utf-8 -*-
"""Regression tests for TushareFetcher HTTP client initialization."""
import importlib.util
import json
import sys
import unittest
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from tests.litellm_stub import ensure_litellm_stub
ensure_litellm_stub()
try:
json_repair_available = importlib.util.find_spec("json_repair") is not None
except ValueError:
json_repair_available = "json_repair" in sys.modules
if not json_repair_available and "json_repair" not in sys.modules:
sys.modules["json_repair"] = MagicMock()
from data_provider.tushare_fetcher import (
TushareFetcher,
_TushareHttpClient,
_resolve_tushare_http_url,
)
class TestTushareHttpClient(unittest.TestCase):
"""Ensure the lightweight HTTP client preserves Tushare Pro request semantics."""
def test_query_posts_to_official_pro_endpoint(self) -> None:
client = _TushareHttpClient(token="demo-token", timeout=15)
response = MagicMock(
status_code=200,
text=json.dumps(
{
"code": 0,
"data": {
"fields": ["ts_code", "close"],
"items": [["600519.SH", 1688.0]],
},
}
),
)
with patch("data_provider.tushare_fetcher.requests.post", return_value=response) as post_mock:
df = client.daily(ts_code="600519.SH", start_date="20260320", end_date="20260325")
post_mock.assert_called_once_with(
"http://api.tushare.pro",
json={
"api_name": "daily",
"token": "demo-token",
"params": {
"ts_code": "600519.SH",
"start_date": "20260320",
"end_date": "20260325",
},
"fields": "",
},
timeout=15,
)
self.assertEqual(df.to_dict(orient="records"), [{"ts_code": "600519.SH", "close": 1688.0}])
class TestTushareFetcherInit(unittest.TestCase):
"""Ensure fetcher initialization no longer depends on the tushare SDK package."""
def test_init_builds_http_client_when_token_present(self) -> None:
config = SimpleNamespace(tushare_token="demo-token")
with patch("data_provider.tushare_fetcher.get_config", return_value=config):
fetcher = TushareFetcher()
self.assertIsInstance(fetcher._api, _TushareHttpClient)
self.assertTrue(fetcher.is_available())
self.assertEqual(fetcher.priority, -1)
class TestResolveTushareHttpUrl(unittest.TestCase):
"""``TUSHARE_HTTP_URL`` 环境变量解析与校验。"""
def test_unset_returns_none(self) -> None:
with patch.dict("os.environ", {}, clear=False):
import os
os.environ.pop("TUSHARE_HTTP_URL", None)
self.assertIsNone(_resolve_tushare_http_url())
def test_empty_or_whitespace_returns_none(self) -> None:
with patch.dict("os.environ", {"TUSHARE_HTTP_URL": " "}):
self.assertIsNone(_resolve_tushare_http_url())
def test_valid_http_url_returned_stripped(self) -> None:
with patch.dict("os.environ", {"TUSHARE_HTTP_URL": " http://gw.example.com/tushare "}):
self.assertEqual(_resolve_tushare_http_url(), "http://gw.example.com/tushare")
def test_https_url_returned(self) -> None:
with patch.dict("os.environ", {"TUSHARE_HTTP_URL": "https://gw.example.com/tushare"}):
self.assertEqual(_resolve_tushare_http_url(), "https://gw.example.com/tushare")
def test_missing_schema_raises_value_error(self) -> None:
# 防止有人误填纯主机名(如 'api.tushare.pro')后被 requests 当成相对路径
with patch.dict("os.environ", {"TUSHARE_HTTP_URL": "gw.example.com"}):
with self.assertRaises(ValueError):
_resolve_tushare_http_url()
class TestTushareFetcherCustomHttpUrl(unittest.TestCase):
"""``TUSHARE_HTTP_URL`` 真正打通到 HTTP client 的接入地址。"""
def test_fetcher_uses_custom_url_when_env_set(self) -> None:
config = SimpleNamespace(tushare_token="demo-token")
with patch("data_provider.tushare_fetcher.get_config", return_value=config), \
patch.dict("os.environ", {"TUSHARE_HTTP_URL": "http://gw.example.com/tushare"}):
fetcher = TushareFetcher()
self.assertIsInstance(fetcher._api, _TushareHttpClient)
self.assertEqual(fetcher._api._api_url, "http://gw.example.com/tushare")
def test_fetcher_falls_back_to_official_when_env_empty(self) -> None:
config = SimpleNamespace(tushare_token="demo-token")
env = {k: v for k, v in __import__("os").environ.items() if k != "TUSHARE_HTTP_URL"}
with patch("data_provider.tushare_fetcher.get_config", return_value=config), \
patch.dict("os.environ", env, clear=True):
fetcher = TushareFetcher()
self.assertIsInstance(fetcher._api, _TushareHttpClient)
self.assertEqual(fetcher._api._api_url, "http://api.tushare.pro")
def test_query_posts_to_custom_endpoint(self) -> None:
"""端到端确保自定义 url 真正驱动 requests.post 的目标地址。"""
config = SimpleNamespace(tushare_token="demo-token")
with patch("data_provider.tushare_fetcher.get_config", return_value=config), \
patch.dict("os.environ", {"TUSHARE_HTTP_URL": "http://gw.example.com/tushare"}):
fetcher = TushareFetcher()
response = MagicMock(
status_code=200,
text=json.dumps(
{
"code": 0,
"data": {
"fields": ["ts_code", "close"],
"items": [["600519.SH", 1688.0]],
},
}
),
)
with patch("data_provider.tushare_fetcher.requests.post", return_value=response) as post_mock:
fetcher._api.daily(ts_code="600519.SH", start_date="20260320", end_date="20260325")
post_mock.assert_called_once_with(
"http://gw.example.com/tushare",
json={
"api_name": "daily",
"token": "demo-token",
"params": {
"ts_code": "600519.SH",
"start_date": "20260320",
"end_date": "20260325",
},
"fields": "",
},
timeout=30,
)
if __name__ == "__main__":
unittest.main()