kiwoom-cli 2.1.1__tar.gz → 2.2.1__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 (53) hide show
  1. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/PKG-INFO +42 -10
  2. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/README.md +41 -9
  3. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/__init__.py +1 -1
  4. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/auth.py +14 -0
  5. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/client.py +6 -0
  6. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/config.py +16 -2
  7. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/main.py +58 -11
  8. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli.egg-info/PKG-INFO +42 -10
  9. kiwoom_cli-2.2.1/tests/test_auth.py +64 -0
  10. kiwoom_cli-2.2.1/tests/test_auth_cli.py +292 -0
  11. kiwoom_cli-2.1.1/tests/test_auth.py +0 -30
  12. kiwoom_cli-2.1.1/tests/test_auth_cli.py +0 -112
  13. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/LICENSE +0 -0
  14. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/api_spec.py +0 -0
  15. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/__init__.py +0 -0
  16. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/_constants.py +0 -0
  17. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/account.py +0 -0
  18. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/dashboard.py +0 -0
  19. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/market.py +0 -0
  20. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/order.py +0 -0
  21. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/stock.py +0 -0
  22. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/stream.py +0 -0
  23. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/us/__init__.py +0 -0
  24. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/us/_constants.py +0 -0
  25. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/us/account_ops.py +0 -0
  26. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/us/detect.py +0 -0
  27. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/us/exchange.py +0 -0
  28. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/us/order_ops.py +0 -0
  29. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/us/stock_ops.py +0 -0
  30. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/watch.py +0 -0
  31. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/formatters.py +0 -0
  32. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/output.py +0 -0
  33. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/streaming.py +0 -0
  34. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli.egg-info/SOURCES.txt +0 -0
  35. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli.egg-info/dependency_links.txt +0 -0
  36. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli.egg-info/entry_points.txt +0 -0
  37. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli.egg-info/requires.txt +0 -0
  38. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli.egg-info/top_level.txt +0 -0
  39. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/pyproject.toml +0 -0
  40. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/setup.cfg +0 -0
  41. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/tests/test_account.py +0 -0
  42. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/tests/test_cli.py +0 -0
  43. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/tests/test_client.py +0 -0
  44. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/tests/test_credentials.py +0 -0
  45. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/tests/test_formatters.py +0 -0
  46. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/tests/test_formatters_property.py +0 -0
  47. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/tests/test_json_purity.py +0 -0
  48. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/tests/test_market.py +0 -0
  49. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/tests/test_migration.py +0 -0
  50. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/tests/test_order.py +0 -0
  51. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/tests/test_stock.py +0 -0
  52. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/tests/test_us.py +0 -0
  53. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/tests/test_us_account.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwoom-cli
3
- Version: 2.1.1
3
+ Version: 2.2.1
4
4
  Summary: User-friendly CLI for the Kiwoom Securities REST API (키움증권)
5
5
  License: LicenseRef-kiwoom-cli-Source-Available-1.0
6
6
  Project-URL: Homepage, https://github.com/gejyn14/kiwoom-cli
@@ -84,6 +84,7 @@ $ kiwoom -f json account balance | jq # AI 에이전트·스크립트 친
84
84
 
85
85
  ```bash
86
86
  # 1. 초기 설정 (appkey, secretkey → OS 키체인)
87
+ # 토큰 저장 방식도 여기서 선택: keychain (기본) 또는 env (KIWOOM_TOKEN 직접 관리)
87
88
  kiwoom config setup
88
89
 
89
90
  # 2. 토큰 발급
@@ -108,14 +109,25 @@ kiwoom config show # 현재 설정 확인
108
109
 
109
110
  ### 환경변수 설정
110
111
 
111
- 도메인과 계좌번호는 환경변수로도 설정 가능합니다.
112
+ 도메인, 계좌번호, 프로필, 토큰은 환경변수로도 설정 가능합니다.
112
113
 
113
114
  ```bash
114
115
  export KIWOOM_DOMAIN="prod" # prod 또는 mock
115
116
  export KIWOOM_ACCOUNT="1234567" # 선택
117
+ export KIWOOM_PROFILE="isa" # 선택
118
+ export KIWOOM_TOKEN="..." # 선택: 키체인 대신 사용할 접근토큰 (샌드박스/CI용)
116
119
  ```
117
120
 
118
- appkey/secretkey는 보안을 위해 환경변수를 지원하지 않습니다. 반드시 `kiwoom config setup`으로 OS 키체인에 저장하세요.
121
+ appkey/secretkey는 보안을 위해 환경변수를 지원하지 않습니다. 반드시 `kiwoom config setup`으로 OS 키체인에 저장하세요. `KIWOOM_TOKEN`은 만료·폐기 가능한 접근토큰만 담는 통로로, 키체인에 접근할 수 없는 환경(샌드박스, CI, AI 에이전트)에서 사용합니다 — 설정 시 키체인 토큰보다 우선합니다.
122
+
123
+ ### 토큰 저장 방식 (keychain vs env)
124
+
125
+ `config setup`에서 토큰 저장 방식을 선택합니다.
126
+
127
+ - **keychain** (기본): `auth login`이 토큰을 OS 키체인에 저장합니다. 본인 터미널에서 쓰는 일반적인 방식.
128
+ - **env**: 토큰을 키체인에 저장하지 않습니다. `auth login`이 토큰과 `export KIWOOM_TOKEN=...` 명령을 출력하면 셸에서 실행하세요. 키체인 접근이 불가능한 환경(샌드박스, CI, 컨테이너)이 주 작업 환경일 때 적합합니다.
129
+
130
+ 이후 전환은 `kiwoom config set token_storage keychain|env`.
119
131
 
120
132
  ### 멀티 프로필
121
133
 
@@ -160,6 +172,26 @@ kiwoom order buy 005930 10 --type market --confirm
160
172
  - 페이지네이션(연속조회)은 자동 처리 — 에이전트가 커서를 관리할 필요가 없습니다.
161
173
  - 자세한 패턴은 [Wiki: AI 에이전트 가이드](https://github.com/gejyn14/kiwoom-cli/wiki/AI-Agents) 참고.
162
174
 
175
+ ### 샌드박스 환경 (키체인 접근 불가)
176
+
177
+ 샌드박스 셸, CI, 컨테이너에서는 OS 키체인을 읽을 수 없습니다. 이때는 본인 터미널에서 토큰을 발급받아 `KIWOOM_TOKEN`으로 전달하세요 — appkey/secretkey는 키체인 밖으로 나가지 않고, 토큰은 만료·폐기 가능합니다.
178
+
179
+ ```bash
180
+ # 본인 터미널에서 (하루 1회 정도) — env 모드라면 login이 export 명령을 그대로 출력
181
+ kiwoom config set token_storage env
182
+ kiwoom auth login
183
+ export KIWOOM_TOKEN='...' # login 출력의 export 라인을 복사해 실행
184
+
185
+ # 이 셸에서 에이전트를 실행하면 환경변수가 상속되어 모든 명령이 동작
186
+ kiwoom auth status # 토큰 있음 (환경변수 KIWOOM_TOKEN)
187
+ ```
188
+
189
+ keychain 모드를 유지하면서 일회성으로 꺼내 쓰려면:
190
+
191
+ ```bash
192
+ export KIWOOM_TOKEN=$(security find-generic-password -s kiwoom-cli -a "default:token" -w) # macOS
193
+ ```
194
+
163
195
  ## 명령어 구조
164
196
 
165
197
  ```
@@ -513,11 +545,11 @@ eval (env _KIWOOM_COMPLETE=fish_source kiwoom)
513
545
 
514
546
  모든 인증정보(appkey, secretkey, 토큰)는 **OS 키체인**(macOS Keychain / Windows Credential Manager / Linux Secret Service)에 저장됩니다. 파일로 존재하지 않으며, 키체인이 디스크 저장 시 암호화를 담당합니다. `gh`, `aws`, `docker` CLI와 동일한 모델입니다.
515
547
 
