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.
Files changed (62) hide show
  1. kiwoom/__init__.py +47 -0
  2. kiwoom/_data/kiwoom_api_spec.json +66372 -0
  3. kiwoom/core/__init__.py +6 -0
  4. kiwoom/core/auth.py +349 -0
  5. kiwoom/core/client.py +244 -0
  6. kiwoom/core/errors.py +177 -0
  7. kiwoom/core/platform_paths.py +68 -0
  8. kiwoom/core/profiles.py +143 -0
  9. kiwoom/core/runtime.py +153 -0
  10. kiwoom/core/secrets.py +217 -0
  11. kiwoom/core/settings.py +64 -0
  12. kiwoom/core/token_store.py +186 -0
  13. kiwoom/core/types.py +28 -0
  14. kiwoom/core/ws_client.py +262 -0
  15. kiwoom/realtime/__init__.py +30 -0
  16. kiwoom/realtime/decoders.py +171 -0
  17. kiwoom/realtime/events.py +98 -0
  18. kiwoom/realtime/packets.py +65 -0
  19. kiwoom/realtime/schemas.py +76 -0
  20. kiwoom/realtime/stream.py +213 -0
  21. kiwoom/specs.py +272 -0
  22. kiwoom_cli/README.md +671 -0
  23. kiwoom_cli/__init__.py +9 -0
  24. kiwoom_cli/__main__.py +5 -0
  25. kiwoom_cli/argument_maps.py +561 -0
  26. kiwoom_cli/arguments.py +147 -0
  27. kiwoom_cli/auth_context.py +64 -0
  28. kiwoom_cli/banner.py +125 -0
  29. kiwoom_cli/commands/__init__.py +1 -0
  30. kiwoom_cli/commands/auth.py +406 -0
  31. kiwoom_cli/commands/groups.py +281 -0
  32. kiwoom_cli/commands/mapped.py +74 -0
  33. kiwoom_cli/commands/orders.py +158 -0
  34. kiwoom_cli/commands/spec.py +98 -0
  35. kiwoom_cli/commands/stocks.py +100 -0
  36. kiwoom_cli/commands/streams.py +470 -0
  37. kiwoom_cli/doctor.py +324 -0
  38. kiwoom_cli/errors.py +24 -0
  39. kiwoom_cli/executor/__init__.py +33 -0
  40. kiwoom_cli/executor/condition.py +374 -0
  41. kiwoom_cli/executor/rest.py +132 -0
  42. kiwoom_cli/executor/waits.py +34 -0
  43. kiwoom_cli/executor/websocket.py +131 -0
  44. kiwoom_cli/main.py +203 -0
  45. kiwoom_cli/maps/README.md +99 -0
  46. kiwoom_cli/maps/api_commands.csv +209 -0
  47. kiwoom_cli/maps/arguments.csv +731 -0
  48. kiwoom_cli/maps/order_confirmation_commands.csv +13 -0
  49. kiwoom_cli/maps/order_confirmation_fields.csv +71 -0
  50. kiwoom_cli/maps/order_price_policies.csv +47 -0
  51. kiwoom_cli/maps/order_value_labels.csv +28 -0
  52. kiwoom_cli/maps/positional_arguments.csv +21 -0
  53. kiwoom_cli/order_confirmation.py +167 -0
  54. kiwoom_cli/output.py +136 -0
  55. kiwoom_cli/registry.py +95 -0
  56. kiwoom_cli/safety.py +30 -0
  57. kiwoom_cli/setup.py +533 -0
  58. kwcli-0.1.0.dist-info/METADATA +215 -0
  59. kwcli-0.1.0.dist-info/RECORD +62 -0
  60. kwcli-0.1.0.dist-info/WHEEL +4 -0
  61. kwcli-0.1.0.dist-info/entry_points.txt +2 -0
  62. 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