reportify-cli 0.1.44__tar.gz → 0.1.46__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.
Files changed (45) hide show
  1. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/PKG-INFO +2 -2
  2. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/pyproject.toml +2 -2
  3. reportify_cli-0.1.46/src/auth_config.py +150 -0
  4. reportify_cli-0.1.46/src/commands/auth.py +242 -0
  5. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/src/main.py +2 -1
  6. reportify_cli-0.1.46/src/settings.py +55 -0
  7. reportify_cli-0.1.46/tests/test_commands/test_auth_commands.py +255 -0
  8. reportify_cli-0.1.46/tests/test_commands/test_auth_config.py +99 -0
  9. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/uv.lock +7 -4
  10. reportify_cli-0.1.44/src/settings.py +0 -30
  11. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/.gitignore +0 -0
  12. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/Makefile +0 -0
  13. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/README.md +0 -0
  14. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/scripts/README.md +0 -0
  15. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/scripts/bump_version.sh +0 -0
  16. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/scripts/publish.sh +0 -0
  17. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/skills/reportify-agent/SKILL.md +0 -0
  18. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/skills/reportify-ai/SKILL.md +0 -0
  19. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/skills/reportify-ai/references/API_REFERENCE.md +0 -0
  20. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/skills/reportify-ai/references/COMMANDS.md +0 -0
  21. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/src/__init__.py +0 -0
  22. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/src/client.py +0 -0
  23. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/src/commands/__init__.py +0 -0
  24. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/src/commands/agent.py +0 -0
  25. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/src/commands/channels.py +0 -0
  26. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/src/commands/concepts.py +0 -0
  27. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/src/commands/docs.py +0 -0
  28. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/src/commands/following.py +0 -0
  29. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/src/commands/kb.py +0 -0
  30. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/src/commands/macro.py +0 -0
  31. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/src/commands/quant.py +0 -0
  32. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/src/commands/search.py +0 -0
  33. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/src/commands/stock.py +0 -0
  34. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/src/commands/timeline.py +0 -0
  35. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/src/commands/user.py +0 -0
  36. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/src/output.py +0 -0
  37. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/src/params.py +0 -0
  38. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/src/utils.py +0 -0
  39. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/tests/__init__.py +0 -0
  40. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/tests/integration/test_docs_integration.py +0 -0
  41. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/tests/integration/test_stock_integration.py +0 -0
  42. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/tests/test_commands/__init__.py +0 -0
  43. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/tests/test_commands/test_docs.py +0 -0
  44. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/tests/test_commands/test_quant.py +0 -0
  45. {reportify_cli-0.1.44 → reportify_cli-0.1.46}/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.44
3
+ Version: 0.1.46
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.46
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.44"
3
+ version = "0.1.46"
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.46",
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,242 @@
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
+ # reportify 的 TokenMiddleware 用 `X-Reportify-Token` 识别 web 登录态 token;
132
+ # `Authorization: Bearer` 那个 slot 给长期 api_key 用的。两个 header 不能混。
133
+ resp = client.post(
134
+ ENDPOINT_API_KEY_ENSURE,
135
+ headers={"X-Reportify-Token": access_token},
136
+ json={"client_id": CLIENT_ID, "source": "cli"},
137
+ )
138
+ if resp.status_code != 200:
139
+ try:
140
+ detail = resp.json().get("detail", resp.text)
141
+ except Exception:
142
+ detail = resp.text
143
+ _err(f"failed to obtain api key (HTTP {resp.status_code}): {detail}")
144
+ return resp.json()
145
+
146
+
147
+ # ------------------------------------------------------------------ #
148
+ # Commands
149
+ # ------------------------------------------------------------------ #
150
+
151
+
152
+ @app.command(name="login")
153
+ def login(
154
+ no_browser: Annotated[
155
+ bool,
156
+ typer.Option(
157
+ "--no-browser",
158
+ help="Do not attempt to open the browser automatically.",
159
+ ),
160
+ ] = False,
161
+ ) -> None:
162
+ """Authenticate to Reportify via the device authorization flow.
163
+
164
+ Opens the browser to a verification page. After you approve the request,
165
+ the CLI fetches a long-lived API key and stores it in ``~/.reportify/config``.
166
+ """
167
+ base_url = get_api_base_url().rstrip("/")
168
+ with httpx.Client(base_url=base_url, timeout=30.0) as client:
169
+ device_resp = _request_device_code(client)
170
+
171
+ verification_uri_complete = device_resp.get("verification_uri_complete")
172
+ verification_uri = device_resp.get("verification_uri")
173
+ user_code = device_resp.get("user_code")
174
+ device_code = device_resp["device_code"]
175
+ interval = int(device_resp.get("interval", 5))
176
+ expires_in = int(device_resp.get("expires_in", 600))
177
+
178
+ typer.echo("")
179
+ typer.echo("Open the following URL in your browser to authorize Reportify CLI:")
180
+ typer.echo(f" {verification_uri_complete or verification_uri}")
181
+ typer.echo(f"Verification code: {user_code}")
182
+ typer.echo("")
183
+
184
+ if not no_browser and verification_uri_complete:
185
+ try:
186
+ webbrowser.open(verification_uri_complete)
187
+ except Exception:
188
+ # 浏览器打开失败不影响主流程,用户可以自己复制链接
189
+ pass
190
+
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)
196
+
197
+ credential = AuthCredential(
198
+ api_key=key_resp["key"],
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
+ )
204
+ path = save_credential(credential)
205
+
206
+ typer.echo("")
207
+ who = credential.nickname or credential.email or credential.user_id or "unknown"
208
+ tier = credential.subscription_tier or "free"
209
+ typer.echo(f"Logged in as {who} (plan: {tier})")
210
+ typer.echo(f"Credential saved to {path}")
211
+
212
+
213
+ @app.command(name="logout")
214
+ def logout() -> None:
215
+ """Remove the locally stored Reportify credential.
216
+
217
+ Does **not** revoke the API key on the server side. If you want the key
218
+ invalidated, also delete it from your Reportify settings page.
219
+ """
220
+ removed = delete_credential()
221
+ if removed:
222
+ typer.echo("Local credential removed.")
223
+ else:
224
+ typer.echo("No local credential to remove.")
225
+
226
+
227
+ @app.command(name="status")
228
+ def status() -> None:
229
+ """Show the current authentication status.
230
+
231
+ Exit code is 0 if logged in, 1 otherwise (useful for Skill scripts to gate
232
+ on ``reportify-cli auth status`` before invoking business commands).
233
+ """
234
+ cred = load_credential()
235
+ if not cred:
236
+ typer.echo("Not logged in. Run `reportify-cli auth login`.")
237
+ raise typer.Exit(1)
238
+
239
+ who = cred.nickname or cred.email or cred.user_id or "unknown"
240
+ tier = cred.subscription_tier or "unknown"
241
+ typer.echo(f"Logged in as {who} (plan: {tier})")
242
+ 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.16"
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.15" },
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.24"
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/62/b1/c66cde3b7651825cb80353af7df4b7821c8f136163f2e5c5568ceb804edd/reportify_sdk-0.3.24.tar.gz", hash = "sha256:d41f42c067b1547300224d93adb028f4c9b7450272c0374a8c9370fb49be199d", size = 23489, upload_time = "2026-03-09T06:29:39.994Z" }
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