reportify-cli 0.1.47__tar.gz → 0.1.48__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.47 → reportify_cli-0.1.48}/PKG-INFO +1 -1
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/pyproject.toml +1 -1
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/src/auth_config.py +49 -8
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/src/commands/auth.py +99 -68
- reportify_cli-0.1.48/src/settings.py +87 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/tests/test_commands/test_auth_commands.py +87 -42
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/uv.lock +2 -2
- reportify_cli-0.1.47/src/settings.py +0 -55
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/.gitignore +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/Makefile +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/README.md +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/scripts/README.md +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/scripts/bump_version.sh +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/scripts/publish.sh +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/skills/reportify-agent/SKILL.md +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/skills/reportify-ai/SKILL.md +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/skills/reportify-ai/references/API_REFERENCE.md +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/skills/reportify-ai/references/COMMANDS.md +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/src/__init__.py +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/src/client.py +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/src/commands/__init__.py +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/src/commands/agent.py +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/src/commands/channels.py +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/src/commands/concepts.py +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/src/commands/docs.py +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/src/commands/following.py +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/src/commands/kb.py +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/src/commands/macro.py +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/src/commands/quant.py +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/src/commands/search.py +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/src/commands/stock.py +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/src/commands/timeline.py +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/src/commands/user.py +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/src/main.py +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/src/output.py +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/src/params.py +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/src/utils.py +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/tests/__init__.py +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/tests/integration/test_docs_integration.py +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/tests/integration/test_stock_integration.py +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/tests/test_commands/__init__.py +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/tests/test_commands/test_auth_config.py +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/tests/test_commands/test_docs.py +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/tests/test_commands/test_quant.py +0 -0
- {reportify_cli-0.1.47 → reportify_cli-0.1.48}/tests/test_commands/test_search.py +0 -0
|
@@ -2,15 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
存储位置:``~/.reportify/config`` (INI 格式),权限 0600。
|
|
4
4
|
|
|
5
|
+
新 OAuth flow (推荐):
|
|
6
|
+
|
|
7
|
+
```ini
|
|
8
|
+
[default]
|
|
9
|
+
access_token = eyJhbGciOiJIUzI1NiIs... # JWT
|
|
10
|
+
refresh_token = q9N1f...
|
|
11
|
+
expires_at = 1716800000 # epoch seconds
|
|
12
|
+
user_id = 12345
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
老 API Key flow (向后兼容):
|
|
16
|
+
|
|
5
17
|
```ini
|
|
6
18
|
[default]
|
|
7
19
|
api_key = 12345abcdef...
|
|
8
20
|
user_id = 12345
|
|
9
|
-
nickname = Foo
|
|
10
|
-
email = foo@bar.com
|
|
11
|
-
subscription_tier = plus
|
|
12
21
|
```
|
|
13
22
|
|
|
23
|
+
两种字段可共存:CLI 读取时优先用未过期的 access_token,回退到 api_key。
|
|
24
|
+
|
|
14
25
|
仅 first-party CLI 内部使用;Skill 不要直接读这个文件,应通过 ``reportify-cli`` 间接使用。
|
|
15
26
|
"""
|
|
16
27
|
|
|
@@ -30,16 +41,34 @@ CONFIG_PATH = CONFIG_DIR / "config"
|
|
|
30
41
|
|
|
31
42
|
@dataclass
|
|
32
43
|
class AuthCredential:
|
|
33
|
-
"""单个 profile 下保存的凭证。
|
|
44
|
+
"""单个 profile 下保存的凭证。
|
|
34
45
|
|
|
35
|
-
|
|
46
|
+
新 OAuth flow 用 access_token/refresh_token/expires_at;
|
|
47
|
+
老 flow 用 api_key(向后兼容)。
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
api_key: Optional[str] = None
|
|
51
|
+
access_token: Optional[str] = None
|
|
52
|
+
refresh_token: Optional[str] = None
|
|
53
|
+
expires_at: Optional[int] = None # epoch seconds
|
|
54
|
+
client_id: Optional[str] = None # DCR 拿到的, 30 天 TTL, 过期需重新注册
|
|
36
55
|
user_id: Optional[str] = None
|
|
37
56
|
nickname: Optional[str] = None
|
|
38
57
|
email: Optional[str] = None
|
|
39
58
|
subscription_tier: Optional[str] = None
|
|
40
59
|
|
|
41
60
|
def to_dict(self) -> dict[str, str]:
|
|
42
|
-
d = {
|
|
61
|
+
d: dict[str, str] = {}
|
|
62
|
+
if self.api_key:
|
|
63
|
+
d["api_key"] = self.api_key
|
|
64
|
+
if self.access_token:
|
|
65
|
+
d["access_token"] = self.access_token
|
|
66
|
+
if self.refresh_token:
|
|
67
|
+
d["refresh_token"] = self.refresh_token
|
|
68
|
+
if self.expires_at:
|
|
69
|
+
d["expires_at"] = str(self.expires_at)
|
|
70
|
+
if self.client_id:
|
|
71
|
+
d["client_id"] = self.client_id
|
|
43
72
|
if self.user_id:
|
|
44
73
|
d["user_id"] = str(self.user_id)
|
|
45
74
|
if self.nickname:
|
|
@@ -66,16 +95,28 @@ def _load_parser() -> configparser.ConfigParser:
|
|
|
66
95
|
|
|
67
96
|
|
|
68
97
|
def load_credential(profile: str = DEFAULT_PROFILE) -> Optional[AuthCredential]:
|
|
69
|
-
"""读取指定 profile
|
|
98
|
+
"""读取指定 profile 的凭证;不存在或两类 token 都缺时返回 None。"""
|
|
70
99
|
parser = _load_parser()
|
|
71
100
|
if not parser.has_section(profile):
|
|
72
101
|
return None
|
|
73
102
|
section = parser[profile]
|
|
74
103
|
api_key = section.get("api_key")
|
|
75
|
-
|
|
104
|
+
access_token = section.get("access_token")
|
|
105
|
+
if not api_key and not access_token:
|
|
76
106
|
return None
|
|
107
|
+
expires_at_raw = section.get("expires_at")
|
|
108
|
+
expires_at: Optional[int] = None
|
|
109
|
+
if expires_at_raw:
|
|
110
|
+
try:
|
|
111
|
+
expires_at = int(expires_at_raw)
|
|
112
|
+
except ValueError:
|
|
113
|
+
expires_at = None
|
|
77
114
|
return AuthCredential(
|
|
78
115
|
api_key=api_key,
|
|
116
|
+
access_token=access_token,
|
|
117
|
+
refresh_token=section.get("refresh_token"),
|
|
118
|
+
expires_at=expires_at,
|
|
119
|
+
client_id=section.get("client_id"),
|
|
79
120
|
user_id=section.get("user_id"),
|
|
80
121
|
nickname=section.get("nickname"),
|
|
81
122
|
email=section.get("email"),
|
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
"""Reportify CLI authentication commands.
|
|
2
2
|
|
|
3
|
-
实现 OAuth 2.
|
|
3
|
+
实现 OAuth 2.1 Device Authorization Grant (RFC 8628) 客户端:
|
|
4
4
|
|
|
5
|
-
reportify-cli auth login # 跑 device flow,
|
|
5
|
+
reportify-cli auth login # 跑 device flow, 保存 JWT, 写本地配置
|
|
6
6
|
reportify-cli auth logout # 删除本地配置
|
|
7
7
|
reportify-cli auth status # 显示当前登录状态
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
POST {base}/v1/
|
|
11
|
-
POST {base}/v1/
|
|
12
|
-
POST {base}/v1/
|
|
9
|
+
后端契约(新 oauth_server 端点):
|
|
10
|
+
POST {base}/v1/oauth/device/code (form: client_id, resource, scope?)
|
|
11
|
+
POST {base}/v1/oauth/token (form: grant_type, device_code, client_id, resource)
|
|
12
|
+
POST {base}/v1/oauth/token (form: grant_type=refresh_token, refresh_token, client_id, resource)
|
|
13
|
+
|
|
14
|
+
返回的 access_token 是 **JWT**(aud=https://api.reportify.cn),客户端把它当
|
|
15
|
+
Bearer 直接发给主 API;主 API 的 TokenMiddleware 会本地验签校验 audience。
|
|
13
16
|
"""
|
|
14
17
|
|
|
15
18
|
from __future__ import annotations
|
|
16
19
|
|
|
17
|
-
import sys
|
|
18
20
|
import time
|
|
19
21
|
import webbrowser
|
|
20
|
-
from typing import Annotated
|
|
22
|
+
from typing import Annotated
|
|
21
23
|
|
|
22
24
|
import httpx
|
|
23
25
|
import typer
|
|
@@ -32,14 +34,15 @@ from src.settings import get_api_base_url
|
|
|
32
34
|
|
|
33
35
|
app = typer.Typer(help="Authentication", rich_markup_mode="rich")
|
|
34
36
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
CLIENT_NAME = "Reportify CLI"
|
|
38
|
+
RESOURCE = "https://api.reportify.cn"
|
|
39
|
+
SCOPE = "api"
|
|
40
|
+
GRANT_TYPE_DEVICE = "urn:ietf:params:oauth:grant-type:device_code"
|
|
41
|
+
GRANT_TYPE_REFRESH = "refresh_token"
|
|
38
42
|
|
|
39
|
-
|
|
40
|
-
ENDPOINT_DEVICE_CODE = "/v1/
|
|
41
|
-
|
|
42
|
-
ENDPOINT_API_KEY_ENSURE = "/api-keys/ensure"
|
|
43
|
+
ENDPOINT_REGISTER = "/v1/oauth/register"
|
|
44
|
+
ENDPOINT_DEVICE_CODE = "/v1/oauth/device/code"
|
|
45
|
+
ENDPOINT_TOKEN = "/v1/oauth/token"
|
|
43
46
|
|
|
44
47
|
|
|
45
48
|
# ------------------------------------------------------------------ #
|
|
@@ -53,16 +56,11 @@ def _err(msg: str, exit_code: int = 1) -> None:
|
|
|
53
56
|
|
|
54
57
|
|
|
55
58
|
def _oauth_error(response: httpx.Response) -> tuple[str, str]:
|
|
56
|
-
"""Extract
|
|
57
|
-
|
|
58
|
-
Server returns ``{"detail": {"error": ..., "error_description": ...}}``.
|
|
59
|
-
"""
|
|
59
|
+
"""Extract OAuth error from a 4xx response."""
|
|
60
60
|
try:
|
|
61
61
|
body = response.json()
|
|
62
62
|
except Exception:
|
|
63
63
|
return ("unknown_error", response.text or "")
|
|
64
|
-
|
|
65
|
-
# FastAPI HTTPException 的 detail 嵌在 'detail' 里
|
|
66
64
|
detail = body.get("detail", body)
|
|
67
65
|
if isinstance(detail, dict):
|
|
68
66
|
return (
|
|
@@ -72,10 +70,29 @@ def _oauth_error(response: httpx.Response) -> tuple[str, str]:
|
|
|
72
70
|
return ("unknown_error", str(detail))
|
|
73
71
|
|
|
74
72
|
|
|
75
|
-
def
|
|
73
|
+
def _register_client(client: httpx.Client) -> str:
|
|
74
|
+
"""RFC 7591 Dynamic Client Registration —— 注册本地 CLI 实例, 返回 client_id。"""
|
|
75
|
+
resp = client.post(
|
|
76
|
+
ENDPOINT_REGISTER,
|
|
77
|
+
json={
|
|
78
|
+
"client_name": CLIENT_NAME,
|
|
79
|
+
"redirect_uris": [], # device flow 不需要
|
|
80
|
+
"grant_types": [GRANT_TYPE_DEVICE, GRANT_TYPE_REFRESH],
|
|
81
|
+
"response_types": [],
|
|
82
|
+
"token_endpoint_auth_method": "none",
|
|
83
|
+
"scope": SCOPE,
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
if resp.status_code != 200:
|
|
87
|
+
err, desc = _oauth_error(resp)
|
|
88
|
+
_err(f"failed to register client ({err}): {desc}")
|
|
89
|
+
return resp.json()["client_id"]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _request_device_code(client: httpx.Client, client_id: str) -> dict:
|
|
76
93
|
resp = client.post(
|
|
77
94
|
ENDPOINT_DEVICE_CODE,
|
|
78
|
-
data={"client_id":
|
|
95
|
+
data={"client_id": client_id, "resource": RESOURCE, "scope": SCOPE},
|
|
79
96
|
)
|
|
80
97
|
if resp.status_code != 200:
|
|
81
98
|
err, desc = _oauth_error(resp)
|
|
@@ -83,8 +100,10 @@ def _request_device_code(client: httpx.Client) -> dict:
|
|
|
83
100
|
return resp.json()
|
|
84
101
|
|
|
85
102
|
|
|
86
|
-
def _poll_token(
|
|
87
|
-
|
|
103
|
+
def _poll_token(
|
|
104
|
+
client: httpx.Client, device_code: str, client_id: str, interval: int, expires_in: int
|
|
105
|
+
) -> dict:
|
|
106
|
+
"""轮询 /v1/oauth/token (grant_type=device_code),直到拿到 JWT 或超时/被拒。"""
|
|
88
107
|
deadline = time.monotonic() + expires_in
|
|
89
108
|
current_interval = max(interval, 1)
|
|
90
109
|
typer.echo("Waiting for authorization", nl=False)
|
|
@@ -97,11 +116,12 @@ def _poll_token(client: httpx.Client, device_code: str, interval: int, expires_i
|
|
|
97
116
|
typer.echo(".", nl=False)
|
|
98
117
|
|
|
99
118
|
resp = client.post(
|
|
100
|
-
|
|
119
|
+
ENDPOINT_TOKEN,
|
|
101
120
|
data={
|
|
102
|
-
"grant_type":
|
|
121
|
+
"grant_type": GRANT_TYPE_DEVICE,
|
|
103
122
|
"device_code": device_code,
|
|
104
|
-
"client_id":
|
|
123
|
+
"client_id": client_id,
|
|
124
|
+
"resource": RESOURCE,
|
|
105
125
|
},
|
|
106
126
|
)
|
|
107
127
|
|
|
@@ -118,30 +138,42 @@ def _poll_token(client: httpx.Client, device_code: str, interval: int, expires_i
|
|
|
118
138
|
if err == "access_denied":
|
|
119
139
|
typer.echo("")
|
|
120
140
|
_err("authorization was denied in the browser")
|
|
121
|
-
if err
|
|
141
|
+
if err in ("expired_token", "invalid_grant"):
|
|
122
142
|
typer.echo("")
|
|
123
143
|
_err("device code expired before authorization; please try `auth login` again")
|
|
124
144
|
|
|
125
|
-
# Any other error → fail loud
|
|
126
145
|
typer.echo("")
|
|
127
146
|
_err(f"failed to obtain access token ({err}): {desc}")
|
|
128
147
|
|
|
129
148
|
|
|
130
|
-
def
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
json={"client_id": CLIENT_ID, "source": "cli"},
|
|
149
|
+
def _credential_from_token_response(token_resp: dict) -> AuthCredential:
|
|
150
|
+
expires_in = int(token_resp.get("expires_in", 3600))
|
|
151
|
+
return AuthCredential(
|
|
152
|
+
access_token=token_resp["access_token"],
|
|
153
|
+
refresh_token=token_resp.get("refresh_token"),
|
|
154
|
+
expires_at=int(time.time()) + expires_in,
|
|
137
155
|
)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def refresh_access_token(refresh_token: str, client_id: str) -> AuthCredential:
|
|
159
|
+
"""刷新 access_token;返回新的 AuthCredential(含轮转后的 refresh_token)。"""
|
|
160
|
+
base_url = get_api_base_url().rstrip("/")
|
|
161
|
+
with httpx.Client(base_url=base_url, timeout=30.0) as client:
|
|
162
|
+
resp = client.post(
|
|
163
|
+
ENDPOINT_TOKEN,
|
|
164
|
+
data={
|
|
165
|
+
"grant_type": GRANT_TYPE_REFRESH,
|
|
166
|
+
"refresh_token": refresh_token,
|
|
167
|
+
"client_id": client_id,
|
|
168
|
+
"resource": RESOURCE,
|
|
169
|
+
},
|
|
170
|
+
)
|
|
171
|
+
if resp.status_code != 200:
|
|
172
|
+
err, desc = _oauth_error(resp)
|
|
173
|
+
_err(f"refresh failed ({err}): {desc}. Please run `reportify-cli auth login` again.")
|
|
174
|
+
cred = _credential_from_token_response(resp.json())
|
|
175
|
+
cred.client_id = client_id # 保持原 client_id
|
|
176
|
+
return cred
|
|
145
177
|
|
|
146
178
|
|
|
147
179
|
# ------------------------------------------------------------------ #
|
|
@@ -159,14 +191,23 @@ def login(
|
|
|
159
191
|
),
|
|
160
192
|
] = False,
|
|
161
193
|
) -> None:
|
|
162
|
-
"""Authenticate to Reportify via the device
|
|
194
|
+
"""Authenticate to Reportify via the OAuth 2.1 device flow.
|
|
163
195
|
|
|
164
196
|
Opens the browser to a verification page. After you approve the request,
|
|
165
|
-
the CLI
|
|
197
|
+
the CLI stores a short-lived access token (JWT) + refresh token in
|
|
198
|
+
``~/.reportify/config``. Subsequent CLI commands use the access token
|
|
199
|
+
automatically; expired tokens are silently refreshed.
|
|
166
200
|
"""
|
|
167
201
|
base_url = get_api_base_url().rstrip("/")
|
|
202
|
+
# 复用已注册的 client_id(30 天 TTL);不存在则发起 DCR
|
|
203
|
+
existing = load_credential()
|
|
204
|
+
client_id = existing.client_id if existing and existing.client_id else None
|
|
205
|
+
|
|
168
206
|
with httpx.Client(base_url=base_url, timeout=30.0) as client:
|
|
169
|
-
|
|
207
|
+
if not client_id:
|
|
208
|
+
client_id = _register_client(client)
|
|
209
|
+
|
|
210
|
+
device_resp = _request_device_code(client, client_id)
|
|
170
211
|
|
|
171
212
|
verification_uri_complete = device_resp.get("verification_uri_complete")
|
|
172
213
|
verification_uri = device_resp.get("verification_uri")
|
|
@@ -185,28 +226,16 @@ def login(
|
|
|
185
226
|
try:
|
|
186
227
|
webbrowser.open(verification_uri_complete)
|
|
187
228
|
except Exception:
|
|
188
|
-
# 浏览器打开失败不影响主流程,用户可以自己复制链接
|
|
189
229
|
pass
|
|
190
230
|
|
|
191
|
-
token_resp = _poll_token(client, device_code, interval, expires_in)
|
|
192
|
-
access_token = token_resp["access_token"]
|
|
193
|
-
user = token_resp.get("user") or {}
|
|
194
|
-
|
|
195
|
-
key_resp = _ensure_api_key(client, access_token)
|
|
231
|
+
token_resp = _poll_token(client, device_code, client_id, interval, expires_in)
|
|
196
232
|
|
|
197
|
-
credential =
|
|
198
|
-
|
|
199
|
-
user_id=str(user.get("user_id") or ""),
|
|
200
|
-
nickname=user.get("nickname") or None,
|
|
201
|
-
email=user.get("email") or None,
|
|
202
|
-
subscription_tier=user.get("subscription_tier") or None,
|
|
203
|
-
)
|
|
233
|
+
credential = _credential_from_token_response(token_resp)
|
|
234
|
+
credential.client_id = client_id
|
|
204
235
|
path = save_credential(credential)
|
|
205
236
|
|
|
206
237
|
typer.echo("")
|
|
207
|
-
|
|
208
|
-
tier = credential.subscription_tier or "free"
|
|
209
|
-
typer.echo(f"Logged in as {who} (plan: {tier})")
|
|
238
|
+
typer.echo("Logged in successfully")
|
|
210
239
|
typer.echo(f"Credential saved to {path}")
|
|
211
240
|
|
|
212
241
|
|
|
@@ -214,8 +243,8 @@ def login(
|
|
|
214
243
|
def logout() -> None:
|
|
215
244
|
"""Remove the locally stored Reportify credential.
|
|
216
245
|
|
|
217
|
-
Does **not** revoke the
|
|
218
|
-
invalidated, also
|
|
246
|
+
Does **not** revoke the token on the server side. If you want the token
|
|
247
|
+
invalidated, also revoke it from your Reportify account settings.
|
|
219
248
|
"""
|
|
220
249
|
removed = delete_credential()
|
|
221
250
|
if removed:
|
|
@@ -236,7 +265,9 @@ def status() -> None:
|
|
|
236
265
|
typer.echo("Not logged in. Run `reportify-cli auth login`.")
|
|
237
266
|
raise typer.Exit(1)
|
|
238
267
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
268
|
+
if cred.access_token:
|
|
269
|
+
remaining = (cred.expires_at or 0) - int(time.time())
|
|
270
|
+
typer.echo(f"Logged in (OAuth, token expires in {max(remaining, 0)}s)")
|
|
271
|
+
elif cred.api_key:
|
|
272
|
+
typer.echo("Logged in (legacy API Key)")
|
|
242
273
|
typer.echo(f"API base: {get_api_base_url()}")
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Settings and configuration for Reportify API CLI."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
# Try to load .env file if python-dotenv is available
|
|
8
|
+
try:
|
|
9
|
+
from dotenv import load_dotenv
|
|
10
|
+
|
|
11
|
+
load_dotenv()
|
|
12
|
+
except ImportError:
|
|
13
|
+
# python-dotenv not installed, skip loading .env file
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
from src.auth_config import load_credential, save_credential
|
|
17
|
+
|
|
18
|
+
# Default API base URL. SDK 默认也是这个;CLI 自己的 auth 命令也走它。
|
|
19
|
+
DEFAULT_API_BASE_URL = "https://api.reportify.cn"
|
|
20
|
+
|
|
21
|
+
# 距过期多少秒以内强制刷新(防止用户长时间 CLI 跑批时半路过期)
|
|
22
|
+
_REFRESH_LEEWAY_SEC = 60
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_api_base_url() -> str:
|
|
26
|
+
"""API 基础 URL。优先级: REPORTIFY_BASE_URL > 默认值。
|
|
27
|
+
|
|
28
|
+
与 reportify-sdk 的 Reportify 客户端保持一致,便于 dev / staging 切换。
|
|
29
|
+
"""
|
|
30
|
+
return os.getenv("REPORTIFY_BASE_URL") or DEFAULT_API_BASE_URL
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_token_fresh(expires_at: int | None) -> bool:
|
|
34
|
+
if not expires_at:
|
|
35
|
+
return False
|
|
36
|
+
return expires_at - int(time.time()) > _REFRESH_LEEWAY_SEC
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_api_key() -> str:
|
|
40
|
+
"""Get a bearer credential, in priority order:
|
|
41
|
+
|
|
42
|
+
1. ``REPORTIFY_API_KEY`` environment variable(可以是 api_key 或 JWT)
|
|
43
|
+
2. 本地配置文件中未过期的 OAuth access_token (JWT)
|
|
44
|
+
3. OAuth refresh_token 自动续期后的新 access_token
|
|
45
|
+
4. 本地配置文件中的老 api_key(向后兼容)
|
|
46
|
+
|
|
47
|
+
主 API 的 TokenMiddleware 同时接受这两种凭证形态。
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
SystemExit: If none of the above is available.
|
|
51
|
+
"""
|
|
52
|
+
env_key = os.getenv("REPORTIFY_API_KEY")
|
|
53
|
+
if env_key:
|
|
54
|
+
return env_key
|
|
55
|
+
|
|
56
|
+
cred = load_credential()
|
|
57
|
+
if cred:
|
|
58
|
+
# 1. 未过期的 JWT 直接用
|
|
59
|
+
if cred.access_token and _is_token_fresh(cred.expires_at):
|
|
60
|
+
return cred.access_token
|
|
61
|
+
|
|
62
|
+
# 2. JWT 过期但有 refresh_token,自动续期
|
|
63
|
+
if cred.refresh_token and cred.client_id:
|
|
64
|
+
# 延迟 import 避开 circular(commands/auth.py 反过来 import settings)
|
|
65
|
+
from src.commands.auth import refresh_access_token
|
|
66
|
+
|
|
67
|
+
refreshed = refresh_access_token(cred.refresh_token, cred.client_id)
|
|
68
|
+
cred.access_token = refreshed.access_token
|
|
69
|
+
cred.refresh_token = refreshed.refresh_token or cred.refresh_token
|
|
70
|
+
cred.expires_at = refreshed.expires_at
|
|
71
|
+
save_credential(cred)
|
|
72
|
+
return cred.access_token
|
|
73
|
+
|
|
74
|
+
# 3. 老 api_key fallback
|
|
75
|
+
if cred.api_key:
|
|
76
|
+
return cred.api_key
|
|
77
|
+
|
|
78
|
+
print("Error: no Reportify credential found.", file=sys.stderr)
|
|
79
|
+
print(
|
|
80
|
+
" - Run `reportify-cli auth login` to authenticate, or",
|
|
81
|
+
file=sys.stderr,
|
|
82
|
+
)
|
|
83
|
+
print(
|
|
84
|
+
" - Set the REPORTIFY_API_KEY environment variable manually.",
|
|
85
|
+
file=sys.stderr,
|
|
86
|
+
)
|
|
87
|
+
sys.exit(1)
|
|
@@ -66,30 +66,31 @@ def _device_code_payload() -> dict:
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
|
|
69
|
-
def
|
|
69
|
+
def _register_payload() -> dict:
|
|
70
|
+
"""RFC 7591 DCR 响应(CLI login 首次调用时拿 client_id)。"""
|
|
70
71
|
return {
|
|
71
|
-
"
|
|
72
|
-
"
|
|
73
|
-
"
|
|
74
|
-
"
|
|
75
|
-
"
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
"subscription_tier": "plus",
|
|
80
|
-
},
|
|
81
|
-
"entitlements": ["official_skills"],
|
|
72
|
+
"client_id": "dyn_cli_test_123",
|
|
73
|
+
"client_id_issued_at": 0,
|
|
74
|
+
"client_name": "Reportify CLI",
|
|
75
|
+
"redirect_uris": [],
|
|
76
|
+
"grant_types": ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"],
|
|
77
|
+
"response_types": [],
|
|
78
|
+
"token_endpoint_auth_method": "none",
|
|
79
|
+
"scope": "api",
|
|
82
80
|
}
|
|
83
81
|
|
|
84
82
|
|
|
85
|
-
def
|
|
83
|
+
def _token_payload() -> dict:
|
|
84
|
+
"""oauth_server /v1/oauth/token (grant_type=device_code) 的响应。
|
|
85
|
+
|
|
86
|
+
JWT 格式不重要(CLI 不解码),随便给个 header.payload.signature。
|
|
87
|
+
"""
|
|
86
88
|
return {
|
|
87
|
-
"
|
|
88
|
-
"
|
|
89
|
-
"
|
|
90
|
-
"
|
|
91
|
-
"
|
|
92
|
-
"created_at": 0,
|
|
89
|
+
"access_token": "header.payload.signature",
|
|
90
|
+
"token_type": "Bearer",
|
|
91
|
+
"expires_in": 3600,
|
|
92
|
+
"refresh_token": "refresh-token-abc",
|
|
93
|
+
"scope": "api",
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
|
|
@@ -122,14 +123,25 @@ class TestStatus:
|
|
|
122
123
|
assert result.exit_code == 1
|
|
123
124
|
assert "Not logged in" in result.stdout
|
|
124
125
|
|
|
125
|
-
def
|
|
126
|
+
def test_logged_in_with_legacy_api_key(self, tmp_config):
|
|
127
|
+
save_credential(AuthCredential(api_key="x"))
|
|
128
|
+
result = runner.invoke(app, ["status"])
|
|
129
|
+
assert result.exit_code == 0
|
|
130
|
+
assert "legacy API Key" in result.stdout
|
|
131
|
+
|
|
132
|
+
def test_logged_in_with_oauth_token(self, tmp_config):
|
|
133
|
+
import time as _time
|
|
134
|
+
|
|
126
135
|
save_credential(
|
|
127
|
-
AuthCredential(
|
|
136
|
+
AuthCredential(
|
|
137
|
+
access_token="header.payload.signature",
|
|
138
|
+
refresh_token="rt",
|
|
139
|
+
expires_at=int(_time.time()) + 3600,
|
|
140
|
+
)
|
|
128
141
|
)
|
|
129
142
|
result = runner.invoke(app, ["status"])
|
|
130
143
|
assert result.exit_code == 0
|
|
131
|
-
assert "
|
|
132
|
-
assert "plus" in result.stdout
|
|
144
|
+
assert "OAuth" in result.stdout
|
|
133
145
|
|
|
134
146
|
|
|
135
147
|
class TestLogout:
|
|
@@ -154,46 +166,66 @@ class TestLoginHappyPath:
|
|
|
154
166
|
self, tmp_config, fast_sleep, patch_client
|
|
155
167
|
):
|
|
156
168
|
patch_client.post.side_effect = [
|
|
157
|
-
_ok(
|
|
158
|
-
_ok(
|
|
159
|
-
_ok(
|
|
169
|
+
_ok(_register_payload()), # /v1/oauth/register (DCR)
|
|
170
|
+
_ok(_device_code_payload()), # /v1/oauth/device/code
|
|
171
|
+
_ok(_token_payload()), # /v1/oauth/token (approved)
|
|
160
172
|
]
|
|
161
173
|
|
|
162
174
|
result = runner.invoke(app, ["login", "--no-browser"])
|
|
163
175
|
assert result.exit_code == 0, result.stdout
|
|
164
176
|
assert "WDJB-MJHT" in result.stdout
|
|
165
|
-
assert "Logged in
|
|
177
|
+
assert "Logged in successfully" in result.stdout
|
|
166
178
|
|
|
167
179
|
cred = load_credential()
|
|
168
180
|
assert cred is not None
|
|
169
|
-
assert cred.
|
|
170
|
-
assert cred.
|
|
171
|
-
assert cred.
|
|
172
|
-
assert cred.
|
|
173
|
-
|
|
181
|
+
assert cred.client_id == "dyn_cli_test_123"
|
|
182
|
+
assert cred.access_token == "header.payload.signature"
|
|
183
|
+
assert cred.refresh_token == "refresh-token-abc"
|
|
184
|
+
assert cred.expires_at is not None and cred.expires_at > 0
|
|
185
|
+
|
|
186
|
+
def test_reuses_existing_client_id_skipping_dcr(
|
|
187
|
+
self, tmp_config, fast_sleep, patch_client
|
|
188
|
+
):
|
|
189
|
+
"""有本地 client_id 时不应重复 DCR,直接进 device flow。"""
|
|
190
|
+
# 模拟用户之前登录过(有 client_id + 旧 token)
|
|
191
|
+
save_credential(
|
|
192
|
+
AuthCredential(
|
|
193
|
+
client_id="cached_client",
|
|
194
|
+
access_token="old.access.token",
|
|
195
|
+
refresh_token="old-refresh",
|
|
196
|
+
expires_at=0, # 过期
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
patch_client.post.side_effect = [
|
|
200
|
+
_ok(_device_code_payload()), # 直接 device flow, 跳过 DCR
|
|
201
|
+
_ok(_token_payload()),
|
|
202
|
+
]
|
|
203
|
+
result = runner.invoke(app, ["login", "--no-browser"])
|
|
204
|
+
assert result.exit_code == 0, result.stdout
|
|
205
|
+
assert load_credential().client_id == "cached_client"
|
|
174
206
|
|
|
175
207
|
def test_authorization_pending_then_approved(
|
|
176
208
|
self, tmp_config, fast_sleep, patch_client
|
|
177
209
|
):
|
|
178
210
|
patch_client.post.side_effect = [
|
|
211
|
+
_ok(_register_payload()),
|
|
179
212
|
_ok(_device_code_payload()),
|
|
180
213
|
_oauth_err("authorization_pending"),
|
|
181
214
|
_oauth_err("authorization_pending"),
|
|
182
215
|
_ok(_token_payload()),
|
|
183
|
-
_ok(_api_key_payload()),
|
|
184
216
|
]
|
|
185
217
|
result = runner.invoke(app, ["login", "--no-browser"])
|
|
186
218
|
assert result.exit_code == 0, result.stdout
|
|
187
|
-
assert load_credential().
|
|
219
|
+
assert load_credential().access_token == "header.payload.signature"
|
|
188
220
|
|
|
189
221
|
def test_slow_down_increments_interval_but_succeeds(
|
|
190
222
|
self, tmp_config, fast_sleep, patch_client
|
|
191
223
|
):
|
|
192
224
|
patch_client.post.side_effect = [
|
|
225
|
+
_ok(_register_payload()),
|
|
193
226
|
_ok(_device_code_payload()),
|
|
194
227
|
_oauth_err("slow_down"),
|
|
195
228
|
_ok(_token_payload()),
|
|
196
|
-
_ok(_api_key_payload()),
|
|
197
229
|
]
|
|
198
230
|
result = runner.invoke(app, ["login", "--no-browser"])
|
|
199
231
|
assert result.exit_code == 0, result.stdout
|
|
@@ -205,6 +237,7 @@ class TestLoginHappyPath:
|
|
|
205
237
|
class TestLoginFailurePaths:
|
|
206
238
|
def test_access_denied_aborts(self, tmp_config, fast_sleep, patch_client):
|
|
207
239
|
patch_client.post.side_effect = [
|
|
240
|
+
_ok(_register_payload()),
|
|
208
241
|
_ok(_device_code_payload()),
|
|
209
242
|
_oauth_err("access_denied"),
|
|
210
243
|
]
|
|
@@ -215,6 +248,7 @@ class TestLoginFailurePaths:
|
|
|
215
248
|
|
|
216
249
|
def test_expired_token_aborts(self, tmp_config, fast_sleep, patch_client):
|
|
217
250
|
patch_client.post.side_effect = [
|
|
251
|
+
_ok(_register_payload()),
|
|
218
252
|
_ok(_device_code_payload()),
|
|
219
253
|
_oauth_err("expired_token"),
|
|
220
254
|
]
|
|
@@ -222,34 +256,45 @@ class TestLoginFailurePaths:
|
|
|
222
256
|
assert result.exit_code == 1
|
|
223
257
|
assert "expired" in _all_output(result)
|
|
224
258
|
|
|
259
|
+
def test_register_failure_aborts(self, tmp_config, patch_client):
|
|
260
|
+
"""DCR 失败时直接 abort, 不进入 device flow。"""
|
|
261
|
+
patch_client.post.side_effect = [
|
|
262
|
+
_oauth_err("invalid_redirect_uri", "bad uri"),
|
|
263
|
+
]
|
|
264
|
+
result = runner.invoke(app, ["login", "--no-browser"])
|
|
265
|
+
assert result.exit_code == 1
|
|
266
|
+
assert "register" in _all_output(result).lower()
|
|
267
|
+
|
|
225
268
|
def test_device_code_endpoint_failure(self, tmp_config, patch_client):
|
|
226
269
|
patch_client.post.side_effect = [
|
|
270
|
+
_ok(_register_payload()),
|
|
227
271
|
_oauth_err("invalid_client", "unknown client_id"),
|
|
228
272
|
]
|
|
229
273
|
result = runner.invoke(app, ["login", "--no-browser"])
|
|
230
274
|
assert result.exit_code == 1
|
|
231
275
|
assert "invalid_client" in _all_output(result)
|
|
232
276
|
|
|
233
|
-
def
|
|
277
|
+
def test_invalid_grant_treated_as_expired(
|
|
234
278
|
self, tmp_config, fast_sleep, patch_client
|
|
235
279
|
):
|
|
280
|
+
"""新 flow: invalid_grant 视同 expired_token,提示用户重跑 login。"""
|
|
236
281
|
patch_client.post.side_effect = [
|
|
282
|
+
_ok(_register_payload()),
|
|
237
283
|
_ok(_device_code_payload()),
|
|
238
|
-
|
|
239
|
-
httpx.Response(status_code=500, json={"detail": "server error"}),
|
|
284
|
+
_oauth_err("invalid_grant", "device_code already used"),
|
|
240
285
|
]
|
|
241
286
|
result = runner.invoke(app, ["login", "--no-browser"])
|
|
242
287
|
assert result.exit_code == 1
|
|
243
|
-
assert "
|
|
244
|
-
assert load_credential() is None
|
|
288
|
+
assert "expired" in _all_output(result).lower()
|
|
245
289
|
|
|
246
290
|
def test_unknown_oauth_error_aborts(
|
|
247
291
|
self, tmp_config, fast_sleep, patch_client
|
|
248
292
|
):
|
|
249
293
|
patch_client.post.side_effect = [
|
|
294
|
+
_ok(_register_payload()),
|
|
250
295
|
_ok(_device_code_payload()),
|
|
251
|
-
_oauth_err("
|
|
296
|
+
_oauth_err("invalid_target", "resource is not allowed"),
|
|
252
297
|
]
|
|
253
298
|
result = runner.invoke(app, ["login", "--no-browser"])
|
|
254
299
|
assert result.exit_code == 1
|
|
255
|
-
assert "
|
|
300
|
+
assert "invalid_target" in _all_output(result)
|
|
@@ -426,7 +426,7 @@ wheels = [
|
|
|
426
426
|
|
|
427
427
|
[[package]]
|
|
428
428
|
name = "reportify-cli"
|
|
429
|
-
version = "0.1.
|
|
429
|
+
version = "0.1.46"
|
|
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.49" },
|
|
449
449
|
{ name = "tabulate", specifier = ">=0.9.0" },
|
|
450
450
|
{ name = "typer", specifier = ">=0.21.1" },
|
|
451
451
|
]
|
|
@@ -1,55 +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
|
-
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)
|
|
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.47 → reportify_cli-0.1.48}/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
|
|
File without changes
|
|
File without changes
|