516
- | 항목 | 저장 방식 | 프롬프트 |
517
- | ------------------ | ------------------ | :------: |
518
- | appkey / secretkey | OS 키체인 | X |
519
- | 토큰 | OS 키체인 | X |
520
- | config.toml | 도메인, 계좌번호만 | X |
548
+ | 항목 | 저장 방식 | 프롬프트 |
549
+ | ------------------ | ------------------------------------------ | :------: |
550
+ | appkey / secretkey | OS 키체인 | X |
551
+ | 토큰 | OS 키체인 또는 KIWOOM_TOKEN 환경변수 (선택) | X |
552
+ | config.toml | 도메인, 계좌번호, 토큰 저장 방식만 | X |
521
553
 
522
554
  - 모든 명령어는 비밀번호/생체인증 프롬프트 없이 동작 (AI 에이전트·자동화 친화적)
523
555
  - 앱 자체 암호화 계층은 의도적으로 두지 않음 — 추가 계층은 명령마다 잠금 해제 프롬프트를 요구하게 되어 CLI 사용성을 해침
@@ -530,9 +562,9 @@ eval (env _KIWOOM_COMPLETE=fish_source kiwoom)
530
562
 
531
563
  | 항목 | 값 |
532
564
  | ---------------- | ---------------------------------------- |
533
- | 설정 파일 | `~/.kiwoom/config.toml` (도메인, 계좌만) |
565
+ | 설정 파일 | `~/.kiwoom/config.toml` (도메인, 계좌, 토큰 저장 방식만) |
534
566
  | appkey/secretkey | OS 키체인 |
535
- | 토큰 | OS 키체인 |
567
+ | 토큰 | OS 키체인 또는 KIWOOM_TOKEN 환경변수 (setup에서 선택) |
536
568
  | 캐시 디렉터리 | `~/.kiwoom/cache/` |
537
569
  | 운영 도메인 | `https://api.kiwoom.com` |
538
570
  | 모의투자 도메인 | `https://mockapi.kiwoom.com` |
@@ -47,6 +47,7 @@ $ kiwoom -f json account balance | jq # AI 에이전트·스크립트 친
47
47
 
48
48
  ```bash
49
49
  # 1. 초기 설정 (appkey, secretkey → OS 키체인)
50
+ # 토큰 저장 방식도 여기서 선택: keychain (기본) 또는 env (KIWOOM_TOKEN 직접 관리)
50
51
  kiwoom config setup
51
52
 
52
53
  # 2. 토큰 발급
@@ -71,14 +72,25 @@ kiwoom config show # 현재 설정 확인
71
72
 
72
73
  ### 환경변수 설정
73
74
 
74
- 도메인과 계좌번호는 환경변수로도 설정 가능합니다.
75
+ 도메인, 계좌번호, 프로필, 토큰은 환경변수로도 설정 가능합니다.
75
76
 
76
77
  ```bash
77
78
  export KIWOOM_DOMAIN="prod" # prod 또는 mock
78
79
  export KIWOOM_ACCOUNT="1234567" # 선택
80
+ export KIWOOM_PROFILE="isa" # 선택
81
+ export KIWOOM_TOKEN="..." # 선택: 키체인 대신 사용할 접근토큰 (샌드박스/CI용)
79
82
  ```
80
83
 
81
- appkey/secretkey는 보안을 위해 환경변수를 지원하지 않습니다. 반드시 `kiwoom config setup`으로 OS 키체인에 저장하세요.
84
+ appkey/secretkey는 보안을 위해 환경변수를 지원하지 않습니다. 반드시 `kiwoom config setup`으로 OS 키체인에 저장하세요. `KIWOOM_TOKEN`은 만료·폐기 가능한 접근토큰만 담는 통로로, 키체인에 접근할 수 없는 환경(샌드박스, CI, AI 에이전트)에서 사용합니다 — 설정 시 키체인 토큰보다 우선합니다.
85
+
86
+ ### 토큰 저장 방식 (keychain vs env)
87
+
88
+ `config setup`에서 토큰 저장 방식을 선택합니다.
89
+
90
+ - **keychain** (기본): `auth login`이 토큰을 OS 키체인에 저장합니다. 본인 터미널에서 쓰는 일반적인 방식.
91
+ - **env**: 토큰을 키체인에 저장하지 않습니다. `auth login`이 토큰과 `export KIWOOM_TOKEN=...` 명령을 출력하면 셸에서 실행하세요. 키체인 접근이 불가능한 환경(샌드박스, CI, 컨테이너)이 주 작업 환경일 때 적합합니다.
92
+
93
+ 이후 전환은 `kiwoom config set token_storage keychain|env`.
82
94
 
83
95
  ### 멀티 프로필
84
96
 
@@ -123,6 +135,26 @@ kiwoom order buy 005930 10 --type market --confirm
123
135
  - 페이지네이션(연속조회)은 자동 처리 — 에이전트가 커서를 관리할 필요가 없습니다.
124
136
  - 자세한 패턴은 [Wiki: AI 에이전트 가이드](https://github.com/gejyn14/kiwoom-cli/wiki/AI-Agents) 참고.
125
137
 
138
+ ### 샌드박스 환경 (키체인 접근 불가)
139
+
140
+ 샌드박스 셸, CI, 컨테이너에서는 OS 키체인을 읽을 수 없습니다. 이때는 본인 터미널에서 토큰을 발급받아 `KIWOOM_TOKEN`으로 전달하세요 — appkey/secretkey는 키체인 밖으로 나가지 않고, 토큰은 만료·폐기 가능합니다.
141
+
142
+ ```bash
143
+ # 본인 터미널에서 (하루 1회 정도) — env 모드라면 login이 export 명령을 그대로 출력
144
+ kiwoom config set token_storage env
145
+ kiwoom auth login
146
+ export KIWOOM_TOKEN='...' # login 출력의 export 라인을 복사해 실행
147
+
148
+ # 이 셸에서 에이전트를 실행하면 환경변수가 상속되어 모든 명령이 동작
149
+ kiwoom auth status # 토큰 있음 (환경변수 KIWOOM_TOKEN)
150
+ ```
151
+
152
+ keychain 모드를 유지하면서 일회성으로 꺼내 쓰려면:
153
+
154
+ ```bash
155
+ export KIWOOM_TOKEN=$(security find-generic-password -s kiwoom-cli -a "default:token" -w) # macOS
156
+ ```
157
+
126
158
  ## 명령어 구조
127
159
 
128
160
  ```
@@ -476,11 +508,11 @@ eval (env _KIWOOM_COMPLETE=fish_source kiwoom)
476
508
 
477
509
  모든 인증정보(appkey, secretkey, 토큰)는 **OS 키체인**(macOS Keychain / Windows Credential Manager / Linux Secret Service)에 저장됩니다. 파일로 존재하지 않으며, 키체인이 디스크 저장 시 암호화를 담당합니다. `gh`, `aws`, `docker` CLI와 동일한 모델입니다.
478
510
 
479
- | 항목 | 저장 방식 | 프롬프트 |
480
- | ------------------ | ------------------ | :------: |
481
- | appkey / secretkey | OS 키체인 | X |
482
- | 토큰 | OS 키체인 | X |
483
- | config.toml | 도메인, 계좌번호만 | X |
511
+ | 항목 | 저장 방식 | 프롬프트 |
512
+ | ------------------ | ------------------------------------------ | :------: |
513
+ | appkey / secretkey | OS 키체인 | X |
514
+ | 토큰 | OS 키체인 또는 KIWOOM_TOKEN 환경변수 (선택) | X |
515
+ | config.toml | 도메인, 계좌번호, 토큰 저장 방식만 | X |
484
516
 
485
517
  - 모든 명령어는 비밀번호/생체인증 프롬프트 없이 동작 (AI 에이전트·자동화 친화적)
486
518
  - 앱 자체 암호화 계층은 의도적으로 두지 않음 — 추가 계층은 명령마다 잠금 해제 프롬프트를 요구하게 되어 CLI 사용성을 해침
@@ -493,9 +525,9 @@ eval (env _KIWOOM_COMPLETE=fish_source kiwoom)
493
525
 
