kwcli 1.0.0__tar.gz → 1.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. {kwcli-1.0.0 → kwcli-1.0.2}/PKG-INFO +2 -2
  2. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/core/auth.py +13 -2
  3. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/core/client.py +13 -2
  4. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/core/errors.py +55 -2
  5. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/core/runtime.py +86 -11
  6. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/core/secrets.py +23 -0
  7. kwcli-1.0.2/kiwoom/core/settings.py +114 -0
  8. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/core/types.py +12 -0
  9. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/core/ws_client.py +2 -4
  10. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/README.md +36 -55
  11. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/maps/README.md +5 -6
  12. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/setup.py +3 -18
  13. {kwcli-1.0.0 → kwcli-1.0.2}/pyproject.toml +1 -1
  14. kwcli-1.0.0/kiwoom/core/settings.py +0 -64
  15. {kwcli-1.0.0 → kwcli-1.0.2}/LICENSE.md +0 -0
  16. {kwcli-1.0.0 → kwcli-1.0.2}/README.md +0 -0
  17. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/__init__.py +0 -0
  18. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/_data/kiwoom_api_spec.json +0 -0
  19. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/core/__init__.py +0 -0
  20. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/core/platform_paths.py +0 -0
  21. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/core/profiles.py +0 -0
  22. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/core/token_store.py +0 -0
  23. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/realtime/__init__.py +0 -0
  24. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/realtime/decoders.py +0 -0
  25. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/realtime/events.py +0 -0
  26. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/realtime/packets.py +0 -0
  27. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/realtime/schemas.py +0 -0
  28. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/realtime/stream.py +0 -0
  29. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/specs.py +0 -0
  30. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/__init__.py +0 -0
  31. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/__main__.py +0 -0
  32. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/argument_maps.py +0 -0
  33. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/arguments.py +0 -0
  34. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/auth_context.py +0 -0
  35. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/banner.py +0 -0
  36. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/commands/__init__.py +0 -0
  37. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/commands/auth.py +0 -0
  38. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/commands/exchange.py +0 -0
  39. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/commands/groups.py +0 -0
  40. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/commands/mapped.py +0 -0
  41. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/commands/orders.py +0 -0
  42. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/commands/spec.py +0 -0
  43. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/commands/stocks.py +0 -0
  44. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/commands/streams.py +0 -0
  45. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/doctor.py +0 -0
  46. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/errors.py +0 -0
  47. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/executor/__init__.py +0 -0
  48. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/executor/condition.py +0 -0
  49. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/executor/rest.py +0 -0
  50. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/executor/waits.py +0 -0
  51. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/executor/websocket.py +0 -0
  52. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/main.py +0 -0
  53. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/maps/api_commands.csv +0 -0
  54. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/maps/arguments.csv +0 -0
  55. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/maps/coupled_arguments.csv +0 -0
  56. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/maps/order_confirmation_commands.csv +0 -0
  57. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/maps/order_confirmation_fields.csv +0 -0
  58. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/maps/order_price_policies.csv +0 -0
  59. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/maps/order_value_labels.csv +0 -0
  60. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/maps/positional_arguments.csv +0 -0
  61. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/order_confirmation.py +0 -0
  62. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/output.py +0 -0
  63. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/registry.py +0 -0
  64. {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/safety.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: kwcli
3
- Version: 1.0.0
3
+ Version: 1.0.2
4
4
  Summary: Kiwoom OpenAPI toolkit
5
5
  License-File: LICENSE.md
6
6
  Requires-Python: >=3.13
@@ -328,8 +328,19 @@ class KiwoomAuth:
328
328
 
329
329
  @staticmethod
330
330
  def _credential_fingerprint(credentials: CredentialSet) -> str:
331
- raw = f"{credentials.appkey}:{credentials.secretkey}".encode("utf-8")
332
- return hashlib.sha256(raw).hexdigest()
331
+ return credential_fingerprint(credentials)
332
+
333
+
334
+ def credential_fingerprint(credentials: CredentialSet) -> str:
335
+ """Non-reversible identity of an appkey/secret pair.
336
+
337
+ Stored beside a cached token so a token issued for one key pair is never
338
+ reused after the credentials change (`_load_valid_token`), and used by the
339
+ runtime to seed a pre-issued token (`KIWOOM_ACCESS_TOKEN`) with the
340
+ fingerprint the auth object will check it against.
341
+ """
342
+ raw = f"{credentials.appkey}:{credentials.secretkey}".encode("utf-8")
343
+ return hashlib.sha256(raw).hexdigest()
333
344
 
334
345
 
335
346
  def _safe_json(response: requests.Response) -> dict:
@@ -6,10 +6,11 @@ import requests
6
6
 
7
7
  from kiwoom.core.auth import KiwoomAuth, get_base_url
8
8
  from kiwoom.core.errors import (
9
- AUTH_RETRY_RETURN_CODES,
10
9
  AuthenticationError,
11
10
  HTTPRequestError,
11
+ auth_retry_code,
12
12
  normalize_return_code,
13
+ raise_for_error_code,
13
14
  )
14
15
  from kiwoom.core.types import Continuation, KiwoomResponse
15
16
 
@@ -81,7 +82,9 @@ class KiwoomClient:
81
82
  }
