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
@@ -0,0 +1,6 @@
1
+ """Runtime primitives for the Kiwoom OpenAPI package.
2
+
3
+ Import concrete submodules (``kiwoom.core.errors``, ``kiwoom.core.secrets``,
4
+ ...) or use the canonical package facade ``kiwoom`` (``get_auth``,
5
+ ``get_client``, ``get_ws_client``, profiles, types).
6
+ """
kiwoom/core/auth.py ADDED
@@ -0,0 +1,349 @@
1
+ from dataclasses import dataclass
2
+ from datetime import UTC, datetime, timedelta, timezone
3
+ import hashlib
4
+ import os
5
+ from typing import Literal
6
+
7
+ import requests
8
+
9
+ from kiwoom.core.errors import (
10
+ AuthenticationError,
11
+ CredentialsNotFoundError,
12
+ HTTPRequestError,
13
+ InvalidTokenCacheError,
14
+ raise_for_error_code,
15
+ )
16
+ from kiwoom.core.secrets import CredentialSet, SecretProvider
17
+ from kiwoom.core.token_store import TokenRecord
18
+ from kiwoom.core.types import Mode, normalize_mode
19
+
20
+
21
+ KST = timezone(timedelta(hours=9))
22
+ TOKEN_PATH = "/oauth2/token"
23
+ REVOKE_PATH = "/oauth2/revoke"
24
+ BASE_URL_ENV_VARS: dict[Mode, str] = {
25
+ "real": "PRD",
26
+ "demo": "MOCK",
27
+ }
28
+ WS_BASE_URL_ENV_VARS: dict[Mode, str] = {
29
+ "real": "W_PRD",
30
+ "demo": "W_MOCK",
31
+ }
32
+ DEFAULT_BASE_URLS: dict[Mode, str] = {
33
+ "real": "https://api.kiwoom.com",
34
+ "demo": "https://mockapi.kiwoom.com",
35
+ }
36
+ DEFAULT_WS_BASE_URLS: dict[Mode, str] = {
37
+ "real": "wss://api.kiwoom.com:10000",
38
+ "demo": "wss://mockapi.kiwoom.com:10000",
39
+ }
40
+
41
+
42
+ def get_base_url(mode: Mode) -> str:
43
+ resolved_mode = normalize_mode(mode)
44
+ return _endpoint_from_env(BASE_URL_ENV_VARS[resolved_mode], DEFAULT_BASE_URLS[resolved_mode])
45
+
46
+
47
+ def get_ws_base_url(mode: Mode) -> str:
48
+ resolved_mode = normalize_mode(mode)
49
+ return _endpoint_from_env(WS_BASE_URL_ENV_VARS[resolved_mode], DEFAULT_WS_BASE_URLS[resolved_mode])
50
+
51
+
52
+ def endpoint_env_var_names(mode: Mode) -> tuple[str, str]:
53
+ resolved_mode = normalize_mode(mode)
54
+ return BASE_URL_ENV_VARS[resolved_mode], WS_BASE_URL_ENV_VARS[resolved_mode]
55
+
56
+
57
+ def _endpoint_from_env(name: str, default: str) -> str:
58
+ value = os.getenv(name, "").strip()
59
+ return value.rstrip("/") if value else default
60
+
61
+
62
+ NextAuthAction = Literal[
63
+ "use_cached_token",
64
+ "issue_before_request",
65
+ "refresh_before_request",
66
+ "credentials_missing",
67
+ ]
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class AuthStatus:
72
+ mode: Mode
73
+ credential_source: str | None
74
+ has_credentials: bool
75
+ has_token: bool
76
+ token_saved_at: datetime | None
77
+ token_expires_at: datetime | None
78
+ token_valid: bool
79
+ token_reusable: bool
80
+ next_auth_action: NextAuthAction
81
+ token_warning: str | None
82
+ profile: str | None = None
83
+
84
+
85
+ class KiwoomAuth:
86
+ def __init__(
87
+ self,
88
+ mode: Mode,
89
+ secret_provider: SecretProvider,
90
+ token_store,
91
+ *,
92
+ profile: str | None = None,
93
+ refresh_buffer_seconds: int = 600,
94
+ timeout_seconds: int = 30,
95
+ ) -> None:
96
+ self.mode = normalize_mode(mode)
97
+ self.secret_provider = secret_provider
98
+ self.token_store = token_store
99
+ self.profile = profile
100
+ self.timeout_seconds = timeout_seconds
101
+
102
+ if refresh_buffer_seconds < 0:
103
+ raise ValueError("refresh_buffer_seconds는 0 이상이어야 합니다.")
104
+ if refresh_buffer_seconds > 60 * 60 * 12:
105
+ raise ValueError("refresh_buffer_seconds 값이 너무 큽니다.")
106
+ self.refresh_buffer = timedelta(seconds=refresh_buffer_seconds)
107
+
108
+ def get_access_token(self) -> str:
109
+ record = self._load_valid_token()
110
+ if record is not None:
111
+ return record.access_token
112
+ return self.refresh_access_token()
113
+
114
+ def refresh_access_token(self) -> str:
115
+ credentials = self.secret_provider.get_credentials(self.mode)
116
+ if credentials is None:
117
+ raise CredentialsNotFoundError(self.mode)
118
+
119
+ payload = {
120
+ "grant_type": "client_credentials",
121
+ "appkey": credentials.appkey,
122
+ "secretkey": credentials.secretkey,
123
+ }
124
+ response = requests.post(
125
+ f"{get_base_url(self.mode)}{TOKEN_PATH}",
126
+ json=payload,
127
+ headers={"Content-Type": "application/json;charset=UTF-8"},
128
+ timeout=self.timeout_seconds,
129
+ )
130
+ data = _safe_json(response)
131
+ if response.status_code >= 400:
132
+ _raise_structured_error(
133
+ response.status_code,
134
+ data,
135
+ fallback_message="토큰 요청에 실패했습니다.",
136
+ )
137
+ if data.get("return_code") not in (None, 0):
138
+ raise_for_error_code(
139
+ int(data["return_code"]),
140
+ str(data.get("return_msg", "인증에 실패했습니다.")),
141
+ )
142
+
143
+ access_token = data.get("token")
144
+ token_type = str(data.get("token_type", "bearer")).lower()
145
+ expires_dt = data.get("expires_dt")
146
+ if not access_token or not expires_dt:
147
+ raise AuthenticationError("토큰 응답에 필요한 값이 없습니다.")
148
+
149
+ record = TokenRecord(
150
+ access_token=access_token,
151
+ token_type=token_type,
152
+ expires_at=self._parse_kiwoom_expiry(expires_dt),
153
+ mode=self.mode,
154
+ profile=self.profile,
155
+ credential_fingerprint=self._credential_fingerprint(credentials),
156
+ saved_at=datetime.now(UTC),
157
+ )
158
+ self.token_store.save(record)
159
+ return record.access_token
160
+
161
+ def authorization_header(self) -> str:
162
+ return f"Bearer {self.get_access_token()}"
163
+
164
+ def clear_token(self) -> None:
165
+ self.token_store.clear(self.mode, profile=self.profile)
166
+
167
+ def recover_from_auth_failure(self) -> str:
168
+ self.clear_token()
169
+ return self.refresh_access_token()
170
+
171
+ def revoke_access_token(self) -> None:
172
+ record = self._load_cached_token_for_revoke()
173
+ if record is None:
174
+ raise AuthenticationError("폐기할 로컬 토큰 캐시가 없습니다. 먼저 토큰을 발급하거나 재발급해 주세요.")
175
+
176
+ credentials = self.secret_provider.get_credentials(self.mode)
177
+ if credentials is None:
178
+ raise CredentialsNotFoundError(self.mode)
179
+
180
+ response = requests.post(
181
+ f"{get_base_url(self.mode)}{REVOKE_PATH}",
182
+ json={
183
+ "appkey": credentials.appkey,
184
+ "secretkey": credentials.secretkey,
185
+ "token": record.access_token,
186
+ },
187
+ headers={"Content-Type": "application/json;charset=UTF-8"},
188
+ timeout=self.timeout_seconds,
189
+ )
190
+ data = _safe_json(response)
191
+ if response.status_code >= 400:
192
+ _raise_structured_error(
193
+ response.status_code,
194
+ data,
195
+ fallback_message="토큰 폐기 요청에 실패했습니다.",
196
+ )
197
+ if "return_code" not in data:
198
+ raise AuthenticationError("토큰 폐기 응답에 성공 여부가 없습니다.")
199
+ if data.get("return_code") != 0:
200
+ raise_for_error_code(
201
+ int(data["return_code"]),
202
+ str(data.get("return_msg", "토큰 폐기에 실패했습니다.")),
203
+ )
204
+
205
+ self.clear_token()
206
+
207
+ def status(self) -> AuthStatus:
208
+ credentials = self.secret_provider.get_credentials(self.mode)
209
+ token = None
210
+ token_warning = None
211
+ try:
212
+ inspect_token = getattr(self.token_store, "peek", self.token_store.load)
213
+ token = inspect_token(self.mode, profile=self.profile)
214
+ except (InvalidTokenCacheError, OSError) as exc:
215
+ token_warning = str(exc)
216
+ token = None
217
+
218
+ token_valid = token is not None and self._is_not_expired(token) and self._has_compatible_credentials(
219
+ token,
220
+ credentials,
221
+ )
222
+ token_reusable = token_valid and token is not None and self._is_valid(token)
223
+ if token_valid and not token_reusable:
224
+ token_warning = token_warning or "토큰 만료가 가까워 다음 요청에서 재발급될 수 있습니다."
225
+ return AuthStatus(
226
+ mode=self.mode,
227
+ profile=self.profile,
228
+ credential_source=credentials.source if credentials else None,
229
+ has_credentials=credentials is not None,
230
+ has_token=token is not None,
231
+ token_saved_at=token.saved_at if token else None,
232
+ token_expires_at=token.expires_at if token else None,
233
+ token_valid=token_valid,
234
+ token_reusable=token_reusable,
235
+ next_auth_action=self._next_auth_action(
236
+ token=token,
237
+ credentials=credentials,
238
+ token_reusable=token_reusable,
239
+ ),
240
+ token_warning=token_warning,
241
+ )
242
+
243
+ def _load_valid_token(self) -> TokenRecord | None:
244
+ try:
245
+ record = self.token_store.load(self.mode, profile=self.profile)
246
+ except (InvalidTokenCacheError, OSError):
247
+ return None
248
+ if record is None:
249
+ return None
250
+ if record.credential_fingerprint is None:
251
+ self.clear_token()
252
+ return None
253
+ credentials = self.secret_provider.get_credentials(self.mode)
254
+ if credentials is not None:
255
+ if not self._credentials_match(record, credentials):
256
+ self.clear_token()
257
+ return None
258
+ if not self._is_valid(record):
259
+ self.clear_token()
260
+ return None
261
+ return record
262
+
263
+ def _load_cached_token_for_revoke(self) -> TokenRecord | None:
264
+ inspect_token = getattr(self.token_store, "peek", self.token_store.load)
265
+ try:
266
+ record = inspect_token(self.mode, profile=self.profile)
267
+ except InvalidTokenCacheError as exc:
268
+ self.clear_token()
269
+ raise AuthenticationError("토큰 캐시가 손상되어 먼저 삭제했습니다. 필요하면 토큰을 다시 발급해 주세요.") from exc
270
+ except OSError:
271
+ return None
272
+ if record is None:
273
+ return None
274
+ if record.credential_fingerprint is None:
275
+ self.clear_token()
276
+ return None
277
+ credentials = self.secret_provider.get_credentials(self.mode)
278
+ if credentials is not None and not self._credentials_match(record, credentials):
279
+ self.clear_token()
280
+ return None
281
+ if not self._is_not_expired(record):
282
+ self.clear_token()
283
+ return None
284
+ return record
285
+
286
+ def _is_valid(self, record: TokenRecord) -> bool:
287
+ now_utc = datetime.now(UTC)
288
+ return now_utc < (record.expires_at - self.refresh_buffer)
289
+
290
+ def _is_not_expired(self, record: TokenRecord) -> bool:
291
+ return datetime.now(UTC) < record.expires_at
292
+
293
+ def _credentials_match(self, record: TokenRecord, credentials: CredentialSet) -> bool:
294
+ if record.credential_fingerprint is None:
295
+ return False
296
+ return record.credential_fingerprint == self._credential_fingerprint(credentials)
297
+
298
+ def _has_compatible_credentials(
299
+ self,
300
+ record: TokenRecord,
301
+ credentials: CredentialSet | None,
302
+ ) -> bool:
303
+ if record.credential_fingerprint is None:
304
+ return False
305
+ if credentials is None:
306
+ return True
307
+ return self._credentials_match(record, credentials)
308
+
309
+ def _next_auth_action(
310
+ self,
311
+ *,
312
+ token: TokenRecord | None,
313
+ credentials: CredentialSet | None,
314
+ token_reusable: bool,
315
+ ) -> NextAuthAction:
316
+ if token_reusable:
317
+ return "use_cached_token"
318
+ if credentials is None:
319
+ return "credentials_missing"
320
+ if token is None:
321
+ return "issue_before_request"
322
+ return "refresh_before_request"
323
+
324
+ @staticmethod
325
+ def _parse_kiwoom_expiry(value: str) -> datetime:
326
+ parsed = datetime.strptime(value, "%Y%m%d%H%M%S")
327
+ return parsed.replace(tzinfo=KST).astimezone(UTC)
328
+
329
+ @staticmethod
330
+ def _credential_fingerprint(credentials: CredentialSet) -> str:
331
+ raw = f"{credentials.appkey}:{credentials.secretkey}".encode("utf-8")
332
+ return hashlib.sha256(raw).hexdigest()
333
+
334
+
335
+ def _safe_json(response: requests.Response) -> dict:
336
+ try:
337
+ payload = response.json()
338
+ except ValueError:
339
+ payload = {}
340
+ return payload if isinstance(payload, dict) else {}
341
+
342
+
343
+ def _raise_structured_error(status_code: int, payload: dict, *, fallback_message: str) -> None:
344
+ if payload.get("return_code") not in (None, 0):
345
+ raise_for_error_code(
346
+ int(payload["return_code"]),
347
+ str(payload.get("return_msg", fallback_message)),
348
+ )
349
+ raise HTTPRequestError(status_code, fallback_message)
kiwoom/core/client.py ADDED
@@ -0,0 +1,244 @@
1
+ import time
2
+ from collections.abc import Iterator
3
+ from typing import Any
4
+
5
+ import requests
6
+
7
+ from kiwoom.core.auth import KiwoomAuth, get_base_url
8
+ from kiwoom.core.errors import (
9
+ AUTH_RETRY_RETURN_CODES,
10
+ AuthenticationError,
11
+ HTTPRequestError,
12
+ normalize_return_code,
13
+ )
14
+ from kiwoom.core.types import Continuation, KiwoomResponse
15
+
16
+ RESERVED_REQUEST_HEADERS = {"content-type", "api-id", "authorization"}
17
+
18
+
19
+ class KiwoomClient:
20
+ def __init__(self, auth: KiwoomAuth, *, timeout_seconds: int = 30) -> None:
21
+ self.auth = auth
22
+ self.timeout_seconds = timeout_seconds
23
+ self.session = requests.Session()
24
+
25
+ def request(
26
+ self,
27
+ *,
28
+ api_url: str | None = None,
29
+ api: str | None = None,
30
+ api_id: str | None = None,
31
+ path: str | None = None,
32
+ body: dict[str, Any] | None = None,
33
+ method: str = "POST",
34
+ extra_headers: dict[str, str] | None = None,
35
+ retry_on_auth_failure: bool = True,
36
+ ) -> KiwoomResponse:
37
+ resolved_api_id, resolved_path = _resolve_api_target(
38
+ api_url=api_url,
39
+ api=api,
40
+ api_id=api_id,
41
+ path=path,
42
+ )
43
+ _validate_extra_headers(extra_headers)
44
+ headers = {
45
+ "Content-Type": "application/json;charset=UTF-8",
46
+ "api-id": resolved_api_id,
47
+ "authorization": self.auth.authorization_header(),
48
+ }
49
+ if extra_headers:
50
+ headers.update(extra_headers)
51
+
52
+ response = self.session.request(
53
+ method=method,
54
+ url=f"{get_base_url(self.auth.mode)}{resolved_path}",
55
+ headers=headers,
56
+ json=body or {},
57
+ timeout=self.timeout_seconds,
58
+ )
59
+
60
+ if response.status_code == 401 and retry_on_auth_failure:
61
+ self.auth.recover_from_auth_failure()
62
+ return self.request(
63
+ api_id=resolved_api_id,
64
+ path=resolved_path,
65
+ body=body,
66
+ method=method,
67
+ extra_headers=extra_headers,
68
+ retry_on_auth_failure=False,
69
+ )
70
+ if response.status_code == 401:
71
+ raise AuthenticationError("인증 정보를 갱신한 뒤 다시 시도했지만 요청에 실패했습니다.")
72
+
73
+ try:
74
+ data = response.json()
75
+ except ValueError as exc:
76
+ if response.status_code >= 400:
77
+ raise HTTPRequestError(response.status_code, "API 응답이 JSON 형식이 아닙니다.") from exc
78
+ data = {
79
+ "return_msg": "API 응답이 JSON 형식이 아닙니다.",
80
+ "raw_text": response.text,
81
+ }
82
+
83
+ return_code = normalize_return_code(data.get("return_code"))
84
+ if return_code in AUTH_RETRY_RETURN_CODES and retry_on_auth_failure:
85
+ self.auth.recover_from_auth_failure()
86
+ return self.request(
87
+ api_id=resolved_api_id,
88
+ path=resolved_path,
89
+ body=body,
90
+ method=method,
91
+ extra_headers=extra_headers,
92
+ retry_on_auth_failure=False,
93
+ )
94
+
95
+ if response.status_code >= 400:
96
+ message = str(data.get("return_msg", "API 요청에 실패했습니다."))
97
+ if return_code not in (None, 0):
98
+ message = f"{message} (return_code={data['return_code']})"
99
+ raise HTTPRequestError(response.status_code, message)
100
+ return _build_response(data, response.headers)
101
+
102
+ def fetch_page(
103
+ self,
104
+ *,
105
+ api_url: str | None = None,
106
+ api: str | None = None,
107
+ api_id: str | None = None,
108
+ path: str | None = None,
109
+ body: dict[str, Any] | None = None,
110
+ method: str = "POST",
111
+ cont_yn: str | None = None,
112
+ next_key: str | None = None,
113
+ ) -> KiwoomResponse:
114
+ """단일 페이지를 조회하고, 연속조회 cursor가 있으면 요청 헤더에 반영합니다."""
115
+ extra_headers: dict[str, str] = {}
116
+ if cont_yn is not None:
117
+ extra_headers["cont-yn"] = cont_yn
118
+ if next_key is not None:
119
+ extra_headers["next-key"] = next_key
120
+
121
+ return self.request(
122
+ api_url=api_url,
123
+ api=api,
124
+ api_id=api_id,
125
+ path=path,
126
+ body=body,
127
+ method=method,
128
+ extra_headers=extra_headers or None,
129
+ )
130
+
131
+ def iterate_pages(
132
+ self,
133
+ *,
134
+ api_url: str | None = None,
135
+ api: str | None = None,
136
+ api_id: str | None = None,
137
+ path: str | None = None,
138
+ body: dict[str, Any] | None = None,
139
+ method: str = "POST",
140
+ max_pages: int = 1,
141
+ page_delay_seconds: float = 0.2,
142
+ ) -> Iterator[KiwoomResponse]:
143
+ """연속조회 cursor를 따라 최대 ``max_pages`` 페이지를 순회합니다.
144
+
145
+ ``max_pages=0``은 서버가 continuation을 제공하는 동안 계속 조회합니다.
146
+ 페이지 사이에는 ``page_delay_seconds``만큼 대기해 서버 요청 간격
147
+ 정책을 지킵니다.
148
+ """
149
+ if max_pages < 0:
150
+ raise ValueError("max_pages must be 0 (unlimited) or a positive page count")
151
+ cont_yn: str | None = None
152
+ next_key: str | None = None
153
+ page_count = 0
154
+ while True:
155
+ response = self.fetch_page(
156
+ api_url=api_url,
157
+ api=api,
158
+ api_id=api_id,
159
+ path=path,
160
+ body=body,
161
+ method=method,
162
+ cont_yn=cont_yn,
163
+ next_key=next_key,
164
+ )
165
+ yield response
166
+ page_count += 1
167
+ if max_pages and page_count >= max_pages:
168
+ return
169
+ if not response.continuation.has_next:
170
+ return
171
+ cont_yn = response.continuation.cont_yn or "Y"
172
+ next_key = response.continuation.next_key or ""
173
+ if page_delay_seconds:
174
+ time.sleep(page_delay_seconds)
175
+
176
+
177
+ def _validate_extra_headers(extra_headers: dict[str, str] | None) -> None:
178
+ if not extra_headers:
179
+ return
180
+
181
+ for header_name in extra_headers:
182
+ if header_name.lower() in RESERVED_REQUEST_HEADERS:
183
+ raise ValueError(f"extra_headers cannot override reserved header: {header_name}")
184
+
185
+
186
+ def _resolve_api_target(
187
+ *,
188
+ api_url: str | None,
189
+ api: str | None,
190
+ api_id: str | None,
191
+ path: str | None,
192
+ ) -> tuple[str, str]:
193
+ if api_url is not None:
194
+ if api is not None or api_id is not None or path is not None:
195
+ raise ValueError("use only one of api_url, api, or api_id/path")
196
+ return _parse_api_url_target(api_url)
197
+
198
+ if api is not None:
199
+ if api_id is not None or path is not None:
200
+ raise ValueError("use only one of api_url, api, or api_id/path")
201
+ return _parse_api_target(api)
202
+
203
+ if not api_id or not path:
204
+ raise ValueError("api_id and path are required when api is not provided")
205
+ return api_id, path
206
+
207
+
208
+ def _parse_api_url_target(api_url: str) -> tuple[str, str]:
209
+ normalized = api_url.strip()
210
+ if not normalized.startswith("/"):
211
+ raise ValueError("api_url must be in the format '<path>/<api_id>'")
212
+
213
+ path, _, api_id = normalized.rpartition("/")
214
+ if not path or not api_id:
215
+ raise ValueError("api_url must be in the format '<path>/<api_id>'")
216
+
217
+ return api_id, path
218
+
219
+
220
+ def _parse_api_target(api: str) -> tuple[str, str]:
221
+ api_spec = api.strip()
222
+ parts = api_spec.split(maxsplit=1)
223
+ if len(parts) != 2:
224
+ raise ValueError("api must be in the format '<api_id> <path>'")
225
+
226
+ api_id, path = parts[0].strip(), parts[1].strip()
227
+ if not api_id or not path.startswith("/"):
228
+ raise ValueError("api must be in the format '<api_id> <path>'")
229
+ return api_id, path
230
+
231
+
232
+ def _build_response(data: dict[str, Any], response_headers: Any) -> KiwoomResponse:
233
+ cont_yn = response_headers.get("cont-yn") or response_headers.get("Cont-Yn")
234
+ next_key = response_headers.get("next-key") or response_headers.get("Next-Key")
235
+ continuation = Continuation(
236
+ has_next=cont_yn == "Y",
237
+ next_key=next_key or None,
238
+ cont_yn=cont_yn or None,
239
+ )
240
+ return KiwoomResponse(
241
+ body=data,
242
+ continuation=continuation,
243
+ headers=dict(response_headers),
244
+ )