quantdb-sdk 0.4.4__tar.gz → 0.4.5__tar.gz

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.
Files changed (40) hide show
  1. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/CHANGELOG.md +9 -0
  2. {quantdb_sdk-0.4.4/quantdb_sdk.egg-info → quantdb_sdk-0.4.5}/PKG-INFO +1 -1
  3. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/pyproject.toml +1 -1
  4. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/quantdb_sdk/__init__.py +1 -1
  5. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/quantdb_sdk/__main__.py +24 -0
  6. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/quantdb_sdk/_utils.py +3 -0
  7. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/quantdb_sdk/async_client.py +1 -1
  8. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/quantdb_sdk/client.py +1 -1
  9. quantdb_sdk-0.4.5/quantdb_sdk/mcp_download.py +111 -0
  10. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5/quantdb_sdk.egg-info}/PKG-INFO +1 -1
  11. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/quantdb_sdk.egg-info/SOURCES.txt +2 -0
  12. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/tests/test_async_client.py +2 -0
  13. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/tests/test_client.py +2 -0
  14. quantdb_sdk-0.4.5/tests/test_mcp_download.py +134 -0
  15. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/LICENSE +0 -0
  16. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/MANIFEST.in +0 -0
  17. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/README.md +0 -0
  18. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/quantdb_sdk/errors.py +0 -0
  19. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/quantdb_sdk/examples/__init__.py +0 -0
  20. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/quantdb_sdk/examples/demo_01_connect_basics.py +0 -0
  21. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/quantdb_sdk/examples/demo_02_kline_v2_daily.py +0 -0
  22. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/quantdb_sdk/examples/demo_03_sync_full_market.py +0 -0
  23. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/quantdb_sdk/examples/demo_04_duckdb_warehouse.py +0 -0
  24. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/quantdb_sdk/examples/demo_05_read_local_data.py +0 -0
  25. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/quantdb_sdk/examples/demo_06_tick_and_factors.py +0 -0
  26. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/quantdb_sdk/examples/demo_07_data_hub.py +0 -0
  27. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/quantdb_sdk/hub.py +0 -0
  28. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/quantdb_sdk/layout.py +0 -0
  29. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/quantdb_sdk/py.typed +0 -0
  30. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/quantdb_sdk.egg-info/dependency_links.txt +0 -0
  31. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/quantdb_sdk.egg-info/entry_points.txt +0 -0
  32. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/quantdb_sdk.egg-info/requires.txt +0 -0
  33. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/quantdb_sdk.egg-info/top_level.txt +0 -0
  34. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/setup.cfg +0 -0
  35. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/tests/test_data_hub.py +0 -0
  36. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/tests/test_hub_models.py +0 -0
  37. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/tests/test_layout.py +0 -0
  38. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/tests/test_technical_indicators.py +0 -0
  39. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/tests/test_update_pipeline.py +0 -0
  40. {quantdb_sdk-0.4.4 → quantdb_sdk-0.4.5}/tests/test_utils.py +0 -0
@@ -3,6 +3,15 @@
3
3
  所有 notable 变更都会记录在此文件。格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
4
4
  版本号遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
5
5
 
6
+ ## \[0.4.5] - 2026-10-18
7
+
8
+ ### Added
9
+
10
+ - 本地 MCP 可调用 `quantdb-sdk` 执行单文件/全市场日分区下载与数据集增量同步,并查询、取消后台任务。远程 MCP 提供用户电脑可执行的 SDK 下载方案。
11
+ - `quantdb download <dataset> --trade-date YYYYMMDD --save-dir <root> --json`:下载 Parquet 并输出保存路径、大小;V1 逐股票文件使用 `--symbol`。V2 下载包含全市场,不做单股票裁切。
12
+ - `quantdb sync <dataset> --json`:复用 SDK release cursor/Manifest 同步并返回精简摘要。
13
+ - `python -m quantdb_sdk.mcp_download`:供本地 MCP 子进程使用的 JSON stdin/stdout 执行入口,凭据不进入进程参数或返回值。
14
+
6
15
  ## \[0.4.4] - 2026-10-11
