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 @@
|
|
|
1
|
+
"""Core package for AKShare CLI harness."""
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
In-memory TTL result cache for akshare API calls.
|
|
3
|
+
|
|
4
|
+
Caches function results to avoid redundant network requests.
|
|
5
|
+
Each entry has a configurable time-to-live (TTL) in seconds.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# TTL Configuration
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
# Functions that should NEVER be cached (real-time data)
|
|
18
|
+
NO_CACHE: set = {
|
|
19
|
+
"stock_zh_a_spot_em",
|
|
20
|
+
"stock_sh_a_spot_em",
|
|
21
|
+
"stock_sz_a_spot_em",
|
|
22
|
+
"stock_bj_a_spot_em",
|
|
23
|
+
"stock_hk_spot_em",
|
|
24
|
+
"stock_us_spot_em",
|
|
25
|
+
"stock_bid_ask_em",
|
|
26
|
+
"stock_intraday_em",
|
|
27
|
+
"forex_spot_em",
|
|
28
|
+
"fund_etf_spot_em",
|
|
29
|
+
"index_global_spot_em",
|
|
30
|
+
"stock_zh_index_spot_em",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Per-function TTL overrides (seconds)
|
|
34
|
+
CACHE_TTL: Dict[str, int] = {
|
|
35
|
+
# Contract / instrument lists — rarely change
|
|
36
|
+
"futures_hist_table_em": 86400, # 24h
|
|
37
|
+
"futures_contract_info_gfex": 86400, # 24h
|
|
38
|
+
"futures_fees_info": 86400, # 24h
|
|
39
|
+
"option_contract_info_ctp": 86400, # 24h
|
|
40
|
+
# Macro — updated monthly at most
|
|
41
|
+
"macro_china_gdp_yearly": 86400, # 24h
|
|
42
|
+
"macro_china_cpi_yearly": 86400,
|
|
43
|
+
"macro_china_cpi_monthly": 86400,
|
|
44
|
+
"macro_china_ppi_yearly": 86400,
|
|
45
|
+
"macro_china_pmi_yearly": 86400,
|
|
46
|
+
"macro_china_m2_yearly": 86400,
|
|
47
|
+
# Bond yield curves — updated daily
|
|
48
|
+
"bond_china_yield": 3600, # 1h
|
|
49
|
+
"bond_zh_us_rate": 3600,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
DEFAULT_TTL: int = 1800 # 30 minutes
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_ttl(func_name: str) -> int:
|
|
56
|
+
"""Return the cache TTL for a function. 0 means do not cache."""
|
|
57
|
+
if func_name in NO_CACHE:
|
|
58
|
+
return 0
|
|
59
|
+
return CACHE_TTL.get(func_name, DEFAULT_TTL)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# ResultCache
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
class ResultCache:
|
|
67
|
+
"""Simple in-memory cache with per-entry TTL."""
|
|
68
|
+
|
|
69
|
+
def __init__(self) -> None:
|
|
70
|
+
self._store: Dict[str, tuple] = {} # key → (data, expire_timestamp)
|
|
71
|
+
self.enabled: bool = True
|
|
72
|
+
self.hits: int = 0
|
|
73
|
+
self.misses: int = 0
|
|
74
|
+
|
|
75
|
+
# -- key helpers --
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _make_key(func_name: str, kwargs: dict) -> str:
|
|
79
|
+
raw = json.dumps({"f": func_name, "k": kwargs}, sort_keys=True, default=str)
|
|
80
|
+
return hashlib.sha256(raw.encode()).hexdigest()[:16]
|
|
81
|
+
|
|
82
|
+
# -- public API --
|
|
83
|
+
|
|
84
|
+
def get(self, func_name: str, kwargs: dict) -> Optional[Any]:
|
|
85
|
+
"""Retrieve a cached result. Returns None on miss or expiry."""
|
|
86
|
+
if not self.enabled:
|
|
87
|
+
return None
|
|
88
|
+
key = self._make_key(func_name, kwargs)
|
|
89
|
+
entry = self._store.get(key)
|
|
90
|
+
if entry is None:
|
|
91
|
+
self.misses += 1
|
|
92
|
+
return None
|
|
93
|
+
data, expires = entry
|
|
94
|
+
if time.time() > expires:
|
|
95
|
+
del self._store[key]
|
|
96
|
+
self.misses += 1
|
|
97
|
+
return None
|
|
98
|
+
self.hits += 1
|
|
99
|
+
return data
|
|
100
|
+
|
|
101
|
+
def put(self, func_name: str, kwargs: dict, data: Any, ttl: int) -> None:
|
|
102
|
+
"""Store a result with TTL (seconds)."""
|
|
103
|
+
if not self.enabled or ttl <= 0:
|
|
104
|
+
return
|
|
105
|
+
key = self._make_key(func_name, kwargs)
|
|
106
|
+
self._store[key] = (data, time.time() + ttl)
|
|
107
|
+
|
|
108
|
+
def clear(self) -> None:
|
|
109
|
+
"""Remove all entries and reset counters."""
|
|
110
|
+
self._store.clear()
|
|
111
|
+
self.hits = 0
|
|
112
|
+
self.misses = 0
|
|
113
|
+
|
|
114
|
+
def stats(self) -> dict:
|
|
115
|
+
total = self.hits + self.misses
|
|
116
|
+
rate = f"{self.hits / total * 100:.1f}%" if total > 0 else "N/A"
|
|
117
|
+
return {
|
|
118
|
+
"enabled": self.enabled,
|
|
119
|
+
"entries": len(self._store),
|
|
120
|
+
"hits": self.hits,
|
|
121
|
+
"misses": self.misses,
|
|
122
|
+
"hit_rate": rate,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Global singleton
|
|
127
|
+
_result_cache = ResultCache()
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Export functionality for AKShare CLI harness.
|
|
3
|
+
|
|
4
|
+
Supports exporting DataFrames to CSV, JSON, Excel, and Markdown.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def export_csv(df: pd.DataFrame, filepath: str, index: bool = False) -> str:
|
|
15
|
+
"""Export DataFrame to CSV file."""
|
|
16
|
+
os.makedirs(os.path.dirname(os.path.abspath(filepath)), exist_ok=True)
|
|
17
|
+
df.to_csv(filepath, index=index, encoding="utf-8-sig")
|
|
18
|
+
return os.path.abspath(filepath)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def export_json(df: pd.DataFrame, filepath: str, orient: str = "records") -> str:
|
|
22
|
+
"""Export DataFrame to JSON file."""
|
|
23
|
+
os.makedirs(os.path.dirname(os.path.abspath(filepath)), exist_ok=True)
|
|
24
|
+
df.to_json(filepath, orient=orient, force_ascii=False, indent=2)
|
|
25
|
+
return os.path.abspath(filepath)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def export_excel(df: pd.DataFrame, filepath: str, sheet_name: str = "Sheet1") -> str:
|
|
29
|
+
"""Export DataFrame to Excel file."""
|
|
30
|
+
os.makedirs(os.path.dirname(os.path.abspath(filepath)), exist_ok=True)
|
|
31
|
+
df.to_excel(filepath, index=False, sheet_name=sheet_name)
|
|
32
|
+
return os.path.abspath(filepath)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def export_markdown(df: pd.DataFrame, filepath: str) -> str:
|
|
36
|
+
"""Export DataFrame to Markdown table file."""
|
|
37
|
+
os.makedirs(os.path.dirname(os.path.abspath(filepath)), exist_ok=True)
|
|
38
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
39
|
+
f.write(df.to_markdown(index=False))
|
|
40
|
+
f.write("\n")
|
|
41
|
+
return os.path.abspath(filepath)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def format_table(
|
|
45
|
+
df: pd.DataFrame,
|
|
46
|
+
max_rows: Optional[int] = None,
|
|
47
|
+
show_index: bool = False,
|
|
48
|
+
max_col_width: int = 30,
|
|
49
|
+
) -> str:
|
|
50
|
+
"""Format DataFrame as a text table for terminal display."""
|
|
51
|
+
if max_rows is not None and len(df) > max_rows:
|
|
52
|
+
display_df = pd.concat([df.head(max_rows // 2), df.tail(max_rows // 2)])
|
|
53
|
+
truncated = True
|
|
54
|
+
total_rows = len(df)
|
|
55
|
+
else:
|
|
56
|
+
display_df = df
|
|
57
|
+
truncated = False
|
|
58
|
+
total_rows = len(df)
|
|
59
|
+
|
|
60
|
+
# Truncate wide columns for display
|
|
61
|
+
display_df = display_df.copy()
|
|
62
|
+
for col in display_df.columns:
|
|
63
|
+
if display_df[col].dtype == object:
|
|
64
|
+
display_df[col] = display_df[col].astype(str).str[:max_col_width]
|
|
65
|
+
|
|
66
|
+
table = display_df.to_string(index=show_index)
|
|
67
|
+
|
|
68
|
+
if truncated:
|
|
69
|
+
table += f"\n\n... [{total_rows} rows total, showing {max_rows}]"
|
|
70
|
+
|
|
71
|
+
return table
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def format_json(df: pd.DataFrame, max_rows: Optional[int] = None) -> str:
|
|
75
|
+
"""Format DataFrame as JSON string."""
|
|
76
|
+
if max_rows is not None:
|
|
77
|
+
df = df.head(max_rows)
|
|
78
|
+
|
|
79
|
+
records = json.loads(df.to_json(orient="records", force_ascii=False))
|
|
80
|
+
result = {
|
|
81
|
+
"total_rows": len(df),
|
|
82
|
+
"columns": list(df.columns),
|
|
83
|
+
"data": records,
|
|
84
|
+
}
|
|
85
|
+
return json.dumps(result, indent=2, ensure_ascii=False, default=str)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def format_csv(df: pd.DataFrame, max_rows: Optional[int] = None) -> str:
|
|
89
|
+
"""Format DataFrame as CSV string."""
|
|
90
|
+
if max_rows is not None:
|
|
91
|
+
df = df.head(max_rows)
|
|
92
|
+
return df.to_csv(index=False)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def auto_export(
|
|
96
|
+
df: pd.DataFrame,
|
|
97
|
+
filepath: str,
|
|
98
|
+
**kwargs,
|
|
99
|
+
) -> str:
|
|
100
|
+
"""Export DataFrame based on file extension."""
|
|
101
|
+
ext = os.path.splitext(filepath)[1].lower()
|
|
102
|
+
exporters = {
|
|
103
|
+
".csv": export_csv,
|
|
104
|
+
".json": export_json,
|
|
105
|
+
".xlsx": export_excel,
|
|
106
|
+
".xls": export_excel,
|
|
107
|
+
".md": export_markdown,
|
|
108
|
+
}
|
|
109
|
+
exporter = exporters.get(ext)
|
|
110
|
+
if exporter is None:
|
|
111
|
+
raise ValueError(
|
|
112
|
+
f"Unsupported file format: {ext}. "
|
|
113
|
+
f"Supported: {', '.join(exporters.keys())}"
|
|
114
|
+
)
|
|
115
|
+
return exporter(df, filepath, **kwargs)
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Function registry and dynamic dispatch for akshare functions.
|
|
3
|
+
|
|
4
|
+
Provides discovery, introspection, and invocation of all akshare public functions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import importlib
|
|
8
|
+
import inspect
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from datetime import date as _date
|
|
12
|
+
from functools import lru_cache
|
|
13
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
from akshare_cli.core.cache import _result_cache, get_ttl
|
|
16
|
+
|
|
17
|
+
# Matches YYYYMMDD date strings
|
|
18
|
+
_DATE_DEFAULT_RE = re.compile(r"^\d{8}$")
|
|
19
|
+
|
|
20
|
+
# Functions disabled due to broken upstream API
|
|
21
|
+
DISABLED_FUNCTIONS: set = {
|
|
22
|
+
"index_news_sentiment_scope",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@lru_cache(maxsize=1)
|
|
27
|
+
def _get_akshare_module():
|
|
28
|
+
"""Lazily import the real akshare library (not the local namespace package).
|
|
29
|
+
|
|
30
|
+
Uses importlib to ensure we get the pip-installed akshare,
|
|
31
|
+
even when running inside the akshare_cli namespace.
|
|
32
|
+
"""
|
|
33
|
+
ak = importlib.import_module("akshare")
|
|
34
|
+
# Verify we got the real library, not the local namespace sub-package
|
|
35
|
+
if not hasattr(ak, "__version__"):
|
|
36
|
+
raise ImportError(
|
|
37
|
+
"Imported 'akshare' does not appear to be the real akshare library. "
|
|
38
|
+
"Ensure akshare is installed: pip install akshare"
|
|
39
|
+
)
|
|
40
|
+
return ak
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@lru_cache(maxsize=1)
|
|
44
|
+
def get_all_functions() -> Dict[str, Callable]:
|
|
45
|
+
"""Return a dict of all public akshare functions keyed by name."""
|
|
46
|
+
ak = _get_akshare_module()
|
|
47
|
+
funcs = {}
|
|
48
|
+
for name in dir(ak):
|
|
49
|
+
if name.startswith("_"):
|
|
50
|
+
continue
|
|
51
|
+
if name in DISABLED_FUNCTIONS:
|
|
52
|
+
continue
|
|
53
|
+
obj = getattr(ak, name)
|
|
54
|
+
if callable(obj) and not isinstance(obj, type):
|
|
55
|
+
funcs[name] = obj
|
|
56
|
+
return funcs
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_function(name: str) -> Optional[Callable]:
|
|
60
|
+
"""Get a single akshare function by exact name."""
|
|
61
|
+
return get_all_functions().get(name)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def search_functions(keyword: str) -> List[str]:
|
|
65
|
+
"""Search function names containing the keyword (case-insensitive)."""
|
|
66
|
+
keyword_lower = keyword.lower()
|
|
67
|
+
return sorted(
|
|
68
|
+
name for name in get_all_functions()
|
|
69
|
+
if keyword_lower in name.lower()
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def list_functions_by_domain(domain: str) -> List[str]:
|
|
74
|
+
"""List functions whose name starts with a given domain prefix."""
|
|
75
|
+
prefix = domain.lower() + "_"
|
|
76
|
+
return sorted(
|
|
77
|
+
name for name in get_all_functions()
|
|
78
|
+
if name.lower().startswith(prefix)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_function_info(name: str) -> Optional[Dict[str, Any]]:
|
|
83
|
+
"""Get detailed info about a function: signature, parameters, docstring."""
|
|
84
|
+
func = get_function(name)
|
|
85
|
+
if func is None:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
sig = inspect.signature(func)
|
|
89
|
+
params = []
|
|
90
|
+
for pname, param in sig.parameters.items():
|
|
91
|
+
p_info = {
|
|
92
|
+
"name": pname,
|
|
93
|
+
"kind": str(param.kind),
|
|
94
|
+
"has_default": param.default is not inspect.Parameter.empty,
|
|
95
|
+
}
|
|
96
|
+
if p_info["has_default"]:
|
|
97
|
+
p_info["default"] = param.default
|
|
98
|
+
if param.annotation is not inspect.Parameter.empty:
|
|
99
|
+
p_info["type"] = str(param.annotation)
|
|
100
|
+
params.append(p_info)
|
|
101
|
+
|
|
102
|
+
doc = inspect.getdoc(func) or ""
|
|
103
|
+
return {
|
|
104
|
+
"name": name,
|
|
105
|
+
"params": params,
|
|
106
|
+
"docstring": doc,
|
|
107
|
+
"module": getattr(func, "__module__", "unknown"),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def parse_param_value(value_str: str, param_info: Dict) -> Any:
|
|
112
|
+
"""Parse a string parameter value into the appropriate Python type.
|
|
113
|
+
|
|
114
|
+
Respects the function's type annotation: if a parameter is typed as str,
|
|
115
|
+
the value is kept as-is (preserving leading zeros like "000001").
|
|
116
|
+
"""
|
|
117
|
+
# If the parameter is annotated as str, keep it as a string
|
|
118
|
+
param_type = param_info.get("type", "")
|
|
119
|
+
if "str" in param_type:
|
|
120
|
+
return value_str
|
|
121
|
+
|
|
122
|
+
if value_str.lower() in ("none", "null"):
|
|
123
|
+
return None
|
|
124
|
+
if value_str.lower() in ("true", "yes"):
|
|
125
|
+
return True
|
|
126
|
+
if value_str.lower() in ("false", "no"):
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
# Try int (but not if it has leading zeros — likely a code like "000001")
|
|
130
|
+
if not (len(value_str) > 1 and value_str.startswith("0") and value_str != "0"):
|
|
131
|
+
try:
|
|
132
|
+
return int(value_str)
|
|
133
|
+
except ValueError:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
# Try float
|
|
137
|
+
try:
|
|
138
|
+
return float(value_str)
|
|
139
|
+
except ValueError:
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
return value_str
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def call_function(name: str, kwargs: Dict[str, str]) -> Any:
|
|
146
|
+
"""
|
|
147
|
+
Call an akshare function by name with string kwargs.
|
|
148
|
+
|
|
149
|
+
Parameters are automatically converted from strings based on type hints
|
|
150
|
+
and defaults in the function signature.
|
|
151
|
+
|
|
152
|
+
Returns the function result (typically a pd.DataFrame).
|
|
153
|
+
Raises ValueError if the function is not found.
|
|
154
|
+
Raises any exception from the underlying akshare function.
|
|
155
|
+
"""
|
|
156
|
+
func = get_function(name)
|
|
157
|
+
if func is None:
|
|
158
|
+
raise ValueError(f"Function '{name}' not found in akshare")
|
|
159
|
+
|
|
160
|
+
info = get_function_info(name)
|
|
161
|
+
param_map = {p["name"]: p for p in info["params"]}
|
|
162
|
+
|
|
163
|
+
converted_kwargs = {}
|
|
164
|
+
for key, val in kwargs.items():
|
|
165
|
+
if key in param_map:
|
|
166
|
+
converted_kwargs[key] = parse_param_value(val, param_map[key])
|
|
167
|
+
else:
|
|
168
|
+
converted_kwargs[key] = val
|
|
169
|
+
|
|
170
|
+
# Auto-fill date-like params that have stale YYYYMMDD defaults
|
|
171
|
+
_auto_fill_date_params(info["params"], converted_kwargs)
|
|
172
|
+
|
|
173
|
+
# Check cache
|
|
174
|
+
cached = _result_cache.get(name, converted_kwargs)
|
|
175
|
+
if cached is not None:
|
|
176
|
+
print(f"(cached) {name}", file=sys.stderr)
|
|
177
|
+
return cached
|
|
178
|
+
|
|
179
|
+
result = func(**converted_kwargs)
|
|
180
|
+
|
|
181
|
+
# Store in cache
|
|
182
|
+
ttl = get_ttl(name)
|
|
183
|
+
if ttl > 0:
|
|
184
|
+
_result_cache.put(name, converted_kwargs, result, ttl)
|
|
185
|
+
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _auto_fill_date_params(params: List[Dict], kwargs: Dict[str, Any]) -> None:
|
|
190
|
+
"""Auto-fill date-like parameters with today's date when not provided by user.
|
|
191
|
+
|
|
192
|
+
A parameter is considered date-like if:
|
|
193
|
+
1. Its name contains 'date' (case-insensitive)
|
|
194
|
+
2. It has a default value matching YYYYMMDD (8-digit string)
|
|
195
|
+
3. The user did not explicitly provide it
|
|
196
|
+
"""
|
|
197
|
+
today_str = _date.today().strftime("%Y%m%d")
|
|
198
|
+
|
|
199
|
+
for pinfo in params:
|
|
200
|
+
pname = pinfo["name"]
|
|
201
|
+
|
|
202
|
+
# Skip if user explicitly provided this param
|
|
203
|
+
if pname in kwargs:
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
# Must have a default value
|
|
207
|
+
if not pinfo.get("has_default"):
|
|
208
|
+
continue
|
|
209
|
+
default = pinfo.get("default")
|
|
210
|
+
if default is None or not isinstance(default, str):
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
# Parameter name must contain 'date'
|
|
214
|
+
if "date" not in pname.lower():
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
# Default must look like YYYYMMDD
|
|
218
|
+
if not _DATE_DEFAULT_RE.match(default):
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
# Auto-fill with today's date
|
|
222
|
+
kwargs[pname] = today_str
|
|
223
|
+
print(
|
|
224
|
+
f"Note: --{pname} not specified, using today: {today_str}",
|
|
225
|
+
file=sys.stderr,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def get_domains() -> List[str]:
|
|
230
|
+
"""Get all unique domain prefixes from function names."""
|
|
231
|
+
domains = set()
|
|
232
|
+
for name in get_all_functions():
|
|
233
|
+
parts = name.split("_")
|
|
234
|
+
if parts:
|
|
235
|
+
domains.add(parts[0])
|
|
236
|
+
return sorted(domains)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session management for the AKShare CLI harness.
|
|
3
|
+
|
|
4
|
+
Tracks state across commands in REPL mode: last result, history, preferences.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Session:
|
|
17
|
+
"""Manages CLI session state including result history and preferences."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, session_dir: Optional[str] = None):
|
|
20
|
+
self._history: List[Dict[str, Any]] = []
|
|
21
|
+
self._last_result: Optional[pd.DataFrame] = None
|
|
22
|
+
self._last_function: Optional[str] = None
|
|
23
|
+
self._preferences: Dict[str, Any] = {
|
|
24
|
+
"output_format": "table",
|
|
25
|
+
"max_rows": 50,
|
|
26
|
+
"show_index": False,
|
|
27
|
+
}
|
|
28
|
+
self._session_dir = session_dir or os.path.join(
|
|
29
|
+
os.path.expanduser("~"), ".akshare-cli"
|
|
30
|
+
)
|
|
31
|
+
self._start_time = datetime.now()
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def last_result(self) -> Optional[pd.DataFrame]:
|
|
35
|
+
"""Get the last query result."""
|
|
36
|
+
return self._last_result
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def last_function(self) -> Optional[str]:
|
|
40
|
+
"""Get the name of the last function called."""
|
|
41
|
+
return self._last_function
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def history(self) -> List[Dict[str, Any]]:
|
|
45
|
+
"""Get command history."""
|
|
46
|
+
return list(self._history)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def preferences(self) -> Dict[str, Any]:
|
|
50
|
+
"""Get current session preferences."""
|
|
51
|
+
return dict(self._preferences)
|
|
52
|
+
|
|
53
|
+
def record_call(self, func_name: str, kwargs: Dict, result: Any) -> None:
|
|
54
|
+
"""Record a function call in the session history."""
|
|
55
|
+
entry = {
|
|
56
|
+
"timestamp": datetime.now().isoformat(),
|
|
57
|
+
"function": func_name,
|
|
58
|
+
"kwargs": kwargs,
|
|
59
|
+
"rows": len(result) if isinstance(result, pd.DataFrame) else None,
|
|
60
|
+
"columns": list(result.columns) if isinstance(result, pd.DataFrame) else None,
|
|
61
|
+
}
|
|
62
|
+
self._history.append(entry)
|
|
63
|
+
|
|
64
|
+
if isinstance(result, pd.DataFrame):
|
|
65
|
+
self._last_result = result
|
|
66
|
+
self._last_function = func_name
|
|
67
|
+
|
|
68
|
+
def set_preference(self, key: str, value: Any) -> None:
|
|
69
|
+
"""Set a session preference."""
|
|
70
|
+
self._preferences[key] = value
|
|
71
|
+
|
|
72
|
+
def get_preference(self, key: str, default: Any = None) -> Any:
|
|
73
|
+
"""Get a session preference."""
|
|
74
|
+
return self._preferences.get(key, default)
|
|
75
|
+
|
|
76
|
+
def save_history(self, filepath: Optional[str] = None) -> str:
|
|
77
|
+
"""Save session history to a JSON file."""
|
|
78
|
+
if filepath is None:
|
|
79
|
+
os.makedirs(self._session_dir, exist_ok=True)
|
|
80
|
+
filepath = os.path.join(
|
|
81
|
+
self._session_dir,
|
|
82
|
+
f"session_{self._start_time.strftime('%Y%m%d_%H%M%S')}.json",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
data = {
|
|
86
|
+
"start_time": self._start_time.isoformat(),
|
|
87
|
+
"end_time": datetime.now().isoformat(),
|
|
88
|
+
"preferences": self._preferences,
|
|
89
|
+
"history": self._history,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
93
|
+
json.dump(data, f, indent=2, ensure_ascii=False, default=str)
|
|
94
|
+
|
|
95
|
+
return filepath
|
|
96
|
+
|
|
97
|
+
def clear(self) -> None:
|
|
98
|
+
"""Clear session state."""
|
|
99
|
+
self._history.clear()
|
|
100
|
+
self._last_result = None
|
|
101
|
+
self._last_function = None
|
|
102
|
+
|
|
103
|
+
def summary(self) -> Dict[str, Any]:
|
|
104
|
+
"""Get a summary of the current session."""
|
|
105
|
+
return {
|
|
106
|
+
"start_time": self._start_time.isoformat(),
|
|
107
|
+
"total_calls": len(self._history),
|
|
108
|
+
"last_function": self._last_function,
|
|
109
|
+
"last_result_shape": (
|
|
110
|
+
f"{self._last_result.shape[0]}x{self._last_result.shape[1]}"
|
|
111
|
+
if self._last_result is not None
|
|
112
|
+
else None
|
|
113
|
+
),
|
|
114
|
+
"preferences": self._preferences,
|
|
115
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tests package for AKShare CLI harness."""
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Conftest for akshare CLI harness tests.
|
|
3
|
+
|
|
4
|
+
Ensures the real akshare library is importable even when running
|
|
5
|
+
inside the akshare_cli package.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def pytest_configure(config):
|
|
13
|
+
"""Ensure 'akshare' resolves to the pip-installed library, not the local namespace."""
|
|
14
|
+
# If 'akshare' in sys.modules points to our local package, remove it
|
|
15
|
+
if "akshare" in sys.modules:
|
|
16
|
+
mod = sys.modules["akshare"]
|
|
17
|
+
mod_file = getattr(mod, "__file__", "") or ""
|
|
18
|
+
if "akshare_cli" in mod_file or "agent-harness" in mod_file:
|
|
19
|
+
del sys.modules["akshare"]
|
|
20
|
+
|
|
21
|
+
# Force import of the real akshare
|
|
22
|
+
# First, temporarily remove CWD and agent-harness paths from sys.path
|
|
23
|
+
original_path = sys.path[:]
|
|
24
|
+
sys.path = [
|
|
25
|
+
p for p in sys.path
|
|
26
|
+
if "agent-harness" not in p
|
|
27
|
+
or "site-packages" in p
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
real_ak = importlib.import_module("akshare")
|
|
32
|
+
if hasattr(real_ak, "__version__"):
|
|
33
|
+
sys.modules["akshare"] = real_ak
|
|
34
|
+
except ImportError:
|
|
35
|
+
pass
|
|
36
|
+
finally:
|
|
37
|
+
sys.path = original_path
|