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/core/__init__.py
ADDED
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
|
+
)
|