reportify-cli 0.1.44__tar.gz → 0.1.45__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.
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/PKG-INFO +2 -2
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/pyproject.toml +2 -2
- reportify_cli-0.1.45/src/auth_config.py +150 -0
- reportify_cli-0.1.45/src/commands/auth.py +240 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/src/main.py +2 -1
- reportify_cli-0.1.45/src/settings.py +55 -0
- reportify_cli-0.1.45/tests/test_commands/test_auth_commands.py +255 -0
- reportify_cli-0.1.45/tests/test_commands/test_auth_config.py +99 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/uv.lock +7 -4
- reportify_cli-0.1.44/src/settings.py +0 -30
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/.gitignore +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/Makefile +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/README.md +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/scripts/README.md +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/scripts/bump_version.sh +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/scripts/publish.sh +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/skills/reportify-agent/SKILL.md +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/skills/reportify-ai/SKILL.md +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/skills/reportify-ai/references/API_REFERENCE.md +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/skills/reportify-ai/references/COMMANDS.md +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/src/__init__.py +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/src/client.py +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/src/commands/__init__.py +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/src/commands/agent.py +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/src/commands/channels.py +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/src/commands/concepts.py +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/src/commands/docs.py +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/src/commands/following.py +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/src/commands/kb.py +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/src/commands/macro.py +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/src/commands/quant.py +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/src/commands/search.py +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/src/commands/stock.py +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/src/commands/timeline.py +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/src/commands/user.py +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/src/output.py +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/src/params.py +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/src/utils.py +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/tests/__init__.py +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/tests/integration/test_docs_integration.py +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/tests/integration/test_stock_integration.py +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/tests/test_commands/__init__.py +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/tests/test_commands/test_docs.py +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/tests/test_commands/test_quant.py +0 -0
- {reportify_cli-0.1.44 → reportify_cli-0.1.45}/tests/test_commands/test_search.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: reportify-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.45
|
|
4
4
|
Summary: CLI wrapper for Reportify SDK - Access Reportify API through command line
|
|
5
5
|
Project-URL: Homepage, https://reportify.ai
|
|
6
6
|
Project-URL: Documentation, https://docs.reportify.ai
|
|
@@ -23,7 +23,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
23
23
|
Requires-Python: >=3.10
|
|
24
24
|
Requires-Dist: pandas>=2.0.0
|
|
25
25
|
Requires-Dist: python-dotenv>=1.2.1
|
|
26
|
-
Requires-Dist: reportify-sdk>=0.3.
|
|
26
|
+
Requires-Dist: reportify-sdk>=0.3.49
|
|
27
27
|
Requires-Dist: tabulate>=0.9.0
|
|
28
28
|
Requires-Dist: typer>=0.21.1
|
|
29
29
|
Description-Content-Type: text/markdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "reportify-cli"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.45"
|
|
4
4
|
description = "CLI wrapper for Reportify SDK - Access Reportify API through command line"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -30,7 +30,7 @@ classifiers = [
|
|
|
30
30
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
31
31
|
]
|
|
32
32
|
dependencies = [
|
|
33
|
-
"reportify-sdk>=0.3.
|
|
33
|
+
"reportify-sdk>=0.3.49",
|
|
34
34
|
"typer>=0.21.1",
|
|
35
35
|
"pandas>=2.0.0",
|
|
36
36
|
"python-dotenv>=1.2.1",
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Reportify CLI 凭证文件读写。
|
|
2
|
+
|
|
3
|
+
存储位置:``~/.reportify/config`` (INI 格式),权限 0600。
|
|
4
|
+
|
|
5
|
+
```ini
|
|
6
|
+
[default]
|
|
7
|
+
api_key = 12345abcdef...
|
|
8
|
+
user_id = 12345
|
|
9
|
+
nickname = Foo
|
|
10
|
+
email = foo@bar.com
|
|
11
|
+
subscription_tier = plus
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
仅 first-party CLI 内部使用;Skill 不要直接读这个文件,应通过 ``reportify-cli`` 间接使用。
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import configparser
|
|
20
|
+
import os
|
|
21
|
+
import stat
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
DEFAULT_PROFILE = "default"
|
|
27
|
+
CONFIG_DIR = Path.home() / ".reportify"
|
|
28
|
+
CONFIG_PATH = CONFIG_DIR / "config"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class AuthCredential:
|
|
33
|
+
"""单个 profile 下保存的凭证。"""
|
|
34
|
+
|
|
35
|
+
api_key: str
|
|
36
|
+
user_id: Optional[str] = None
|
|
37
|
+
nickname: Optional[str] = None
|
|
38
|
+
email: Optional[str] = None
|
|
39
|
+
subscription_tier: Optional[str] = None
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> dict[str, str]:
|
|
42
|
+
d = {"api_key": self.api_key}
|
|
43
|
+
if self.user_id:
|
|
44
|
+
d["user_id"] = str(self.user_id)
|
|
45
|
+
if self.nickname:
|
|
46
|
+
d["nickname"] = self.nickname
|
|
47
|
+
if self.email:
|
|
48
|
+
d["email"] = self.email
|
|
49
|
+
if self.subscription_tier:
|
|
50
|
+
d["subscription_tier"] = self.subscription_tier
|
|
51
|
+
return d
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _config_path() -> Path:
|
|
55
|
+
"""允许通过 REPORTIFY_CONFIG_PATH 环境变量覆盖(测试用)。"""
|
|
56
|
+
override = os.environ.get("REPORTIFY_CONFIG_PATH")
|
|
57
|
+
return Path(override) if override else CONFIG_PATH
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _load_parser() -> configparser.ConfigParser:
|
|
61
|
+
parser = configparser.ConfigParser()
|
|
62
|
+
path = _config_path()
|
|
63
|
+
if path.exists():
|
|
64
|
+
parser.read(path, encoding="utf-8")
|
|
65
|
+
return parser
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load_credential(profile: str = DEFAULT_PROFILE) -> Optional[AuthCredential]:
|
|
69
|
+
"""读取指定 profile 的凭证;不存在或缺 api_key 时返回 None。"""
|
|
70
|
+
parser = _load_parser()
|
|
71
|
+
if not parser.has_section(profile):
|
|
72
|
+
return None
|
|
73
|
+
section = parser[profile]
|
|
74
|
+
api_key = section.get("api_key")
|
|
75
|
+
if not api_key:
|
|
76
|
+
return None
|
|
77
|
+
return AuthCredential(
|
|
78
|
+
api_key=api_key,
|
|
79
|
+
user_id=section.get("user_id"),
|
|
80
|
+
nickname=section.get("nickname"),
|
|
81
|
+
email=section.get("email"),
|
|
82
|
+
subscription_tier=section.get("subscription_tier"),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def save_credential(
|
|
87
|
+
credential: AuthCredential, profile: str = DEFAULT_PROFILE
|
|
88
|
+
) -> Path:
|
|
89
|
+
"""写入指定 profile 的凭证;文件权限强制 0600(owner-only read/write)。"""
|
|
90
|
+
path = _config_path()
|
|
91
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
|
|
93
|
+
parser = _load_parser()
|
|
94
|
+
parser[profile] = credential.to_dict()
|
|
95
|
+
|
|
96
|
+
# 先以受限权限创建文件,再写入内容,避免凭证被其他用户读到
|
|
97
|
+
fd = os.open(
|
|
98
|
+
path,
|
|
99
|
+
os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
|
|
100
|
+
stat.S_IRUSR | stat.S_IWUSR, # 0600
|
|
101
|
+
)
|
|
102
|
+
try:
|
|
103
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
104
|
+
parser.write(f)
|
|
105
|
+
finally:
|
|
106
|
+
# 防御性:如果文件已存在权限可能不对,强制修正
|
|
107
|
+
try:
|
|
108
|
+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
|
109
|
+
except OSError:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
# 同样确保目录权限不要过宽(0700)
|
|
113
|
+
try:
|
|
114
|
+
os.chmod(path.parent, stat.S_IRWXU)
|
|
115
|
+
except OSError:
|
|
116
|
+
pass
|
|
117
|
+
return path
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def delete_credential(profile: str = DEFAULT_PROFILE) -> bool:
|
|
121
|
+
"""删除指定 profile 的凭证。
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
True 如果删了;False 如果原本就不存在。
|
|
125
|
+
"""
|
|
126
|
+
parser = _load_parser()
|
|
127
|
+
if not parser.has_section(profile):
|
|
128
|
+
return False
|
|
129
|
+
parser.remove_section(profile)
|
|
130
|
+
|
|
131
|
+
path = _config_path()
|
|
132
|
+
if not parser.sections():
|
|
133
|
+
# 没有任何 profile 剩下了 → 直接删整个文件
|
|
134
|
+
try:
|
|
135
|
+
path.unlink()
|
|
136
|
+
except FileNotFoundError:
|
|
137
|
+
pass
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
141
|
+
parser.write(f)
|
|
142
|
+
try:
|
|
143
|
+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
|
144
|
+
except OSError:
|
|
145
|
+
pass
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def list_profiles() -> list[str]:
|
|
150
|
+
return _load_parser().sections()
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Reportify CLI authentication commands.
|
|
2
|
+
|
|
3
|
+
实现 OAuth 2.0 Device Authorization Grant (RFC 8628) 客户端:
|
|
4
|
+
|
|
5
|
+
reportify-cli auth login # 跑 device flow, 拿 api_key, 写本地配置
|
|
6
|
+
reportify-cli auth logout # 删除本地配置
|
|
7
|
+
reportify-cli auth status # 显示当前登录状态
|
|
8
|
+
|
|
9
|
+
后端契约:
|
|
10
|
+
POST {base}/v1/cli-oauth/device/code (form: client_id, scope?)
|
|
11
|
+
POST {base}/v1/cli-oauth/device/token (form: grant_type, device_code, client_id)
|
|
12
|
+
POST {base}/v1/api-keys/ensure (json: {client_id, source}, Bearer access_token)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
import webbrowser
|
|
20
|
+
from typing import Annotated, Optional
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
import typer
|
|
24
|
+
|
|
25
|
+
from src.auth_config import (
|
|
26
|
+
AuthCredential,
|
|
27
|
+
delete_credential,
|
|
28
|
+
load_credential,
|
|
29
|
+
save_credential,
|
|
30
|
+
)
|
|
31
|
+
from src.settings import get_api_base_url
|
|
32
|
+
|
|
33
|
+
app = typer.Typer(help="Authentication", rich_markup_mode="rich")
|
|
34
|
+
|
|
35
|
+
CLIENT_ID = "reportify-cli"
|
|
36
|
+
SCOPE = "profile api_key"
|
|
37
|
+
GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
|
|
38
|
+
|
|
39
|
+
# 端点路径(统一 /v1 前缀,与 SDK 同源)
|
|
40
|
+
ENDPOINT_DEVICE_CODE = "/v1/cli-oauth/device/code"
|
|
41
|
+
ENDPOINT_DEVICE_TOKEN = "/v1/cli-oauth/device/token"
|
|
42
|
+
ENDPOINT_API_KEY_ENSURE = "/v1/api-keys/ensure"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ------------------------------------------------------------------ #
|
|
46
|
+
# Helpers
|
|
47
|
+
# ------------------------------------------------------------------ #
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _err(msg: str, exit_code: int = 1) -> None:
|
|
51
|
+
typer.echo(f"Error: {msg}", err=True)
|
|
52
|
+
raise typer.Exit(exit_code)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _oauth_error(response: httpx.Response) -> tuple[str, str]:
|
|
56
|
+
"""Extract RFC 8628 OAuth error from a 4xx response.
|
|
57
|
+
|
|
58
|
+
Server returns ``{"detail": {"error": ..., "error_description": ...}}``.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
body = response.json()
|
|
62
|
+
except Exception:
|
|
63
|
+
return ("unknown_error", response.text or "")
|
|
64
|
+
|
|
65
|
+
# FastAPI HTTPException 的 detail 嵌在 'detail' 里
|
|
66
|
+
detail = body.get("detail", body)
|
|
67
|
+
if isinstance(detail, dict):
|
|
68
|
+
return (
|
|
69
|
+
detail.get("error", "unknown_error"),
|
|
70
|
+
detail.get("error_description", ""),
|
|
71
|
+
)
|
|
72
|
+
return ("unknown_error", str(detail))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _request_device_code(client: httpx.Client) -> dict:
|
|
76
|
+
resp = client.post(
|
|
77
|
+
ENDPOINT_DEVICE_CODE,
|
|
78
|
+
data={"client_id": CLIENT_ID, "scope": SCOPE},
|
|
79
|
+
)
|
|
80
|
+
if resp.status_code != 200:
|
|
81
|
+
err, desc = _oauth_error(resp)
|
|
82
|
+
_err(f"failed to start device flow ({err}): {desc}")
|
|
83
|
+
return resp.json()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _poll_token(client: httpx.Client, device_code: str, interval: int, expires_in: int) -> dict:
|
|
87
|
+
"""轮询 /device/token,直到拿到 access_token 或超时 / 用户拒绝。"""
|
|
88
|
+
deadline = time.monotonic() + expires_in
|
|
89
|
+
current_interval = max(interval, 1)
|
|
90
|
+
typer.echo("Waiting for authorization", nl=False)
|
|
91
|
+
while True:
|
|
92
|
+
if time.monotonic() >= deadline:
|
|
93
|
+
typer.echo("")
|
|
94
|
+
_err("device code expired before authorization; please try `auth login` again")
|
|
95
|
+
|
|
96
|
+
time.sleep(current_interval)
|
|
97
|
+
typer.echo(".", nl=False)
|
|
98
|
+
|
|
99
|
+
resp = client.post(
|
|
100
|
+
ENDPOINT_DEVICE_TOKEN,
|
|
101
|
+
data={
|
|
102
|
+
"grant_type": GRANT_TYPE,
|
|
103
|
+
"device_code": device_code,
|
|
104
|
+
"client_id": CLIENT_ID,
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if resp.status_code == 200:
|
|
109
|
+
typer.echo(" ✓")
|
|
110
|
+
return resp.json()
|
|
111
|
+
|
|
112
|
+
err, desc = _oauth_error(resp)
|
|
113
|
+
if err == "authorization_pending":
|
|
114
|
+
continue
|
|
115
|
+
if err == "slow_down":
|
|
116
|
+
current_interval += 5
|
|
117
|
+
continue
|
|
118
|
+
if err == "access_denied":
|
|
119
|
+
typer.echo("")
|
|
120
|
+
_err("authorization was denied in the browser")
|
|
121
|
+
if err == "expired_token":
|
|
122
|
+
typer.echo("")
|
|
123
|
+
_err("device code expired before authorization; please try `auth login` again")
|
|
124
|
+
|
|
125
|
+
# Any other error → fail loud
|
|
126
|
+
typer.echo("")
|
|
127
|
+
_err(f"failed to obtain access token ({err}): {desc}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _ensure_api_key(client: httpx.Client, access_token: str) -> dict:
|
|
131
|
+
resp = client.post(
|
|
132
|
+
ENDPOINT_API_KEY_ENSURE,
|
|
133
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
134
|
+
json={"client_id": CLIENT_ID, "source": "cli"},
|
|
135
|
+
)
|
|
136
|
+
if resp.status_code != 200:
|
|
137
|
+
try:
|
|
138
|
+
detail = resp.json().get("detail", resp.text)
|
|
139
|
+
except Exception:
|
|
140
|
+
detail = resp.text
|
|
141
|
+
_err(f"failed to obtain api key (HTTP {resp.status_code}): {detail}")
|
|
142
|
+
return resp.json()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ------------------------------------------------------------------ #
|
|
146
|
+
# Commands
|
|
147
|
+
# ------------------------------------------------------------------ #
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@app.command(name="login")
|
|
151
|
+
def login(
|
|
152
|
+
no_browser: Annotated[
|
|
153
|
+
bool,
|
|
154
|
+
typer.Option(
|
|
155
|
+
"--no-browser",
|
|
156
|
+
help="Do not attempt to open the browser automatically.",
|
|
157
|
+
),
|
|
158
|
+
] = False,
|
|
159
|
+
) -> None:
|
|
160
|
+
"""Authenticate to Reportify via the device authorization flow.
|
|
161
|
+
|
|
162
|
+
Opens the browser to a verification page. After you approve the request,
|
|
163
|
+
the CLI fetches a long-lived API key and stores it in ``~/.reportify/config``.
|
|
164
|
+
"""
|
|
165
|
+
base_url = get_api_base_url().rstrip("/")
|
|
166
|
+
with httpx.Client(base_url=base_url, timeout=30.0) as client:
|
|
167
|
+
device_resp = _request_device_code(client)
|
|
168
|
+
|
|
169
|
+
verification_uri_complete = device_resp.get("verification_uri_complete")
|
|
170
|
+
verification_uri = device_resp.get("verification_uri")
|
|
171
|
+
user_code = device_resp.get("user_code")
|
|
172
|
+
device_code = device_resp["device_code"]
|
|
173
|
+
interval = int(device_resp.get("interval", 5))
|
|
174
|
+
expires_in = int(device_resp.get("expires_in", 600))
|
|
175
|
+
|
|
176
|
+
typer.echo("")
|
|
177
|
+
typer.echo("Open the following URL in your browser to authorize Reportify CLI:")
|
|
178
|
+
typer.echo(f" {verification_uri_complete or verification_uri}")
|
|
179
|
+
typer.echo(f"Verification code: {user_code}")
|
|
180
|
+
typer.echo("")
|
|
181
|
+
|
|
182
|
+
if not no_browser and verification_uri_complete:
|
|
183
|
+
try:
|
|
184
|
+
webbrowser.open(verification_uri_complete)
|
|
185
|
+
except Exception:
|
|
186
|
+
# 浏览器打开失败不影响主流程,用户可以自己复制链接
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
token_resp = _poll_token(client, device_code, interval, expires_in)
|
|
190
|
+
access_token = token_resp["access_token"]
|
|
191
|
+
user = token_resp.get("user") or {}
|
|
192
|
+
|
|
193
|
+
key_resp = _ensure_api_key(client, access_token)
|
|
194
|
+
|
|
195
|
+
credential = AuthCredential(
|
|
196
|
+
api_key=key_resp["key"],
|
|
197
|
+
user_id=str(user.get("user_id") or ""),
|
|
198
|
+
nickname=user.get("nickname") or None,
|
|
199
|
+
email=user.get("email") or None,
|
|
200
|
+
subscription_tier=user.get("subscription_tier") or None,
|
|
201
|
+
)
|
|
202
|
+
path = save_credential(credential)
|
|
203
|
+
|
|
204
|
+
typer.echo("")
|
|
205
|
+
who = credential.nickname or credential.email or credential.user_id or "unknown"
|
|
206
|
+
tier = credential.subscription_tier or "free"
|
|
207
|
+
typer.echo(f"Logged in as {who} (plan: {tier})")
|
|
208
|
+
typer.echo(f"Credential saved to {path}")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@app.command(name="logout")
|
|
212
|
+
def logout() -> None:
|
|
213
|
+
"""Remove the locally stored Reportify credential.
|
|
214
|
+
|
|
215
|
+
Does **not** revoke the API key on the server side. If you want the key
|
|
216
|
+
invalidated, also delete it from your Reportify settings page.
|
|
217
|
+
"""
|
|
218
|
+
removed = delete_credential()
|
|
219
|
+
if removed:
|
|
220
|
+
typer.echo("Local credential removed.")
|
|
221
|
+
else:
|
|
222
|
+
typer.echo("No local credential to remove.")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@app.command(name="status")
|
|
226
|
+
def status() -> None:
|
|
227
|
+
"""Show the current authentication status.
|
|
228
|
+
|
|
229
|
+
Exit code is 0 if logged in, 1 otherwise (useful for Skill scripts to gate
|
|
230
|
+
on ``reportify-cli auth status`` before invoking business commands).
|
|
231
|
+
"""
|
|
232
|
+
cred = load_credential()
|
|
233
|
+
if not cred:
|
|
234
|
+
typer.echo("Not logged in. Run `reportify-cli auth login`.")
|
|
235
|
+
raise typer.Exit(1)
|
|
236
|
+
|
|
237
|
+
who = cred.nickname or cred.email or cred.user_id or "unknown"
|
|
238
|
+
tier = cred.subscription_tier or "unknown"
|
|
239
|
+
typer.echo(f"Logged in as {who} (plan: {tier})")
|
|
240
|
+
typer.echo(f"API base: {get_api_base_url()}")
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import typer
|
|
4
4
|
from typer.core import TyperGroup
|
|
5
5
|
|
|
6
|
-
from src.commands import agent, channels, concepts, docs, following, kb, macro, quant, search, stock, timeline, user
|
|
6
|
+
from src.commands import agent, auth, channels, concepts, docs, following, kb, macro, quant, search, stock, timeline, user
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
class AliasGroup(TyperGroup):
|
|
@@ -23,6 +23,7 @@ app = typer.Typer(
|
|
|
23
23
|
)
|
|
24
24
|
|
|
25
25
|
# Add all command modules as sub-apps
|
|
26
|
+
app.add_typer(auth.app, name="auth", help="Authentication (login / logout / status)")
|
|
26
27
|
app.add_typer(search.app, name="search", help="Document search")
|
|
27
28
|
app.add_typer(docs.app, name="docs", help="Document management")
|
|
28
29
|
app.add_typer(kb.app, name="kb", help="Knowledge base")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Settings and configuration for Reportify API CLI."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
# Try to load .env file if python-dotenv is available
|
|
7
|
+
try:
|
|
8
|
+
from dotenv import load_dotenv
|
|
9
|
+
|
|
10
|
+
load_dotenv()
|
|
11
|
+
except ImportError:
|
|
12
|
+
# python-dotenv not installed, skip loading .env file
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
from src.auth_config import load_credential
|
|
16
|
+
|
|
17
|
+
# Default API base URL. SDK 默认也是这个;CLI 自己的 auth 命令也走它。
|
|
18
|
+
DEFAULT_API_BASE_URL = "https://api.reportify.cn"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_api_base_url() -> str:
|
|
22
|
+
"""API 基础 URL。优先级: REPORTIFY_BASE_URL > 默认值。
|
|
23
|
+
|
|
24
|
+
与 reportify-sdk 的 Reportify 客户端保持一致,便于 dev / staging 切换。
|
|
25
|
+
"""
|
|
26
|
+
return os.getenv("REPORTIFY_BASE_URL") or DEFAULT_API_BASE_URL
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_api_key() -> str:
|
|
30
|
+
"""Get API key, in priority order:
|
|
31
|
+
|
|
32
|
+
1. ``REPORTIFY_API_KEY`` environment variable
|
|
33
|
+
2. ``~/.reportify/config`` [default] profile (written by ``reportify-cli auth login``)
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
SystemExit: If neither source has a key.
|
|
37
|
+
"""
|
|
38
|
+
api_key = os.getenv("REPORTIFY_API_KEY")
|
|
39
|
+
if api_key:
|
|
40
|
+
return api_key
|
|
41
|
+
|
|
42
|
+
cred = load_credential()
|
|
43
|
+
if cred and cred.api_key:
|
|
44
|
+
return cred.api_key
|
|
45
|
+
|
|
46
|
+
print("Error: no Reportify API key found.", file=sys.stderr)
|
|
47
|
+
print(
|
|
48
|
+
" - Run `reportify-cli auth login` to authenticate, or",
|
|
49
|
+
file=sys.stderr,
|
|
50
|
+
)
|
|
51
|
+
print(
|
|
52
|
+
" - Set the REPORTIFY_API_KEY environment variable manually.",
|
|
53
|
+
file=sys.stderr,
|
|
54
|
+
)
|
|
55
|
+
sys.exit(1)
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Tests for src.commands.auth — device flow + login/logout/status."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from unittest.mock import MagicMock, patch
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import pytest
|
|
10
|
+
from typer.testing import CliRunner
|
|
11
|
+
|
|
12
|
+
from src.auth_config import AuthCredential, load_credential, save_credential
|
|
13
|
+
from src.commands.auth import app
|
|
14
|
+
|
|
15
|
+
# mix_stderr=True 让 result.stdout 同时包含 stderr,便于断言错误信息(typer.echo(err=True) 走 stderr)
|
|
16
|
+
try:
|
|
17
|
+
runner = CliRunner(mix_stderr=True)
|
|
18
|
+
except TypeError: # click >= 8.2 removed the kwarg
|
|
19
|
+
runner = CliRunner()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _all_output(result) -> str:
|
|
23
|
+
"""合并 stdout + stderr 用于断言(兼容不同 click 版本)。"""
|
|
24
|
+
parts = [result.output or ""]
|
|
25
|
+
stderr = getattr(result, "stderr", None)
|
|
26
|
+
if stderr:
|
|
27
|
+
parts.append(stderr)
|
|
28
|
+
return "\n".join(parts)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ----------------------------- fixtures ----------------------------- #
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture
|
|
35
|
+
def tmp_config(monkeypatch, tmp_path: Path):
|
|
36
|
+
path = tmp_path / "config"
|
|
37
|
+
monkeypatch.setenv("REPORTIFY_CONFIG_PATH", str(path))
|
|
38
|
+
return path
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.fixture
|
|
42
|
+
def fast_sleep(monkeypatch):
|
|
43
|
+
"""轮询测试加速:把 time.sleep 替换成 no-op。"""
|
|
44
|
+
monkeypatch.setattr("src.commands.auth.time.sleep", lambda _s: None)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _ok(json_body: dict, status: int = 200) -> httpx.Response:
|
|
48
|
+
return httpx.Response(status_code=status, json=json_body)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _oauth_err(error: str, desc: str = "", status: int = 400) -> httpx.Response:
|
|
52
|
+
return httpx.Response(
|
|
53
|
+
status_code=status,
|
|
54
|
+
json={"detail": {"error": error, "error_description": desc}},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _device_code_payload() -> dict:
|
|
59
|
+
return {
|
|
60
|
+
"device_code": "fake-device-code",
|
|
61
|
+
"user_code": "WDJB-MJHT",
|
|
62
|
+
"verification_uri": "https://reportify.cn/oauth/device",
|
|
63
|
+
"verification_uri_complete": "https://reportify.cn/oauth/device?user_code=WDJB-MJHT",
|
|
64
|
+
"expires_in": 600,
|
|
65
|
+
"interval": 5,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _token_payload() -> dict:
|
|
70
|
+
return {
|
|
71
|
+
"access_token": "fake-access-token",
|
|
72
|
+
"token_type": "Bearer",
|
|
73
|
+
"expires_in": 3600,
|
|
74
|
+
"scope": "profile api_key",
|
|
75
|
+
"user": {
|
|
76
|
+
"user_id": "12345",
|
|
77
|
+
"nickname": "Foo",
|
|
78
|
+
"email": "foo@bar.com",
|
|
79
|
+
"subscription_tier": "plus",
|
|
80
|
+
},
|
|
81
|
+
"entitlements": ["official_skills"],
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _api_key_payload() -> dict:
|
|
86
|
+
return {
|
|
87
|
+
"id": 99,
|
|
88
|
+
"key": "raw-api-key-12345",
|
|
89
|
+
"name": "Reportify CLI",
|
|
90
|
+
"client_id": "reportify-cli",
|
|
91
|
+
"source": "cli",
|
|
92
|
+
"created_at": 0,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.fixture
|
|
97
|
+
def patch_client(monkeypatch):
|
|
98
|
+
"""Patch httpx.Client to return a MagicMock whose .post we drive per test.
|
|
99
|
+
|
|
100
|
+
Returns the mock client object so the test can configure side_effect.
|
|
101
|
+
"""
|
|
102
|
+
mock_client = MagicMock()
|
|
103
|
+
# httpx.Client used as context manager
|
|
104
|
+
mock_client.__enter__.return_value = mock_client
|
|
105
|
+
mock_client.__exit__.return_value = False
|
|
106
|
+
|
|
107
|
+
def _ctor(*a, **kw):
|
|
108
|
+
return mock_client
|
|
109
|
+
|
|
110
|
+
monkeypatch.setattr("src.commands.auth.httpx.Client", _ctor)
|
|
111
|
+
# 阻止真实打开浏览器
|
|
112
|
+
monkeypatch.setattr("src.commands.auth.webbrowser.open", lambda *_a, **_kw: True)
|
|
113
|
+
return mock_client
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ----------------------------- status / logout ----------------------------- #
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TestStatus:
|
|
120
|
+
def test_not_logged_in_exits_nonzero(self, tmp_config):
|
|
121
|
+
result = runner.invoke(app, ["status"])
|
|
122
|
+
assert result.exit_code == 1
|
|
123
|
+
assert "Not logged in" in result.stdout
|
|
124
|
+
|
|
125
|
+
def test_logged_in_exits_zero(self, tmp_config):
|
|
126
|
+
save_credential(
|
|
127
|
+
AuthCredential(api_key="x", nickname="Foo", subscription_tier="plus")
|
|
128
|
+
)
|
|
129
|
+
result = runner.invoke(app, ["status"])
|
|
130
|
+
assert result.exit_code == 0
|
|
131
|
+
assert "Foo" in result.stdout
|
|
132
|
+
assert "plus" in result.stdout
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class TestLogout:
|
|
136
|
+
def test_no_op_when_not_logged_in(self, tmp_config):
|
|
137
|
+
result = runner.invoke(app, ["logout"])
|
|
138
|
+
assert result.exit_code == 0
|
|
139
|
+
assert "No local credential" in result.stdout
|
|
140
|
+
|
|
141
|
+
def test_removes_credential(self, tmp_config):
|
|
142
|
+
save_credential(AuthCredential(api_key="x"))
|
|
143
|
+
result = runner.invoke(app, ["logout"])
|
|
144
|
+
assert result.exit_code == 0
|
|
145
|
+
assert "removed" in result.stdout
|
|
146
|
+
assert load_credential() is None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ----------------------------- login: happy path ----------------------------- #
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class TestLoginHappyPath:
|
|
153
|
+
def test_full_flow_persists_credential(
|
|
154
|
+
self, tmp_config, fast_sleep, patch_client
|
|
155
|
+
):
|
|
156
|
+
patch_client.post.side_effect = [
|
|
157
|
+
_ok(_device_code_payload()), # /device/code
|
|
158
|
+
_ok(_token_payload()), # /device/token (approved)
|
|
159
|
+
_ok(_api_key_payload()), # /api-keys/ensure
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
result = runner.invoke(app, ["login", "--no-browser"])
|
|
163
|
+
assert result.exit_code == 0, result.stdout
|
|
164
|
+
assert "WDJB-MJHT" in result.stdout
|
|
165
|
+
assert "Logged in as Foo" in result.stdout
|
|
166
|
+
|
|
167
|
+
cred = load_credential()
|
|
168
|
+
assert cred is not None
|
|
169
|
+
assert cred.api_key == "raw-api-key-12345"
|
|
170
|
+
assert cred.user_id == "12345"
|
|
171
|
+
assert cred.nickname == "Foo"
|
|
172
|
+
assert cred.email == "foo@bar.com"
|
|
173
|
+
assert cred.subscription_tier == "plus"
|
|
174
|
+
|
|
175
|
+
def test_authorization_pending_then_approved(
|
|
176
|
+
self, tmp_config, fast_sleep, patch_client
|
|
177
|
+
):
|
|
178
|
+
patch_client.post.side_effect = [
|
|
179
|
+
_ok(_device_code_payload()),
|
|
180
|
+
_oauth_err("authorization_pending"),
|
|
181
|
+
_oauth_err("authorization_pending"),
|
|
182
|
+
_ok(_token_payload()),
|
|
183
|
+
_ok(_api_key_payload()),
|
|
184
|
+
]
|
|
185
|
+
result = runner.invoke(app, ["login", "--no-browser"])
|
|
186
|
+
assert result.exit_code == 0, result.stdout
|
|
187
|
+
assert load_credential().api_key == "raw-api-key-12345"
|
|
188
|
+
|
|
189
|
+
def test_slow_down_increments_interval_but_succeeds(
|
|
190
|
+
self, tmp_config, fast_sleep, patch_client
|
|
191
|
+
):
|
|
192
|
+
patch_client.post.side_effect = [
|
|
193
|
+
_ok(_device_code_payload()),
|
|
194
|
+
_oauth_err("slow_down"),
|
|
195
|
+
_ok(_token_payload()),
|
|
196
|
+
_ok(_api_key_payload()),
|
|
197
|
+
]
|
|
198
|
+
result = runner.invoke(app, ["login", "--no-browser"])
|
|
199
|
+
assert result.exit_code == 0, result.stdout
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ----------------------------- login: failure paths ----------------------------- #
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class TestLoginFailurePaths:
|
|
206
|
+
def test_access_denied_aborts(self, tmp_config, fast_sleep, patch_client):
|
|
207
|
+
patch_client.post.side_effect = [
|
|
208
|
+
_ok(_device_code_payload()),
|
|
209
|
+
_oauth_err("access_denied"),
|
|
210
|
+
]
|
|
211
|
+
result = runner.invoke(app, ["login", "--no-browser"])
|
|
212
|
+
assert result.exit_code == 1
|
|
213
|
+
assert "denied" in _all_output(result)
|
|
214
|
+
assert load_credential() is None
|
|
215
|
+
|
|
216
|
+
def test_expired_token_aborts(self, tmp_config, fast_sleep, patch_client):
|
|
217
|
+
patch_client.post.side_effect = [
|
|
218
|
+
_ok(_device_code_payload()),
|
|
219
|
+
_oauth_err("expired_token"),
|
|
220
|
+
]
|
|
221
|
+
result = runner.invoke(app, ["login", "--no-browser"])
|
|
222
|
+
assert result.exit_code == 1
|
|
223
|
+
assert "expired" in _all_output(result)
|
|
224
|
+
|
|
225
|
+
def test_device_code_endpoint_failure(self, tmp_config, patch_client):
|
|
226
|
+
patch_client.post.side_effect = [
|
|
227
|
+
_oauth_err("invalid_client", "unknown client_id"),
|
|
228
|
+
]
|
|
229
|
+
result = runner.invoke(app, ["login", "--no-browser"])
|
|
230
|
+
assert result.exit_code == 1
|
|
231
|
+
assert "invalid_client" in _all_output(result)
|
|
232
|
+
|
|
233
|
+
def test_api_key_ensure_failure(
|
|
234
|
+
self, tmp_config, fast_sleep, patch_client
|
|
235
|
+
):
|
|
236
|
+
patch_client.post.side_effect = [
|
|
237
|
+
_ok(_device_code_payload()),
|
|
238
|
+
_ok(_token_payload()),
|
|
239
|
+
httpx.Response(status_code=500, json={"detail": "server error"}),
|
|
240
|
+
]
|
|
241
|
+
result = runner.invoke(app, ["login", "--no-browser"])
|
|
242
|
+
assert result.exit_code == 1
|
|
243
|
+
assert "api key" in _all_output(result).lower()
|
|
244
|
+
assert load_credential() is None
|
|
245
|
+
|
|
246
|
+
def test_unknown_oauth_error_aborts(
|
|
247
|
+
self, tmp_config, fast_sleep, patch_client
|
|
248
|
+
):
|
|
249
|
+
patch_client.post.side_effect = [
|
|
250
|
+
_ok(_device_code_payload()),
|
|
251
|
+
_oauth_err("invalid_grant", "device_code already used"),
|
|
252
|
+
]
|
|
253
|
+
result = runner.invoke(app, ["login", "--no-browser"])
|
|
254
|
+
assert result.exit_code == 1
|
|
255
|
+
assert "invalid_grant" in _all_output(result)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Tests for src.auth_config — credential file read/write."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from src.auth_config import (
|
|
10
|
+
AuthCredential,
|
|
11
|
+
delete_credential,
|
|
12
|
+
list_profiles,
|
|
13
|
+
load_credential,
|
|
14
|
+
save_credential,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def tmp_config(monkeypatch, tmp_path: Path):
|
|
20
|
+
"""每个测试用例独立的临时配置文件,避免污染真实 ~/.reportify/config。"""
|
|
21
|
+
path = tmp_path / "config"
|
|
22
|
+
monkeypatch.setenv("REPORTIFY_CONFIG_PATH", str(path))
|
|
23
|
+
return path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_load_returns_none_when_missing(tmp_config):
|
|
27
|
+
assert load_credential() is None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_save_and_load_roundtrip(tmp_config):
|
|
31
|
+
cred = AuthCredential(
|
|
32
|
+
api_key="sekret-key",
|
|
33
|
+
user_id="12345",
|
|
34
|
+
nickname="Foo",
|
|
35
|
+
email="foo@bar.com",
|
|
36
|
+
subscription_tier="plus",
|
|
37
|
+
)
|
|
38
|
+
save_credential(cred)
|
|
39
|
+
|
|
40
|
+
loaded = load_credential()
|
|
41
|
+
assert loaded is not None
|
|
42
|
+
assert loaded.api_key == "sekret-key"
|
|
43
|
+
assert loaded.user_id == "12345"
|
|
44
|
+
assert loaded.nickname == "Foo"
|
|
45
|
+
assert loaded.email == "foo@bar.com"
|
|
46
|
+
assert loaded.subscription_tier == "plus"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_save_writes_owner_only_perms(tmp_config):
|
|
50
|
+
save_credential(AuthCredential(api_key="x"))
|
|
51
|
+
mode = stat.S_IMODE(os.stat(tmp_config).st_mode)
|
|
52
|
+
# 0600 - owner read/write only
|
|
53
|
+
assert mode == 0o600, f"expected 0600 perms, got {oct(mode)}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_save_overwrites_existing_profile(tmp_config):
|
|
57
|
+
save_credential(AuthCredential(api_key="old"))
|
|
58
|
+
save_credential(AuthCredential(api_key="new", nickname="Updated"))
|
|
59
|
+
loaded = load_credential()
|
|
60
|
+
assert loaded.api_key == "new"
|
|
61
|
+
assert loaded.nickname == "Updated"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_delete_returns_false_when_missing(tmp_config):
|
|
65
|
+
assert delete_credential() is False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_delete_removes_credential(tmp_config):
|
|
69
|
+
save_credential(AuthCredential(api_key="x"))
|
|
70
|
+
assert delete_credential() is True
|
|
71
|
+
assert load_credential() is None
|
|
72
|
+
# 唯一 profile 被删 → 文件也应不在
|
|
73
|
+
assert not tmp_config.exists()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_multi_profile(tmp_config):
|
|
77
|
+
save_credential(AuthCredential(api_key="key-default"), profile="default")
|
|
78
|
+
save_credential(AuthCredential(api_key="key-work"), profile="work")
|
|
79
|
+
|
|
80
|
+
assert sorted(list_profiles()) == ["default", "work"]
|
|
81
|
+
assert load_credential("default").api_key == "key-default"
|
|
82
|
+
assert load_credential("work").api_key == "key-work"
|
|
83
|
+
|
|
84
|
+
delete_credential("default")
|
|
85
|
+
# 还有 work,文件应保留
|
|
86
|
+
assert tmp_config.exists()
|
|
87
|
+
assert load_credential("default") is None
|
|
88
|
+
assert load_credential("work").api_key == "key-work"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_missing_api_key_treated_as_no_credential(tmp_config):
|
|
92
|
+
"""配置文件 section 存在但缺 api_key 视为 None。"""
|
|
93
|
+
import configparser
|
|
94
|
+
|
|
95
|
+
parser = configparser.ConfigParser()
|
|
96
|
+
parser["default"] = {"nickname": "Foo"} # 没有 api_key
|
|
97
|
+
with open(tmp_config, "w") as f:
|
|
98
|
+
parser.write(f)
|
|
99
|
+
assert load_credential() is None
|
|
@@ -426,7 +426,7 @@ wheels = [
|
|
|
426
426
|
|
|
427
427
|
[[package]]
|
|
428
428
|
name = "reportify-cli"
|
|
429
|
-
version = "0.1.
|
|
429
|
+
version = "0.1.44"
|
|
430
430
|
source = { editable = "." }
|
|
431
431
|
dependencies = [
|
|
432
432
|
{ name = "pandas" },
|
|
@@ -445,7 +445,7 @@ dev = [
|
|
|
445
445
|
requires-dist = [
|
|
446
446
|
{ name = "pandas", specifier = ">=2.0.0" },
|
|
447
447
|
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
|
448
|
-
{ name = "reportify-sdk", specifier = ">=0.3.
|
|
448
|
+
{ name = "reportify-sdk", specifier = ">=0.3.46" },
|
|
449
449
|
{ name = "tabulate", specifier = ">=0.9.0" },
|
|
450
450
|
{ name = "typer", specifier = ">=0.21.1" },
|
|
451
451
|
]
|
|
@@ -455,13 +455,16 @@ dev = [{ name = "pytest", specifier = ">=9.0.2" }]
|
|
|
455
455
|
|
|
456
456
|
[[package]]
|
|
457
457
|
name = "reportify-sdk"
|
|
458
|
-
version = "0.3.
|
|
458
|
+
version = "0.3.49"
|
|
459
459
|
source = { registry = "https://pypi.org/simple" }
|
|
460
460
|
dependencies = [
|
|
461
461
|
{ name = "httpx" },
|
|
462
462
|
{ name = "pandas" },
|
|
463
463
|
]
|
|
464
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
464
|
+
sdist = { url = "https://files.pythonhosted.org/packages/51/55/05e3dcd47930cef152f1ed3dc399121c848a87ed0121e583ceeb2b6ce98d/reportify_sdk-0.3.49.tar.gz", hash = "sha256:88343639e22514cac687a7d6a509aef19a025dbabbce5e0526271b5b79d2ca00", size = 26716, upload_time = "2026-05-08T06:11:34.513Z" }
|
|
465
|
+
wheels = [
|
|
466
|
+
{ url = "https://files.pythonhosted.org/packages/17/0c/927db26ca174c72640e8f224caebcb4cf86d8f72d22656d5671c44824485/reportify_sdk-0.3.49-py3-none-any.whl", hash = "sha256:4e3fa10b25175670a7107fb4c999eeff2c40d430bac4cde8874d7ceab004f889", size = 35217, upload_time = "2026-05-08T06:11:36.181Z" },
|
|
467
|
+
]
|
|
465
468
|
|
|
466
469
|
[[package]]
|
|
467
470
|
name = "rich"
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
"""Settings and configuration for Reportify API CLI."""
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import sys
|
|
5
|
-
|
|
6
|
-
# Try to load .env file if python-dotenv is available
|
|
7
|
-
try:
|
|
8
|
-
from dotenv import load_dotenv
|
|
9
|
-
|
|
10
|
-
load_dotenv()
|
|
11
|
-
except ImportError:
|
|
12
|
-
# python-dotenv not installed, skip loading .env file
|
|
13
|
-
pass
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def get_api_key() -> str:
|
|
17
|
-
"""Get API key from environment variable.
|
|
18
|
-
|
|
19
|
-
Returns:
|
|
20
|
-
str: API key
|
|
21
|
-
|
|
22
|
-
Raises:
|
|
23
|
-
SystemExit: If API key is not found
|
|
24
|
-
"""
|
|
25
|
-
api_key = os.getenv("REPORTIFY_API_KEY")
|
|
26
|
-
if not api_key:
|
|
27
|
-
print("Error: REPORTIFY_API_KEY environment variable is not set.", file=sys.stderr)
|
|
28
|
-
print("Please set it with: export REPORTIFY_API_KEY='your-api-key'", file=sys.stderr)
|
|
29
|
-
sys.exit(1)
|
|
30
|
-
return api_key
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{reportify_cli-0.1.44 → reportify_cli-0.1.45}/skills/reportify-ai/references/API_REFERENCE.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|