mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
* 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
176 lines
6.5 KiB
Python
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()
|