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.
@@ -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