kwcli 0.1.0__py3-none-any.whl
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/__init__.py +47 -0
- kiwoom/_data/kiwoom_api_spec.json +66372 -0
- kiwoom/core/__init__.py +6 -0
- kiwoom/core/auth.py +349 -0
- kiwoom/core/client.py +244 -0
- kiwoom/core/errors.py +177 -0
- kiwoom/core/platform_paths.py +68 -0
- kiwoom/core/profiles.py +143 -0
- kiwoom/core/runtime.py +153 -0
- kiwoom/core/secrets.py +217 -0
- kiwoom/core/settings.py +64 -0
- kiwoom/core/token_store.py +186 -0
- kiwoom/core/types.py +28 -0
- kiwoom/core/ws_client.py +262 -0
- kiwoom/realtime/__init__.py +30 -0
- kiwoom/realtime/decoders.py +171 -0
- kiwoom/realtime/events.py +98 -0
- kiwoom/realtime/packets.py +65 -0
- kiwoom/realtime/schemas.py +76 -0
- kiwoom/realtime/stream.py +213 -0
- kiwoom/specs.py +272 -0
- kiwoom_cli/README.md +671 -0
- kiwoom_cli/__init__.py +9 -0
- kiwoom_cli/__main__.py +5 -0
- kiwoom_cli/argument_maps.py +561 -0
- kiwoom_cli/arguments.py +147 -0
- kiwoom_cli/auth_context.py +64 -0
- kiwoom_cli/banner.py +125 -0
- kiwoom_cli/commands/__init__.py +1 -0
- kiwoom_cli/commands/auth.py +406 -0
- kiwoom_cli/commands/groups.py +281 -0
- kiwoom_cli/commands/mapped.py +74 -0
- kiwoom_cli/commands/orders.py +158 -0
- kiwoom_cli/commands/spec.py +98 -0
- kiwoom_cli/commands/stocks.py +100 -0
- kiwoom_cli/commands/streams.py +470 -0
- kiwoom_cli/doctor.py +324 -0
- kiwoom_cli/errors.py +24 -0
- kiwoom_cli/executor/__init__.py +33 -0
- kiwoom_cli/executor/condition.py +374 -0
- kiwoom_cli/executor/rest.py +132 -0
- kiwoom_cli/executor/waits.py +34 -0
- kiwoom_cli/executor/websocket.py +131 -0
- kiwoom_cli/main.py +203 -0
- kiwoom_cli/maps/README.md +99 -0
- kiwoom_cli/maps/api_commands.csv +209 -0
- kiwoom_cli/maps/arguments.csv +731 -0
- kiwoom_cli/maps/order_confirmation_commands.csv +13 -0
- kiwoom_cli/maps/order_confirmation_fields.csv +71 -0
- kiwoom_cli/maps/order_price_policies.csv +47 -0
- kiwoom_cli/maps/order_value_labels.csv +28 -0
- kiwoom_cli/maps/positional_arguments.csv +21 -0
- kiwoom_cli/order_confirmation.py +167 -0
- kiwoom_cli/output.py +136 -0
- kiwoom_cli/registry.py +95 -0
- kiwoom_cli/safety.py +30 -0
- kiwoom_cli/setup.py +533 -0
- kwcli-0.1.0.dist-info/METADATA +215 -0
- kwcli-0.1.0.dist-info/RECORD +62 -0
- kwcli-0.1.0.dist-info/WHEEL +4 -0
- kwcli-0.1.0.dist-info/entry_points.txt +2 -0
- kwcli-0.1.0.dist-info/licenses/LICENSE.md +36 -0
kiwoom_cli/setup.py
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from dataclasses import dataclass, replace
|
|
3
|
+
from datetime import timedelta, timezone
|
|
4
|
+
from getpass import getpass
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from kiwoom import get_auth, get_client
|
|
8
|
+
from kiwoom.core.auth import AuthStatus
|
|
9
|
+
from kiwoom.core.errors import KiwoomError, SetupError
|
|
10
|
+
from kiwoom.core.profiles import (
|
|
11
|
+
AuthProfile,
|
|
12
|
+
get_current_profile,
|
|
13
|
+
load_profiles,
|
|
14
|
+
save_profile,
|
|
15
|
+
set_current_profile,
|
|
16
|
+
validate_profile_alias,
|
|
17
|
+
)
|
|
18
|
+
from kiwoom.core.secrets import (
|
|
19
|
+
CredentialSet,
|
|
20
|
+
EnvSecretProvider,
|
|
21
|
+
default_secret_provider,
|
|
22
|
+
)
|
|
23
|
+
from kiwoom.core.settings import restore_settings, snapshot_settings
|
|
24
|
+
from kiwoom.core.token_store import TokenRecord
|
|
25
|
+
from kiwoom.core.types import Mode, VALID_MODES, normalize_mode
|
|
26
|
+
from kiwoom_cli.arguments import resolve_optional_alias
|
|
27
|
+
from kiwoom_cli.auth_context import build_auth_context
|
|
28
|
+
from kiwoom_cli.banner import print_banner
|
|
29
|
+
from kiwoom_cli.doctor import onboarding_tool_report, os_label, path_kiwoom_entries
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
KST = timezone(timedelta(hours=9))
|
|
33
|
+
TEST_STOCK_CODE = "005930" # 조회 테스트용, 삼성전자
|
|
34
|
+
|
|
35
|
+
_WIZARD_RULE = "─" * 52
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _SetupWizard:
|
|
39
|
+
"""설치 마법사 단계 표시기.
|
|
40
|
+
|
|
41
|
+
setup에서는 enabled=True로 `[n/총]` 진행 표시를 찍고, 공유되는 run_login이
|
|
42
|
+
`auth login`에서 호출될 때는 enabled=False로 `▸` 헤더만 찍는다.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, *, enabled: bool, total: int = 0) -> None:
|
|
46
|
+
self.enabled = enabled
|
|
47
|
+
self.total = total
|
|
48
|
+
self._n = 0
|
|
49
|
+
|
|
50
|
+
def step(self, title: str) -> None:
|
|
51
|
+
self._n += 1
|
|
52
|
+
print()
|
|
53
|
+
if self.enabled:
|
|
54
|
+
print(f"[{self._n}/{self.total}] {title}")
|
|
55
|
+
else:
|
|
56
|
+
print(f"▸ {title}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _wz_ok(message: str) -> None:
|
|
60
|
+
print(f" ✓ {message}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _wz_warn(message: str) -> None:
|
|
64
|
+
print(f" ⚠ {message}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _wz_note(message: str) -> None:
|
|
68
|
+
print(f" {message}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def add_setup_parser(subparsers: argparse._SubParsersAction) -> None:
|
|
72
|
+
setup_parser = subparsers.add_parser(
|
|
73
|
+
"setup", help="대화형 계좌 인증 초기 설정을 실행합니다."
|
|
74
|
+
)
|
|
75
|
+
setup_parser.add_argument("alias_arg", nargs="?", metavar="alias", help="저장할 계좌 별칭")
|
|
76
|
+
setup_parser.add_argument("--alias", help="저장할 계좌 별칭")
|
|
77
|
+
setup_parser.add_argument("--mode", choices=VALID_MODES, help="계좌 별칭의 Kiwoom 모드")
|
|
78
|
+
setup_parser.set_defaults(handler=handle_setup)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def handle_setup(args: argparse.Namespace) -> None:
|
|
82
|
+
run_setup(
|
|
83
|
+
alias=resolve_optional_alias(args.alias_arg, args.alias, "--alias"),
|
|
84
|
+
mode=args.mode,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class _EphemeralSecretProvider:
|
|
90
|
+
appkey: str
|
|
91
|
+
secretkey: str
|
|
92
|
+
|
|
93
|
+
def get_credentials(self, mode: Mode) -> CredentialSet | None:
|
|
94
|
+
return CredentialSet(appkey=self.appkey, secretkey=self.secretkey, source="prompt")
|
|
95
|
+
|
|
96
|
+
def set_credentials(self, mode: Mode, appkey: str, secretkey: str) -> None:
|
|
97
|
+
raise RuntimeError("임시 자격 증명 공급자는 읽기 전용입니다.")
|
|
98
|
+
|
|
99
|
+
def clear_credentials(self, mode: Mode) -> None:
|
|
100
|
+
raise RuntimeError("임시 자격 증명 공급자는 읽기 전용입니다.")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def run_setup(*, alias: str | None = None, mode: str | None = None) -> None:
|
|
104
|
+
print_banner()
|
|
105
|
+
print()
|
|
106
|
+
print(" Kiwoom CLI · 초기 설정 마법사")
|
|
107
|
+
print(f" {_WIZARD_RULE}")
|
|
108
|
+
print(" 계좌를 인증하면 바로 시세 조회를 시작할 수 있습니다. (5단계)")
|
|
109
|
+
wizard = _SetupWizard(enabled=True, total=5)
|
|
110
|
+
_run_preflight(wizard)
|
|
111
|
+
run_login(alias=alias, mode=mode, wizard=wizard)
|
|
112
|
+
_print_readiness_summary()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# Windows 온보딩 도구가 없을 때의 안내. 실행 필수는 아니므로 경고만 한다.
|
|
116
|
+
_ONBOARDING_TOOL_HINTS = {
|
|
117
|
+
"git": "auth export의 .gitignore 등록 등에 쓰입니다: https://git-scm.com/download/win",
|
|
118
|
+
"python": "설치/업데이트에 필요할 수 있습니다: https://www.python.org/downloads/windows/",
|
|
119
|
+
"uv": "설치/업데이트에 필요할 수 있습니다: https://docs.astral.sh/uv/",
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _run_preflight(wizard: _SetupWizard) -> None:
|
|
124
|
+
wizard.step("환경 점검")
|
|
125
|
+
_wz_ok(f"운영체제: {os_label()}")
|
|
126
|
+
available, detail = _credential_store_status()
|
|
127
|
+
if available:
|
|
128
|
+
_wz_ok(f"자격 증명 저장소 사용 가능 ({detail})")
|
|
129
|
+
else:
|
|
130
|
+
_wz_warn(f"자격 증명 저장소 사용 불가 - {detail}")
|
|
131
|
+
_wz_note("App Key/Secret은 이 저장소에 저장됩니다. 저장 단계에서 실패할 수 있습니다.")
|
|
132
|
+
_wz_note("지원되는 keyring 백엔드를 설치하거나 환경변수(APP_KEY/APP_SECRET)를 사용하세요.")
|
|
133
|
+
for tool, found in onboarding_tool_report():
|
|
134
|
+
if found:
|
|
135
|
+
_wz_ok(f"{tool} 감지됨")
|
|
136
|
+
else:
|
|
137
|
+
_wz_warn(f"{tool} 미감지 — {_ONBOARDING_TOOL_HINTS[tool]}")
|
|
138
|
+
entries = path_kiwoom_entries()
|
|
139
|
+
if len(entries) > 1:
|
|
140
|
+
_wz_warn(
|
|
141
|
+
f"PATH에 kiwoom 실행 파일이 {len(entries)}개 있습니다. "
|
|
142
|
+
"`which -a kiwoom`로 우선순위를 확인하세요."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _credential_store_status() -> tuple[bool, str]:
|
|
147
|
+
try:
|
|
148
|
+
import keyring
|
|
149
|
+
|
|
150
|
+
backend = keyring.get_keyring()
|
|
151
|
+
except Exception as exc: # keyring import/백엔드 조회 자체 실패
|
|
152
|
+
return False, str(exc)
|
|
153
|
+
module = type(backend).__module__
|
|
154
|
+
if module.endswith(".fail") or module.endswith(".null"):
|
|
155
|
+
return False, "사용 가능한 keyring 백엔드가 없습니다"
|
|
156
|
+
return True, type(backend).__name__
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _print_readiness_summary() -> None:
|
|
160
|
+
current = get_current_profile()
|
|
161
|
+
if current is None:
|
|
162
|
+
return
|
|
163
|
+
try:
|
|
164
|
+
context = build_auth_context(profile=current.alias)
|
|
165
|
+
except KiwoomError:
|
|
166
|
+
return
|
|
167
|
+
print()
|
|
168
|
+
print(" 준비 상태")
|
|
169
|
+
print(f" 현재 계좌 {current.alias} ({current.mode})")
|
|
170
|
+
print(f" 지금 호출 {'가능' if context.can_call_now else '불가'}")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def run_login(
|
|
174
|
+
*,
|
|
175
|
+
alias: str | None = None,
|
|
176
|
+
mode: str | None = None,
|
|
177
|
+
wizard: _SetupWizard | None = None,
|
|
178
|
+
) -> None:
|
|
179
|
+
if wizard is None:
|
|
180
|
+
wizard = _SetupWizard(enabled=False)
|
|
181
|
+
|
|
182
|
+
if alias is None:
|
|
183
|
+
wizard.step("서버 선택")
|
|
184
|
+
selected_mode = normalize_mode(mode) if mode is not None else _select_mode()
|
|
185
|
+
_wz_ok(_server_label(selected_mode))
|
|
186
|
+
wizard.step("계좌 별칭")
|
|
187
|
+
profile_alias = _select_profile_alias(alias, mode=selected_mode)
|
|
188
|
+
_wz_ok(f"계좌 별칭: {profile_alias}")
|
|
189
|
+
existing_profile = _maybe_get_profile(profile_alias)
|
|
190
|
+
if existing_profile is not None and existing_profile.mode != selected_mode:
|
|
191
|
+
raise SetupError("같은 계좌 별칭이 이미 다른 모드로 등록되어 있습니다. 다른 별칭을 입력해 주세요.")
|
|
192
|
+
else:
|
|
193
|
+
wizard.step("계좌 별칭")
|
|
194
|
+
profile_alias = _select_profile_alias(alias, mode=normalize_mode(mode) if mode is not None else None)
|
|
195
|
+
_wz_ok(f"계좌 별칭: {profile_alias}")
|
|
196
|
+
existing_profile = _maybe_get_profile(profile_alias)
|
|
197
|
+
wizard.step("서버 선택")
|
|
198
|
+
selected_mode = _resolve_login_mode(mode=mode, existing_profile=existing_profile)
|
|
199
|
+
_wz_ok(_server_label(selected_mode))
|
|
200
|
+
|
|
201
|
+
if existing_profile is not None:
|
|
202
|
+
status = get_auth(profile=profile_alias).status()
|
|
203
|
+
_print_existing_setup_summary(profile_alias, selected_mode, status)
|
|
204
|
+
action = _prompt_existing_state_action(status)
|
|
205
|
+
if action == "keep":
|
|
206
|
+
set_current_profile(profile_alias)
|
|
207
|
+
print()
|
|
208
|
+
_wz_ok("기존 계좌 별칭을 현재 계좌로 선택했습니다.")
|
|
209
|
+
_wz_note(f"다음: kiwoomcli auth status --profile {profile_alias}")
|
|
210
|
+
return
|
|
211
|
+
if action == "refresh":
|
|
212
|
+
_refresh_token_only(profile_alias, selected_mode)
|
|
213
|
+
return
|
|
214
|
+
if action == "reset":
|
|
215
|
+
_reset_profile(profile_alias, selected_mode)
|
|
216
|
+
# reconfigure/reset both continue to credential collection.
|
|
217
|
+
|
|
218
|
+
wizard.step("App Key / Secret 입력·검증")
|
|
219
|
+
appkey, secretkey, token_record, stock_name = _collect_and_validate(selected_mode)
|
|
220
|
+
wizard.step("저장 및 마무리")
|
|
221
|
+
_persist_setup_result(profile_alias, selected_mode, appkey, secretkey, token_record)
|
|
222
|
+
_print_success_message(
|
|
223
|
+
profile_alias,
|
|
224
|
+
selected_mode,
|
|
225
|
+
stock_name,
|
|
226
|
+
summary="설정 완료",
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _select_profile_alias(alias: str | None = None, *, mode: Mode | None = None) -> str:
|
|
231
|
+
if alias is not None:
|
|
232
|
+
try:
|
|
233
|
+
return validate_profile_alias(alias)
|
|
234
|
+
except ValueError as exc:
|
|
235
|
+
raise SetupError(str(exc)) from exc
|
|
236
|
+
|
|
237
|
+
default_alias = _default_profile_alias(mode)
|
|
238
|
+
if not sys.stdin.isatty():
|
|
239
|
+
return default_alias
|
|
240
|
+
|
|
241
|
+
_wz_note("예: 모의계좌, 실전계좌, 가족계좌-demo (Enter=기본값)")
|
|
242
|
+
while True:
|
|
243
|
+
value = input(f" 계좌 별칭 [{default_alias}] > ").strip()
|
|
244
|
+
if not value:
|
|
245
|
+
return default_alias
|
|
246
|
+
try:
|
|
247
|
+
return validate_profile_alias(value)
|
|
248
|
+
except ValueError as exc:
|
|
249
|
+
_wz_note(str(exc))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _resolve_login_mode(*, mode: str | None, existing_profile: AuthProfile | None) -> Mode:
|
|
253
|
+
if mode is not None:
|
|
254
|
+
selected = normalize_mode(mode)
|
|
255
|
+
if existing_profile is not None and selected != existing_profile.mode:
|
|
256
|
+
raise SetupError("기존 계좌 별칭의 모드와 다른 모드로는 덮어쓸 수 없습니다. 새 계좌 별칭을 사용해 주세요.")
|
|
257
|
+
return selected
|
|
258
|
+
if existing_profile is not None:
|
|
259
|
+
return existing_profile.mode
|
|
260
|
+
return _select_mode()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _maybe_get_profile(alias: str) -> AuthProfile | None:
|
|
264
|
+
return load_profiles().get(alias)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _default_profile_alias(mode: Mode | None) -> str:
|
|
268
|
+
if mode == "real":
|
|
269
|
+
return "실전계좌"
|
|
270
|
+
return "모의계좌"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _select_mode(default: Mode | None = None) -> Mode:
|
|
274
|
+
# demo first and as the Enter default so a stray keystroke can't land on
|
|
275
|
+
# the real (실전) server during the bootstrap wizard.
|
|
276
|
+
enter_default = default if default is not None else "demo"
|
|
277
|
+
if not sys.stdin.isatty():
|
|
278
|
+
if default is not None:
|
|
279
|
+
return default
|
|
280
|
+
raise SetupError("비대화형 환경에서는 --mode demo|real 을 지정해 주세요.")
|
|
281
|
+
print(" [1] demo (모의투자)")
|
|
282
|
+
print(" [2] real (실전투자)")
|
|
283
|
+
while True:
|
|
284
|
+
choice = input(f" 선택 [{_mode_label(enter_default)}] > ").strip()
|
|
285
|
+
if choice == "":
|
|
286
|
+
return enter_default
|
|
287
|
+
if choice == "1":
|
|
288
|
+
return "demo"
|
|
289
|
+
if choice == "2":
|
|
290
|
+
return "real"
|
|
291
|
+
_wz_note("1 또는 2를 입력해 주세요.")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _collect_and_validate(mode: Mode) -> tuple[str, str, TokenRecord, str]:
|
|
295
|
+
env_credentials = EnvSecretProvider().get_credentials(mode)
|
|
296
|
+
if env_credentials is not None:
|
|
297
|
+
_wz_note(f"환경변수에서 {_mode_label(mode)} App Key/Secret을 읽었습니다.")
|
|
298
|
+
token_record, stock_name = _validate_credentials(
|
|
299
|
+
mode, env_credentials.appkey, env_credentials.secretkey
|
|
300
|
+
)
|
|
301
|
+
return env_credentials.appkey, env_credentials.secretkey, token_record, stock_name
|
|
302
|
+
|
|
303
|
+
_wz_note(f"키움에서 발급받은 {_mode_label(mode)} App Key와 Secret Key를 붙여넣어 주세요.")
|
|
304
|
+
while True:
|
|
305
|
+
appkey, secretkey = _collect_from_prompt()
|
|
306
|
+
try:
|
|
307
|
+
token_record, stock_name = _validate_credentials(mode, appkey, secretkey)
|
|
308
|
+
except KiwoomError as exc:
|
|
309
|
+
print(f" ✗ 검증 실패: {exc}", file=sys.stderr)
|
|
310
|
+
_wz_note("App Key/Secret Key를 다시 확인해 주세요. (취소: Ctrl-C)")
|
|
311
|
+
continue
|
|
312
|
+
return appkey, secretkey, token_record, stock_name
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _collect_from_prompt() -> tuple[str, str]:
|
|
316
|
+
if not sys.stdin.isatty():
|
|
317
|
+
raise SetupError("setup에서 자격 증명 입력은 대화형 터미널에서만 지원합니다.")
|
|
318
|
+
_wz_note("입력한 값은 화면에 표시되지 않습니다.")
|
|
319
|
+
appkey = getpass(" App Key: ").strip()
|
|
320
|
+
secretkey = getpass(" Secret Key: ").strip()
|
|
321
|
+
if not appkey or not secretkey:
|
|
322
|
+
raise SetupError("App Key와 Secret Key를 모두 입력해 주세요.")
|
|
323
|
+
return appkey, secretkey
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _validate_credentials(mode: Mode, appkey: str, secretkey: str) -> tuple[TokenRecord, str]:
|
|
327
|
+
_wz_note("입력한 자격 증명을 확인하는 중입니다…")
|
|
328
|
+
auth = get_auth(
|
|
329
|
+
mode=mode,
|
|
330
|
+
secret_provider=_EphemeralSecretProvider(appkey=appkey, secretkey=secretkey),
|
|
331
|
+
token_store_kind="memory",
|
|
332
|
+
)
|
|
333
|
+
client = get_client(auth=auth)
|
|
334
|
+
response = client.fetch_page(
|
|
335
|
+
api_url="/api/dostk/stkinfo/ka10001",
|
|
336
|
+
body={"stk_cd": TEST_STOCK_CODE},
|
|
337
|
+
)
|
|
338
|
+
token_record = auth.token_store.load(mode)
|
|
339
|
+
if token_record is None:
|
|
340
|
+
raise SetupError("검증은 성공했지만 토큰이 저장되지 않았습니다.")
|
|
341
|
+
payload = response.body
|
|
342
|
+
stock_name = str(payload.get("stk_nm", TEST_STOCK_CODE))
|
|
343
|
+
_wz_ok(f"연결 검증 성공 · {stock_name}({TEST_STOCK_CODE}) 조회")
|
|
344
|
+
return token_record, stock_name
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _refresh_token_only(alias: str, mode: Mode) -> None:
|
|
348
|
+
credentials = default_secret_provider(profile=alias).get_credentials(mode)
|
|
349
|
+
if credentials is None:
|
|
350
|
+
raise SetupError(f"{alias} 계좌 별칭의 자격 증명이 없어 토큰을 다시 발급할 수 없습니다.")
|
|
351
|
+
|
|
352
|
+
print()
|
|
353
|
+
_wz_note("저장된 자격 증명으로 토큰을 다시 발급합니다.")
|
|
354
|
+
token_record, stock_name = _validate_credentials(mode, credentials.appkey, credentials.secretkey)
|
|
355
|
+
_persist_token_result(alias, mode, token_record)
|
|
356
|
+
_print_success_message(
|
|
357
|
+
alias,
|
|
358
|
+
mode,
|
|
359
|
+
stock_name,
|
|
360
|
+
summary="토큰을 새로 발급했습니다.",
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _reset_profile(alias: str, mode: Mode) -> None:
|
|
365
|
+
provider = default_secret_provider(profile=alias)
|
|
366
|
+
credentials = provider.get_credentials(mode)
|
|
367
|
+
get_auth(profile=alias).clear_token()
|
|
368
|
+
print()
|
|
369
|
+
_wz_ok(f"{alias} 계좌 별칭의 토큰 캐시를 삭제했습니다.")
|
|
370
|
+
if credentials is None:
|
|
371
|
+
_wz_note("삭제할 자격 증명이 없습니다.")
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
provider.clear_credentials(mode)
|
|
375
|
+
_wz_ok("운영체제 자격 증명 저장소의 값도 삭제했습니다.")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _print_existing_setup_summary(alias: str, mode: Mode, status: AuthStatus) -> None:
|
|
379
|
+
print()
|
|
380
|
+
print(" 이미 설정된 계좌 별칭을 찾았습니다.")
|
|
381
|
+
print(f" {_WIZARD_RULE}")
|
|
382
|
+
print(f" 계좌 별칭 {alias}")
|
|
383
|
+
print(f" 모드 {mode}")
|
|
384
|
+
print(f" 자격 증명 {'있음' if status.has_credentials else '없음'} "
|
|
385
|
+
f"({_credential_source_label(status.credential_source)})")
|
|
386
|
+
print(f" 토큰 존재 {'예' if status.has_token else '아니오'} · "
|
|
387
|
+
f"유효 {'예' if status.token_valid else '아니오'} · "
|
|
388
|
+
f"재사용 {'예' if status.token_reusable else '아니오'}")
|
|
389
|
+
print(f" 토큰 저장 {_format_token_saved_at(status)}")
|
|
390
|
+
print(f" 토큰 만료 {_format_token_expires_at(status)}")
|
|
391
|
+
print(f" 다음 인증 {_next_auth_action_label(status.next_auth_action)}")
|
|
392
|
+
if status.token_warning:
|
|
393
|
+
_wz_warn(f"토큰 경고: {status.token_warning}")
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _prompt_existing_state_action(status: AuthStatus) -> str:
|
|
397
|
+
if not sys.stdin.isatty():
|
|
398
|
+
raise SetupError(
|
|
399
|
+
"비대화형 환경에서는 기존 계좌 처리를 선택할 수 없습니다. "
|
|
400
|
+
"대화형 터미널에서 실행하거나 kiwoomcli auth 하위 명령을 사용하세요."
|
|
401
|
+
)
|
|
402
|
+
print()
|
|
403
|
+
print(" 어떻게 할까요?")
|
|
404
|
+
print(" [1] 이 계좌 별칭으로 전환")
|
|
405
|
+
if status.has_credentials:
|
|
406
|
+
print(" [2] 토큰만 다시 발급")
|
|
407
|
+
else:
|
|
408
|
+
# Refresh needs stored credentials; show it disabled instead of letting
|
|
409
|
+
# the user pick an action that can only fail in _refresh_token_only.
|
|
410
|
+
print(" [2] 토큰만 다시 발급 - 사용할 수 없음: 키/시크릿이 없습니다.")
|
|
411
|
+
print(" [3] App Key / Secret Key 다시 입력")
|
|
412
|
+
print(" [4] 자격 증명과 토큰 삭제 후 다시 설정")
|
|
413
|
+
while True:
|
|
414
|
+
choice = input(" > ").strip()
|
|
415
|
+
if choice == "1":
|
|
416
|
+
return "keep"
|
|
417
|
+
if choice == "2":
|
|
418
|
+
if not status.has_credentials:
|
|
419
|
+
_wz_note("키/시크릿이 없어 토큰만 재발급할 수 없습니다. [3]으로 키/시크릿을 다시 입력해 주세요.")
|
|
420
|
+
continue
|
|
421
|
+
return "refresh"
|
|
422
|
+
if choice == "3":
|
|
423
|
+
return "reconfigure"
|
|
424
|
+
if choice == "4":
|
|
425
|
+
return "reset"
|
|
426
|
+
_wz_note("1, 2, 3, 4 중 하나를 입력해 주세요.")
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _mode_label(mode: Mode) -> str:
|
|
430
|
+
return "real(실전)" if mode == "real" else "demo(모의)"
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _server_label(mode: Mode) -> str:
|
|
434
|
+
return "실전투자(real)" if mode == "real" else "모의투자(demo)"
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _credential_source_label(source: str | None) -> str:
|
|
438
|
+
return {
|
|
439
|
+
"env": "환경변수",
|
|
440
|
+
"keyring": "운영체제 자격 증명 저장소",
|
|
441
|
+
"prompt": "입력값",
|
|
442
|
+
None: "없음",
|
|
443
|
+
}.get(source, source or "없음")
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _format_token_expires_at(status: AuthStatus) -> str:
|
|
447
|
+
return _format_kst_datetime(status.token_expires_at)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _format_token_saved_at(status: AuthStatus) -> str:
|
|
451
|
+
return _format_kst_datetime(status.token_saved_at)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _format_kst_datetime(value) -> str:
|
|
455
|
+
if value is None:
|
|
456
|
+
return "없음"
|
|
457
|
+
return f"{value.astimezone(KST).isoformat()} (KST)"
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _next_auth_action_label(action: str) -> str:
|
|
461
|
+
return {
|
|
462
|
+
"use_cached_token": "캐시 토큰 사용",
|
|
463
|
+
"issue_before_request": "다음 API 호출 시 새 토큰 자동 발급",
|
|
464
|
+
"refresh_before_request": "다음 API 호출 시 토큰 자동 재발급",
|
|
465
|
+
"credentials_missing": "자격 증명 필요",
|
|
466
|
+
}.get(action, action)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _print_success_message(alias: str, mode: Mode, stock_name: str, *, summary: str) -> None:
|
|
470
|
+
print()
|
|
471
|
+
print(f" ✓ {summary}")
|
|
472
|
+
print(f" {_WIZARD_RULE}")
|
|
473
|
+
print(f" 계좌 별칭 {alias}")
|
|
474
|
+
print(f" 서버 {_server_label(mode)}")
|
|
475
|
+
print(" 키/시크릿 저장됨")
|
|
476
|
+
print(" 토큰 발급됨")
|
|
477
|
+
print(f" API 검증 성공 ({stock_name} {TEST_STOCK_CODE} 조회)")
|
|
478
|
+
print()
|
|
479
|
+
print(" 다음 명령")
|
|
480
|
+
print(f" kiwoomcli auth status --profile {alias}")
|
|
481
|
+
print(f" kiwoomcli domestic stocks info --code {TEST_STOCK_CODE} --profile {alias}")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _persist_token_result(alias: str, mode: Mode, token_record: TokenRecord) -> None:
|
|
485
|
+
previous_settings = snapshot_settings()
|
|
486
|
+
save_profile(alias, mode, make_current=True)
|
|
487
|
+
token_store = get_auth(profile=alias).token_store
|
|
488
|
+
previous_token = token_store.peek(mode, profile=alias)
|
|
489
|
+
try:
|
|
490
|
+
token_store.save(replace(token_record, mode=mode, profile=alias))
|
|
491
|
+
set_current_profile(alias)
|
|
492
|
+
except Exception:
|
|
493
|
+
if previous_token is None:
|
|
494
|
+
token_store.clear(mode, profile=alias)
|
|
495
|
+
else:
|
|
496
|
+
token_store.save(previous_token)
|
|
497
|
+
restore_settings(previous_settings)
|
|
498
|
+
raise
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _persist_setup_result(alias: str, mode: Mode, appkey: str, secretkey: str, token_record: TokenRecord) -> None:
|
|
502
|
+
previous_settings = snapshot_settings()
|
|
503
|
+
save_profile(alias, mode, make_current=True)
|
|
504
|
+
provider = default_secret_provider(profile=alias)
|
|
505
|
+
token_store = get_auth(profile=alias).token_store
|
|
506
|
+
previous_credentials = provider.get_credentials(mode)
|
|
507
|
+
previous_token = token_store.peek(mode, profile=alias)
|
|
508
|
+
|
|
509
|
+
try:
|
|
510
|
+
provider.set_credentials(mode, appkey, secretkey)
|
|
511
|
+
_wz_ok("운영체제 자격 증명 저장소에 저장 완료")
|
|
512
|
+
token_store.save(replace(token_record, mode=mode, profile=alias))
|
|
513
|
+
set_current_profile(alias)
|
|
514
|
+
except Exception:
|
|
515
|
+
if previous_token is None:
|
|
516
|
+
token_store.clear(mode, profile=alias)
|
|
517
|
+
else:
|
|
518
|
+
token_store.save(previous_token)
|
|
519
|
+
|
|
520
|
+
try:
|
|
521
|
+
if previous_credentials is None:
|
|
522
|
+
provider.clear_credentials(mode)
|
|
523
|
+
else:
|
|
524
|
+
provider.set_credentials(
|
|
525
|
+
mode,
|
|
526
|
+
previous_credentials.appkey,
|
|
527
|
+
previous_credentials.secretkey,
|
|
528
|
+
)
|
|
529
|
+
except Exception:
|
|
530
|
+
pass
|
|
531
|
+
finally:
|
|
532
|
+
restore_settings(previous_settings)
|
|
533
|
+
raise
|