7
16
 
8
17
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quantdb-sdk
3
- Version: 0.4.4
3
+ Version: 0.4.5
4
4
  Summary: QuantDB 量化数据平台官方 Python SDK
5
5
  Author: QuantDB Team
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "quantdb-sdk"
7
- version = "0.4.4"
7
+ version = "0.4.5"
8
8
  description = "QuantDB 量化数据平台官方 Python SDK"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -5,7 +5,7 @@ try:
5
5
  # 注意:此处用 PyPI 包名(连字符)查找版本号,不是模块名(下划线)
6
6
  __version__ = version("quantdb-sdk")
7
7
  except ImportError:
8
- __version__ = "0.4.4"
8
+ __version__ = "0.4.5"
9
9
 
10
10
  from . import layout
11
11
  from ._utils import scan_local_directory
@@ -13,6 +13,7 @@
13
13
  """
14
14
 
15
15
  import argparse
16
+ import json
16
17
  import os
17
18
  import sys
18
19
  from typing import Optional
@@ -105,6 +106,14 @@ def main() -> int:
105
106
  sync_parser.add_argument("--save-dir", default=None,
106
107
  help="本地同步根目录(默认读 QUANTDB_DATA_DIR,未设置时用默认下载目录)")
107
108
  sync_parser.add_argument("--after-release", default=None, help="覆盖本地 release cursor(一般无需传入)")
109
+ sync_parser.add_argument("--json", action="store_true", help="输出精简 JSON 摘要(适合 MCP 下载方案)")
110
+
111
+ download_parser = subparsers.add_parser("download", help="下载单个 Parquet 文件或全市场日分区")
112
+ download_parser.add_argument("dataset", help="数据集,如 daily_forward、balance")
113
+ download_parser.add_argument("--symbol", default=None, help="仅逐股票 V1 文件填写;V2 分区包含全市场")
114
+ download_parser.add_argument("--trade-date", default=None, help="V2 必填:YYYY-MM-DD 或 YYYYMMDD")
115
+ download_parser.add_argument("--save-dir", default=None, help="本地数据根目录")
116
+ download_parser.add_argument("--json", action="store_true", help="输出 JSON 保存路径和文件大小")
108
117
 
109
118
  args = parser.parse_args()
110
119
 
@@ -123,6 +132,21 @@ def main() -> int:
123
132
  parser.error(f"请先设置 API Key 环境变量 {args.api_key_env}")
124
133
 
125
134
  try:
135
+ if args.command == "download" or (args.command == "sync" and args.json):
136
+ from .mcp_download import execute_download
137
+
138
+ result = execute_download({
139
+ "operation": args.command, "dataset": args.dataset,
140
+ "symbol": getattr(args, "symbol", None),
141
+ "trade_date": getattr(args, "trade_date", None), "save_dir": args.save_dir,
142
+ "after_release": getattr(args, "after_release", None),
143
+ }, args.api_host, api_key)
144
+ if args.json:
145
+ print(json.dumps(result, ensure_ascii=False))
146
+ else:
147
+ print(f"下载完成: {result['path']} ({result['size_bytes']} bytes)")
148
+ return 0
149
+
126
150
  client = QuantDBClient(api_host=args.api_host, api_key=api_key)
127
151
 
128
152
  if args.command == "check":
@@ -96,6 +96,9 @@ def safe_join(root: str, relative_path: str) -> str:
96
96
  target = os.path.abspath(os.path.join(root_abs, *parts))
97
97
  if os.path.commonpath([root_abs, target]) != root_abs:
98
98
  raise ValueError("发布清单路径超出同步目录")
99
+ root_real = os.path.realpath(root_abs)
100
+ if os.path.commonpath([root_real, os.path.realpath(target)]) != root_real:
101
+ raise ValueError("发布清单路径通过符号链接超出同步目录")
99
102
  return target
100
103
 
101
104
 
@@ -28,7 +28,7 @@ from .errors import (
28
28
  )
29
29
 
30
30
  # 维护提示:每次发版必须与 pyproject.toml 版本号同步
31
- _USER_AGENT = "QuantDB-Python-SDK/0.4.4"
31
+ _USER_AGENT = "QuantDB-Python-SDK/0.4.5"
32
32
 
33
33
 
34
34
  def _etag_hex(value: Optional[str]) -> Optional[str]:
@@ -32,7 +32,7 @@ from .errors import (
32
32
  )
33
33
 
34
34
  # 维护提示:每次发版必须与 pyproject.toml 版本号同步
35
- _USER_AGENT = "QuantDB-Python-SDK/0.4.4"
35
+ _USER_AGENT = "QuantDB-Python-SDK/0.4.5"
36
36
 
37
37
 
38
38
  def _etag_hex(value: Optional[str]) -> Optional[str]:
@@ -0,0 +1,111 @@
1
+ """SDK download runner used by the local MCP process (JSON over stdin/stdout).
2
+
3
+ Credentials travel over stdin, never in process arguments or returned results.
4
+ All network transfers, traffic accounting and release cursors use QuantDBClient.
5
+ """
6
+
7
+ import contextlib
8
+ import json
9
+ import os
10
+ import re
11
+ import sys
12
+ from datetime import datetime
13
+ from typing import Any, Dict
14
+
15
+ from .client import QuantDBClient
16
+ from .errors import ValidationError
17
+ from .layout import CATEGORY_DIRS, V2_DAILY_PARTITION, data_root
18
+ from ._utils import SYNC_DATASET_CATEGORIES
19
+
20
+ PARTITIONED = V2_DAILY_PARTITION | {"min1_kline", "min5_kline", "tick_data", "etf_kline"}
21
+ STATIC = {"sector_concept", "instrument_detail", "trading_calendar", "convertible_bond", "etf_pcf"}
22
+
23
+
24
+ def validate_request(request: Dict[str, Any]) -> Dict[str, Any]:
25
+ operation = request.get("operation", "download")
26
+ dataset = request.get("dataset", "")
27
+ if operation not in {"download", "sync"} or dataset not in SYNC_DATASET_CATEGORIES:
28
+ raise ValidationError("Unknown operation or dataset")
29
+ result = {"operation": operation, "dataset": dataset}
30
+ for key in ("symbol", "trade_date", "save_dir"):
31
+ value = request.get(key) or ""
32
+ if not isinstance(value, str) or len(value) > 4096:
33
+ raise ValidationError("Invalid download parameter")
34
+ result[key] = value.strip()
35
+ if operation == "sync":
36
+ if result["symbol"] or result["trade_date"]:
37
+ raise ValidationError("Dataset sync does not accept symbol or trade_date")
38
+ return result
39
+ if dataset in PARTITIONED:
40
+ if result["symbol"]:
41
+ raise ValidationError("V2 downloads contain the entire market partition; omit symbol")
42
+ date = result["trade_date"].replace("-", "")
43
+ if not re.fullmatch(r"\d{8}", date):
44
+ raise ValidationError("V2 downloads require trade_date (YYYY-MM-DD or YYYYMMDD)")
45
+ try:
46
+ datetime.strptime(date, "%Y%m%d")
47
+ except ValueError as exc:
48
+ raise ValidationError("Invalid trade_date") from exc
49
+ result["trade_date"] = date
50
+ else:
51
+ if result["trade_date"]:
52
+ raise ValidationError("This dataset does not accept trade_date")
53
+ if dataset in STATIC:
54
+ if result["symbol"]:
55
+ raise ValidationError("This dataset is a whole-market static file; omit symbol")
56
+ elif not re.fullmatch(r"[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*", result["symbol"]):
57
+ raise ValidationError("A valid symbol is required for this dataset")
58
+ result["symbol"] = result["symbol"].upper()
59
+ return result
60
+
61
+
62
+ def execute_download(request: Dict[str, Any], api_host: str, api_key: str) -> Dict[str, Any]:
63
+ spec = validate_request(request)
64
+ if not api_key:
65
+ raise ValidationError("QUANTDB_API_KEY is required")
66
+ root = os.path.realpath(os.path.abspath(spec["save_dir"] or data_root()))
67
+ client = QuantDBClient(api_host=api_host, api_key=api_key)
68
+ try:
69
+ if spec["operation"] == "sync":
70
+ result = client.data.sync(spec["dataset"], save_dir=root, after_release=request.get("after_release"))
71
+ return {
72
+ "operation": "sync", "dataset": spec["dataset"], "save_dir": root,
73
+ "downloaded_count": len(result["downloaded"]),
74
+ "matched_count": len(result.get("matched", [])),
75
+ "release_id": result.get("release_id"), "layout": result.get("layout"),
76
+ }
77
+ dataset = spec["dataset"]
78
+ target = os.path.join(root, CATEGORY_DIRS[SYNC_DATASET_CATEGORIES[dataset]], dataset)
79
+ if spec["trade_date"]:
80
+ target = os.path.join(target, "dt=" + spec["trade_date"])
81
+ # Resolve symlinks before creating files, including existing subdirectories.
82
+ if os.path.commonpath([root, os.path.realpath(target)]) != root:
83
+ raise ValidationError("Download destination escapes save_dir")
84
+ path = client.data.download(
85
+ dataset, symbol=spec["symbol"] or None, trade_date=spec["trade_date"] or None,
86
+ save_dir=target, layout="v2" if dataset in PARTITIONED else "v1",
87
+ )
88
+ return {"operation": "download", "dataset": dataset, "save_dir": root,
89
+ "path": path, "size_bytes": os.path.getsize(path), "downloaded_count": 1}
90
+ finally:
91
+ client.session.close()
92
+
93
+
94
+ def main() -> int:
95
+ try:
96
+ raw = sys.stdin.read(65537)
97
+ if len(raw) > 65536:
98
+ raise ValidationError("Request too large")
99
+ request = json.loads(raw)
100
+ # SDK/HTTP diagnostics must not leak credentials or signed URLs into MCP.
101
+ with open(os.devnull, "w") as sink, contextlib.redirect_stdout(sink), contextlib.redirect_stderr(sink):
102
+ result = execute_download(request, request["api_host"], request["api_key"])
103
+ print(json.dumps({"result": result}, ensure_ascii=True))
104
+ return 0
105
+ except Exception as exc:
106
+ print(json.dumps({"error": {"type": type(exc).__name__, "message": "SDK download failed; check credentials, access, quota, dataset and local disk."}}))
107
+ return 1
108
+
109
+
110
+ if __name__ == "__main__":
111
+ sys.exit(main())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quantdb-sdk
3
- Version: 0.4.4
3
+ Version: 0.4.5
4
4
  Summary: QuantDB 量化数据平台官方 Python SDK
5
5
  Author: QuantDB Team
6
6
  License: MIT
@@ -11,6 +11,7 @@ quantdb_sdk/client.py
11
11
  quantdb_sdk/errors.py
12
12
  quantdb_sdk/hub.py
13
13
  quantdb_sdk/layout.py
14
+ quantdb_sdk/mcp_download.py
14
15
  quantdb_sdk/py.typed
15
16
  quantdb_sdk.egg-info/PKG-INFO
16
17
  quantdb_sdk.egg-info/SOURCES.txt
@@ -31,6 +32,7 @@ tests/test_client.py
31
32
  tests/test_data_hub.py
32
33
  tests/test_hub_models.py
33
34
  tests/test_layout.py
35
+ tests/test_mcp_download.py
34
36
  tests/test_technical_indicators.py
35
37
  tests/test_update_pipeline.py
36
38
  tests/test_utils.py
@@ -144,6 +144,8 @@ async def test_a_insufficient_traffic_error():
144
144
  @pytest.mark.asyncio
145
145
  @respx.mock
146
146
  async def test_a_get_usage_with_subscription():
147
+ # a_get_usage also queries the traffic-package ledger since SDK 0.4.4.
148
+ respx.get(f"{API_HOST}/api/v1/subscription/traffic-packages").mock(return_value=httpx.Response(200, json={"packages": []}))
147
149
  respx.get(f"{API_HOST}/api/v1/auth/usage").mock(
148
150
  return_value=httpx.Response(
149
151
  200,
@@ -213,6 +213,8 @@ def test_insufficient_traffic_error():
213
213
 
214
214
  @responses.activate
215
215
  def test_get_usage_with_subscription():
216
+ # get_usage also queries the traffic-package ledger since SDK 0.4.4.
217
+ responses.get(f"{API_HOST}/api/v1/subscription/traffic-packages", json={"packages": []}, status=200)
216
218
  responses.get(
217
219
  f"{API_HOST}/api/v1/auth/usage",
218
220
  json={
@@ -0,0 +1,134 @@
1
+ """MCP download runner reuses real SDK transfer and release-sync behavior."""
2
+
3
+ import hashlib
4
+ import json
5
+ import sys
6
+ from urllib.parse import parse_qs, urlparse
7
+
8
+ import pytest
9
+ import responses
10
+
11
+ from quantdb_sdk.errors import ValidationError
12
+ from quantdb_sdk.mcp_download import execute_download, main, validate_request
13
+
14
+ HOST = "http://localhost:5000"
15
+
16
+
17
+ @responses.activate
18
+ def test_download_partition_preserves_dates_and_forwards_key(tmp_path):
19
+ def download(request):
20
+ params = parse_qs(urlparse(request.url).query)
21
+ assert request.headers["X-API-Key"] == "local-secret"
22
+ assert params["layout"] == ["v2"]
23
+ assert "symbol" not in params
24
+ return 200, {"Content-Disposition": 'attachment; filename="data.parquet"'}, params["trade_date"][0].encode()
25
+
26
+ responses.add_callback(responses.GET, HOST + "/api/v1/data/download", callback=download)
27
+ paths = []
28
+ for date in ("2026-10-15", "20261016"):
29
+ result = execute_download({"dataset": "min1_kline", "trade_date": date, "save_dir": str(tmp_path)}, HOST, "local-secret")
30
+ paths.append(result["path"])
31
+ assert result["downloaded_count"] == 1
32
+ assert result["size_bytes"] == 8
33
+ assert paths[0] != paths[1]
34
+ assert (tmp_path / "1_kline_data/min1_kline/dt=20261016/data.parquet").read_bytes() == b"20261016"
35
+
36
+
37
+ @responses.activate
38
+ def test_download_v1_and_static_files(tmp_path):
39
+ def download(request):
40
+ params = parse_qs(urlparse(request.url).query)
41
+ assert params["layout"] == ["v1"]
42
+ if params["sub_category"] == ["balance"]:
43
+ assert params["symbol"] == ["600519.SH"]
44
+ else:
45
+ assert "symbol" not in params
46
+ return 200, {}, b"file"
47
+ responses.add_callback(responses.GET, HOST + "/api/v1/data/download", callback=download)
48
+ result = execute_download({"dataset": "balance", "symbol": "600519.sh", "save_dir": str(tmp_path)}, HOST, "key")
49
+ assert result["path"].endswith("balance_600519.SH.parquet")
50
+ result = execute_download({"dataset": "sector_concept", "save_dir": str(tmp_path)}, HOST, "key")
51
+ assert result["path"].endswith("sector_concept.parquet")
52
+
53
+
54
+ @responses.activate
55
+ def test_sync_uses_sdk_cursor_and_skips_existing_content(tmp_path):
56
+ payload = b"partition"
57
+ key = "1_kline_data/daily_forward/dt=20261016/data.parquet"
58
+ calls = []
59
+ def releases(request):
60
+ calls.append(parse_qs(urlparse(request.url).query, keep_blank_values=True).get("after_release"))
61
+ return 200, {}, json.dumps({"releases": [{"release_id": "20261016.1", "objects": [
62
+ {"key": key, "size": len(payload), "sha256": hashlib.sha256(payload).hexdigest()}
63
+ ]}]})
64
+ responses.add_callback(responses.GET, HOST + "/api/v1/data/releases", callback=releases)
65
+ responses.get(HOST + "/api/v1/data/download", body=payload, status=200)
66
+ spec = {"operation": "sync", "dataset": "daily_forward", "save_dir": str(tmp_path)}
67
+ first = execute_download(spec, HOST, "key")
68
+ second = execute_download(spec, HOST, "key")
69
+ assert first["downloaded_count"] == 1
70
+ assert second["downloaded_count"] == 0
71
+ assert calls == [[""], ["20261016.1"]]
72
+ assert (tmp_path / key).read_bytes() == payload
73
+ assert "downloaded" not in first # bounded MCP summary, even for huge syncs
74
+
75
+
76
+ @pytest.mark.parametrize("spec", [
77
+ {"dataset": "unknown"},
78
+ {"dataset": "daily_forward"},
79
+ {"dataset": "daily_forward", "trade_date": "20260230"},
80
+ {"dataset": "daily_forward", "trade_date": "20261016", "symbol": "600519.SH"},
81
+ {"dataset": "balance", "symbol": "../../outside"},
82
+ {"dataset": "balance", "symbol": "600519.SH", "trade_date": "20261016"},
83
+ {"dataset": "sector_concept", "symbol": "600519.SH"},
84
+ {"operation": "sync", "dataset": "balance", "symbol": "600519.SH"},
85
+ ])
86
+ def test_validation_prevents_ambiguous_or_unsafe_requests(spec):
87
+ with pytest.raises(ValidationError):
88
+ validate_request(spec)
89
+
90
+
91
+ @responses.activate
92
+ def test_worker_never_returns_upstream_secrets(tmp_path, monkeypatch, capsys):
93
+ responses.get(HOST + "/api/v1/data/download", json={"detail": "local-secret https://signed.example?sign=secret"}, status=403)
94
+ request = {"dataset": "balance", "symbol": "600519.SH", "save_dir": str(tmp_path), "api_host": HOST, "api_key": "local-secret"}
95
+ import io
96
+ monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(request)))
97
+ assert main() == 1
98
+ output = capsys.readouterr()
99
+ assert "local-secret" not in output.out + output.err
100
+ assert "signed.example" not in output.out + output.err
101
+ assert json.loads(output.out)["error"]["type"]
102
+
103
+
104
+ def test_cli_download_and_sync_json_delegate_sdk(monkeypatch, capsys, tmp_path):
105
+ from quantdb_sdk.__main__ import main as cli
106
+ from unittest.mock import patch
107
+ monkeypatch.setenv("QUANTDB_API_KEY", "cli-secret")
108
+ for operation in ("download", "sync"):
109
+ args = ["quantdb", operation, "balance", "--save-dir", str(tmp_path), "--json"]
110
+ if operation == "download":
111
+ args += ["--symbol", "600519.SH"]
112
+ else:
113
+ args += ["--after-release", "cursor"]
114
+ monkeypatch.setattr(sys, "argv", args)
115
+ with patch("quantdb_sdk.mcp_download.execute_download", return_value={"downloaded_count": 1}) as execute:
116
+ assert cli() == 0
117
+ assert execute.call_args.args[0]["operation"] == operation
118
+ if operation == "sync":
119
+ assert execute.call_args.args[0]["after_release"] == "cursor"
120
+ assert json.loads(capsys.readouterr().out)["downloaded_count"] == 1
121
+
122
+
123
+ def test_sync_destination_rejects_symlink_escape(tmp_path):
124
+ from quantdb_sdk._utils import safe_join
125
+ outside = tmp_path / "outside"
126
+ root = tmp_path / "root"
127
+ outside.mkdir()
128
+ root.mkdir()
129
+ try:
130
+ (root / "linked").symlink_to(outside, target_is_directory=True)
131
+ except OSError:
132
+ pytest.skip("symlinks are unavailable on this platform")
133
+ with pytest.raises(ValueError, match="符号链接"):
134
+ safe_join(str(root), "linked/data.parquet")
File without changes
File without changes
File without changes
File without changes