cyberquant 1.0.0__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.
- cyberquant-1.0.0/.gitignore +53 -0
- cyberquant-1.0.0/PKG-INFO +8 -0
- cyberquant-1.0.0/README.md +202 -0
- cyberquant-1.0.0/pyproject.toml +20 -0
- cyberquant-1.0.0/src/cyberquant/__init__.py +1 -0
- cyberquant-1.0.0/src/cyberquant/__main__.py +35 -0
- cyberquant-1.0.0/src/cyberquant/commands/__init__.py +0 -0
- cyberquant-1.0.0/src/cyberquant/commands/config.py +62 -0
- cyberquant-1.0.0/src/cyberquant/commands/query.py +158 -0
- cyberquant-1.0.0/src/cyberquant/commands/query.py.bak2 +67 -0
- cyberquant-1.0.0/src/cyberquant/commands/stream.py +60 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
.pnpm-store/
|
|
4
|
+
|
|
5
|
+
# Python
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.py[cod]
|
|
8
|
+
*$py.class
|
|
9
|
+
*.so
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
*.egg-info/
|
|
24
|
+
.installed.cfg
|
|
25
|
+
*.egg
|
|
26
|
+
MANIFEST
|
|
27
|
+
.venv/
|
|
28
|
+
venv/
|
|
29
|
+
ENV/
|
|
30
|
+
env/
|
|
31
|
+
|
|
32
|
+
# Node
|
|
33
|
+
dist/
|
|
34
|
+
*.log
|
|
35
|
+
npm-debug.log*
|
|
36
|
+
yarn-debug.log*
|
|
37
|
+
yarn-error.log*
|
|
38
|
+
|
|
39
|
+
# IDE
|
|
40
|
+
.vscode/
|
|
41
|
+
.idea/
|
|
42
|
+
*.swp
|
|
43
|
+
*.swo
|
|
44
|
+
*~
|
|
45
|
+
.DS_Store
|
|
46
|
+
|
|
47
|
+
# Environment
|
|
48
|
+
.env
|
|
49
|
+
.env.local
|
|
50
|
+
.env.*.local
|
|
51
|
+
|
|
52
|
+
# Config
|
|
53
|
+
~/.cyberquant/
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# cyberquant — Python CLI
|
|
2
|
+
|
|
3
|
+
数据共享API服务平台命令行工具(Python 版),通过 pipx 或 pip 安装使用。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# 一次性运行
|
|
9
|
+
pipx run cyberquant query stock_daily --code 000001.SZ
|
|
10
|
+
|
|
11
|
+
# 安装后使用
|
|
12
|
+
pipx install cyberquant
|
|
13
|
+
cyberquant query stock_daily --code 000001.SZ
|
|
14
|
+
|
|
15
|
+
# 或 pip 安装
|
|
16
|
+
pip install cyberquant
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## 首次配置
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
cyberquant config set
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
交互式输入 API 端点地址和 API Key,保存后自动验证。
|
|
26
|
+
|
|
27
|
+
配置文件存储在 `~/.cyberquant/config.json`(与 Node.js 版共用):
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"endpoint": "https://api.example.com",
|
|
32
|
+
"apiKey": "sk_live_xxxxx",
|
|
33
|
+
"pageSize": 50
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## 命令
|
|
38
|
+
|
|
39
|
+
### config — 配置管理
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# 交互式设置端点地址和 API Key
|
|
43
|
+
cyberquant config set
|
|
44
|
+
|
|
45
|
+
# 查看当前配置
|
|
46
|
+
cyberquant config list
|
|
47
|
+
|
|
48
|
+
# 查询当前 API Key 对应的用户状态
|
|
49
|
+
cyberquant config status
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`config status` 调用 API Gateway 的 `/api/v1/me` 端点,显示:
|
|
53
|
+
- 邮箱、Tier 等级、可用市场、过期时间、限流配置
|
|
54
|
+
|
|
55
|
+
### query — 数据查询
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
cyberquant query <routeSlug> [动态参数...] [选项]
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**选项**:
|
|
62
|
+
|
|
63
|
+
| 选项 | 说明 | 默认值 |
|
|
64
|
+
|------|------|--------|
|
|
65
|
+
| `--format <format>` | 输出格式:`json` 或 `csv` | `json` |
|
|
66
|
+
| `--output <file>` | 输出到文件(不指定则输出到终端) | - |
|
|
67
|
+
| `--pageSize <number>` | 每页条数 | config.json > 50 |
|
|
68
|
+
| `--cursor <cursor>` | 游标翻页 | - |
|
|
69
|
+
|
|
70
|
+
**动态参数**:除上述固定选项外,所有 `--key value` 直接作为查询参数透传给 API Gateway,CLI 不做校验。
|
|
71
|
+
|
|
72
|
+
**示例**:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# 基本查询(JSON 输出)
|
|
76
|
+
cyberquant query stock_daily --code 000001.SZ
|
|
77
|
+
|
|
78
|
+
# CSV 格式输出到文件
|
|
79
|
+
cyberquant query stock_daily --code 000001.SZ --format csv --output daily.csv
|
|
80
|
+
|
|
81
|
+
# 带日期范围和自定义每页条数
|
|
82
|
+
cyberquant query stock_daily --code 000001.SZ --startDate 2026-01-01 --endDate 2026-05-01 --pageSize 100
|
|
83
|
+
|
|
84
|
+
# 游标翻页(返回结果中会提示 nextCursor)
|
|
85
|
+
cyberquant query stock_daily --code 000001.SZ --cursor eyJ0cmFkZV9kYXRlIjoiMjAyNC0wMS0wMiJ9
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**pageSize 优先级**:`--pageSize` 参数 > `config.json` 中的 `pageSize` > 默认 50
|
|
89
|
+
|
|
90
|
+
### stream — 实时流
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
cyberquant stream <routeSlug> [动态参数...] [选项]
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**选项**:
|
|
97
|
+
|
|
98
|
+
| 选项 | 说明 | 默认值 |
|
|
99
|
+
|------|------|--------|
|
|
100
|
+
| `--pageSize <number>` | 每批条数 | config.json > 50 |
|
|
101
|
+
| `--format <format>` | 输出格式:`json` 或 `csv` | `json` |
|
|
102
|
+
| `--output <file>` | 输出到文件(不指定则输出到终端) | - |
|
|
103
|
+
|
|
104
|
+
**示例**:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
# 实时行情(JSON 输出到终端)
|
|
108
|
+
cyberquant stream stock_realtime --code 000001.SZ
|
|
109
|
+
|
|
110
|
+
# 输出到 CSV 文件
|
|
111
|
+
cyberquant stream stock_realtime --code 000001.SZ --format csv --output realtime.csv
|
|
112
|
+
|
|
113
|
+
# Ctrl+C 优雅退出
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## 技术栈
|
|
117
|
+
|
|
118
|
+
| 依赖 | 用途 |
|
|
119
|
+
|------|------|
|
|
120
|
+
| Typer | CLI 框架(Click 现代封装) |
|
|
121
|
+
| Rich | 终端美化(表格、面板、颜色、进度条) |
|
|
122
|
+
| httpx | HTTP 客户端 |
|
|
123
|
+
| hatchling | 构建后端 |
|
|
124
|
+
|
|
125
|
+
**Python 版本**:>= 3.10
|
|
126
|
+
|
|
127
|
+
## 项目结构
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
src/cyberquant/
|
|
131
|
+
├── __init__.py # 版本信息
|
|
132
|
+
├── __main__.py # Typer 入口
|
|
133
|
+
├── commands/
|
|
134
|
+
│ ├── config.py # config set/list/status
|
|
135
|
+
│ ├── query.py # query 命令 + 动态参数解析
|
|
136
|
+
│ ├── stream.py # stream 命令(SSE)
|
|
137
|
+
│ └── export.py # export 命令(ThreadPoolExecutor 并发)
|
|
138
|
+
└── lib/
|
|
139
|
+
├── api_client.py # httpx 客户端(自动重试、错误映射)
|
|
140
|
+
├── sse_client.py # SSE 客户端(httpx stream 解析)
|
|
141
|
+
├── config.py # 读写 ~/.cyberquant/config.json
|
|
142
|
+
├── formatter.py # JSON/CSV 格式化输出
|
|
143
|
+
├── display.py # Rich 面板展示用户信息
|
|
144
|
+
└── errors.py # 异常类 + 中文消息映射
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## 开发
|
|
148
|
+
|
|
149
|
+
### 本地开发模式(推荐)
|
|
150
|
+
|
|
151
|
+
使用 `pip install -e .` 进行**可编辑模式安装**,修改代码后无需重新安装即可直接运行:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
cd packages/api-cli-py
|
|
155
|
+
|
|
156
|
+
# 可编辑模式安装(仅需执行一次)
|
|
157
|
+
pip install -e .
|
|
158
|
+
|
|
159
|
+
# 运行
|
|
160
|
+
cyberquant --help
|
|
161
|
+
cyberquant query stock_daily --code 000001.SZ
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### 直接运行模块模式(不安装)
|
|
165
|
+
|
|
166
|
+
如果不希望安装到系统,也可以直接运行模块:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
cd packages/api-cli-py
|
|
170
|
+
|
|
171
|
+
# 不安装直接运行
|
|
172
|
+
PYTHONPATH=src python3 -m cyberquant --help
|
|
173
|
+
PYTHONPATH=src python3 -m cyberquant query stock_daily --code 000001.SZ
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**注意**:直接运行模块模式需要每次都设置 `PYTHONPATH=src`,不如可编辑模式方便。
|
|
177
|
+
|
|
178
|
+
### 卸载开发模式
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
pip uninstall cyberquant
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## 构建
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
pip install build twine
|
|
188
|
+
python3 -m build
|
|
189
|
+
twine upload dist/*
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## 错误码
|
|
193
|
+
|
|
194
|
+
| HTTP 状态码 | 中文消息 |
|
|
195
|
+
|-------------|---------|
|
|
196
|
+
| 400 | 参数校验失败 |
|
|
197
|
+
| 401 | API Key 无效或已过期 |
|
|
198
|
+
| 403 | 权限不足 |
|
|
199
|
+
| 404 | 未找到 API 路由 |
|
|
200
|
+
| 429 | 请求频率超限(自动重试) |
|
|
201
|
+
| 500 | 服务器内部错误 |
|
|
202
|
+
| 503 | 服务暂不可用(自动重试) |
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cyberquant"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "数据共享API服务平台命令行工具"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"typer>=0.15.0",
|
|
12
|
+
"rich>=13.9.0",
|
|
13
|
+
"httpx>=0.28.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
cyberquant = "cyberquant.__main__:cli"
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build.targets.wheel]
|
|
20
|
+
packages = ["src/cyberquant"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from cyberquant.commands import config, query, stream
|
|
4
|
+
|
|
5
|
+
cli = typer.Typer(help="数据共享API服务平台命令行工具", no_args_is_help=True)
|
|
6
|
+
|
|
7
|
+
cli.add_typer(config.app, name="config")
|
|
8
|
+
|
|
9
|
+
# 直接注册 query 和 stream 为命令
|
|
10
|
+
# 设置 allow_extra_args 和 allow_interspersed_args 以支持任意动态参数
|
|
11
|
+
@cli.command(name="query", context_settings={"ignore_unknown_options": True, "allow_extra_args": True, "allow_interspersed_args": True})
|
|
12
|
+
def query_cmd(
|
|
13
|
+
ctx: typer.Context,
|
|
14
|
+
route_slug: str = typer.Argument(help="路由名称"),
|
|
15
|
+
format: str = typer.Option("json", "--format", help="输出格式: json | csv"),
|
|
16
|
+
output: Optional[str] = typer.Option(None, "--output", help="输出到文件"),
|
|
17
|
+
page_size: Optional[int] = typer.Option(None, "--pageSize", help="每页条数"),
|
|
18
|
+
cursor: Optional[str] = typer.Option(None, "--cursor", help="游标翻页"),
|
|
19
|
+
fetch_all: bool = typer.Option(False, "--all", help="自动翻页查询全部数据(流式写入)"),
|
|
20
|
+
) -> None:
|
|
21
|
+
"""查询数据"""
|
|
22
|
+
query.query(ctx, route_slug, format, output, page_size, cursor, fetch_all)
|
|
23
|
+
|
|
24
|
+
@cli.command(name="stream", context_settings={"ignore_unknown_options": True, "allow_extra_args": True, "allow_interspersed_args": True})
|
|
25
|
+
def stream_cmd(
|
|
26
|
+
ctx: typer.Context,
|
|
27
|
+
route_slug: str = typer.Argument(help="路由名称"),
|
|
28
|
+
format: str = typer.Option("json", "--format", help="输出格式: json | csv"),
|
|
29
|
+
output: Optional[str] = typer.Option(None, "--output", help="输出到文件"),
|
|
30
|
+
) -> None:
|
|
31
|
+
"""流式查询"""
|
|
32
|
+
stream.stream_cmd(ctx, route_slug, format, output)
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
cli()
|
|
File without changes
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.prompt import Prompt
|
|
6
|
+
|
|
7
|
+
from cyberquant.lib.config import load_config, require_config, save_config, mask_api_key
|
|
8
|
+
from cyberquant.lib.api_client import ApiClient
|
|
9
|
+
from cyberquant.lib.display import show_user_info
|
|
10
|
+
from cyberquant.lib.errors import ConfigError
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="配置管理")
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.command("set")
|
|
17
|
+
def config_set() -> None:
|
|
18
|
+
"""设置 API 端点和密钥"""
|
|
19
|
+
existing = load_config()
|
|
20
|
+
|
|
21
|
+
endpoint = Prompt.ask("API 端点地址", default=existing.get("endpoint", "http://localhost:4000") if existing else "http://localhost:4000")
|
|
22
|
+
api_key = Prompt.ask("API Key", password=True)
|
|
23
|
+
|
|
24
|
+
with console.status("验证中..."):
|
|
25
|
+
with ApiClient({"endpoint": endpoint, "apiKey": api_key}) as client:
|
|
26
|
+
try:
|
|
27
|
+
res = client.get_profile()
|
|
28
|
+
if not res.get("success") or not res.get("data"):
|
|
29
|
+
console.print(f"[red]验证失败: {res.get('error', '未知错误')}[/red]")
|
|
30
|
+
return
|
|
31
|
+
save_config({"endpoint": endpoint, "apiKey": api_key})
|
|
32
|
+
console.print("[green]配置已保存并验证通过[/green]")
|
|
33
|
+
show_user_info(res["data"])
|
|
34
|
+
except Exception as e:
|
|
35
|
+
console.print(f"[red]验证失败: {e}[/red]")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.command("list")
|
|
39
|
+
def config_list() -> None:
|
|
40
|
+
"""查看当前配置"""
|
|
41
|
+
cfg = require_config()
|
|
42
|
+
lines = [
|
|
43
|
+
f"端点: {cfg['endpoint']}",
|
|
44
|
+
f"API Key: {mask_api_key(cfg['apiKey'])}",
|
|
45
|
+
]
|
|
46
|
+
if cfg.get("pageSize"):
|
|
47
|
+
lines.append(f"默认条数: {cfg['pageSize']}")
|
|
48
|
+
console.print("\n".join(lines))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command("status")
|
|
52
|
+
def config_status() -> None:
|
|
53
|
+
"""查询当前 API Key 用户状态"""
|
|
54
|
+
cfg = require_config()
|
|
55
|
+
with console.status("查询中..."):
|
|
56
|
+
with ApiClient(cfg) as client:
|
|
57
|
+
res = client.get_profile()
|
|
58
|
+
if not res.get("success") or not res.get("data"):
|
|
59
|
+
console.print(f"[red]查询失败: {res.get('error', '未知错误')}[/red]")
|
|
60
|
+
return
|
|
61
|
+
console.print("[green]查询成功[/green]")
|
|
62
|
+
show_user_info(res["data"])
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from cyberquant.lib.config import require_config
|
|
7
|
+
from cyberquant.lib.api_client import ApiClient
|
|
8
|
+
from cyberquant.lib.formatter import format_json, format_csv, create_stream_writer
|
|
9
|
+
from cyberquant.lib.stream_writer import StreamWriter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _parse_extra_args(args: list[str], known_options: list[str] | None = None) -> dict[str, str | list[str]]:
|
|
13
|
+
"""解析动态参数,支持重复参数转为数组
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
args: 原始参数列表
|
|
17
|
+
known_options: 已知选项列表,这些选项不会被解析为动态参数
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
动态参数字典,重复的 key 会转为 list
|
|
21
|
+
"""
|
|
22
|
+
params: dict[str, str | list[str]] = {}
|
|
23
|
+
known_set = set(known_options or [])
|
|
24
|
+
|
|
25
|
+
i = 0
|
|
26
|
+
while i < len(args):
|
|
27
|
+
if args[i].startswith("--"):
|
|
28
|
+
key = args[i][2:]
|
|
29
|
+
|
|
30
|
+
# 跳过已知选项及其值
|
|
31
|
+
if key in known_set:
|
|
32
|
+
i += 2
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
# 处理动态参数
|
|
36
|
+
if i + 1 < len(args) and not args[i + 1].startswith("--"):
|
|
37
|
+
_append_param(params, key, args[i + 1])
|
|
38
|
+
i += 2
|
|
39
|
+
else:
|
|
40
|
+
_append_param(params, key, "true")
|
|
41
|
+
i += 1
|
|
42
|
+
else:
|
|
43
|
+
i += 1
|
|
44
|
+
|
|
45
|
+
return params
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _append_param(params: dict[str, str | list[str]], key: str, value: str) -> None:
|
|
49
|
+
"""添加参数到字典,如果 key 已存在则转为数组"""
|
|
50
|
+
if key in params:
|
|
51
|
+
existing = params[key]
|
|
52
|
+
if isinstance(existing, list):
|
|
53
|
+
existing.append(value)
|
|
54
|
+
else:
|
|
55
|
+
params[key] = [existing, value]
|
|
56
|
+
else:
|
|
57
|
+
params[key] = value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def query(
|
|
61
|
+
ctx: typer.Context,
|
|
62
|
+
route_slug: str = typer.Argument(help="路由名称"),
|
|
63
|
+
format: str = typer.Option("json", "--format", help="输出格式: json | csv"),
|
|
64
|
+
output: Optional[str] = typer.Option(None, "--output", help="输出到文件"),
|
|
65
|
+
page_size: Optional[int] = typer.Option(None, "--pageSize", help="每页条数"),
|
|
66
|
+
cursor: Optional[str] = typer.Option(None, "--cursor", help="游标翻页"),
|
|
67
|
+
fetch_all: bool = typer.Option(False, "--all", help="自动翻页查询全部数据(流式写入)"),
|
|
68
|
+
) -> None:
|
|
69
|
+
"""查询数据"""
|
|
70
|
+
cfg = require_config()
|
|
71
|
+
ps = page_size or cfg.get("pageSize", 50)
|
|
72
|
+
|
|
73
|
+
# 从原始命令行参数解析动态参数
|
|
74
|
+
known_options = ["format", "output", "pageSize", "cursor", "all"]
|
|
75
|
+
dynamic = _parse_extra_args(ctx.args, known_options)
|
|
76
|
+
params: dict[str, str | list[str] | int] = {**dynamic, "pageSize": ps}
|
|
77
|
+
|
|
78
|
+
# 如果指定了 --all,忽略手动传入的 cursor
|
|
79
|
+
if not fetch_all and cursor:
|
|
80
|
+
params["cursor"] = cursor
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
if fetch_all:
|
|
84
|
+
# 自动翻页查询全部数据,使用流式写入
|
|
85
|
+
# 确保 output 是字符串或 None,防止 Typer 的 OptionInfo 类型泄漏
|
|
86
|
+
output_path: str | None = output if isinstance(output, (str, type(None))) else None
|
|
87
|
+
writer: StreamWriter = create_stream_writer(format, output_path, cfg)
|
|
88
|
+
writer.initialize()
|
|
89
|
+
|
|
90
|
+
query_cursor: str | None = None
|
|
91
|
+
count = 0
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
# 使用连接池复用 HTTP 连接
|
|
95
|
+
with ApiClient(cfg) as client:
|
|
96
|
+
while True:
|
|
97
|
+
query_params = {**params}
|
|
98
|
+
if query_cursor:
|
|
99
|
+
query_params["cursor"] = query_cursor
|
|
100
|
+
|
|
101
|
+
res = client.query(route_slug, query_params)
|
|
102
|
+
if not res.get("success"):
|
|
103
|
+
print(res.get("error", "查询失败"), file=sys.stderr)
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
data = res.get("data", [])
|
|
107
|
+
if isinstance(data, list):
|
|
108
|
+
for record in data:
|
|
109
|
+
writer.write_record(record)
|
|
110
|
+
count += 1
|
|
111
|
+
|
|
112
|
+
pagination = res.get("meta", {}).get("pagination", {})
|
|
113
|
+
if not pagination.get("hasMore"):
|
|
114
|
+
break
|
|
115
|
+
|
|
116
|
+
query_cursor = pagination.get("nextCursor")
|
|
117
|
+
|
|
118
|
+
finally:
|
|
119
|
+
# 确保 Ctrl+C 时也能正确闭合文件
|
|
120
|
+
writer.finalize()
|
|
121
|
+
|
|
122
|
+
# 输出警告(如果有)
|
|
123
|
+
warning = writer.get_warning() if hasattr(writer, "get_warning") else None
|
|
124
|
+
if warning:
|
|
125
|
+
print(warning, file=sys.stderr)
|
|
126
|
+
else:
|
|
127
|
+
# 单页查询
|
|
128
|
+
with ApiClient(cfg) as client:
|
|
129
|
+
res = client.query(route_slug, params)
|
|
130
|
+
|
|
131
|
+
if not res.get("success"):
|
|
132
|
+
print(res.get("error", "查询失败"), file=sys.stderr)
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
data = res.get("data", [])
|
|
136
|
+
|
|
137
|
+
if format == "csv":
|
|
138
|
+
content = format_csv(data)
|
|
139
|
+
else:
|
|
140
|
+
content = format_json(data, pretty=False)
|
|
141
|
+
|
|
142
|
+
if output:
|
|
143
|
+
from pathlib import Path
|
|
144
|
+
expanded = Path(output).expanduser()
|
|
145
|
+
with open(expanded, "w", encoding="utf-8") as f:
|
|
146
|
+
f.write(content)
|
|
147
|
+
print(f"数据已保存 → {output}", file=sys.stderr)
|
|
148
|
+
else:
|
|
149
|
+
print(content)
|
|
150
|
+
|
|
151
|
+
# 提示翻页
|
|
152
|
+
pagination = res.get("meta", {}).get("pagination", {})
|
|
153
|
+
if pagination.get("hasMore"):
|
|
154
|
+
nc = pagination.get("nextCursor", "")
|
|
155
|
+
print(f"还有更多数据,使用 --cursor {nc} 翻页", file=sys.stderr)
|
|
156
|
+
|
|
157
|
+
except Exception as e:
|
|
158
|
+
print(str(e), file=sys.stderr)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
from cyberquant.lib.config import require_config
|
|
7
|
+
from cyberquant.lib.api_client import ApiClient
|
|
8
|
+
from cyberquant.lib.formatter import format_json, format_csv, write_output
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _parse_extra_args(args: list[str]) -> dict[str, str]:
|
|
14
|
+
params: dict[str, str] = {}
|
|
15
|
+
i = 0
|
|
16
|
+
while i < len(args):
|
|
17
|
+
if args[i].startswith("--"):
|
|
18
|
+
key = args[i][2:]
|
|
19
|
+
if i + 1 < len(args) and not args[i + 1].startswith("--"):
|
|
20
|
+
params[key] = args[i + 1]
|
|
21
|
+
i += 2
|
|
22
|
+
else:
|
|
23
|
+
params[key] = "true"
|
|
24
|
+
i += 1
|
|
25
|
+
else:
|
|
26
|
+
i += 1
|
|
27
|
+
return params
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def query(
|
|
31
|
+
ctx: typer.Context,
|
|
32
|
+
route_slug: str = typer.Argument(help="路由名称"),
|
|
33
|
+
format: str = typer.Option("json", "--format", help="输出格式: json | csv"),
|
|
34
|
+
output: Optional[str] = typer.Option(None, "--output", help="输出到文件"),
|
|
35
|
+
page_size: Optional[int] = typer.Option(None, "--pageSize", help="每页条数"),
|
|
36
|
+
cursor: Optional[str] = typer.Option(None, "--cursor", help="游标翻页"),
|
|
37
|
+
) -> None:
|
|
38
|
+
"""查询数据"""
|
|
39
|
+
cfg = require_config()
|
|
40
|
+
client = ApiClient(cfg)
|
|
41
|
+
|
|
42
|
+
ps = page_size or cfg.get("pageSize", 50)
|
|
43
|
+
dynamic = _parse_extra_args(ctx.args)
|
|
44
|
+
params: dict = {**dynamic, "pageSize": ps}
|
|
45
|
+
if cursor:
|
|
46
|
+
params["cursor"] = cursor
|
|
47
|
+
|
|
48
|
+
with console.status("查询中..."):
|
|
49
|
+
res = client.query(route_slug, params)
|
|
50
|
+
|
|
51
|
+
if not res.get("success"):
|
|
52
|
+
console.print(f"[red]{res.get('error', '查询失败')}[/red]")
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
data = res.get("data", [])
|
|
56
|
+
console.print(f"[green]查询成功,共 {len(data)} 条[/green]")
|
|
57
|
+
|
|
58
|
+
if format == "csv":
|
|
59
|
+
content = format_csv(data)
|
|
60
|
+
else:
|
|
61
|
+
content = format_json(data)
|
|
62
|
+
write_output(content, output)
|
|
63
|
+
|
|
64
|
+
pagination = res.get("meta", {}).get("pagination", {})
|
|
65
|
+
if pagination.get("hasMore"):
|
|
66
|
+
nc = pagination.get("nextCursor", "")
|
|
67
|
+
console.print(f"[dim]还有更多数据,使用 --cursor {nc} 翻页[/dim]")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from cyberquant.lib.config import require_config
|
|
7
|
+
from cyberquant.lib.sse_client import connect_sse
|
|
8
|
+
from cyberquant.lib.formatter import create_stream_writer
|
|
9
|
+
from cyberquant.commands.query import _parse_extra_args
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def stream_cmd(
|
|
13
|
+
ctx: typer.Context,
|
|
14
|
+
route_slug: str = typer.Argument(help="路由名称"),
|
|
15
|
+
page_size: Optional[int] = typer.Option(None, "--pageSize", help="每批条数"),
|
|
16
|
+
format: str = typer.Option("json", "--format", help="输出格式: json | csv"),
|
|
17
|
+
output: Optional[str] = typer.Option(None, "--output", help="输出到文件"),
|
|
18
|
+
) -> None:
|
|
19
|
+
"""实时流式数据"""
|
|
20
|
+
cfg = require_config()
|
|
21
|
+
ps = page_size or cfg.get("pageSize", 50)
|
|
22
|
+
|
|
23
|
+
known_options = ["pageSize", "format", "output"]
|
|
24
|
+
dynamic = _parse_extra_args(ctx.args, known_options)
|
|
25
|
+
params: dict[str, str | list[str] | int] = {**dynamic, "pageSize": ps}
|
|
26
|
+
|
|
27
|
+
# 文件输出或 JSON 模式:使用流式写入
|
|
28
|
+
from cyberquant.lib.stream_writer import StreamWriter
|
|
29
|
+
|
|
30
|
+
# 确保 output 是字符串或 None,防止 Typer 的 OptionInfo 类型泄漏
|
|
31
|
+
output_path: str | None = output if isinstance(output, (str, type(None))) else None
|
|
32
|
+
writer: StreamWriter = create_stream_writer(format, output_path, cfg)
|
|
33
|
+
writer.initialize()
|
|
34
|
+
|
|
35
|
+
count = 0
|
|
36
|
+
|
|
37
|
+
def on_data(data: dict) -> None:
|
|
38
|
+
nonlocal count
|
|
39
|
+
writer.write_record(data)
|
|
40
|
+
count += 1
|
|
41
|
+
|
|
42
|
+
def on_complete(info=None) -> None:
|
|
43
|
+
writer.finalize()
|
|
44
|
+
|
|
45
|
+
# 输出警告(如果有)
|
|
46
|
+
warning = writer.get_warning() if hasattr(writer, "get_warning") else None
|
|
47
|
+
if warning:
|
|
48
|
+
print(warning, file=sys.stderr)
|
|
49
|
+
|
|
50
|
+
if output_path:
|
|
51
|
+
print(f"数据已保存 → {output_path}", file=sys.stderr)
|
|
52
|
+
|
|
53
|
+
def on_error(error: str) -> None:
|
|
54
|
+
print(f"流式错误: {error}", file=sys.stderr)
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
connect_sse(cfg, route_slug, params, on_data, on_complete, on_error)
|
|
58
|
+
except KeyboardInterrupt:
|
|
59
|
+
writer.finalize()
|
|
60
|
+
print("已断开连接", file=sys.stderr)
|