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/secrets.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import os
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
import keyring
|
|
7
|
+
from keyring.errors import KeyringError, NoKeyringError
|
|
8
|
+
|
|
9
|
+
from kiwoom.core.errors import KeyringUnavailableError
|
|
10
|
+
from kiwoom.core.types import Mode
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class CredentialSet:
|
|
15
|
+
appkey: str
|
|
16
|
+
secretkey: str
|
|
17
|
+
source: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SecretProvider(Protocol):
|
|
21
|
+
def get_credentials(self, mode: Mode) -> CredentialSet | None: ...
|
|
22
|
+
|
|
23
|
+
def set_credentials(self, mode: Mode, appkey: str, secretkey: str) -> None: ...
|
|
24
|
+
|
|
25
|
+
def clear_credentials(self, mode: Mode) -> None: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EnvSecretProvider:
|
|
29
|
+
def get_credentials(self, mode: Mode) -> CredentialSet | None:
|
|
30
|
+
appkey_var, secretkey_var = env_var_names(mode)
|
|
31
|
+
appkey = os.getenv(appkey_var)
|
|
32
|
+
secretkey = os.getenv(secretkey_var)
|
|
33
|
+
if not appkey or not secretkey:
|
|
34
|
+
return None
|
|
35
|
+
return CredentialSet(appkey=appkey, secretkey=secretkey, source="env")
|
|
36
|
+
|
|
37
|
+
def set_credentials(self, mode: Mode, appkey: str, secretkey: str) -> None:
|
|
38
|
+
raise RuntimeError("환경변수 공급자는 읽기 전용입니다.")
|
|
39
|
+
|
|
40
|
+
def clear_credentials(self, mode: Mode) -> None:
|
|
41
|
+
raise RuntimeError("환경변수 공급자는 읽기 전용입니다.")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class KeyringSecretProvider:
|
|
45
|
+
def get_credentials(self, mode: Mode) -> CredentialSet | None:
|
|
46
|
+
try:
|
|
47
|
+
appkey = keyring.get_password(self._service_name(mode), "appkey")
|
|
48
|
+
secretkey = keyring.get_password(self._service_name(mode), "secretkey")
|
|
49
|
+
except NoKeyringError as exc:
|
|
50
|
+
raise KeyringUnavailableError(
|
|
51
|
+
"사용 가능한 운영체제 자격 증명 저장소를 찾을 수 없습니다. "
|
|
52
|
+
"환경변수를 사용하거나 지원되는 keyring 백엔드를 설치해 주세요."
|
|
53
|
+
) from exc
|
|
54
|
+
except KeyringError as exc:
|
|
55
|
+
raise KeyringUnavailableError(f"운영체제 자격 증명 저장소를 읽는 중 오류가 발생했습니다: {exc}") from exc
|
|
56
|
+
|
|
57
|
+
if not appkey or not secretkey:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
return CredentialSet(appkey=appkey, secretkey=secretkey, source="keyring")
|
|
61
|
+
|
|
62
|
+
def set_credentials(self, mode: Mode, appkey: str, secretkey: str) -> None:
|
|
63
|
+
try:
|
|
64
|
+
keyring.set_password(self._service_name(mode), "appkey", appkey)
|
|
65
|
+
keyring.set_password(self._service_name(mode), "secretkey", secretkey)
|
|
66
|
+
except NoKeyringError as exc:
|
|
67
|
+
raise KeyringUnavailableError(
|
|
68
|
+
"사용 가능한 운영체제 자격 증명 저장소를 찾을 수 없습니다. "
|
|
69
|
+
"환경변수를 사용하거나 지원되는 keyring 백엔드를 설치해 주세요."
|
|
70
|
+
) from exc
|
|
71
|
+
except KeyringError as exc:
|
|
72
|
+
self._best_effort_clear(mode)
|
|
73
|
+
raise KeyringUnavailableError(f"운영체제 자격 증명 저장소에 저장하는 중 오류가 발생했습니다: {exc}") from exc
|
|
74
|
+
|
|
75
|
+
def clear_credentials(self, mode: Mode) -> None:
|
|
76
|
+
try:
|
|
77
|
+
for name in ("appkey", "secretkey"):
|
|
78
|
+
try:
|
|
79
|
+
keyring.delete_password(self._service_name(mode), name)
|
|
80
|
+
except keyring.errors.PasswordDeleteError:
|
|
81
|
+
continue
|
|
82
|
+
except NoKeyringError as exc:
|
|
83
|
+
raise KeyringUnavailableError(
|
|
84
|
+
"사용 가능한 운영체제 자격 증명 저장소를 찾을 수 없습니다. "
|
|
85
|
+
"환경변수를 사용하거나 지원되는 keyring 백엔드를 설치해 주세요."
|
|
86
|
+
) from exc
|
|
87
|
+
except KeyringError as exc:
|
|
88
|
+
raise KeyringUnavailableError(f"운영체제 자격 증명 저장소를 정리하는 중 오류가 발생했습니다: {exc}") from exc
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def _service_name(mode: Mode) -> str:
|
|
92
|
+
return f"kiwoom-{mode}"
|
|
93
|
+
|
|
94
|
+
def _best_effort_clear(self, mode: Mode) -> None:
|
|
95
|
+
for name in ("appkey", "secretkey"):
|
|
96
|
+
try:
|
|
97
|
+
keyring.delete_password(self._service_name(mode), name)
|
|
98
|
+
except Exception:
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ProfileKeyringSecretProvider:
|
|
103
|
+
def __init__(self, profile_alias: str) -> None:
|
|
104
|
+
self.profile_alias = profile_alias
|
|
105
|
+
|
|
106
|
+
def get_credentials(self, mode: Mode) -> CredentialSet | None:
|
|
107
|
+
try:
|
|
108
|
+
appkey = keyring.get_password(self._service_name(), "appkey")
|
|
109
|
+
secretkey = keyring.get_password(self._service_name(), "secretkey")
|
|
110
|
+
except NoKeyringError as exc:
|
|
111
|
+
raise KeyringUnavailableError(
|
|
112
|
+
"사용 가능한 운영체제 자격 증명 저장소를 찾을 수 없습니다. "
|
|
113
|
+
"환경변수를 사용하거나 지원되는 keyring 백엔드를 설치해 주세요."
|
|
114
|
+
) from exc
|
|
115
|
+
except KeyringError as exc:
|
|
116
|
+
raise KeyringUnavailableError(f"운영체제 자격 증명 저장소를 읽는 중 오류가 발생했습니다: {exc}") from exc
|
|
117
|
+
|
|
118
|
+
if not appkey or not secretkey:
|
|
119
|
+
return None
|
|
120
|
+
return CredentialSet(appkey=appkey, secretkey=secretkey, source="keyring")
|
|
121
|
+
|
|
122
|
+
def set_credentials(self, mode: Mode, appkey: str, secretkey: str) -> None:
|
|
123
|
+
try:
|
|
124
|
+
keyring.set_password(self._service_name(), "appkey", appkey)
|
|
125
|
+
keyring.set_password(self._service_name(), "secretkey", secretkey)
|
|
126
|
+
except NoKeyringError as exc:
|
|
127
|
+
raise KeyringUnavailableError(
|
|
128
|
+
"사용 가능한 운영체제 자격 증명 저장소를 찾을 수 없습니다. "
|
|
129
|
+
"환경변수를 사용하거나 지원되는 keyring 백엔드를 설치해 주세요."
|
|
130
|
+
) from exc
|
|
131
|
+
except KeyringError as exc:
|
|
132
|
+
self._best_effort_clear()
|
|
133
|
+
raise KeyringUnavailableError(f"운영체제 자격 증명 저장소에 저장하는 중 오류가 발생했습니다: {exc}") from exc
|
|
134
|
+
|
|
135
|
+
def clear_credentials(self, mode: Mode) -> None:
|
|
136
|
+
try:
|
|
137
|
+
for name in ("appkey", "secretkey"):
|
|
138
|
+
try:
|
|
139
|
+
keyring.delete_password(self._service_name(), name)
|
|
140
|
+
except keyring.errors.PasswordDeleteError:
|
|
141
|
+
continue
|
|
142
|
+
except NoKeyringError as exc:
|
|
143
|
+
raise KeyringUnavailableError(
|
|
144
|
+
"사용 가능한 운영체제 자격 증명 저장소를 찾을 수 없습니다. "
|
|
145
|
+
"환경변수를 사용하거나 지원되는 keyring 백엔드를 설치해 주세요."
|
|
146
|
+
) from exc
|
|
147
|
+
except KeyringError as exc:
|
|
148
|
+
raise KeyringUnavailableError(f"운영체제 자격 증명 저장소를 정리하는 중 오류가 발생했습니다: {exc}") from exc
|
|
149
|
+
|
|
150
|
+
def _service_name(self) -> str:
|
|
151
|
+
digest = hashlib.sha256(self.profile_alias.encode("utf-8")).hexdigest()[:16]
|
|
152
|
+
return f"kiwoom-profile-{digest}"
|
|
153
|
+
|
|
154
|
+
def _best_effort_clear(self) -> None:
|
|
155
|
+
for name in ("appkey", "secretkey"):
|
|
156
|
+
try:
|
|
157
|
+
keyring.delete_password(self._service_name(), name)
|
|
158
|
+
except Exception:
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class CompositeSecretProvider:
|
|
163
|
+
def __init__(
|
|
164
|
+
self,
|
|
165
|
+
*providers: SecretProvider,
|
|
166
|
+
writable_provider: SecretProvider | None = None,
|
|
167
|
+
suppress_read_errors: tuple[type[Exception], ...] = (),
|
|
168
|
+
) -> None:
|
|
169
|
+
self.providers = providers
|
|
170
|
+
self.writable_provider = writable_provider
|
|
171
|
+
self.suppress_read_errors = suppress_read_errors
|
|
172
|
+
|
|
173
|
+
def get_credentials(self, mode: Mode) -> CredentialSet | None:
|
|
174
|
+
for provider in self.providers:
|
|
175
|
+
try:
|
|
176
|
+
credentials = provider.get_credentials(mode)
|
|
177
|
+
except self.suppress_read_errors:
|
|
178
|
+
continue
|
|
179
|
+
if credentials is not None:
|
|
180
|
+
return credentials
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
def set_credentials(self, mode: Mode, appkey: str, secretkey: str) -> None:
|
|
184
|
+
if self.writable_provider is None:
|
|
185
|
+
raise RuntimeError("이 자격 증명 공급자는 읽기 전용입니다.")
|
|
186
|
+
self.writable_provider.set_credentials(mode, appkey, secretkey)
|
|
187
|
+
|
|
188
|
+
def clear_credentials(self, mode: Mode) -> None:
|
|
189
|
+
if self.writable_provider is None:
|
|
190
|
+
raise RuntimeError("이 자격 증명 공급자는 읽기 전용입니다.")
|
|
191
|
+
self.writable_provider.clear_credentials(mode)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def default_secret_provider(*, profile: str | None = None) -> CompositeSecretProvider:
|
|
195
|
+
if profile is not None:
|
|
196
|
+
profile_keyring_provider = ProfileKeyringSecretProvider(profile)
|
|
197
|
+
return CompositeSecretProvider(
|
|
198
|
+
profile_keyring_provider,
|
|
199
|
+
writable_provider=profile_keyring_provider,
|
|
200
|
+
suppress_read_errors=(KeyringUnavailableError,),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
keyring_provider = KeyringSecretProvider()
|
|
204
|
+
return CompositeSecretProvider(
|
|
205
|
+
EnvSecretProvider(),
|
|
206
|
+
keyring_provider,
|
|
207
|
+
writable_provider=keyring_provider,
|
|
208
|
+
suppress_read_errors=(KeyringUnavailableError,),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def env_var_names(mode: Mode) -> tuple[str, str]:
|
|
213
|
+
if mode == "real":
|
|
214
|
+
return "APP_KEY", "APP_SECRET"
|
|
215
|
+
if mode == "demo":
|
|
216
|
+
return "APP_KEY_MOCK", "APP_SECRET_MOCK"
|
|
217
|
+
raise ValueError(f"Unsupported mode: {mode!r}")
|
kiwoom/core/settings.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
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)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from stat import S_IMODE
|
|
8
|
+
from tempfile import NamedTemporaryFile
|
|
9
|
+
|
|
10
|
+
from kiwoom.core.errors import InvalidTokenCacheError
|
|
11
|
+
from kiwoom.core.platform_paths import cache_dir, ensure_private_directory, protect_file
|
|
12
|
+
from kiwoom.core.profiles import validate_profile_alias
|
|
13
|
+
from kiwoom.core.types import Mode
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
VALID_TOKEN_TYPES = {"bearer"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class TokenRecord:
|
|
21
|
+
access_token: str
|
|
22
|
+
token_type: str
|
|
23
|
+
expires_at: datetime
|
|
24
|
+
mode: Mode
|
|
25
|
+
profile: str | None = None
|
|
26
|
+
credential_fingerprint: str | None = None
|
|
27
|
+
saved_at: datetime | None = None
|
|
28
|
+
|
|
29
|
+
def to_json(self) -> dict[str, str]:
|
|
30
|
+
payload = {
|
|
31
|
+
"access_token": self.access_token,
|
|
32
|
+
"token_type": self.token_type,
|
|
33
|
+
"expires_at": self.expires_at.astimezone(UTC).isoformat(),
|
|
34
|
+
"mode": self.mode,
|
|
35
|
+
}
|
|
36
|
+
if self.profile:
|
|
37
|
+
payload["profile"] = self.profile
|
|
38
|
+
if self.credential_fingerprint:
|
|
39
|
+
payload["credential_fingerprint"] = self.credential_fingerprint
|
|
40
|
+
if self.saved_at:
|
|
41
|
+
payload["saved_at"] = self.saved_at.astimezone(UTC).isoformat()
|
|
42
|
+
return payload
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_json(cls, payload: dict[str, str], *, path: Path) -> "TokenRecord":
|
|
46
|
+
try:
|
|
47
|
+
token_type = payload["token_type"].lower()
|
|
48
|
+
expires_at = datetime.fromisoformat(payload["expires_at"])
|
|
49
|
+
saved_at_value = payload.get("saved_at")
|
|
50
|
+
saved_at = datetime.fromisoformat(saved_at_value) if saved_at_value else None
|
|
51
|
+
mode = payload["mode"]
|
|
52
|
+
access_token = payload["access_token"]
|
|
53
|
+
except KeyError as exc:
|
|
54
|
+
raise InvalidTokenCacheError(path, f"필수 필드가 없습니다: {exc.args[0]}") from exc
|
|
55
|
+
except ValueError as exc:
|
|
56
|
+
raise InvalidTokenCacheError(path, f"필드 형식이 올바르지 않습니다: {exc}") from exc
|
|
57
|
+
|
|
58
|
+
if token_type not in VALID_TOKEN_TYPES:
|
|
59
|
+
raise InvalidTokenCacheError(path, f"지원하지 않는 token_type 값입니다: {token_type}")
|
|
60
|
+
if not access_token:
|
|
61
|
+
raise InvalidTokenCacheError(path, "access_token 값이 비어 있습니다.")
|
|
62
|
+
if expires_at.tzinfo is None:
|
|
63
|
+
raise InvalidTokenCacheError(path, "expires_at에는 시간대 정보가 포함되어야 합니다.")
|
|
64
|
+
if saved_at is not None and saved_at.tzinfo is None:
|
|
65
|
+
raise InvalidTokenCacheError(path, "saved_at에는 시간대 정보가 포함되어야 합니다.")
|
|
66
|
+
if mode not in ("real", "demo"):
|
|
67
|
+
raise InvalidTokenCacheError(path, f"지원하지 않는 mode 값입니다: {mode}")
|
|
68
|
+
|
|
69
|
+
profile = payload.get("profile")
|
|
70
|
+
if profile is not None:
|
|
71
|
+
try:
|
|
72
|
+
profile = validate_profile_alias(profile)
|
|
73
|
+
except ValueError as exc:
|
|
74
|
+
raise InvalidTokenCacheError(path, f"profile 값이 올바르지 않습니다: {profile!r}") from exc
|
|
75
|
+
|
|
76
|
+
return cls(
|
|
77
|
+
access_token=access_token,
|
|
78
|
+
token_type=token_type,
|
|
79
|
+
expires_at=expires_at.astimezone(UTC),
|
|
80
|
+
mode=mode,
|
|
81
|
+
profile=profile,
|
|
82
|
+
credential_fingerprint=payload.get("credential_fingerprint"),
|
|
83
|
+
saved_at=saved_at.astimezone(UTC) if saved_at else None,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class MemoryTokenStore:
|
|
88
|
+
def __init__(self) -> None:
|
|
89
|
+
self._records: dict[str, TokenRecord] = {}
|
|
90
|
+
|
|
91
|
+
def load(self, mode: Mode, *, profile: str | None = None) -> TokenRecord | None:
|
|
92
|
+
return self._records.get(self._key(mode, profile=profile))
|
|
93
|
+
|
|
94
|
+
def save(self, record: TokenRecord) -> None:
|
|
95
|
+
self._records[self._key(record.mode, profile=record.profile)] = record
|
|
96
|
+
|
|
97
|
+
def clear(self, mode: Mode, *, profile: str | None = None) -> None:
|
|
98
|
+
self._records.pop(self._key(mode, profile=profile), None)
|
|
99
|
+
|
|
100
|
+
def peek(self, mode: Mode, *, profile: str | None = None) -> TokenRecord | None:
|
|
101
|
+
return self.load(mode, profile=profile)
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _key(mode: Mode, *, profile: str | None) -> str:
|
|
105
|
+
return f"profile:{profile}" if profile else f"mode:{mode}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class FileTokenStore:
|
|
109
|
+
def __init__(self, *, strict_permissions: bool = True) -> None:
|
|
110
|
+
self.strict_permissions = strict_permissions
|
|
111
|
+
|
|
112
|
+
def load(self, mode: Mode, *, profile: str | None = None) -> TokenRecord | None:
|
|
113
|
+
return self._read(mode, profile=profile, clear_on_error=True)
|
|
114
|
+
|
|
115
|
+
def peek(self, mode: Mode, *, profile: str | None = None) -> TokenRecord | None:
|
|
116
|
+
return self._read(mode, profile=profile, clear_on_error=False)
|
|
117
|
+
|
|
118
|
+
def _read(self, mode: Mode, *, profile: str | None, clear_on_error: bool) -> TokenRecord | None:
|
|
119
|
+
path = self._token_path(mode, profile=profile)
|
|
120
|
+
if not path.exists():
|
|
121
|
+
return None
|
|
122
|
+
self._validate_permissions(path, clear_on_error=clear_on_error)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
126
|
+
record = TokenRecord.from_json(payload, path=path)
|
|
127
|
+
except json.JSONDecodeError as exc:
|
|
128
|
+
if clear_on_error:
|
|
129
|
+
self.clear(mode, profile=profile)
|
|
130
|
+
raise InvalidTokenCacheError(path, f"JSON 파싱에 실패했습니다: {exc}") from exc
|
|
131
|
+
except InvalidTokenCacheError:
|
|
132
|
+
if clear_on_error:
|
|
133
|
+
self.clear(mode, profile=profile)
|
|
134
|
+
raise
|
|
135
|
+
|
|
136
|
+
if record.mode != mode:
|
|
137
|
+
if clear_on_error:
|
|
138
|
+
self.clear(mode, profile=profile)
|
|
139
|
+
raise InvalidTokenCacheError(path, "저장된 mode 값이 요청한 mode와 일치하지 않습니다.")
|
|
140
|
+
|
|
141
|
+
return record
|
|
142
|
+
|
|
143
|
+
def save(self, record: TokenRecord) -> None:
|
|
144
|
+
directory = ensure_private_directory(self._token_directory(record.profile), strict=self.strict_permissions)
|
|
145
|
+
path = self._token_path(record.mode, profile=record.profile)
|
|
146
|
+
payload = json.dumps(record.to_json(), ensure_ascii=True, indent=2)
|
|
147
|
+
|
|
148
|
+
with NamedTemporaryFile(
|
|
149
|
+
"w",
|
|
150
|
+
encoding="utf-8",
|
|
151
|
+
dir=directory,
|
|
152
|
+
delete=False,
|
|
153
|
+
) as tmp:
|
|
154
|
+
tmp.write(payload)
|
|
155
|
+
tmp.flush()
|
|
156
|
+
os.fsync(tmp.fileno())
|
|
157
|
+
temp_path = Path(tmp.name)
|
|
158
|
+
|
|
159
|
+
protect_file(temp_path, strict=self.strict_permissions)
|
|
160
|
+
temp_path.replace(path)
|
|
161
|
+
protect_file(path, strict=self.strict_permissions)
|
|
162
|
+
|
|
163
|
+
def clear(self, mode: Mode, *, profile: str | None = None) -> None:
|
|
164
|
+
path = self._token_path(mode, profile=profile)
|
|
165
|
+
path.unlink(missing_ok=True)
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def _token_directory(profile: str | None) -> Path:
|
|
169
|
+
return cache_dir() / "profiles" if profile else cache_dir()
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def _token_path(mode: Mode, *, profile: str | None = None) -> Path:
|
|
173
|
+
if profile:
|
|
174
|
+
alias = validate_profile_alias(profile)
|
|
175
|
+
digest = hashlib.sha256(alias.encode("utf-8")).hexdigest()[:16]
|
|
176
|
+
return cache_dir() / "profiles" / f"profile-{digest}-token.json"
|
|
177
|
+
return cache_dir() / f"{mode}-token.json"
|
|
178
|
+
|
|
179
|
+
def _validate_permissions(self, path: Path, *, clear_on_error: bool) -> None:
|
|
180
|
+
if os.name == "nt":
|
|
181
|
+
return
|
|
182
|
+
mode = S_IMODE(path.stat().st_mode)
|
|
183
|
+
if mode & 0o077:
|
|
184
|
+
if clear_on_error:
|
|
185
|
+
path.unlink(missing_ok=True)
|
|
186
|
+
raise InvalidTokenCacheError(path, "토큰 캐시 파일 권한이 너무 넓게 열려 있습니다.")
|
kiwoom/core/types.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
Mode = Literal["real", "demo"]
|
|
7
|
+
|
|
8
|
+
VALID_MODES = ("real", "demo")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def normalize_mode(value: str) -> Mode:
|
|
12
|
+
if value not in VALID_MODES:
|
|
13
|
+
raise ValueError(f"Unsupported mode: {value!r}. Expected one of {VALID_MODES}.")
|
|
14
|
+
return value # type: ignore[return-value]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Continuation:
|
|
19
|
+
has_next: bool
|
|
20
|
+
next_key: str | None
|
|
21
|
+
cont_yn: str | None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class KiwoomResponse:
|
|
26
|
+
body: dict[str, Any]
|
|
27
|
+
continuation: Continuation
|
|
28
|
+
headers: dict[str, str]
|