kiwoom-cli 2.1.1__tar.gz → 2.2.0__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.0}/PKG-INFO +42 -10
  2. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/README.md +41 -9
  3. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli/__init__.py +1 -1
  4. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli/auth.py +14 -0
  5. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli/config.py +16 -2
  6. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli/main.py +41 -10
  7. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli.egg-info/PKG-INFO +42 -10
  8. kiwoom_cli-2.2.0/tests/test_auth.py +64 -0
  9. kiwoom_cli-2.2.0/tests/test_auth_cli.py +236 -0
  10. kiwoom_cli-2.1.1/tests/test_auth.py +0 -30
  11. kiwoom_cli-2.1.1/tests/test_auth_cli.py +0 -112
  12. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/LICENSE +0 -0
  13. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli/api_spec.py +0 -0
  14. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli/client.py +0 -0
  15. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli/commands/__init__.py +0 -0
  16. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli/commands/_constants.py +0 -0
  17. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli/commands/account.py +0 -0
  18. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli/commands/dashboard.py +0 -0
  19. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli/commands/market.py +0 -0
  20. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli/commands/order.py +0 -0
  21. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli/commands/stock.py +0 -0
  22. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli/commands/stream.py +0 -0
  23. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli/commands/us/__init__.py +0 -0
  24. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli/commands/us/_constants.py +0 -0
  25. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli/commands/us/account_ops.py +0 -0
  26. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli/commands/us/detect.py +0 -0
  27. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli/commands/us/exchange.py +0 -0
  28. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli/commands/us/order_ops.py +0 -0
  29. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli/commands/us/stock_ops.py +0 -0
  30. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli/commands/watch.py +0 -0
  31. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli/formatters.py +0 -0
  32. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli/output.py +0 -0
  33. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli/streaming.py +0 -0
  34. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli.egg-info/SOURCES.txt +0 -0
  35. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli.egg-info/dependency_links.txt +0 -0
  36. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli.egg-info/entry_points.txt +0 -0
  37. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli.egg-info/requires.txt +0 -0
  38. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/kiwoom_cli.egg-info/top_level.txt +0 -0
  39. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/pyproject.toml +0 -0
  40. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/setup.cfg +0 -0
  41. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/tests/test_account.py +0 -0
  42. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/tests/test_cli.py +0 -0
  43. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/tests/test_client.py +0 -0
  44. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/tests/test_credentials.py +0 -0
  45. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/tests/test_formatters.py +0 -0
  46. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/tests/test_formatters_property.py +0 -0
  47. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/tests/test_json_purity.py +0 -0
  48. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/tests/test_market.py +0 -0
  49. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/tests/test_migration.py +0 -0
  50. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/tests/test_order.py +0 -0
  51. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/tests/test_stock.py +0 -0
  52. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/tests/test_us.py +0 -0
  53. {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.0}/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.0
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.0"
@@ -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
 
@@ -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,6 +3,7 @@
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
@@ -122,7 +123,11 @@ def config_cmd():
122
123
  @click.option("--secretkey", prompt="Secret Key", hide_input=True, help="키움 API Secret Key")
123
124
  @click.option("--domain", prompt="도메인 (prod=실거래, mock=모의투자)", type=click.Choice(["prod", "mock"]), default="mock", help="도메인")
124
125
  @click.option("--account", prompt="계좌번호 (없으면 Enter)", default="", help="계좌번호")
125
- def config_setup(profile: str, appkey: str, secretkey: str, domain: str, account: str):
126
+ @click.option("--token-storage", "token_storage",
127
+ prompt="토큰 저장 방식 (keychain=OS 키체인, env=KIWOOM_TOKEN 직접 관리)",
128
+ type=click.Choice(list(config.TOKEN_STORAGES)), default="keychain",
129
+ help="접근토큰 저장 방식")
130
+ def config_setup(profile: str, appkey: str, secretkey: str, domain: str, account: str, token_storage: str):
126
131
  """초기 설정 (App Key, Secret Key, 도메인)."""
