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.
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/PKG-INFO +42 -10
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/README.md +41 -9
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/__init__.py +1 -1
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/auth.py +14 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/client.py +6 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/config.py +16 -2
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/main.py +58 -11
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli.egg-info/PKG-INFO +42 -10
- kiwoom_cli-2.2.1/tests/test_auth.py +64 -0
- kiwoom_cli-2.2.1/tests/test_auth_cli.py +292 -0
- kiwoom_cli-2.1.1/tests/test_auth.py +0 -30
- kiwoom_cli-2.1.1/tests/test_auth_cli.py +0 -112
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/LICENSE +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/api_spec.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/__init__.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/_constants.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/account.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/dashboard.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/market.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/order.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/stock.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/stream.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/us/__init__.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/us/_constants.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/us/account_ops.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/us/detect.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/us/exchange.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/us/order_ops.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/us/stock_ops.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/commands/watch.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/formatters.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/output.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli/streaming.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli.egg-info/SOURCES.txt +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli.egg-info/dependency_links.txt +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli.egg-info/entry_points.txt +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli.egg-info/requires.txt +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/kiwoom_cli.egg-info/top_level.txt +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/pyproject.toml +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/setup.cfg +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/tests/test_account.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/tests/test_cli.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/tests/test_client.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/tests/test_credentials.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/tests/test_formatters.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/tests/test_formatters_property.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/tests/test_json_purity.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/tests/test_market.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/tests/test_migration.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/tests/test_order.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/tests/test_stock.py +0 -0
- {kiwoom_cli-2.1.1 → kiwoom_cli-2.2.1}/tests/test_us.py +0 -0
- {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.
|
|
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 키체인
|
|
519
|
-
| 토큰 | OS 키체인
|
|
520
|
-
| config.toml | 도메인,
|
|
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 키체인
|
|
482
|
-
| 토큰 | OS 키체인
|
|
483
|
-
| config.toml | 도메인,
|
|
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` |
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
|
291
|
-
|
|
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.
|
|
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 키체인
|
|
519
|
-
| 토큰 | OS 키체인
|
|
520
|
-
| config.toml | 도메인,
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|