akshare-cli 0.2.0__py3-none-any.whl
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.
- akshare_cli/__init__.py +3 -0
- akshare_cli/cli.py +1285 -0
- akshare_cli/core/__init__.py +1 -0
- akshare_cli/core/cache.py +127 -0
- akshare_cli/core/export.py +115 -0
- akshare_cli/core/registry.py +236 -0
- akshare_cli/core/session.py +115 -0
- akshare_cli/tests/__init__.py +1 -0
- akshare_cli/tests/conftest.py +37 -0
- akshare_cli/tests/test_cache.py +111 -0
- akshare_cli/tests/test_core.py +439 -0
- akshare_cli/tests/test_doc_examples.py +636 -0
- akshare_cli/tests/test_full_e2e.py +262 -0
- akshare_cli/utils/__init__.py +1 -0
- akshare_cli/utils/formatting.py +62 -0
- akshare_cli-0.2.0.dist-info/METADATA +1212 -0
- akshare_cli-0.2.0.dist-info/RECORD +20 -0
- akshare_cli-0.2.0.dist-info/WHEEL +5 -0
- akshare_cli-0.2.0.dist-info/entry_points.txt +2 -0
- akshare_cli-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""
|
|
2
|
+
End-to-end tests for AKShare CLI harness.
|
|
3
|
+
|
|
4
|
+
Tests the installed CLI via subprocess. Includes TestCLISubprocess with
|
|
5
|
+
_resolve_cli() for testing the installed command.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TestCLISubprocess:
|
|
19
|
+
"""E2E tests that invoke the CLI as a subprocess."""
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def _resolve_cli(name: str = "akshare-cli") -> str:
|
|
23
|
+
"""
|
|
24
|
+
Resolve the CLI executable path.
|
|
25
|
+
|
|
26
|
+
If CLI_ANYTHING_FORCE_INSTALLED=1 is set, uses the system PATH.
|
|
27
|
+
Otherwise, falls back to the venv binary.
|
|
28
|
+
"""
|
|
29
|
+
if os.environ.get("CLI_ANYTHING_FORCE_INSTALLED") == "1":
|
|
30
|
+
path = shutil.which(name)
|
|
31
|
+
if path:
|
|
32
|
+
return path
|
|
33
|
+
raise FileNotFoundError(
|
|
34
|
+
f"'{name}' not found in PATH. "
|
|
35
|
+
f"Install with: pip install -e ."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Try venv first
|
|
39
|
+
venv_bin = os.path.join(
|
|
40
|
+
os.path.dirname(os.path.dirname(os.path.dirname(
|
|
41
|
+
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
42
|
+
))),
|
|
43
|
+
".venv", "bin", name,
|
|
44
|
+
)
|
|
45
|
+
if os.path.isfile(venv_bin):
|
|
46
|
+
return venv_bin
|
|
47
|
+
|
|
48
|
+
# Try system PATH
|
|
49
|
+
path = shutil.which(name)
|
|
50
|
+
if path:
|
|
51
|
+
return path
|
|
52
|
+
|
|
53
|
+
# Fallback: run as module
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def _run_cli(cls, args: list, timeout: int = 30, env: dict = None) -> subprocess.CompletedProcess:
|
|
58
|
+
"""Run CLI command and return the result."""
|
|
59
|
+
cli_path = cls._resolve_cli()
|
|
60
|
+
|
|
61
|
+
run_env = os.environ.copy()
|
|
62
|
+
if env:
|
|
63
|
+
run_env.update(env)
|
|
64
|
+
|
|
65
|
+
if cli_path:
|
|
66
|
+
cmd = [cli_path] + args
|
|
67
|
+
else:
|
|
68
|
+
cmd = [sys.executable, "-m", "akshare_cli.cli"] + args
|
|
69
|
+
|
|
70
|
+
return subprocess.run(
|
|
71
|
+
cmd,
|
|
72
|
+
capture_output=True,
|
|
73
|
+
text=True,
|
|
74
|
+
timeout=timeout,
|
|
75
|
+
env=run_env,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# ─── Basic CLI Tests ─────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
def test_help(self):
|
|
81
|
+
result = self._run_cli(["--help"])
|
|
82
|
+
assert result.returncode == 0
|
|
83
|
+
assert "AKShare CLI" in result.stdout
|
|
84
|
+
assert "Commands:" in result.stdout
|
|
85
|
+
|
|
86
|
+
def test_version_flag(self):
|
|
87
|
+
result = self._run_cli(["--version"])
|
|
88
|
+
assert result.returncode == 0
|
|
89
|
+
assert "0.1.0" in result.stdout
|
|
90
|
+
|
|
91
|
+
def test_version_command(self):
|
|
92
|
+
result = self._run_cli(["version"])
|
|
93
|
+
assert result.returncode == 0
|
|
94
|
+
assert "cli_harness_version" in result.stdout
|
|
95
|
+
assert "akshare_version" in result.stdout
|
|
96
|
+
|
|
97
|
+
# ─── Search Tests ────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
def test_search(self):
|
|
100
|
+
result = self._run_cli(["search", "stock_zh_a"])
|
|
101
|
+
assert result.returncode == 0
|
|
102
|
+
assert "stock_zh_a_hist" in result.stdout
|
|
103
|
+
|
|
104
|
+
def test_search_json(self):
|
|
105
|
+
result = self._run_cli(["--json", "search", "stock_zh_a"])
|
|
106
|
+
assert result.returncode == 0
|
|
107
|
+
data = json.loads(result.stdout)
|
|
108
|
+
assert data["count"] > 0
|
|
109
|
+
assert "stock_zh_a_hist" in data["functions"]
|
|
110
|
+
|
|
111
|
+
def test_search_no_results(self):
|
|
112
|
+
result = self._run_cli(["search", "zzzznonexistent999"])
|
|
113
|
+
assert result.returncode == 0
|
|
114
|
+
assert "No functions found" in result.stdout
|
|
115
|
+
|
|
116
|
+
# ─── List Tests ──────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
def test_list_domains(self):
|
|
119
|
+
result = self._run_cli(["list"])
|
|
120
|
+
assert result.returncode == 0
|
|
121
|
+
assert "stock" in result.stdout
|
|
122
|
+
assert "fund" in result.stdout
|
|
123
|
+
assert "functions" in result.stdout
|
|
124
|
+
|
|
125
|
+
def test_list_domain_stock(self):
|
|
126
|
+
result = self._run_cli(["list", "stock"])
|
|
127
|
+
assert result.returncode == 0
|
|
128
|
+
assert "stock_zh_a_hist" in result.stdout
|
|
129
|
+
|
|
130
|
+
def test_list_json(self):
|
|
131
|
+
result = self._run_cli(["--json", "list"])
|
|
132
|
+
assert result.returncode == 0
|
|
133
|
+
data = json.loads(result.stdout)
|
|
134
|
+
assert "total_functions" in data
|
|
135
|
+
assert data["total_functions"] > 500
|
|
136
|
+
|
|
137
|
+
# ─── Info Tests ──────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
def test_info(self):
|
|
140
|
+
result = self._run_cli(["info", "stock_zh_a_hist"])
|
|
141
|
+
assert result.returncode == 0
|
|
142
|
+
assert "stock_zh_a_hist" in result.stdout
|
|
143
|
+
assert "--symbol" in result.stdout
|
|
144
|
+
assert "--period" in result.stdout
|
|
145
|
+
|
|
146
|
+
def test_info_json(self):
|
|
147
|
+
result = self._run_cli(["--json", "info", "stock_zh_a_hist"])
|
|
148
|
+
assert result.returncode == 0
|
|
149
|
+
data = json.loads(result.stdout)
|
|
150
|
+
assert data["name"] == "stock_zh_a_hist"
|
|
151
|
+
assert isinstance(data["params"], list)
|
|
152
|
+
|
|
153
|
+
def test_info_not_found(self):
|
|
154
|
+
result = self._run_cli(["info", "nonexistent_xyz"])
|
|
155
|
+
assert result.returncode != 0
|
|
156
|
+
|
|
157
|
+
# ─── Call Tests (with real network) ──────────────────────────────────
|
|
158
|
+
|
|
159
|
+
@pytest.mark.network
|
|
160
|
+
def test_call_with_data(self):
|
|
161
|
+
"""Test calling a real akshare function (requires network)."""
|
|
162
|
+
result = self._run_cli(["call", "macro_china_gdp_yearly"], timeout=60)
|
|
163
|
+
assert result.returncode == 0
|
|
164
|
+
assert "rows" in result.stderr or len(result.stdout) > 50
|
|
165
|
+
|
|
166
|
+
@pytest.mark.network
|
|
167
|
+
def test_call_json_output(self):
|
|
168
|
+
"""Test JSON output mode with real data."""
|
|
169
|
+
result = self._run_cli(["--json", "call", "macro_china_gdp_yearly"], timeout=60)
|
|
170
|
+
assert result.returncode == 0
|
|
171
|
+
data = json.loads(result.stdout)
|
|
172
|
+
assert "total_rows" in data
|
|
173
|
+
assert "data" in data
|
|
174
|
+
assert len(data["data"]) > 0
|
|
175
|
+
|
|
176
|
+
@pytest.mark.network
|
|
177
|
+
def test_call_csv_output(self):
|
|
178
|
+
"""Test CSV output mode with real data."""
|
|
179
|
+
result = self._run_cli(["--csv", "call", "macro_china_gdp_yearly"], timeout=60)
|
|
180
|
+
assert result.returncode == 0
|
|
181
|
+
lines = result.stdout.strip().split("\n")
|
|
182
|
+
assert len(lines) > 1
|
|
183
|
+
|
|
184
|
+
def test_call_unknown_function(self):
|
|
185
|
+
result = self._run_cli(["call", "totally_nonexistent_xyz"])
|
|
186
|
+
assert result.returncode != 0
|
|
187
|
+
assert "not found" in result.stdout.lower() or "error" in result.stdout.lower() or "not found" in result.stderr.lower()
|
|
188
|
+
|
|
189
|
+
# ─── Subcommand Tests ────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
def test_stock_help(self):
|
|
192
|
+
result = self._run_cli(["stock", "--help"])
|
|
193
|
+
assert result.returncode == 0
|
|
194
|
+
assert "hist" in result.stdout
|
|
195
|
+
assert "spot" in result.stdout
|
|
196
|
+
|
|
197
|
+
def test_fund_help(self):
|
|
198
|
+
result = self._run_cli(["fund", "--help"])
|
|
199
|
+
assert result.returncode == 0
|
|
200
|
+
assert "etf" in result.stdout
|
|
201
|
+
|
|
202
|
+
def test_futures_help(self):
|
|
203
|
+
result = self._run_cli(["futures", "--help"])
|
|
204
|
+
assert result.returncode == 0
|
|
205
|
+
assert "hist" in result.stdout
|
|
206
|
+
|
|
207
|
+
def test_macro_help(self):
|
|
208
|
+
result = self._run_cli(["macro", "--help"])
|
|
209
|
+
assert result.returncode == 0
|
|
210
|
+
assert "gdp" in result.stdout
|
|
211
|
+
|
|
212
|
+
def test_forex_help(self):
|
|
213
|
+
result = self._run_cli(["forex", "--help"])
|
|
214
|
+
assert result.returncode == 0
|
|
215
|
+
assert "spot" in result.stdout
|
|
216
|
+
assert "hist" in result.stdout
|
|
217
|
+
|
|
218
|
+
# ─── Export Tests ────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
@pytest.mark.network
|
|
221
|
+
def test_output_to_file(self):
|
|
222
|
+
"""Test saving output directly to a file."""
|
|
223
|
+
with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as f:
|
|
224
|
+
filepath = f.name
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
result = self._run_cli(
|
|
228
|
+
["--output", filepath, "call", "macro_china_gdp_yearly"],
|
|
229
|
+
timeout=60,
|
|
230
|
+
)
|
|
231
|
+
assert result.returncode == 0
|
|
232
|
+
assert os.path.exists(filepath)
|
|
233
|
+
assert os.path.getsize(filepath) > 0
|
|
234
|
+
finally:
|
|
235
|
+
if os.path.exists(filepath):
|
|
236
|
+
os.unlink(filepath)
|
|
237
|
+
|
|
238
|
+
# ─── Workflow Tests ──────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
def test_workflow_search_then_info(self):
|
|
241
|
+
"""Workflow: search for functions then get info on one."""
|
|
242
|
+
# Step 1: Search
|
|
243
|
+
result = self._run_cli(["search", "forex"])
|
|
244
|
+
assert result.returncode == 0
|
|
245
|
+
assert "forex_spot_em" in result.stdout
|
|
246
|
+
|
|
247
|
+
# Step 2: Get info
|
|
248
|
+
result = self._run_cli(["info", "forex_spot_em"])
|
|
249
|
+
assert result.returncode == 0
|
|
250
|
+
assert "forex_spot_em" in result.stdout
|
|
251
|
+
|
|
252
|
+
def test_workflow_json_pipeline(self):
|
|
253
|
+
"""Workflow: JSON output for machine consumption."""
|
|
254
|
+
result = self._run_cli(["--json", "list"])
|
|
255
|
+
assert result.returncode == 0
|
|
256
|
+
data = json.loads(result.stdout)
|
|
257
|
+
assert isinstance(data["total_functions"], int)
|
|
258
|
+
|
|
259
|
+
result = self._run_cli(["--json", "search", "macro_china"])
|
|
260
|
+
assert result.returncode == 0
|
|
261
|
+
data = json.loads(result.stdout)
|
|
262
|
+
assert len(data["functions"]) > 0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Utilities package for AKShare CLI harness."""
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Formatting and display utilities for AKShare CLI harness.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from akshare_cli.core.export import format_csv, format_json, format_table
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def display_result(
|
|
14
|
+
df: pd.DataFrame,
|
|
15
|
+
output_format: str = "table",
|
|
16
|
+
max_rows: Optional[int] = None,
|
|
17
|
+
show_index: bool = False,
|
|
18
|
+
output_file: Optional[str] = None,
|
|
19
|
+
) -> str:
|
|
20
|
+
"""
|
|
21
|
+
Format and display a DataFrame result.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
df: The DataFrame to display.
|
|
25
|
+
output_format: One of "table", "json", "csv".
|
|
26
|
+
max_rows: Maximum rows to display.
|
|
27
|
+
show_index: Whether to show row index.
|
|
28
|
+
output_file: If set, write output to this file path.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
The formatted string.
|
|
32
|
+
"""
|
|
33
|
+
if output_format == "json":
|
|
34
|
+
output = format_json(df, max_rows=max_rows)
|
|
35
|
+
elif output_format == "csv":
|
|
36
|
+
output = format_csv(df, max_rows=max_rows)
|
|
37
|
+
else:
|
|
38
|
+
output = format_table(df, max_rows=max_rows, show_index=show_index)
|
|
39
|
+
|
|
40
|
+
if output_file:
|
|
41
|
+
from akshare_cli.core.export import auto_export
|
|
42
|
+
auto_export(df, output_file)
|
|
43
|
+
return f"Saved to {output_file}"
|
|
44
|
+
|
|
45
|
+
return output
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def print_error(message: str) -> None:
|
|
49
|
+
"""Print an error message to stderr."""
|
|
50
|
+
print(f"Error: {message}", file=sys.stderr)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def print_info(message: str) -> None:
|
|
54
|
+
"""Print an informational message."""
|
|
55
|
+
print(message)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def truncate_string(s: str, max_len: int = 60) -> str:
|
|
59
|
+
"""Truncate a string with ellipsis if it exceeds max_len."""
|
|
60
|
+
if len(s) <= max_len:
|
|
61
|
+
return s
|
|
62
|
+
return s[: max_len - 3] + "..."
|