494
526
  | 항목 | 값 |
495
527
  | ---------------- | ---------------------------------------- |
496
- | 설정 파일 | `~/.kiwoom/config.toml` (도메인, 계좌만) |
528
+ | 설정 파일 | `~/.kiwoom/config.toml` (도메인, 계좌, 토큰 저장 방식만) |
497
529
  | appkey/secretkey | OS 키체인 |
498
- | 토큰 | OS 키체인 |
530
+ | 토큰 | OS 키체인 또는 KIWOOM_TOKEN 환경변수 (setup에서 선택) |
499
531
  | 캐시 디렉터리 | `~/.kiwoom/cache/` |
500
532
  | 운영 도메인 | `https://api.kiwoom.com` |
501
533
  | 모의투자 도메인 | `https://mockapi.kiwoom.com` |
@@ -1,3 +1,3 @@
1
1
  """Kiwoom Securities REST API CLI."""
2
2
 
3
- __version__ = "2.1.1"
3
+ __version__ = "2.2.1"
@@ -3,10 +3,19 @@
3
3
  Tokens are stored in the OS keychain (plain, not encrypted).
4
4
  Tokens are short-lived and can be re-issued, so the security risk
5
5
  of plain keychain storage is low compared to appkey/secretkey.
6
+
7
+ KIWOOM_TOKEN 환경변수가 설정되어 있으면 키체인보다 우선합니다. 키체인에
8
+ 접근할 수 없는 샌드박스/CI/AI 에이전트 환경을 위한 통로로, 만료되는
9
+ 토큰만 허용됩니다 — appkey/secretkey는 여전히 환경변수를 지원하지 않습니다.
10
+
11
+ 프로필의 token_storage가 "env"이면 save_token은 키체인에 쓰지 않습니다.
12
+ auth login이 토큰을 출력하고, 사용자가 KIWOOM_TOKEN으로 직접 관리합니다.
6
13
  """
7
14
 
8
15
  from __future__ import annotations
9
16
 
17
+ import os
18
+
10
19
  import keyring
11
20
 
12
21
  from . import config
@@ -16,10 +25,15 @@ KEYRING_SERVICE = config.KEYRING_SERVICE
16
25
 
17
26
  def save_token(token: str, profile: str | None = None) -> None:
18
27
  p = config.resolve_profile(profile)
28
+ if config.get_token_storage(p) == "env":
29
+ return
19
30
  keyring.set_password(KEYRING_SERVICE, f"{p}:token", token)
20
31
 
21
32
 
22
33
  def load_token(profile: str | None = None) -> str | None:
34
+ env = os.environ.get("KIWOOM_TOKEN")
35
+ if env:
36
+ return env
23
37
  p = config.resolve_profile(profile)
24
38
  return config._keyring_get(f"{p}:token")
25
39
 
@@ -24,6 +24,10 @@ class KiwoomAPIError(Exception):
24
24
  super().__init__(f"[{code}] {msg}")
25
25
 
26
26
 
27
+ class KiwoomAuthError(Exception):
28
+ """No access token available — the caller must authenticate first."""
29
+
30
+
27
31
  class KiwoomClient:
28
32
  """Synchronous client for the Kiwoom REST API."""
29
33
 
@@ -80,6 +84,8 @@ class KiwoomClient:
80
84
  next_key: str = "",
81
85
  ) -> tuple[dict[str, Any], dict[str, str]]:
82
86
  """Make a single API request. Returns (body_json, response_headers)."""
87
+ if not self.token:
88
+ raise KiwoomAuthError()
83
89
  url_path = get_url(api_id)
84
90
  headers = self._headers(api_id, cont_yn, next_key)
85
91
  if self._should_spin():
@@ -7,7 +7,9 @@ keychain via keyring (macOS Keychain / Windows Credential Manager /
7
7
  Linux Secret Service). The keychain encrypts secrets at rest; no
8
8
  app-level password is required — commands never prompt.
9
9
 
10
- Non-sensitive settings (domain, account) remain in config.toml.
10
+ Non-sensitive settings (domain, account, token_storage) remain in config.toml.
11
+ token_storage: "keychain" (기본, auth login이 토큰을 키체인에 저장) 또는
12
+ "env" (키체인에 저장하지 않음 — 사용자가 KIWOOM_TOKEN 환경변수로 직접 관리).
11
13
 
12
14
  Environment variables (non-sensitive only):
13
15
  KIWOOM_DOMAIN 도메인 (prod / mock)
@@ -19,6 +21,7 @@ Config file: ~/.kiwoom/config.toml
19
21
 
20
22
  from __future__ import annotations
21
23
 
24
+ import copy
22
25
  import os
23
26
  import sys
24
27
  from pathlib import Path
@@ -42,6 +45,8 @@ DOMAINS = {
42
45
  "mock": "https://mockapi.kiwoom.com",
43
46
  }
44
47
 
48
+ TOKEN_STORAGES = ("keychain", "env")
49
+
45
50
  DEFAULT_CONFIG = {
46
51
  "general": {"default_profile": "default"},
47
52
  "profiles": {"default": {"domain": "mock", "account": ""}},
@@ -57,7 +62,8 @@ def ensure_cache_dir() -> None:
57
62
 
58
63
  def load_config() -> dict:
59
64
  if not CONFIG_FILE.exists():
60
- return dict(DEFAULT_CONFIG)
65
+ # deepcopy: 호출자가 중첩 dict를 수정해도 DEFAULT_CONFIG가 오염되지 않도록
66
+ return copy.deepcopy(DEFAULT_CONFIG)
61
67
  with open(CONFIG_FILE, "rb") as f:
62
68
  return tomllib.load(f)
63
69
 
@@ -167,6 +173,14 @@ def set_secretkey(value: str, profile: str | None = None) -> None:
167
173
  keyring.set_password(KEYRING_SERVICE, f"{p}:secretkey", value)
168
174
 
169
175
 
176
+ def get_token_storage(profile: str | None = None) -> str:
177
+ """토큰 저장 방식: "keychain" (OS 키체인) 또는 "env" (KIWOOM_TOKEN 직접 관리)."""
178
+ p = resolve_profile(profile)
179
+ cfg = load_config()
180
+ value = cfg.get("profiles", {}).get(p, {}).get("token_storage", "keychain")
181
+ return value if value in TOKEN_STORAGES else "keychain"
182
+
183
+
170
184
  def get_account(profile: str | None = None) -> str:
171
185
  env = os.environ.get("KIWOOM_ACCOUNT")
172
186
  if env:
@@ -3,12 +3,14 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import os
6
7
 
7
8
  import click
8
9
  import httpx
10
+ import keyring
9
11
 
10
12
  from . import __version__, auth, config
11
- from .client import KiwoomClient, KiwoomAPIError
13
+ from .client import KiwoomClient, KiwoomAPIError, KiwoomAuthError
12
14
  from .commands.account import account
13
15
  from .commands.dashboard import dashboard
14
16
  from .commands.market import market
@@ -44,6 +46,21 @@ class KiwoomGroup(click.Group):
44
46
  else:
45
47
  console.print(f"[red]API 오류:[/] {e}")
46
48
  ctx.exit(EXIT_API)
49
+ except KiwoomAuthError:
50
+ fmt = ctx.obj.get("format", "table") if ctx.obj else "table"
51
+ msg = "토큰이 없습니다. 'kiwoom auth login'으로 발급하세요."
52
+ if fmt == "json":
53
+ click.echo(json.dumps({"error": msg, "code": "auth_required"}, ensure_ascii=False))
54
+ else:
55
+ console.print("[red]인증 필요:[/] 토큰이 없습니다. [dim]kiwoom auth login[/]")
56
+ ctx.exit(EXIT_AUTH)
57
+ except keyring.errors.KeyringError:
58
+ console.print("[red]키체인 오류:[/] OS 키체인에 접근할 수 없습니다 (잠김 또는 비대화형 세션).")
59
+ console.print(
60
+ "[dim]키체인 없는 환경에서는 본인 터미널에서 토큰을 발급한 뒤 "
61
+ "KIWOOM_TOKEN 환경변수로 전달하세요. (README '샌드박스 환경' 참고)[/]"
62
+ )
63
+ ctx.exit(EXIT_INPUT)
47
64
  except httpx.HTTPStatusError as e:
48
65
  if e.response.status_code == 401:
49
66
  console.print("[red]인증 오류:[/] 토큰이 만료되었습니다. [dim]kiwoom auth login[/]")
@@ -122,7 +139,11 @@ def config_cmd():
122
139
  @click.option("--secretkey", prompt="Secret Key", hide_input=True, help="키움 API Secret Key")
123
140
  @click.option("--domain", prompt="도메인 (prod=실거래, mock=모의투자)", type=click.Choice(["prod", "mock"]), default="mock", help="도메인")
124
141
  @click.option("--account", prompt="계좌번호 (없으면 Enter)", default="", help="계좌번호")
125
- def config_setup(profile: str, appkey: str, secretkey: str, domain: str, account: str):
142
+ @click.option("--token-storage", "token_storage",
143
+ prompt="토큰 저장 방식 (keychain=OS 키체인, env=KIWOOM_TOKEN 직접 관리)",
144
+ type=click.Choice(list(config.TOKEN_STORAGES)), default="keychain",
145
+ help="접근토큰 저장 방식")
146
+ def config_setup(profile: str, appkey: str, secretkey: str, domain: str, account: str, token_storage: str):
126
147
  """초기 설정 (App Key, Secret Key, 도메인)."""
127
148
  if config.is_legacy_encrypted():
128
149
  # Old password-encrypted entries are unusable — purge before writing new keys
@@ -134,6 +155,7 @@ def config_setup(profile: str, appkey: str, secretkey: str, domain: str, account
134
155
  cfg.setdefault("profiles", {}).setdefault(profile, {})["domain"] = domain
135
156
  if account:
136
157
  cfg["profiles"][profile]["account"] = account
158
+ cfg["profiles"][profile]["token_storage"] = token_storage
137
159
  if "default_profile" not in cfg.get("general", {}):
138
160
  cfg.setdefault("general", {})["default_profile"] = profile
139
161
  cfg.pop("auth", None)
@@ -141,6 +163,10 @@ def config_setup(profile: str, appkey: str, secretkey: str, domain: str, account
141
163
  console.print(f"[green]설정 완료![/] (프로필: {profile})")
142
164
  console.print(" App Key/Secret Key: [bold]OS 키체인에 저장됨[/]")
143
165
  console.print(f" 도메인: [bold]{config.DOMAINS[domain]}[/]")
166
+ if token_storage == "env":
167
+ console.print(" 토큰 저장: [bold]환경변수 (KIWOOM_TOKEN)[/] — auth login 후 안내되는 export를 실행하세요")
168
+ else:
169
+ console.print(" 토큰 저장: [bold]OS 키체인[/]")
144
170
 
145
171
 
146
172
  @config_cmd.command("show")
@@ -151,6 +177,7 @@ def config_show(ctx):
151
177
  cfg = config.load_config()
152
178
  configured = config.is_configured(profile)
153
179
  profile_cfg = cfg.get("profiles", {}).get(profile, {})
180
+ token_storage = config.get_token_storage(profile)
154
181
  if _get_format() == "json":
155
182
  _output_json({
156
183
  "profile": profile,
@@ -158,6 +185,7 @@ def config_show(ctx):
158
185
  "domain": profile_cfg.get("domain", "mock"),
159
186
  "configured": configured,
160
187
  "account": profile_cfg.get("account", ""),
188
+ "token_storage": token_storage,
161
189
  })
162
190
  return
163
191
  human(f" 프로필: [bold]{profile}[/]")
@@ -165,11 +193,12 @@ def config_show(ctx):
165
193
  human(f" 도메인: {profile_cfg.get('domain', 'mock')}")
166
194
  human(f" App Key: {'[dim]설정됨 (키체인)[/]' if configured else '(미설정)'}")
167
195
  human(f" 계좌번호: {profile_cfg.get('account', '(미설정)')}")
196
+ human(f" 토큰 저장: {'환경변수 (KIWOOM_TOKEN)' if token_storage == 'env' else 'OS 키체인'}")
168
197
  human(f" 보안: [bold]{'OS 키체인 저장' if configured else '미설정'}[/]")
169
198
 
170
199
 
171
200
  @config_cmd.command("set")
172
- @click.argument("key", type=click.Choice(["domain", "account"]))
201
+ @click.argument("key", type=click.Choice(["domain", "account", "token_storage"]))
173
202
  @click.argument("value")
174
203
  @click.pass_context
175
204
  def config_set(ctx, key: str, value: str):
@@ -178,6 +207,9 @@ def config_set(ctx, key: str, value: str):
178
207
  if key == "domain" and value not in ("prod", "mock"):
179
208
  console.print("[red]domain은 prod 또는 mock만 가능합니다.[/]")
180
209
  raise SystemExit(1)
210
+ if key == "token_storage" and value not in config.TOKEN_STORAGES:
211
+ console.print("[red]token_storage는 keychain 또는 env만 가능합니다.[/]")
212
+ raise SystemExit(1)
181
213
  cfg = config.load_config()
182
214
  cfg.setdefault("profiles", {}).setdefault(profile, {})[key] = value
183
215
  config.save_config(cfg)
@@ -243,10 +275,14 @@ def auth_login(ctx):
243
275
  with KiwoomClient() as c:
244
276
  try:
245
277
  token = c.issue_token()
246
- masked = token[:10] + "..." + token[-4:] if len(token) > 14 else token
247
278
  human("[green]토큰 발급 완료![/]")
248
- human(f" 토큰: {masked}")
249
- human(" 저장 위치: [bold]키체인[/]")
279
+ if config.get_token_storage(profile) == "env":
280
+ human(" 저장 위치: [bold]없음 (env 모드)[/] — 아래를 셸에서 실행하세요:")
281
+ human(f" export KIWOOM_TOKEN='{token}'")
282
+ else:
283
+ masked = token[:10] + "..." + token[-4:] if len(token) > 14 else token
284
+ human(f" 토큰: {masked}")
285
+ human(" 저장 위치: [bold]키체인[/]")
250
286
  except KiwoomAPIError as e:
251
287
  human(f"[red]토큰 발급 실패:[/] {e}")
252
288
 
@@ -265,6 +301,8 @@ def auth_logout(ctx):
265
301
  human("[green]토큰 폐기 완료.[/]")
266
302
  except KiwoomAPIError as e:
267
303
  human(f"[red]토큰 폐기 실패:[/] {e}")
304
+ if os.environ.get("KIWOOM_TOKEN"):
305
+ human("[yellow]KIWOOM_TOKEN 환경변수가 설정되어 있어 이 셸에서는 해당 토큰이 계속 사용됩니다. unset KIWOOM_TOKEN 으로 제거하세요.[/]")
268
306
 
269
307
 
270
308
  @auth_cmd.command("status")
@@ -273,22 +311,31 @@ def auth_status(ctx):
273
311
  """토큰 상태 확인."""
274
312
  profile = config.resolve_profile(ctx.obj.get("profile") if ctx.obj else None)
275
313
  configured = config.is_configured(profile)
276
- has_token = configured and auth.load_token(profile=profile) is not None
314
+ token_storage = config.get_token_storage(profile)
315
+ token = auth.load_token(profile=profile)
316
+ token_source = None
317
+ if token is not None:
318
+ token_source = "env" if os.environ.get("KIWOOM_TOKEN") else "keyring"
277
319
  if _get_format() == "json":
278
320
  cfg = config.load_config()
279
321
  _output_json({
280
322
  "profile": profile,
281
323
  "domain": cfg.get("profiles", {}).get(profile, {}).get("domain", "mock"),
282
324
  "configured": configured,
283
- "has_token": has_token,
325
+ "has_token": token is not None,
326
+ "token_source": token_source,
327
+ "token_storage": token_storage,
284
328
  })
285
329
  return
286
- if not configured:
330
+ if not configured and token is None:
287
331
  human("[yellow]설정 필요.[/] 'kiwoom config setup' 으로 설정하세요.")
288
332
  return
289
333
  human(f" 프로필: [bold]{profile}[/]")
290
- if has_token:
291
- human("[green]토큰 있음[/] [dim](키체인 저장됨)[/]")
334
+ if token is not None:
335
+ source_label = "환경변수 KIWOOM_TOKEN" if token_source == "env" else "키체인 저장됨"
336
+ human(f"[green]토큰 있음[/] [dim]({source_label})[/]")
337
+ elif token_storage == "env":
338
+ human("[yellow]토큰 없음.[/] 'kiwoom auth login' 으로 발급 후 안내되는 export KIWOOM_TOKEN을 실행하세요.")
292
339
  else:
293
340
  human("[yellow]토큰 없음.[/] 'kiwoom auth login' 으로 발급하세요.")
294
341
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwoom-cli
3
- Version: 2.1.1
3
+ Version: 2.2.1
4
4
  Summary: User-friendly CLI for the Kiwoom Securities REST API (키움증권)
5
5
  License: LicenseRef-kiwoom-cli-Source-Available-1.0
6
6
  Project-URL: Homepage, https://github.com/gejyn14/kiwoom-cli
@@ -84,6 +84,7 @@ $ kiwoom -f json account balance | jq # AI 에이전트·스크립트 친
84
84
 
85
85
  ```bash
86
86
  # 1. 초기 설정 (appkey, secretkey → OS 키체인)
87
+ # 토큰 저장 방식도 여기서 선택: keychain (기본) 또는 env (KIWOOM_TOKEN 직접 관리)
87
88
  kiwoom config setup
88
89
 
89
90
  # 2. 토큰 발급
@@ -108,14 +109,25 @@ kiwoom config show # 현재 설정 확인
108
109
 
109
110
  ### 환경변수 설정
110
111
 
111
- 도메인과 계좌번호는 환경변수로도 설정 가능합니다.
112
+ 도메인, 계좌번호, 프로필, 토큰은 환경변수로도 설정 가능합니다.
112
113
 
113
114
  ```bash
114
115
  export KIWOOM_DOMAIN="prod" # prod 또는 mock
115
116
  export KIWOOM_ACCOUNT="1234567" # 선택
117
+ export KIWOOM_PROFILE="isa" # 선택
118
+ export KIWOOM_TOKEN="..." # 선택: 키체인 대신 사용할 접근토큰 (샌드박스/CI용)
116
119
  ```
117
120
 
118
- appkey/secretkey는 보안을 위해 환경변수를 지원하지 않습니다. 반드시 `kiwoom config setup`으로 OS 키체인에 저장하세요.
121
+ appkey/secretkey는 보안을 위해 환경변수를 지원하지 않습니다. 반드시 `kiwoom config setup`으로 OS 키체인에 저장하세요. `KIWOOM_TOKEN`은 만료·폐기 가능한 접근토큰만 담는 통로로, 키체인에 접근할 수 없는 환경(샌드박스, CI, AI 에이전트)에서 사용합니다 — 설정 시 키체인 토큰보다 우선합니다.
122
+
123
+ ### 토큰 저장 방식 (keychain vs env)
124
+
125
+ `config setup`에서 토큰 저장 방식을 선택합니다.
126
+
127
+ - **keychain** (기본): `auth login`이 토큰을 OS 키체인에 저장합니다. 본인 터미널에서 쓰는 일반적인 방식.
128
+ - **env**: 토큰을 키체인에 저장하지 않습니다. `auth login`이 토큰과 `export KIWOOM_TOKEN=...` 명령을 출력하면 셸에서 실행하세요. 키체인 접근이 불가능한 환경(샌드박스, CI, 컨테이너)이 주 작업 환경일 때 적합합니다.
129
+
130
+ 이후 전환은 `kiwoom config set token_storage keychain|env`.
119
131
 
120
132
  ### 멀티 프로필
121
133
 
@@ -160,6 +172,26 @@ kiwoom order buy 005930 10 --type market --confirm
160
172
  - 페이지네이션(연속조회)은 자동 처리 — 에이전트가 커서를 관리할 필요가 없습니다.
161
173
  - 자세한 패턴은 [Wiki: AI 에이전트 가이드](https://github.com/gejyn14/kiwoom-cli/wiki/AI-Agents) 참고.
162
174
 
175
+ ### 샌드박스 환경 (키체인 접근 불가)
176
+
177
+ 샌드박스 셸, CI, 컨테이너에서는 OS 키체인을 읽을 수 없습니다. 이때는 본인 터미널에서 토큰을 발급받아 `KIWOOM_TOKEN`으로 전달하세요 — appkey/secretkey는 키체인 밖으로 나가지 않고, 토큰은 만료·폐기 가능합니다.
178
+
179
+ ```bash
180
+ # 본인 터미널에서 (하루 1회 정도) — env 모드라면 login이 export 명령을 그대로 출력
181
+ kiwoom config set token_storage env
182
+ kiwoom auth login
183
+ export KIWOOM_TOKEN='...' # login 출력의 export 라인을 복사해 실행
184
+
185
+ # 이 셸에서 에이전트를 실행하면 환경변수가 상속되어 모든 명령이 동작
186
+ kiwoom auth status # 토큰 있음 (환경변수 KIWOOM_TOKEN)
187
+ ```
188
+
189
+ keychain 모드를 유지하면서 일회성으로 꺼내 쓰려면:
190
+
191
+ ```bash
192
+ export KIWOOM_TOKEN=$(security find-generic-password -s kiwoom-cli -a "default:token" -w) # macOS
193
+ ```
194
+
163
195
  ## 명령어 구조
164
196
 
165
197
  ```
@@ -513,11 +545,11 @@ eval (env _KIWOOM_COMPLETE=fish_source kiwoom)
513
545
 
514
546
  모든 인증정보(appkey, secretkey, 토큰)는 **OS 키체인**(macOS Keychain / Windows Credential Manager / Linux Secret Service)에 저장됩니다. 파일로 존재하지 않으며, 키체인이 디스크 저장 시 암호화를 담당합니다. `gh`, `aws`, `docker` CLI와 동일한 모델입니다.
515
547
 
516
- | 항목 | 저장 방식 | 프롬프트 |
517
- | ------------------ | ------------------ | :------: |
518
- | appkey / secretkey | OS 키체인 | X |
519
- | 토큰 | OS 키체인 | X |
520
- | config.toml | 도메인, 계좌번호만 | X |
548
+ | 항목 | 저장 방식 | 프롬프트 |
549
+ | ------------------ | ------------------------------------------ | :------: |
550
+ | appkey / secretkey | OS 키체인 | X |
551
+ | 토큰 | OS 키체인 또는 KIWOOM_TOKEN 환경변수 (선택) | X |
552
+ | config.toml | 도메인, 계좌번호, 토큰 저장 방식만 | X |
521
553
 
522
554
  - 모든 명령어는 비밀번호/생체인증 프롬프트 없이 동작 (AI 에이전트·자동화 친화적)
523
555
  - 앱 자체 암호화 계층은 의도적으로 두지 않음 — 추가 계층은 명령마다 잠금 해제 프롬프트를 요구하게 되어 CLI 사용성을 해침
@@ -530,9 +562,9 @@ eval (env _KIWOOM_COMPLETE=fish_source kiwoom)
530
562
 
531
563
  | 항목 | 값 |
532
564
  | ---------------- | ---------------------------------------- |
533
- | 설정 파일 | `~/.kiwoom/config.toml` (도메인, 계좌만) |
565
+ | 설정 파일 | `~/.kiwoom/config.toml` (도메인, 계좌, 토큰 저장 방식만) |
534
566
  | appkey/secretkey | OS 키체인 |
535
- | 토큰 | OS 키체인 |
567
+ | 토큰 | OS 키체인 또는 KIWOOM_TOKEN 환경변수 (setup에서 선택) |
536
568
  | 캐시 디렉터리 | `~/.kiwoom/cache/` |
537
569
  | 운영 도메인 | `https://api.kiwoom.com` |
538
570
  | 모의투자 도메인 | `https://mockapi.kiwoom.com` |
@@ -0,0 +1,64 @@
1
+ """Tests for kiwoom_cli.auth — keyring-backed token storage."""
2
+
3
+ import keyring
4
+
5
+ from kiwoom_cli import auth
6
+
7
+
8
+ def test_save_token_stores_in_keyring():
9
+ auth.save_token("abc123", profile="test-profile")
10
+ assert keyring.get_password(auth.KEYRING_SERVICE, "test-profile:token") == "abc123"
11
+
12
+
13
+ def test_load_token_returns_stored_value():
14
+ auth.save_token("xyz789", profile="test-profile")
15
+ assert auth.load_token(profile="test-profile") == "xyz789"
16
+
17
+
18
+ def test_load_token_returns_none_when_missing():
19
+ assert auth.load_token(profile="nonexistent") is None
20
+
21
+
22
+ def test_delete_token_removes_stored_value():
23
+ auth.save_token("to-delete", profile="test-profile")
24
+ assert auth.load_token(profile="test-profile") == "to-delete"
25
+ auth.delete_token(profile="test-profile")
26
+ assert auth.load_token(profile="test-profile") is None
27
+
28
+
29
+ def test_delete_token_missing_key_is_noop():
30
+ auth.delete_token(profile="never-existed") # must not raise
31
+
32
+
33
+ # ── KIWOOM_TOKEN 환경변수 ────────────────────────────
34
+ # 키체인에 접근할 수 없는 샌드박스/CI/AI 에이전트 환경용 통로.
35
+ # appkey/secretkey는 여전히 환경변수 미지원.
36
+
37
+
38
+ def test_load_token_env_var_takes_precedence(monkeypatch):
39
+ auth.save_token("keyring-token", profile="test-profile")
40
+ monkeypatch.setenv("KIWOOM_TOKEN", "env-token")
41
+ assert auth.load_token(profile="test-profile") == "env-token"
42
+
43
+
44
+ def test_load_token_env_var_works_without_keyring(monkeypatch):
45
+ """잠긴 키체인에서도 KIWOOM_TOKEN이면 토큰 확보."""
46
+
47
+ def _raise(svc, key):
48
+ raise keyring.errors.KeyringError("errSecInteractionNotAllowed (-25308)")
49
+
50
+ monkeypatch.setattr(keyring, "get_password", _raise)
51
+ monkeypatch.setenv("KIWOOM_TOKEN", "env-token")
52
+ assert auth.load_token(profile="default") == "env-token"
53
+
54
+
55
+ def test_load_token_falls_back_to_keyring_when_env_unset(monkeypatch):
56
+ monkeypatch.delenv("KIWOOM_TOKEN", raising=False)
57
+ auth.save_token("keyring-token", profile="test-profile")
58
+ assert auth.load_token(profile="test-profile") == "keyring-token"
59
+
60
+
61
+ def test_load_token_empty_env_var_is_ignored(monkeypatch):
62
+ monkeypatch.setenv("KIWOOM_TOKEN", "")
63
+ auth.save_token("keyring-token", profile="test-profile")
64
+ assert auth.load_token(profile="test-profile") == "keyring-token"
@@ -0,0 +1,292 @@
1
+ """CLI-level tests: no password prompts anywhere after keychain-only change."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import keyring
6
+ import pytest
7
+ from click.testing import CliRunner
8
+
9
+ from kiwoom_cli import config
10
+ from kiwoom_cli.main import cli
11
+
12
+
13
+ @pytest.fixture(autouse=True)
14
+ def mem_keyring(monkeypatch):
15
+ data: dict[str, str] = {}
16
+ monkeypatch.setattr(keyring, "get_password", lambda svc, key: data.get(f"{svc}:{key}"))
17
+ monkeypatch.setattr(keyring, "set_password", lambda svc, key, val: data.__setitem__(f"{svc}:{key}", val))
18
+
19
+ def _delete(svc, key):
20
+ if f"{svc}:{key}" not in data:
21
+ raise keyring.errors.PasswordDeleteError(key)
22
+ del data[f"{svc}:{key}"]
23
+
24
+ monkeypatch.setattr(keyring, "delete_password", _delete)
25
+ return data
26
+
27
+
28
+ @pytest.fixture(autouse=True)
29
+ def isolated_config(tmp_path, monkeypatch):
30
+ monkeypatch.setattr(config, "CONFIG_DIR", tmp_path)
31
+ monkeypatch.setattr(config, "CONFIG_FILE", tmp_path / "config.toml")
32
+ return tmp_path
33
+
34
+
35
+ def test_config_setup_no_password_prompt(mem_keyring):
36
+ """setup succeeds with only appkey/secretkey/domain/account inputs."""
37
+ runner = CliRunner()
38
+ result = runner.invoke(
39
+ cli,
40
+ ["config", "setup"],
41
+ input="my-appkey\nmy-secretkey\nmock\n\n\n",
42
+ )
43
+ assert result.exit_code == 0, result.output
44
+ assert "비밀번호" not in result.output
45
+ assert mem_keyring[f"{config.KEYRING_SERVICE}:default:appkey"] == "my-appkey"
46
+ assert mem_keyring[f"{config.KEYRING_SERVICE}:default:secretkey"] == "my-secretkey"
47
+ # Enter만 치면 기본값 keychain
48
+ assert config.get_token_storage("default") == "keychain"
49
+
50
+
51
+ def test_config_setup_purges_legacy_format(mem_keyring):
52
+ """setup clears _salt/_verify and old ciphertext entries."""
53
+ keyring.set_password(config.KEYRING_SERVICE, "_salt", "oldsalt")
54
+ keyring.set_password(config.KEYRING_SERVICE, "_verify", "oldverify")
55
+ keyring.set_password(config.KEYRING_SERVICE, "default:appkey", "gAAAA-cipher")
56
+
57
+ runner = CliRunner()
58
+ result = runner.invoke(
59
+ cli,
60
+ ["config", "setup"],
61
+ input="new-appkey\nnew-secretkey\nmock\n\n\n",
62
+ )
63
+ assert result.exit_code == 0, result.output
64
+ assert keyring.get_password(config.KEYRING_SERVICE, "_salt") is None
65
+ assert keyring.get_password(config.KEYRING_SERVICE, "_verify") is None
66
+ assert config.get_appkey(profile="default") == "new-appkey"
67
+
68
+
69
+ def test_auth_login_no_password_prompt(monkeypatch, mem_keyring):
70
+ """auth login issues a token without any interactive prompt."""
71
+ from kiwoom_cli import client as client_mod
72
+
73
+ keyring.set_password(config.KEYRING_SERVICE, "default:appkey", "ak")
74
+ keyring.set_password(config.KEYRING_SERVICE, "default:secretkey", "sk")
75
+
76
+ monkeypatch.setattr(
77
+ client_mod.KiwoomClient, "issue_token", lambda self: "issued-token-value-12345"
78
+ )
79
+
80
+ runner = CliRunner()
81
+ result = runner.invoke(cli, ["auth", "login"], input="")
82
+ assert result.exit_code == 0, result.output
83
+ assert "비밀번호" not in result.output
84
+ assert "토큰 발급 완료" in result.output
85
+
86
+
87
+ def test_auth_login_unconfigured_tells_user_to_setup(mem_keyring):
88
+ runner = CliRunner()
89
+ result = runner.invoke(cli, ["auth", "login"])
90
+ assert result.exit_code != 0
91
+ assert "config setup" in result.output
92
+
93
+
94
+ def test_legacy_format_shows_resetup_notice(mem_keyring):
95
+ """Any command under legacy format prints the re-setup notice."""
96
+ keyring.set_password(config.KEYRING_SERVICE, "_salt", "oldsalt")
97
+ runner = CliRunner()
98
+ result = runner.invoke(cli, ["auth", "status"])
99
+ assert "kiwoom config setup" in result.output or "config setup" in result.output
100
+
101
+
102
+ def test_readonly_commands_survive_broken_keyring(monkeypatch):
103
+ """Locked/absent keychain (headless, CI): config show / auth status must
104
+ exit 0 as "미설정" instead of crashing with a KeyringError traceback."""
105
+
106
+ def _raise(svc, key):
107
+ raise keyring.errors.KeyringError("errSecInteractionNotAllowed (-25308)")
108
+
109
+ monkeypatch.setattr(keyring, "get_password", _raise)
110
+ runner = CliRunner()
111
+
112
+ for argv in (["config", "show"], ["auth", "status"], ["config", "profiles"]):
113
+ result = runner.invoke(cli, argv)
114
+ assert result.exit_code == 0, (argv, result.exception)
115
+
116
+
117
+ # ── token_storage: keychain vs env (config setup에서 선택) ──────────
118
+
119
+
120
+ def test_config_setup_env_storage_choice(mem_keyring):
121
+ """setup에서 env를 고르면 token_storage=env가 프로필에 저장된다."""
122
+ runner = CliRunner()
123
+ result = runner.invoke(
124
+ cli,
125
+ ["config", "setup"],
126
+ input="my-appkey\nmy-secretkey\nmock\n\nenv\n",
127
+ )
128
+ assert result.exit_code == 0, result.output
129
+ assert config.get_token_storage("default") == "env"
130
+ assert "KIWOOM_TOKEN" in result.output
131
+
132
+
133
+ def test_auth_login_env_storage_prints_export_and_skips_keyring(monkeypatch, mem_keyring):
134
+ """env 모드: 토큰을 키체인에 저장하지 않고 export 안내를 출력한다."""
135
+ from kiwoom_cli import auth
136
+ from kiwoom_cli.client import KiwoomClient as RealClient
137
+
138
+ keyring.set_password(config.KEYRING_SERVICE, "default:appkey", "ak")
139
+ keyring.set_password(config.KEYRING_SERVICE, "default:secretkey", "sk")
140
+ cfg = config.load_config()
141
+ cfg.setdefault("profiles", {}).setdefault("default", {})["token_storage"] = "env"
142
+ config.save_config(cfg)
143
+
144
+ def _issue(self):
145
+ auth.save_token("issued-token-value-12345", profile=self.profile)
146
+ return "issued-token-value-12345"
147
+
148
+ monkeypatch.setattr(RealClient, "issue_token", _issue)
149
+
150
+ runner = CliRunner()
151
+ result = runner.invoke(cli, ["auth", "login"])
152
+ assert result.exit_code == 0, result.output
153
+ assert "export KIWOOM_TOKEN='issued-token-value-12345'" in result.output
154
+ assert f"{config.KEYRING_SERVICE}:default:token" not in mem_keyring
155
+
156
+
157
+ def test_auth_login_keychain_storage_saves_to_keyring(monkeypatch, mem_keyring):
158
+ """keychain 모드(기본): 토큰이 키체인에 저장되고 마스킹되어 출력된다."""
159
+ from kiwoom_cli import auth
160
+ from kiwoom_cli.client import KiwoomClient as RealClient
161
+
162
+ keyring.set_password(config.KEYRING_SERVICE, "default:appkey", "ak")
163
+ keyring.set_password(config.KEYRING_SERVICE, "default:secretkey", "sk")
164
+
165
+ def _issue(self):
166
+ auth.save_token("issued-token-value-12345", profile=self.profile)
167
+ return "issued-token-value-12345"
168
+
169
+ monkeypatch.setattr(RealClient, "issue_token", _issue)
170
+
171
+ runner = CliRunner()
172
+ result = runner.invoke(cli, ["auth", "login"])
173
+ assert result.exit_code == 0, result.output
174
+ assert "issued-token-value-12345" not in result.output # full token never shown
175
+ assert mem_keyring[f"{config.KEYRING_SERVICE}:default:token"] == "issued-token-value-12345"
176
+
177
+
178
+ def test_config_set_token_storage(mem_keyring):
179
+ """config set token_storage env/keychain 으로 전환 가능."""
180
+ runner = CliRunner()
181
+ result = runner.invoke(cli, ["config", "set", "token_storage", "env"])
182
+ assert result.exit_code == 0, result.output
183
+ assert config.get_token_storage("default") == "env"
184
+
185
+ result = runner.invoke(cli, ["config", "set", "token_storage", "keychain"])
186
+ assert result.exit_code == 0, result.output
187
+ assert config.get_token_storage("default") == "keychain"
188
+
189
+
190
+ def test_config_set_token_storage_rejects_bad_value(mem_keyring):
191
+ runner = CliRunner()
192
+ result = runner.invoke(cli, ["config", "set", "token_storage", "file"])
193
+ assert result.exit_code == 1
194
+
195
+
196
+ def test_auth_status_shows_env_storage_mode(mem_keyring):
197
+ """env 모드 + 토큰 없음: status가 export 안내를 보여준다."""
198
+ import json as _json
199
+
200
+ keyring.set_password(config.KEYRING_SERVICE, "default:appkey", "ak")
201
+ cfg = config.load_config()
202
+ cfg.setdefault("profiles", {}).setdefault("default", {})["token_storage"] = "env"
203
+ config.save_config(cfg)
204
+
205
+ runner = CliRunner()
206
+ result = runner.invoke(cli, ["-f", "json", "auth", "status"])
207
+ assert result.exit_code == 0
208
+ doc = _json.loads(result.stdout)
209
+ assert doc["token_storage"] == "env"
210
+ assert doc["has_token"] is False
211
+
212
+ result = runner.invoke(cli, ["auth", "status"])
213
+ assert result.exit_code == 0
214
+ assert "KIWOOM_TOKEN" in result.output
215
+
216
+
217
+ def test_auth_status_reflects_env_token_with_broken_keyring(monkeypatch):
218
+ """샌드박스(잠긴 키체인) + KIWOOM_TOKEN: auth status가 토큰을 인식해야 한다."""
219
+ import json as _json
220
+
221
+ def _raise(svc, key):
222
+ raise keyring.errors.KeyringError("errSecInteractionNotAllowed (-25308)")
223
+
224
+ monkeypatch.setattr(keyring, "get_password", _raise)
225
+ monkeypatch.setenv("KIWOOM_TOKEN", "env-token")
226
+ runner = CliRunner()
227
+
228
+ result = runner.invoke(cli, ["-f", "json", "auth", "status"])
229
+ assert result.exit_code == 0
230
+ doc = _json.loads(result.stdout)
231
+ assert doc["has_token"] is True
232
+ assert doc["token_source"] == "env"
233
+
234
+ result = runner.invoke(cli, ["auth", "status"])
235
+ assert result.exit_code == 0
236
+ assert "KIWOOM_TOKEN" in result.output
237
+
238
+
239
+ # ── 키체인 쓰기 실패 / 토큰 부재 처리 ─────────────────────
240
+
241
+
242
+ def test_config_setup_keychain_write_failure_friendly_error(monkeypatch, mem_keyring):
243
+ """키체인 쓰기 불가(-25308 등): traceback 대신 친절한 안내 + exit 1."""
244
+
245
+ def _locked(svc, key, val):
246
+ raise keyring.errors.KeyringError("errSecInteractionNotAllowed (-25308)")
247
+
248
+ monkeypatch.setattr(keyring, "set_password", _locked)
249
+ runner = CliRunner()
250
+ result = runner.invoke(
251
+ cli, ["config", "setup"], input="ak\nsk\nmock\n\n\n"
252
+ )
253
+ assert result.exit_code == 1, result.output
254
+ # 예외가 새어나오지 않고 CLI가 스스로 종료해야 한다
255
+ assert result.exception is None or isinstance(result.exception, SystemExit)
256
+ combined = result.output + result.stderr
257
+ assert "키체인" in combined
258
+ assert "KIWOOM_TOKEN" in combined
259
+
260
+
261
+ def test_missing_token_exits_3_before_any_request(monkeypatch, mem_keyring):
262
+ """토큰이 전혀 없으면 요청을 보내지 않고 exit 3 + auth login 힌트."""
263
+ import httpx
264
+
265
+ monkeypatch.delenv("KIWOOM_TOKEN", raising=False)
266
+
267
+ def _no_network(*args, **kwargs):
268
+ raise AssertionError("token 없이 HTTP 요청이 나가면 안 된다")
269
+
270
+ monkeypatch.setattr(httpx.Client, "post", _no_network)
271
+ runner = CliRunner()
272
+ result = runner.invoke(cli, ["stock", "info", "005930"])
273
+ assert result.exit_code == 3, result.output
274
+ assert "auth login" in result.output + result.stderr
275
+
276
+
277
+ def test_missing_token_json_mode_single_json_error_doc(monkeypatch, mem_keyring):
278
+ """-f json + 토큰 부재: stdout은 단일 JSON 에러 문서, exit 3."""
279
+ import json as _json
280
+
281
+ import httpx
282
+
283
+ monkeypatch.delenv("KIWOOM_TOKEN", raising=False)
284
+ monkeypatch.setattr(
285
+ httpx.Client, "post",
286
+ lambda *a, **k: (_ for _ in ()).throw(AssertionError("no request expected")),
287
+ )
288
+ runner = CliRunner()
289
+ result = runner.invoke(cli, ["-f", "json", "stock", "info", "005930"])
290
+ assert result.exit_code == 3, result.output
291
+ doc = _json.loads(result.stdout)
292
+ assert "error" in doc
@@ -1,30 +0,0 @@
1
- """Tests for kiwoom_cli.auth — keyring-backed token storage."""
2
-
3
- import keyring
4
-
5
- from kiwoom_cli import auth
6
-
7
-
8
- def test_save_token_stores_in_keyring():
9
- auth.save_token("abc123", profile="test-profile")
10
- assert keyring.get_password(auth.KEYRING_SERVICE, "test-profile:token") == "abc123"
11
-
12
-
13
- def test_load_token_returns_stored_value():
14
- auth.save_token("xyz789", profile="test-profile")
15
- assert auth.load_token(profile="test-profile") == "xyz789"
16
-
17
-
18
- def test_load_token_returns_none_when_missing():
19
- assert auth.load_token(profile="nonexistent") is None
20
-
21
-
22
- def test_delete_token_removes_stored_value():
23
- auth.save_token("to-delete", profile="test-profile")
24
- assert auth.load_token(profile="test-profile") == "to-delete"
25
- auth.delete_token(profile="test-profile")
26
- assert auth.load_token(profile="test-profile") is None
27
-
28
-
29
- def test_delete_token_missing_key_is_noop():
30
- auth.delete_token(profile="never-existed") # must not raise
@@ -1,112 +0,0 @@
1
- """CLI-level tests: no password prompts anywhere after keychain-only change."""
2
-
3
- from __future__ import annotations
4
-
5
- import keyring
6
- import pytest
7
- from click.testing import CliRunner
8
-
9
- from kiwoom_cli import config
10
- from kiwoom_cli.main import cli
11
-
12
-
13
- @pytest.fixture(autouse=True)
14
- def mem_keyring(monkeypatch):
15
- data: dict[str, str] = {}
16
- monkeypatch.setattr(keyring, "get_password", lambda svc, key: data.get(f"{svc}:{key}"))
17
- monkeypatch.setattr(keyring, "set_password", lambda svc, key, val: data.__setitem__(f"{svc}:{key}", val))
18
-
19
- def _delete(svc, key):
20
- if f"{svc}:{key}" not in data:
21
- raise keyring.errors.PasswordDeleteError(key)
22
- del data[f"{svc}:{key}"]
23
-
24
- monkeypatch.setattr(keyring, "delete_password", _delete)
25
- return data
26
-
27
-
28
- @pytest.fixture(autouse=True)
29
- def isolated_config(tmp_path, monkeypatch):
30
- monkeypatch.setattr(config, "CONFIG_DIR", tmp_path)
31
- monkeypatch.setattr(config, "CONFIG_FILE", tmp_path / "config.toml")
32
- return tmp_path
33
-
34
-
35
- def test_config_setup_no_password_prompt(mem_keyring):
36
- """setup succeeds with only appkey/secretkey/domain/account inputs."""
37
- runner = CliRunner()
38
- result = runner.invoke(
39
- cli,
40
- ["config", "setup"],
41
- input="my-appkey\nmy-secretkey\nmock\n\n",
42
- )
43
- assert result.exit_code == 0, result.output
44
- assert "비밀번호" not in result.output
45
- assert mem_keyring[f"{config.KEYRING_SERVICE}:default:appkey"] == "my-appkey"
46
- assert mem_keyring[f"{config.KEYRING_SERVICE}:default:secretkey"] == "my-secretkey"
47
-
48
-
49
- def test_config_setup_purges_legacy_format(mem_keyring):
50
- """setup clears _salt/_verify and old ciphertext entries."""
51
- keyring.set_password(config.KEYRING_SERVICE, "_salt", "oldsalt")
52
- keyring.set_password(config.KEYRING_SERVICE, "_verify", "oldverify")
53
- keyring.set_password(config.KEYRING_SERVICE, "default:appkey", "gAAAA-cipher")
54
-
55
- runner = CliRunner()
56
- result = runner.invoke(
57
- cli,
58
- ["config", "setup"],
59
- input="new-appkey\nnew-secretkey\nmock\n\n",
60
- )
61
- assert result.exit_code == 0, result.output
62
- assert keyring.get_password(config.KEYRING_SERVICE, "_salt") is None
63
- assert keyring.get_password(config.KEYRING_SERVICE, "_verify") is None
64
- assert config.get_appkey(profile="default") == "new-appkey"
65
-
66
-
67
- def test_auth_login_no_password_prompt(monkeypatch, mem_keyring):
68
- """auth login issues a token without any interactive prompt."""
69
- from kiwoom_cli import client as client_mod
70
-
71
- keyring.set_password(config.KEYRING_SERVICE, "default:appkey", "ak")
72
- keyring.set_password(config.KEYRING_SERVICE, "default:secretkey", "sk")
73
-
74
- monkeypatch.setattr(
75
- client_mod.KiwoomClient, "issue_token", lambda self: "issued-token-value-12345"
76
- )
77
-
78
- runner = CliRunner()
79
- result = runner.invoke(cli, ["auth", "login"], input="")
80
- assert result.exit_code == 0, result.output
81
- assert "비밀번호" not in result.output
82
- assert "토큰 발급 완료" in result.output
83
-
84
-
85
- def test_auth_login_unconfigured_tells_user_to_setup(mem_keyring):
86
- runner = CliRunner()
87
- result = runner.invoke(cli, ["auth", "login"])
88
- assert result.exit_code != 0
89
- assert "config setup" in result.output
90
-
91
-
92
- def test_legacy_format_shows_resetup_notice(mem_keyring):
93
- """Any command under legacy format prints the re-setup notice."""
94
- keyring.set_password(config.KEYRING_SERVICE, "_salt", "oldsalt")
95
- runner = CliRunner()
96
- result = runner.invoke(cli, ["auth", "status"])
97
- assert "kiwoom config setup" in result.output or "config setup" in result.output
98
-
99
-
100
- def test_readonly_commands_survive_broken_keyring(monkeypatch):
101
- """Locked/absent keychain (headless, CI): config show / auth status must
102
- exit 0 as "미설정" instead of crashing with a KeyringError traceback."""
103
-
104
- def _raise(svc, key):
105
- raise keyring.errors.KeyringError("errSecInteractionNotAllowed (-25308)")
106
-
107
- monkeypatch.setattr(keyring, "get_password", _raise)
108
- runner = CliRunner()
109
-
110
- for argv in (["config", "show"], ["auth", "status"], ["config", "profiles"]):
111
- result = runner.invoke(cli, argv)
112
- assert result.exit_code == 0, (argv, result.exception)
File without changes
File without changes
File without changes
File without changes
File without changes