127
132
  if config.is_legacy_encrypted():
128
133
  # Old password-encrypted entries are unusable — purge before writing new keys
@@ -134,6 +139,7 @@ def config_setup(profile: str, appkey: str, secretkey: str, domain: str, account
134
139
  cfg.setdefault("profiles", {}).setdefault(profile, {})["domain"] = domain
135
140
  if account:
136
141
  cfg["profiles"][profile]["account"] = account
142
+ cfg["profiles"][profile]["token_storage"] = token_storage
137
143
  if "default_profile" not in cfg.get("general", {}):
138
144
  cfg.setdefault("general", {})["default_profile"] = profile
139
145
  cfg.pop("auth", None)
@@ -141,6 +147,10 @@ def config_setup(profile: str, appkey: str, secretkey: str, domain: str, account
141
147
  console.print(f"[green]설정 완료![/] (프로필: {profile})")
142
148
  console.print(" App Key/Secret Key: [bold]OS 키체인에 저장됨[/]")
143
149
  console.print(f" 도메인: [bold]{config.DOMAINS[domain]}[/]")
150
+ if token_storage == "env":
151
+ console.print(" 토큰 저장: [bold]환경변수 (KIWOOM_TOKEN)[/] — auth login 후 안내되는 export를 실행하세요")
152
+ else:
153
+ console.print(" 토큰 저장: [bold]OS 키체인[/]")
144
154
 
145
155
 
146
156
  @config_cmd.command("show")
@@ -151,6 +161,7 @@ def config_show(ctx):
151
161
  cfg = config.load_config()
152
162
  configured = config.is_configured(profile)
153
163
  profile_cfg = cfg.get("profiles", {}).get(profile, {})
164
+ token_storage = config.get_token_storage(profile)
154
165
  if _get_format() == "json":
155
166
  _output_json({
156
167
  "profile": profile,
@@ -158,6 +169,7 @@ def config_show(ctx):
158
169
  "domain": profile_cfg.get("domain", "mock"),
159
170
  "configured": configured,
160
171
  "account": profile_cfg.get("account", ""),
172
+ "token_storage": token_storage,
161
173
  })
162
174
  return
163
175
  human(f" 프로필: [bold]{profile}[/]")
@@ -165,11 +177,12 @@ def config_show(ctx):
165
177
  human(f" 도메인: {profile_cfg.get('domain', 'mock')}")
166
178
  human(f" App Key: {'[dim]설정됨 (키체인)[/]' if configured else '(미설정)'}")
167
179
  human(f" 계좌번호: {profile_cfg.get('account', '(미설정)')}")
180
+ human(f" 토큰 저장: {'환경변수 (KIWOOM_TOKEN)' if token_storage == 'env' else 'OS 키체인'}")
168
181
  human(f" 보안: [bold]{'OS 키체인 저장' if configured else '미설정'}[/]")
169
182
 
170
183
 
171
184
  @config_cmd.command("set")
172
- @click.argument("key", type=click.Choice(["domain", "account"]))
185
+ @click.argument("key", type=click.Choice(["domain", "account", "token_storage"]))
173
186
  @click.argument("value")
174
187
  @click.pass_context
175
188
  def config_set(ctx, key: str, value: str):
@@ -178,6 +191,9 @@ def config_set(ctx, key: str, value: str):
178
191
  if key == "domain" and value not in ("prod", "mock"):
179
192
  console.print("[red]domain은 prod 또는 mock만 가능합니다.[/]")
180
193
  raise SystemExit(1)
194
+ if key == "token_storage" and value not in config.TOKEN_STORAGES:
195
+ console.print("[red]token_storage는 keychain 또는 env만 가능합니다.[/]")
196
+ raise SystemExit(1)
181
197
  cfg = config.load_config()
182
198
  cfg.setdefault("profiles", {}).setdefault(profile, {})[key] = value
183
199
  config.save_config(cfg)
@@ -243,10 +259,14 @@ def auth_login(ctx):
243
259
  with KiwoomClient() as c:
244
260
  try:
245
261
  token = c.issue_token()
246
- masked = token[:10] + "..." + token[-4:] if len(token) > 14 else token
247
262
  human("[green]토큰 발급 완료![/]")
248
- human(f" 토큰: {masked}")
249
- human(" 저장 위치: [bold]키체인[/]")
263
+ if config.get_token_storage(profile) == "env":
264
+ human(" 저장 위치: [bold]없음 (env 모드)[/] — 아래를 셸에서 실행하세요:")
265
+ human(f" export KIWOOM_TOKEN='{token}'")
266
+ else:
267
+ masked = token[:10] + "..." + token[-4:] if len(token) > 14 else token
268
+ human(f" 토큰: {masked}")
269
+ human(" 저장 위치: [bold]키체인[/]")
250
270
  except KiwoomAPIError as e:
251
271
  human(f"[red]토큰 발급 실패:[/] {e}")
252
272
 
@@ -265,6 +285,8 @@ def auth_logout(ctx):
265
285
  human("[green]토큰 폐기 완료.[/]")
266
286
  except KiwoomAPIError as e:
267
287
  human(f"[red]토큰 폐기 실패:[/] {e}")
288
+ if os.environ.get("KIWOOM_TOKEN"):
289
+ human("[yellow]KIWOOM_TOKEN 환경변수가 설정되어 있어 이 셸에서는 해당 토큰이 계속 사용됩니다. unset KIWOOM_TOKEN 으로 제거하세요.[/]")
268
290
 
269
291
 
270
292
  @auth_cmd.command("status")
@@ -273,22 +295,31 @@ def auth_status(ctx):
273
295
  """토큰 상태 확인."""
274
296
  profile = config.resolve_profile(ctx.obj.get("profile") if ctx.obj else None)
275
297
  configured = config.is_configured(profile)
276
- has_token = configured and auth.load_token(profile=profile) is not None
298
+ token_storage = config.get_token_storage(profile)
299
+ token = auth.load_token(profile=profile)
300
+ token_source = None
301
+ if token is not None:
302
+ token_source = "env" if os.environ.get("KIWOOM_TOKEN") else "keyring"
277
303
  if _get_format() == "json":
278
304
  cfg = config.load_config()
279
305
  _output_json({
280
306
  "profile": profile,
281
307
  "domain": cfg.get("profiles", {}).get(profile, {}).get("domain", "mock"),
282
308
  "configured": configured,
283
- "has_token": has_token,
309
+ "has_token": token is not None,
310
+ "token_source": token_source,
311
+ "token_storage": token_storage,
284
312
  })
285
313
  return
286
- if not configured:
314
+ if not configured and token is None:
287
315
  human("[yellow]설정 필요.[/] 'kiwoom config setup' 으로 설정하세요.")
288
316
  return
289
317
  human(f" 프로필: [bold]{profile}[/]")
290
- if has_token:
291
- human("[green]토큰 있음[/] [dim](키체인 저장됨)[/]")
318
+ if token is not None:
319
+ source_label = "환경변수 KIWOOM_TOKEN" if token_source == "env" else "키체인 저장됨"
320
+ human(f"[green]토큰 있음[/] [dim]({source_label})[/]")
321
+ elif token_storage == "env":
322
+ human("[yellow]토큰 없음.[/] 'kiwoom auth login' 으로 발급 후 안내되는 export KIWOOM_TOKEN을 실행하세요.")
292
323
  else:
293
324
  human("[yellow]토큰 없음.[/] 'kiwoom auth login' 으로 발급하세요.")
294
325
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwoom-cli
3
- Version: 2.1.1
3
+ Version: 2.2.0
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,236 @@
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
@@ -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