82
83
 
83
84
  return_code = normalize_return_code(data.get("return_code"))
84
- if return_code in AUTH_RETRY_RETURN_CODES and retry_on_auth_failure:
85
+ # 키움은 만료/무효 토큰을 top-level return_code=8005로도, return_code=3 +
86
+ # return_msg "[8005:...]"로도 돌려준다 — 어느 쪽이든 한 번 재발급 후 재시도.
87
+ if retry_on_auth_failure and auth_retry_code(return_code, data.get("return_msg")) is not None:
85
88
  self.auth.recover_from_auth_failure()
86
89
  return self.request(
87
90
  api_id=resolved_api_id,
@@ -97,6 +100,14 @@ class KiwoomClient:
97
100
  if return_code not in (None, 0):
98
101
  message = f"{message} (return_code={data['return_code']})"
99
102
  raise HTTPRequestError(response.status_code, message)
103
+
104
+ # 키움은 업무 오류도 HTTP 200으로 내려보내고 실패 여부는 본문 return_code에만 담는다.
105
+ if return_code not in (None, 0):
106
+ raise_for_error_code(
107
+ return_code,
108
+ str(data.get("return_msg") or "API 요청에 실패했습니다."),
109
+ )
110
+
100
111
  return _build_response(data, response.headers)
101
112
 
102
113
  def fetch_page(
@@ -1,9 +1,31 @@
1
+ import re
1
2
  from pathlib import Path
2
3
 
3
4
  # Kiwoom auth-expiry return codes that warrant a one-shot credential
4
5
  # recovery + retry (shared by the REST client and the WebSocket client).
5
6
  AUTH_RETRY_RETURN_CODES = frozenset({8005, 8031, 8103})
6
7
 
8
+ # Kiwoom often reports a generic top-level ``return_code`` (e.g. 3 = "인증에
9
+ # 실패했습니다") and puts the specific code inside ``return_msg`` as
10
+ # ``[8005:Token이 유효하지 않습니다]`` (REST) or ``CODE=8005`` (WebSocket).
11
+ _EMBEDDED_CODE_RE = re.compile(r"\[(\d{3,5}):|CODE=(\d{3,5})")
12
+
13
+
14
+ def embedded_return_code(return_msg: object) -> int | None:
15
+ """Return the specific code embedded in a Kiwoom ``return_msg``, if any."""
16
+ match = _EMBEDDED_CODE_RE.search(str(return_msg or ""))
17
+ if match is None:
18
+ return None
19
+ return int(match.group(1) or match.group(2))
20
+
21
+
22
+ def auth_retry_code(return_code: int | None, return_msg: object) -> int | None:
23
+ """The auth-expiry code (top-level or embedded) that warrants recovery, else None."""
24
+ if return_code in AUTH_RETRY_RETURN_CODES:
25
+ return return_code
26
+ embedded = embedded_return_code(return_msg)
27
+ return embedded if embedded in AUTH_RETRY_RETURN_CODES else None
28
+
7
29
 
8
30
  def normalize_return_code(value: object) -> int | None:
9
31
  """Normalize a Kiwoom ``return_code`` payload value.
@@ -137,6 +159,13 @@ class DeviceAuthenticationError(AuthenticationError):
137
159
  self.return_msg = return_msg
138
160
 
139
161
 
162
+ class DemoUnsupportedError(APIError):
163
+ """Raised when the API exists but is not served in demo (모의투자) mode."""
164
+
165
+ def __init__(self, return_code: int, return_msg: str):
166
+ super().__init__(return_code, f"모의투자에서 지원하지 않는 API입니다. ({return_msg})")
167
+
168
+
140
169
  INPUT_VALIDATION_CODES = {
141
170
  1501,
142
171
  1504,
@@ -151,15 +180,35 @@ INPUT_VALIDATION_CODES = {
151
180
  1687,
152
181
  8020,
153
182
  }
154
- RATE_LIMIT_CODES = {1700}
155
- SYMBOL_NOT_FOUND_CODES = {1901, 1902}
183
+ RATE_LIMIT_CODES = {1700, 1701, 1702}
184
+ SYMBOL_NOT_FOUND_CODES = {1901, 1902, 1903}
156
185
  INVALID_CREDENTIAL_CODES = {8001, 8002, 8011, 8012}
157
186
  INVALID_TOKEN_CODES = {8003, 8005, 8006, 8009, 8015, 8016}
158
187
  MODE_MISMATCH_CODES = {8030, 8031}
159
188
  DEVICE_AUTH_CODES = {8010, 8040, 8050, 8103}
189
+ DEMO_UNSUPPORTED_CODES = {8104}
190
+
191
+
192
+ _CLASSIFIED_CODES = (
193
+ INPUT_VALIDATION_CODES
194
+ | RATE_LIMIT_CODES
195
+ | SYMBOL_NOT_FOUND_CODES
196
+ | INVALID_CREDENTIAL_CODES
197
+ | INVALID_TOKEN_CODES
198
+ | MODE_MISMATCH_CODES
199
+ | DEVICE_AUTH_CODES
200
+ | DEMO_UNSUPPORTED_CODES
201
+ )
160
202
 
161
203
 
162
204
  def raise_for_error_code(return_code: int, return_msg: str) -> None:
205
+ # A generic top-level code (3, 5, ...) with a specific code embedded in the
206
+ # message is classified by the embedded code, so callers get the same
207
+ # error class either way; the message keeps Kiwoom's original text.
208
+ if return_code not in _CLASSIFIED_CODES:
209
+ embedded = embedded_return_code(return_msg)
210
+ if embedded in _CLASSIFIED_CODES:
211
+ return_code = embedded
163
212
  if return_code in INPUT_VALIDATION_CODES:
164
213
  raise InputValidationError(return_code, return_msg)
165
214
  if return_code in RATE_LIMIT_CODES:
@@ -174,4 +223,8 @@ def raise_for_error_code(return_code: int, return_msg: str) -> None:
174
223
  raise ModeMismatchError(return_code, return_msg)
175
224
  if return_code in DEVICE_AUTH_CODES:
176
225
  raise DeviceAuthenticationError(return_code, return_msg)
226
+ if return_code in DEMO_UNSUPPORTED_CODES:
227
+ raise DemoUnsupportedError(return_code, return_msg)
228
+ # 1999(예기치 못한 오류)·8200(법인 미지원)처럼 분류해도 대응이 달라지지 않는 코드는
229
+ # 원문 메시지를 그대로 노출하는 APIError로 남긴다.
177
230
  raise APIError(return_code, return_msg)
@@ -1,18 +1,30 @@
1
1
  from dataclasses import dataclass
2
+ from datetime import UTC, datetime
2
3
  from typing import Literal
3
4
 
4
- from kiwoom.core.auth import KiwoomAuth, get_base_url as _auth_base_url, get_ws_base_url as _auth_ws_base_url
5
+ from kiwoom.core.auth import (
6
+ KiwoomAuth,
7
+ credential_fingerprint,
8
+ get_base_url as _auth_base_url,
9
+ get_ws_base_url as _auth_ws_base_url,
10
+ )
5
11
  from kiwoom.core.client import KiwoomClient
6
12
  from kiwoom.core.errors import ModeNotConfiguredError
7
13
  from kiwoom.core.profiles import AuthProfile, get_current_profile, get_profile
8
14
  from kiwoom.core.secrets import SecretProvider, default_secret_provider
9
- from kiwoom.core.settings import get_mode_from_env, get_profile_from_env
10
- from kiwoom.core.token_store import FileTokenStore, MemoryTokenStore
11
- from kiwoom.core.types import Mode, normalize_mode
15
+ from kiwoom.core.settings import (
16
+ ACCESS_TOKEN_ENV_VAR,
17
+ TOKEN_STORE_ENV_VAR,
18
+ PreissuedToken,
19
+ get_mode_from_env,
20
+ get_preissued_token_from_env,
21
+ get_profile_from_env,
22
+ get_token_store_kind_from_env,
23
+ )
24
+ from kiwoom.core.token_store import FileTokenStore, MemoryTokenStore, TokenRecord
25
+ from kiwoom.core.types import Mode, TokenStoreKind, normalize_mode
12
26
  from kiwoom.core.ws_client import KiwoomWebSocketClient
13
27
 
14
- TokenStoreKind = Literal["file", "memory"]
15
-
16
28
  SelectionSource = Literal[
17
29
  "--profile",
18
30
  "KIWOOM_PROFILE",
@@ -102,15 +114,28 @@ def get_auth(
102
114
  *,
103
115
  profile: str | None = None,
104
116
  secret_provider: SecretProvider | None = None,
105
- token_store_kind: TokenStoreKind = "file",
117
+ token_store_kind: TokenStoreKind | None = None,
106
118
  ) -> KiwoomAuth:
119
+ """Build the auth object for the selected target.
120
+
121
+ `token_store_kind=None` (the default) resolves via `resolve_token_store_kind`,
122
+ i.e. `KIWOOM_TOKEN_STORE` when set, else the on-disk file cache. Pass an
123
+ explicit kind to override the environment (setup does this for its
124
+ throwaway validation auth).
125
+ """
107
126
  selection = _resolve_selection(mode=mode, profile=profile)
108
127
  profile_alias = selection.profile.alias if selection.profile else None
128
+ provider = secret_provider or default_secret_provider(profile=profile_alias)
109
129
  return KiwoomAuth(
110
130
  mode=selection.mode,
111
131
  profile=profile_alias,
112
- secret_provider=secret_provider or default_secret_provider(profile=profile_alias),
113
- token_store=_build_token_store(token_store_kind),
132
+ secret_provider=provider,
133
+ token_store=_build_token_store(
134
+ resolve_token_store_kind(token_store_kind),
135
+ mode=selection.mode,
136
+ profile=profile_alias,
137
+ secret_provider=provider,
138
+ ),
114
139
  )
115
140
 
116
141
 
@@ -145,9 +170,59 @@ def get_ws_base_url(mode: str | None = None, *, profile: str | None = None) -> s
145
170
  return _auth_ws_base_url(resolve_mode(mode, profile=profile))
146
171
 
147
172
 
148
- def _build_token_store(token_store_kind: TokenStoreKind):
173
+ def resolve_token_store_kind(token_store_kind: TokenStoreKind | None = None) -> TokenStoreKind:
174
+ """Explicit argument > `KIWOOM_TOKEN_STORE` > `file`."""
175
+ if token_store_kind is not None:
176
+ return token_store_kind
177
+ return get_token_store_kind_from_env() or "file"
178
+
179
+
180
+ def _build_token_store(
181
+ token_store_kind: TokenStoreKind,
182
+ *,
183
+ mode: Mode,
184
+ profile: str | None,
185
+ secret_provider: SecretProvider,
186
+ ):
187
+ preissued = get_preissued_token_from_env()
149
188
  if token_store_kind == "file":
189
+ if preissued is not None:
190
+ raise ValueError(f"{ACCESS_TOKEN_ENV_VAR} requires {TOKEN_STORE_ENV_VAR}=memory.")
150
191
  return FileTokenStore()
151
192
  if token_store_kind == "memory":
152
- return MemoryTokenStore()
193
+ store = MemoryTokenStore()
194
+ if preissued is not None:
195
+ _seed_preissued_token(store, preissued, mode=mode, profile=profile, secret_provider=secret_provider)
196
+ return store
153
197
  raise ValueError(f"unsupported token_store_kind: {token_store_kind}")
198
+
199
+
200
+ def _seed_preissued_token(
201
+ store: MemoryTokenStore,
202
+ preissued: PreissuedToken,
203
+ *,
204
+ mode: Mode,
205
+ profile: str | None,
206
+ secret_provider: SecretProvider,
207
+ ) -> None:
208
+ """Place a token handed in via env into the in-memory store.
209
+
210
+ The record carries the fingerprint of the credentials this process resolves,
211
+ so `KiwoomAuth._load_valid_token` accepts it exactly as it would a token it
212
+ issued itself; if the credentials are missing the token is not seeded and the
213
+ usual credentials error surfaces on first use.
214
+ """
215
+ credentials = secret_provider.get_credentials(mode)
216
+ if credentials is None:
217
+ return
218
+ store.save(
219
+ TokenRecord(
220
+ access_token=preissued.access_token,
221
+ token_type="bearer",
222
+ expires_at=preissued.expires_at.astimezone(UTC),
223
+ mode=mode,
224
+ profile=profile,
225
+ credential_fingerprint=credential_fingerprint(credentials),
226
+ saved_at=datetime.now(UTC),
227
+ )
228
+ )
@@ -25,6 +25,29 @@ class SecretProvider(Protocol):
25
25
  def clear_credentials(self, mode: Mode) -> bool: ...
26
26
 
27
27
 
28
+ class StaticSecretProvider:
29
+ """Read-only provider holding one appkey/secret pair in memory.
30
+
31
+ Used where credentials arrive for a single operation and must not be
32
+ persisted: setup's validation call before anything is stored, and a
33
+ server issuing a token for credentials received on a request.
34
+ """
35
+
36
+ def __init__(self, appkey: str, secretkey: str, *, source: str = "static") -> None:
37
+ self.appkey = appkey
38
+ self.secretkey = secretkey
39
+ self.source = source
40
+
41
+ def get_credentials(self, mode: Mode) -> CredentialSet | None:
42
+ return CredentialSet(appkey=self.appkey, secretkey=self.secretkey, source=self.source)
43
+
44
+ def set_credentials(self, mode: Mode, appkey: str, secretkey: str) -> None:
45
+ raise RuntimeError("정적 자격 증명 공급자는 읽기 전용입니다.")
46
+
47
+ def clear_credentials(self, mode: Mode) -> bool:
48
+ raise RuntimeError("정적 자격 증명 공급자는 읽기 전용입니다.")
49
+
50
+
28
51
  class EnvSecretProvider:
29
52
  def get_credentials(self, mode: Mode) -> CredentialSet | None:
30
53
  appkey_var, secretkey_var = env_var_names(mode)
@@ -0,0 +1,114 @@
1
+ import os
2
+ from dataclasses import dataclass
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from tempfile import NamedTemporaryFile
6
+
7
+ from kiwoom.core.errors import InvalidModeError, SettingsError
8
+ from kiwoom.core.platform_paths import config_dir, ensure_private_directory, protect_file
9
+ from kiwoom.core.types import Mode, TokenStoreKind, normalize_mode, normalize_token_store_kind
10
+
11
+
12
+ SETTINGS_FILE_NAME = "settings.json"
13
+ MODE_ENV_VAR = "KIWOOM_MODE"
14
+ PROFILE_ENV_VAR = "KIWOOM_PROFILE"
15
+ TOKEN_STORE_ENV_VAR = "KIWOOM_TOKEN_STORE"
16
+ ACCESS_TOKEN_ENV_VAR = "KIWOOM_ACCESS_TOKEN"
17
+ ACCESS_TOKEN_EXPIRES_AT_ENV_VAR = "KIWOOM_ACCESS_TOKEN_EXPIRES_AT"
18
+
19
+
20
+ def get_mode_from_env() -> Mode | None:
21
+ value = os.getenv(MODE_ENV_VAR)
22
+ if not value:
23
+ return None
24
+ try:
25
+ return normalize_mode(value)
26
+ except ValueError as exc:
27
+ raise InvalidModeError(value) from exc
28
+
29
+
30
+ def get_profile_from_env() -> str | None:
31
+ value = os.getenv(PROFILE_ENV_VAR)
32
+ if not value:
33
+ return None
34
+ return value.strip() or None
35
+
36
+
37
+ def get_token_store_kind_from_env() -> TokenStoreKind | None:
38
+ """Return the token store kind selected by KIWOOM_TOKEN_STORE, or None.
39
+
40
+ `file` (default when unset) caches the access token under the user cache
41
+ directory; `memory` keeps it in-process only, so a short-lived process
42
+ (e.g. one MCP request → one kiwoomcli subprocess) leaves no token on disk.
43
+ """
44
+ value = os.getenv(TOKEN_STORE_ENV_VAR)
45
+ if not value:
46
+ return None
47
+ return normalize_token_store_kind(value.strip().lower())
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class PreissuedToken:
52
+ access_token: str
53
+ expires_at: datetime
54
+
55
+
56
+ def get_preissued_token_from_env() -> PreissuedToken | None:
57
+ """Return the token in KIWOOM_ACCESS_TOKEN + KIWOOM_ACCESS_TOKEN_EXPIRES_AT, or None.
58
+
59
+ A caller that already holds a valid token for the same credentials (e.g. a
60
+ server that issued it once and keeps it in memory) hands it to a short-lived
61
+ process this way, so the process does not spend a `/oauth2/token` call of
62
+ its own. Both variables must be set together; expires_at is ISO 8601 with a
63
+ timezone. Only meaningful with KIWOOM_TOKEN_STORE=memory (see runtime).
64
+ """
65
+ token = os.getenv(ACCESS_TOKEN_ENV_VAR)
66
+ expires_raw = os.getenv(ACCESS_TOKEN_EXPIRES_AT_ENV_VAR)
67
+ if not token and not expires_raw:
68
+ return None
69
+ if not token or not expires_raw:
70
+ raise ValueError(
71
+ f"{ACCESS_TOKEN_ENV_VAR} and {ACCESS_TOKEN_EXPIRES_AT_ENV_VAR} must be set together."
72
+ )
73
+ try:
74
+ expires_at = datetime.fromisoformat(expires_raw.strip())
75
+ except ValueError as exc:
76
+ raise ValueError(f"{ACCESS_TOKEN_EXPIRES_AT_ENV_VAR} must be an ISO 8601 datetime.") from exc
77
+ if expires_at.tzinfo is None:
78
+ raise ValueError(f"{ACCESS_TOKEN_EXPIRES_AT_ENV_VAR} must include a timezone offset.")
79
+ return PreissuedToken(access_token=token.strip(), expires_at=expires_at)
80
+
81
+
82
+ def settings_path() -> Path:
83
+ return config_dir() / SETTINGS_FILE_NAME
84
+
85
+
86
+ def snapshot_settings() -> str | None:
87
+ path = settings_path()
88
+ if not path.exists():
89
+ return None
90
+ try:
91
+ return path.read_text(encoding="utf-8")
92
+ except OSError as exc:
93
+ raise SettingsError("settings.json 파일을 읽을 수 없습니다.") from exc
94
+
95
+
96
+ def restore_settings(snapshot: str | None) -> None:
97
+ path = settings_path()
98
+ if snapshot is None:
99
+ path.unlink(missing_ok=True)
100
+ return
101
+
102
+ directory = ensure_private_directory(config_dir(), strict=False)
103
+ with NamedTemporaryFile(
104
+ "w",
105
+ encoding="utf-8",
106
+ dir=directory,
107
+ delete=False,
108
+ ) as tmp:
109
+ tmp.write(snapshot)
110
+ temp_path = Path(tmp.name)
111
+
112
+ protect_file(temp_path, strict=False)
113
+ temp_path.replace(path)
114
+ protect_file(path, strict=False)
@@ -7,6 +7,10 @@ Mode = Literal["real", "demo"]
7
7
 
8
8
  VALID_MODES = ("real", "demo")
9
9
 
10
+ TokenStoreKind = Literal["file", "memory"]
11
+
12
+ VALID_TOKEN_STORE_KINDS = ("file", "memory")
13
+
10
14
 
11
15
  def normalize_mode(value: str) -> Mode:
12
16
  if value not in VALID_MODES:
@@ -14,6 +18,14 @@ def normalize_mode(value: str) -> Mode:
14
18
  return value # type: ignore[return-value]
15
19
 
16
20
 
21
+ def normalize_token_store_kind(value: str) -> TokenStoreKind:
22
+ if value not in VALID_TOKEN_STORE_KINDS:
23
+ raise ValueError(
24
+ f"Unsupported token store kind: {value!r}. Expected one of {VALID_TOKEN_STORE_KINDS}."
25
+ )
26
+ return value # type: ignore[return-value]
27
+
28
+
17
29
  @dataclass(frozen=True)
18
30
  class Continuation:
19
31
  has_next: bool
@@ -5,7 +5,7 @@ from collections.abc import AsyncIterator, Callable
5
5
  from typing import Any
6
6
 
7
7
  from kiwoom.core.auth import KiwoomAuth, get_ws_base_url
8
- from kiwoom.core.errors import AUTH_RETRY_RETURN_CODES, normalize_return_code
8
+ from kiwoom.core.errors import auth_retry_code, normalize_return_code
9
9
 
10
10
  logger = logging.getLogger(__name__)
11
11
 
@@ -194,9 +194,7 @@ class KiwoomWebSocketClient:
194
194
 
195
195
 
196
196
  def _is_auth_retry_login_error(exc: WebSocketLoginError) -> bool:
197
- if exc.return_code in AUTH_RETRY_RETURN_CODES:
198
- return True
199
- return any(f"CODE={code}" in exc.return_msg for code in AUTH_RETRY_RETURN_CODES)
197
+ return auth_retry_code(exc.return_code, exc.return_msg) is not None
200
198
 
201
199
 
202
200
  async def _default_connect(uri: str, timeout_seconds: int) -> Any:
@@ -16,8 +16,9 @@ actions. Runtime/auth logic stays in `kiwoom/core/`; CLI use cases stay under
16
16
  ## Quickstart
17
17
 
18
18
  The everyday flow for both humans and AI agents is discovery first, then a
19
- single command. See [`docs/MANUAL_CLI.md`](../docs/MANUAL_CLI.md) for the user
20
- manual, and use `-h` on any command for its live, mapped contract.
19
+ single command. The user manual is `docs/NOTICE_CLI.md` in this delivery tree
20
+ (the same text becomes the PyPI package README). Use `-h` on any command for
21
+ its live, mapped contract.
21
22
 
22
23
  ```sh
23
24
  1. kiwoomcli setup # onboard: alias, demo/real, keys, verify
@@ -554,7 +555,7 @@ separate account/`.env` for 금현물 where the demo account differs. All order
554
555
  writes (domestic stock/credit/gold and overseas) are `guarded` and submit with
555
556
  `--confirm`; no rows remain `preview-only`.
556
557
 
557
- See [`docs/MANUAL_CLI.md`](../docs/MANUAL_CLI.md) for installation, command
558
+ See [`docs/NOTICE_CLI.md`](../docs/NOTICE_CLI.md) for installation, command
558
559
  groups, output formats, streaming, and order safety guidance.
559
560
 
560
561
  Policy-design API families:
@@ -625,7 +626,7 @@ Rules:
625
626
  - New CLI behavior must acquire runtime objects through the package facade:
626
627
  `get_auth`, `get_client`, or `get_ws_client`.
627
628
  - New implemented CLI behavior must update this README and
628
- `docs/MANUAL_CLI.md`.
629
+ `docs/NOTICE_CLI.md`.
629
630
 
630
631
  Preferred runtime pattern:
631
632
 
@@ -638,40 +639,46 @@ response = client.fetch_page(api_id=api_id, path=path, body=body, method=method)
638
639
 
639
640
  ## Internal Layout
640
641
 
641
- Current CLI implementation layout:
642
+ Current CLI implementation layout (see [`docs/DEVELOPER_CLI.md`](../docs/DEVELOPER_CLI.md)):
642
643
 
643
644
  ```text
644
645
  kiwoom_cli/
645
646
  main.py # router only: parse args, dispatch to args.handler, error funnel
646
647
  arguments.py
647
- executor.py
648
+ argument_maps.py
648
649
  output.py
649
650
  registry.py
650
651
  safety.py
651
652
  setup.py # setup command parser/handler + onboarding logic
652
653
  doctor.py # doctor command parser/handler
653
654
  order_confirmation.py
655
+ banner.py
656
+ errors.py
657
+ auth_context.py
654
658
  README.md
655
- commands/ # one module per command group, each add_*_parser + handlers
659
+ executor/ # REST, WebSocket, condition search, waits
660
+ rest.py
661
+ websocket.py
662
+ condition.py
663
+ waits.py
664
+ commands/
665
+ groups.py # MappedGroup table for pure mapped groups
666
+ mapped.py # shared mapped REST parser/runner
667
+ stocks.py # stocks-only parser (domestic/overseas)
668
+ orders.py # order writes + --confirm
669
+ streams.py
670
+ exchange.py
656
671
  auth.py
657
672
  spec.py
658
- accounts.py
659
- candles.py
660
- elws.py
661
- etfs.py
662
- investors.py
663
- orderbooks.py
664
- orders.py
665
- quotes.py
666
- rankings.py
667
- securities_lending.py
668
- short_selling.py
669
- stocks.py
670
- streams.py
671
- themes.py
672
673
  maps/
673
674
  api_commands.csv
674
675
  arguments.csv
676
+ positional_arguments.csv
677
+ coupled_arguments.csv
678
+ order_price_policies.csv
679
+ order_confirmation_commands.csv
680
+ order_confirmation_fields.csv
681
+ order_value_labels.csv
675
682
  README.md
676
683
  ```
677
684
 
@@ -679,39 +686,11 @@ kiwoom_cli/
679
686
  domestic stock 206, and US stock 129. All rows are implemented; coverage is
680
687
  `public` (252) or `guarded` (85).
681
688
 
682
- As additional domain commands are implemented, add small modules and maps:
683
-
684
- ```text
685
- kiwoom_cli/
686
- registry.py
687
- arguments.py
688
- executor.py
689
- output.py
690
- safety.py
691
- commands/
692
- accounts.py
693
- stocks.py
694
- quotes.py
695
- orderbooks.py
696
- candles.py
697
- rankings.py
698
- etfs.py
699
- elws.py
700
- investors.py
701
- short_selling.py
702
- securities_lending.py
703
- themes.py
704
- orders.py
705
- streams.py
706
- maps/
707
- api_commands.csv
708
- arguments.csv
709
- positional_arguments.csv
710
- order_price_policies.csv
711
- order_confirmation_commands.csv
712
- order_confirmation_fields.csv
713
- order_value_labels.csv
714
- ```
689
+ New commands go into `maps/*.csv` and `commands/groups.py` (`MappedGroup`)
690
+ when the behavior is a pure mapping. Only stocks/orders/streams/exchange keep
691
+ dedicated modules. Do not add a new `commands/<group>.py` per API family.
692
+ See [`docs/DEVELOPER_CLI.md`](../docs/DEVELOPER_CLI.md) and
693
+ [`docs/DEVELOPER_EXTEND.md`](../docs/DEVELOPER_EXTEND.md).
715
694
 
716
695
  Domain modules should register command groups and delegate to shared execution.
717
696
  They should not call `requests` directly, issue tokens directly, construct
@@ -719,7 +698,9 @@ ad-hoc request bodies, or bypass common safety checks.
719
698
 
720
699
  ## Documentation Contract
721
700
 
722
- - [`docs/MANUAL_CLI.md`](../docs/MANUAL_CLI.md) is the Korean user manual.
701
+ - [`docs/NOTICE_CLI.md`](../docs/NOTICE_CLI.md) is the Korean user manual.
702
+ - [`docs/DEVELOPER_CLI.md`](../docs/DEVELOPER_CLI.md) is the architecture /
703
+ maintenance guide (command pipeline, safety gate, how to add commands).
723
704
  - This README documents the package structure and complete command inventory.
724
705
  - `kiwoom_cli/maps/README.md` documents map files and current API counts.
725
706
  - Maps and documentation must remain synchronized with
@@ -2,8 +2,8 @@
2
2
 
3
3
  This folder contains the runtime maps bundled with the full Kiwoom CLI.
4
4
  The current map covers all 337 APIs: OAuth 2, domestic stock 206, and US stock
5
- 129. `tools/process_active_kiwoom_cli.py` copies the generated maps from
6
- `kiwoom-dev` and validates them against the bundled specification.
5
+ 129. The delivery pipeline copies generated maps from the development registry
6
+ and validates them against the bundled specification.
7
7
 
8
8
  ## Files
9
9
 
@@ -76,16 +76,15 @@ coverage is either `public` (252) or `guarded` (85).
76
76
 
77
77
  ## Validation Scope
78
78
 
79
- `tools/process_active_kiwoom_cli.py` validates the maps before and after
80
- replacing the Active Project output:
79
+ The bundled maps are validated against the packaged spec before they ship:
81
80
 
82
81
  - bundled spec and `api_commands.csv` contain the same 337 API IDs;
83
82
  - category counts are OAuth 2, domestic stock 206, and US stock 129;
84
83
  - domestic and overseas command paths use their corresponding parser roots;
85
84
  - every map row is implemented and Python sources compile;
86
85
  - `exchange.py` and `coupled_arguments.csv` remain available;
87
- - `kiwoom_cli_prove/domestic` and `kiwoom_cli_prove/overseas` contain only
88
- mapped `{api_id}/{command}.{txt|png}` evidence.
86
+ - CLI prove evidence, when present, is stored as
87
+ `{domestic|overseas}/{api_id}/{command}.{txt|png}`.
89
88
 
90
89
  Domestic and overseas order writes remain guarded: without `--confirm`, the CLI
91
90
  prints a 미전송 주문 확인 and does not submit the order.
@@ -1,5 +1,5 @@
1
1
  import argparse
2
- from dataclasses import dataclass, replace
2
+ from dataclasses import replace
3
3
  from datetime import timedelta, timezone
4
4
  from getpass import getpass
5
5
  import sys
@@ -16,8 +16,8 @@ from kiwoom.core.profiles import (
16
16
  validate_profile_alias,
17
17
  )
18
18
  from kiwoom.core.secrets import (
19
- CredentialSet,
20
19
  EnvSecretProvider,
20
+ StaticSecretProvider,
21
21
  default_secret_provider,
22
22
  )
23
23
  from kiwoom.core.settings import restore_settings, snapshot_settings
@@ -85,21 +85,6 @@ def handle_setup(args: argparse.Namespace) -> None:
85
85
  )
86
86
 
87
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) -> bool:
100
- raise RuntimeError("임시 자격 증명 공급자는 읽기 전용입니다.")
101
-
102
-
103
88
  def run_setup(*, alias: str | None = None, mode: str | None = None) -> None:
104
89
  print_banner()
105
90
  print()
@@ -327,7 +312,7 @@ def _validate_credentials(mode: Mode, appkey: str, secretkey: str) -> tuple[Toke
327
312
  _wz_note("입력한 자격 증명을 확인하는 중입니다…")
328
313
  auth = get_auth(
329
314
  mode=mode,
330
- secret_provider=_EphemeralSecretProvider(appkey=appkey, secretkey=secretkey),
315
+ secret_provider=StaticSecretProvider(appkey, secretkey, source="prompt"),
331
316
  token_store_kind="memory",
332
317
  )
333
318
  client = get_client(auth=auth)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kwcli"
3
- version = "1.0.0"
3
+ version = "1.0.2"
4
4
  description = "Kiwoom OpenAPI toolkit"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -1,64 +0,0 @@
1
- import os
2
- from pathlib import Path
3
- from tempfile import NamedTemporaryFile
4
-
5
- from kiwoom.core.errors import InvalidModeError, SettingsError
6
- from kiwoom.core.platform_paths import config_dir, ensure_private_directory, protect_file
7
- from kiwoom.core.types import Mode, normalize_mode
8
-
9
-
10
- SETTINGS_FILE_NAME = "settings.json"
11
- MODE_ENV_VAR = "KIWOOM_MODE"
12
- PROFILE_ENV_VAR = "KIWOOM_PROFILE"
13
-
14
-
15
- def get_mode_from_env() -> Mode | None:
16
- value = os.getenv(MODE_ENV_VAR)
17
- if not value:
18
- return None
19
- try:
20
- return normalize_mode(value)
21
- except ValueError as exc:
22
- raise InvalidModeError(value) from exc
23
-
24
-
25
- def get_profile_from_env() -> str | None:
26
- value = os.getenv(PROFILE_ENV_VAR)
27
- if not value:
28
- return None
29
- return value.strip() or None
30
-
31
-
32
- def settings_path() -> Path:
33
- return config_dir() / SETTINGS_FILE_NAME
34
-
35
-
36
- def snapshot_settings() -> str | None:
37
- path = settings_path()
38
- if not path.exists():
39
- return None
40
- try:
41
- return path.read_text(encoding="utf-8")
42
- except OSError as exc:
43
- raise SettingsError("settings.json 파일을 읽을 수 없습니다.") from exc
44
-
45
-
46
- def restore_settings(snapshot: str | None) -> None:
47
- path = settings_path()
48
- if snapshot is None:
49
- path.unlink(missing_ok=True)
50
- return
51
-
52
- directory = ensure_private_directory(config_dir(), strict=False)
53
- with NamedTemporaryFile(
54
- "w",
55
- encoding="utf-8",
56
- dir=directory,
57
- delete=False,
58
- ) as tmp:
59
- tmp.write(snapshot)
60
- temp_path = Path(tmp.name)
61
-
62
- protect_file(temp_path, strict=False)
63
- temp_path.replace(path)
64
- protect_file(path, strict=False)
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