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/errors.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
# Kiwoom auth-expiry return codes that warrant a one-shot credential
|
|
4
|
+
# recovery + retry (shared by the REST client and the WebSocket client).
|
|
5
|
+
AUTH_RETRY_RETURN_CODES = frozenset({8005, 8031, 8103})
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def normalize_return_code(value: object) -> int | None:
|
|
9
|
+
"""Normalize a Kiwoom ``return_code`` payload value.
|
|
10
|
+
|
|
11
|
+
The server emits the code as an int or a numeric string ("0", "0000",
|
|
12
|
+
"8005") depending on the endpoint. ``None``, blank, and non-numeric
|
|
13
|
+
values mean "no code present".
|
|
14
|
+
"""
|
|
15
|
+
if value is None or isinstance(value, bool):
|
|
16
|
+
return None
|
|
17
|
+
if isinstance(value, int):
|
|
18
|
+
return value
|
|
19
|
+
text = str(value).strip()
|
|
20
|
+
if not text or not text.lstrip("-").isdigit():
|
|
21
|
+
return None
|
|
22
|
+
return int(text)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class KiwoomError(Exception):
|
|
26
|
+
"""Base error for the Kiwoom helper package."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SetupError(KiwoomError):
|
|
30
|
+
"""Raised when setup onboarding cannot complete."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class InvalidModeError(KiwoomError):
|
|
34
|
+
def __init__(self, value: str):
|
|
35
|
+
super().__init__(
|
|
36
|
+
f"지원하지 않는 모드 값입니다: {value!r}. "
|
|
37
|
+
"사용 가능한 값은 'real' 또는 'demo' 입니다."
|
|
38
|
+
)
|
|
39
|
+
self.value = value
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SettingsError(KiwoomError):
|
|
43
|
+
"""Raised when the local settings file is unreadable or invalid."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ModeNotConfiguredError(KiwoomError):
|
|
47
|
+
def __init__(self):
|
|
48
|
+
super().__init__(
|
|
49
|
+
"사용할 계좌가 설정되어 있지 않습니다. "
|
|
50
|
+
"`kiwoomcli setup`으로 계좌를 등록하거나, 이미 등록한 계좌가 있으면 "
|
|
51
|
+
"`kiwoomcli auth switch <별칭>`으로 선택해 주세요."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CredentialsNotFoundError(KiwoomError):
|
|
56
|
+
def __init__(self, mode: str):
|
|
57
|
+
super().__init__(
|
|
58
|
+
f"{mode!r} 모드 자격 증명을 찾을 수 없습니다. "
|
|
59
|
+
"다음 명령으로 초기 설정을 진행하거나, 환경변수를 설정해 주세요: "
|
|
60
|
+
"kiwoomcli setup"
|
|
61
|
+
)
|
|
62
|
+
self.mode = mode
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class KeyringUnavailableError(KiwoomError):
|
|
66
|
+
"""Raised when the OS credential store is unavailable."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class InvalidTokenCacheError(KiwoomError):
|
|
70
|
+
def __init__(self, path: Path, reason: str):
|
|
71
|
+
super().__init__(f"토큰 캐시 파일이 올바르지 않습니다: {path} ({reason})")
|
|
72
|
+
self.path = path
|
|
73
|
+
self.reason = reason
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class HTTPRequestError(KiwoomError):
|
|
77
|
+
def __init__(self, status_code: int, message: str):
|
|
78
|
+
super().__init__(f"HTTP 요청 실패 ({status_code}): {message}")
|
|
79
|
+
self.status_code = status_code
|
|
80
|
+
self.message = message
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class AuthenticationError(KiwoomError):
|
|
84
|
+
"""Raised when token issuance or auth recovery fails."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class APIError(KiwoomError):
|
|
88
|
+
def __init__(self, return_code: int, return_msg: str):
|
|
89
|
+
super().__init__(f"키움 API 오류 ({return_code}): {return_msg}")
|
|
90
|
+
self.return_code = return_code
|
|
91
|
+
self.return_msg = return_msg
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class InputValidationError(APIError):
|
|
95
|
+
"""Raised when required fields or formats are invalid."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class RateLimitError(APIError):
|
|
99
|
+
"""Raised when the API request limit is exceeded."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, return_code: int, return_msg: str):
|
|
102
|
+
super().__init__(return_code, f"요청 한도를 초과했습니다. 잠시 후 다시 시도해 주세요. ({return_msg})")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class SymbolNotFoundError(APIError):
|
|
106
|
+
"""Raised when a requested symbol or market code is invalid."""
|
|
107
|
+
|
|
108
|
+
def __init__(self, return_code: int, return_msg: str):
|
|
109
|
+
super().__init__(return_code, f"종목 코드 또는 시장 정보가 올바르지 않습니다. ({return_msg})")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class InvalidCredentialsError(AuthenticationError):
|
|
113
|
+
def __init__(self, return_code: int, return_msg: str):
|
|
114
|
+
super().__init__(f"앱키 또는 시크릿키가 올바르지 않습니다 ({return_code}): {return_msg}")
|
|
115
|
+
self.return_code = return_code
|
|
116
|
+
self.return_msg = return_msg
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class InvalidTokenError(AuthenticationError):
|
|
120
|
+
def __init__(self, return_code: int, return_msg: str):
|
|
121
|
+
super().__init__(f"접근 토큰이 올바르지 않거나 만료되었습니다 ({return_code}): {return_msg}")
|
|
122
|
+
self.return_code = return_code
|
|
123
|
+
self.return_msg = return_msg
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class ModeMismatchError(AuthenticationError):
|
|
127
|
+
def __init__(self, return_code: int, return_msg: str):
|
|
128
|
+
super().__init__(f"실전/모의 모드가 일치하지 않습니다 ({return_code}): {return_msg}")
|
|
129
|
+
self.return_code = return_code
|
|
130
|
+
self.return_msg = return_msg
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class DeviceAuthenticationError(AuthenticationError):
|
|
134
|
+
def __init__(self, return_code: int, return_msg: str):
|
|
135
|
+
super().__init__(f"단말기 인증에 실패했습니다 ({return_code}): {return_msg}")
|
|
136
|
+
self.return_code = return_code
|
|
137
|
+
self.return_msg = return_msg
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
INPUT_VALIDATION_CODES = {
|
|
141
|
+
1501,
|
|
142
|
+
1504,
|
|
143
|
+
1505,
|
|
144
|
+
1511,
|
|
145
|
+
1512,
|
|
146
|
+
1513,
|
|
147
|
+
1514,
|
|
148
|
+
1515,
|
|
149
|
+
1516,
|
|
150
|
+
1517,
|
|
151
|
+
1687,
|
|
152
|
+
8020,
|
|
153
|
+
}
|
|
154
|
+
RATE_LIMIT_CODES = {1700}
|
|
155
|
+
SYMBOL_NOT_FOUND_CODES = {1901, 1902}
|
|
156
|
+
INVALID_CREDENTIAL_CODES = {8001, 8002, 8011, 8012}
|
|
157
|
+
INVALID_TOKEN_CODES = {8003, 8005, 8006, 8009, 8015, 8016}
|
|
158
|
+
MODE_MISMATCH_CODES = {8030, 8031}
|
|
159
|
+
DEVICE_AUTH_CODES = {8010, 8040, 8050, 8103}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def raise_for_error_code(return_code: int, return_msg: str) -> None:
|
|
163
|
+
if return_code in INPUT_VALIDATION_CODES:
|
|
164
|
+
raise InputValidationError(return_code, return_msg)
|
|
165
|
+
if return_code in RATE_LIMIT_CODES:
|
|
166
|
+
raise RateLimitError(return_code, return_msg)
|
|
167
|
+
if return_code in SYMBOL_NOT_FOUND_CODES:
|
|
168
|
+
raise SymbolNotFoundError(return_code, return_msg)
|
|
169
|
+
if return_code in INVALID_CREDENTIAL_CODES:
|
|
170
|
+
raise InvalidCredentialsError(return_code, return_msg)
|
|
171
|
+
if return_code in INVALID_TOKEN_CODES:
|
|
172
|
+
raise InvalidTokenError(return_code, return_msg)
|
|
173
|
+
if return_code in MODE_MISMATCH_CODES:
|
|
174
|
+
raise ModeMismatchError(return_code, return_msg)
|
|
175
|
+
if return_code in DEVICE_AUTH_CODES:
|
|
176
|
+
raise DeviceAuthenticationError(return_code, return_msg)
|
|
177
|
+
raise APIError(return_code, return_msg)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from platformdirs import user_cache_dir, user_config_dir
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
APP_NAME = "kiwoom"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def config_dir() -> Path:
|
|
13
|
+
return Path(user_config_dir(APP_NAME))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def cache_dir() -> Path:
|
|
17
|
+
return Path(user_cache_dir(APP_NAME))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def ensure_private_directory(path: Path, *, strict: bool = False) -> Path:
|
|
21
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
_protect_path(path, strict=strict, is_dir=True)
|
|
23
|
+
return path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def protect_file(path: Path, *, strict: bool = False) -> Path:
|
|
27
|
+
_protect_path(path, strict=strict, is_dir=False)
|
|
28
|
+
return path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _protect_path(path: Path, *, strict: bool, is_dir: bool) -> None:
|
|
32
|
+
if os.name == "nt":
|
|
33
|
+
success = _protect_windows_path(path, is_dir=is_dir)
|
|
34
|
+
if strict and not success:
|
|
35
|
+
raise PermissionError(f"Could not restrict access to {path}")
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
mode = 0o700 if is_dir else 0o600
|
|
39
|
+
path.chmod(mode)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _protect_windows_path(path: Path, *, is_dir: bool) -> bool:
|
|
43
|
+
# Prefer OS-specific user directories and then try to remove inherited ACLs.
|
|
44
|
+
icacls = shutil.which("icacls")
|
|
45
|
+
if not icacls:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
target = str(path)
|
|
49
|
+
commands = [
|
|
50
|
+
[icacls, target, "/inheritance:r"],
|
|
51
|
+
[icacls, target, "/grant:r", f"{os.environ.get('USERNAME', '%USERNAME%')}:F"],
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
if is_dir:
|
|
55
|
+
commands[1].append("/t")
|
|
56
|
+
commands[1].append("/c")
|
|
57
|
+
|
|
58
|
+
for command in commands:
|
|
59
|
+
result = subprocess.run(
|
|
60
|
+
command,
|
|
61
|
+
check=False,
|
|
62
|
+
capture_output=True,
|
|
63
|
+
text=True,
|
|
64
|
+
)
|
|
65
|
+
if result.returncode != 0:
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
return True
|
kiwoom/core/profiles.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from tempfile import NamedTemporaryFile
|
|
5
|
+
|
|
6
|
+
from kiwoom.core.errors import SettingsError
|
|
7
|
+
from kiwoom.core.platform_paths import config_dir, ensure_private_directory, protect_file
|
|
8
|
+
from kiwoom.core.settings import SETTINGS_FILE_NAME, settings_path
|
|
9
|
+
from kiwoom.core.types import Mode, normalize_mode
|
|
10
|
+
|
|
11
|
+
PROFILE_ALIAS_MAX_LENGTH = 64
|
|
12
|
+
PROFILE_ALIAS_ALLOWED_PUNCTUATION = {" ", ".", "_", "-"}
|
|
13
|
+
PROFILE_ALIAS_FORBIDDEN_CHARACTERS = {"/", "\\", ":"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class AuthProfile:
|
|
18
|
+
alias: str
|
|
19
|
+
mode: Mode
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def validate_profile_alias(alias: str) -> str:
|
|
23
|
+
normalized = alias.strip()
|
|
24
|
+
if not normalized:
|
|
25
|
+
raise ValueError("계좌 별칭을 입력해 주세요. 예: 모의계좌, 실전계좌, family-demo")
|
|
26
|
+
if len(normalized) > PROFILE_ALIAS_MAX_LENGTH:
|
|
27
|
+
raise ValueError("계좌 별칭은 64자 이하여야 합니다.")
|
|
28
|
+
if normalized in {".", ".."}:
|
|
29
|
+
raise ValueError("계좌 별칭으로 '.', '..'는 사용할 수 없습니다.")
|
|
30
|
+
if any(character in PROFILE_ALIAS_FORBIDDEN_CHARACTERS for character in normalized):
|
|
31
|
+
raise ValueError("계좌 별칭에는 '/', '\\', ':' 문자를 사용할 수 없습니다.")
|
|
32
|
+
if any(ord(character) < 32 for character in normalized):
|
|
33
|
+
raise ValueError("계좌 별칭에는 제어 문자를 사용할 수 없습니다.")
|
|
34
|
+
if not any(character.isalnum() for character in normalized):
|
|
35
|
+
raise ValueError("계좌 별칭에는 한글/영문/숫자 중 하나 이상이 포함되어야 합니다.")
|
|
36
|
+
invalid_characters = [
|
|
37
|
+
character
|
|
38
|
+
for character in normalized
|
|
39
|
+
if not character.isalnum() and character not in PROFILE_ALIAS_ALLOWED_PUNCTUATION
|
|
40
|
+
]
|
|
41
|
+
if invalid_characters:
|
|
42
|
+
raise ValueError("계좌 별칭에는 한글, 영문, 숫자, 공백, '.', '_', '-'만 사용할 수 있습니다.")
|
|
43
|
+
return normalized
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_profiles() -> dict[str, AuthProfile]:
|
|
47
|
+
payload = _load_settings_payload()
|
|
48
|
+
raw_profiles = payload.get("profiles", {})
|
|
49
|
+
if not isinstance(raw_profiles, dict):
|
|
50
|
+
raise SettingsError("settings.json의 profiles 값은 객체(JSON object)여야 합니다.")
|
|
51
|
+
|
|
52
|
+
profiles: dict[str, AuthProfile] = {}
|
|
53
|
+
for alias, raw in raw_profiles.items():
|
|
54
|
+
if not isinstance(alias, str) or not isinstance(raw, dict):
|
|
55
|
+
raise SettingsError("settings.json의 profiles 항목 형식이 올바르지 않습니다.")
|
|
56
|
+
profile_alias = validate_profile_alias(alias)
|
|
57
|
+
mode_value = raw.get("mode")
|
|
58
|
+
if not isinstance(mode_value, str):
|
|
59
|
+
raise SettingsError(f"profile {alias!r}의 mode 값은 문자열이어야 합니다.")
|
|
60
|
+
profiles[profile_alias] = AuthProfile(alias=profile_alias, mode=normalize_mode(mode_value))
|
|
61
|
+
return profiles
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_profile(alias: str) -> AuthProfile:
|
|
65
|
+
profile_alias = validate_profile_alias(alias)
|
|
66
|
+
profiles = load_profiles()
|
|
67
|
+
try:
|
|
68
|
+
return profiles[profile_alias]
|
|
69
|
+
except KeyError as exc:
|
|
70
|
+
raise SettingsError(f"저장된 Kiwoom 계좌 별칭을 찾을 수 없습니다: {profile_alias}") from exc
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_current_profile() -> AuthProfile | None:
|
|
74
|
+
payload = _load_settings_payload()
|
|
75
|
+
current = payload.get("current_profile")
|
|
76
|
+
if current is None:
|
|
77
|
+
return None
|
|
78
|
+
if not isinstance(current, str):
|
|
79
|
+
raise SettingsError("settings.json의 current_profile 값은 문자열이어야 합니다.")
|
|
80
|
+
profiles = load_profiles()
|
|
81
|
+
return profiles.get(validate_profile_alias(current))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def save_profile(alias: str, mode: Mode, *, make_current: bool = True) -> AuthProfile:
|
|
85
|
+
profile = AuthProfile(alias=validate_profile_alias(alias), mode=normalize_mode(mode))
|
|
86
|
+
payload = _load_settings_payload()
|
|
87
|
+
profiles = payload.get("profiles", {})
|
|
88
|
+
if not isinstance(profiles, dict):
|
|
89
|
+
profiles = {}
|
|
90
|
+
profiles[profile.alias] = {"mode": profile.mode}
|
|
91
|
+
payload["profiles"] = profiles
|
|
92
|
+
if make_current:
|
|
93
|
+
payload["current_profile"] = profile.alias
|
|
94
|
+
_write_settings_payload(payload)
|
|
95
|
+
return profile
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def set_current_profile(alias: str) -> AuthProfile:
|
|
99
|
+
profile = get_profile(alias)
|
|
100
|
+
payload = _load_settings_payload()
|
|
101
|
+
payload["current_profile"] = profile.alias
|
|
102
|
+
_write_settings_payload(payload)
|
|
103
|
+
return profile
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def delete_profile(alias: str) -> None:
|
|
107
|
+
profile_alias = validate_profile_alias(alias)
|
|
108
|
+
payload = _load_settings_payload()
|
|
109
|
+
profiles = payload.get("profiles", {})
|
|
110
|
+
if isinstance(profiles, dict):
|
|
111
|
+
profiles.pop(profile_alias, None)
|
|
112
|
+
payload["profiles"] = profiles
|
|
113
|
+
if payload.get("current_profile") == profile_alias:
|
|
114
|
+
payload.pop("current_profile", None)
|
|
115
|
+
_write_settings_payload(payload)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _load_settings_payload() -> dict:
|
|
119
|
+
path = settings_path()
|
|
120
|
+
if not path.exists():
|
|
121
|
+
return {}
|
|
122
|
+
try:
|
|
123
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
124
|
+
except json.JSONDecodeError as exc:
|
|
125
|
+
raise SettingsError("settings.json 파일 형식이 올바르지 않습니다.") from exc
|
|
126
|
+
except OSError as exc:
|
|
127
|
+
raise SettingsError("settings.json 파일을 읽을 수 없습니다.") from exc
|
|
128
|
+
if not isinstance(payload, dict):
|
|
129
|
+
raise SettingsError("settings.json 최상위 형식은 객체(JSON object)여야 합니다.")
|
|
130
|
+
return payload
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _write_settings_payload(payload: dict) -> Path:
|
|
134
|
+
directory = ensure_private_directory(config_dir(), strict=False)
|
|
135
|
+
path = directory / SETTINGS_FILE_NAME
|
|
136
|
+
rendered = json.dumps(payload, ensure_ascii=True, indent=2)
|
|
137
|
+
with NamedTemporaryFile("w", encoding="utf-8", dir=directory, delete=False) as tmp:
|
|
138
|
+
tmp.write(rendered)
|
|
139
|
+
temp_path = Path(tmp.name)
|
|
140
|
+
protect_file(temp_path, strict=False)
|
|
141
|
+
temp_path.replace(path)
|
|
142
|
+
protect_file(path, strict=False)
|
|
143
|
+
return path
|
kiwoom/core/runtime.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
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.client import KiwoomClient
|
|
6
|
+
from kiwoom.core.errors import ModeNotConfiguredError
|
|
7
|
+
from kiwoom.core.profiles import AuthProfile, get_current_profile, get_profile
|
|
8
|
+
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
|
|
12
|
+
from kiwoom.core.ws_client import KiwoomWebSocketClient
|
|
13
|
+
|
|
14
|
+
TokenStoreKind = Literal["file", "memory"]
|
|
15
|
+
|
|
16
|
+
SelectionSource = Literal[
|
|
17
|
+
"--profile",
|
|
18
|
+
"KIWOOM_PROFILE",
|
|
19
|
+
"--mode",
|
|
20
|
+
"KIWOOM_MODE",
|
|
21
|
+
"current_profile",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class _Selection:
|
|
27
|
+
"""How `mode`/`profile` inputs resolved to a concrete auth target.
|
|
28
|
+
|
|
29
|
+
Single source of truth shared by `resolve_mode`, `get_auth`, and
|
|
30
|
+
`describe_selection` so the selection precedence cannot diverge.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
source: SelectionSource
|
|
34
|
+
profile: AuthProfile | None
|
|
35
|
+
mode: Mode
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _resolve_selection(*, mode: str | None, profile: str | None) -> _Selection:
|
|
39
|
+
explicit_profile = profile
|
|
40
|
+
env_profile = get_profile_from_env() if profile is None else None
|
|
41
|
+
profile_alias = explicit_profile if explicit_profile is not None else env_profile
|
|
42
|
+
env_mode = None if mode is not None else get_mode_from_env()
|
|
43
|
+
|
|
44
|
+
if profile_alias is not None:
|
|
45
|
+
selected = get_profile(profile_alias)
|
|
46
|
+
selected_mode = normalize_mode(mode) if mode is not None else env_mode
|
|
47
|
+
if selected_mode is not None and selected_mode != selected.mode:
|
|
48
|
+
raise ValueError("지정한 mode가 선택한 계좌 별칭의 mode와 일치하지 않습니다")
|
|
49
|
+
source: SelectionSource = "--profile" if explicit_profile is not None else "KIWOOM_PROFILE"
|
|
50
|
+
return _Selection(source=source, profile=selected, mode=selected.mode)
|
|
51
|
+
|
|
52
|
+
if mode is not None:
|
|
53
|
+
return _Selection(source="--mode", profile=None, mode=normalize_mode(mode))
|
|
54
|
+
|
|
55
|
+
if env_mode is not None:
|
|
56
|
+
return _Selection(source="KIWOOM_MODE", profile=None, mode=env_mode)
|
|
57
|
+
|
|
58
|
+
current_profile = get_current_profile()
|
|
59
|
+
if current_profile is not None:
|
|
60
|
+
return _Selection(source="current_profile", profile=current_profile, mode=current_profile.mode)
|
|
61
|
+
|
|
62
|
+
raise ModeNotConfiguredError()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class SelectionContext:
|
|
67
|
+
"""Public description of which input won auth target selection."""
|
|
68
|
+
|
|
69
|
+
selection_source: SelectionSource
|
|
70
|
+
mode: Mode
|
|
71
|
+
profile: str | None
|
|
72
|
+
uses_profile: bool
|
|
73
|
+
target_label: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def describe_selection(mode: str | None = None, *, profile: str | None = None) -> SelectionContext:
|
|
77
|
+
"""Report how `mode`/`profile` resolve, without building an auth object.
|
|
78
|
+
|
|
79
|
+
Raises `ModeNotConfiguredError` when nothing selects a target, matching
|
|
80
|
+
`resolve_mode`/`get_auth`.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
selection = _resolve_selection(mode=mode, profile=profile)
|
|
84
|
+
alias = selection.profile.alias if selection.profile else None
|
|
85
|
+
uses_profile = selection.profile is not None
|
|
86
|
+
target_label = f"계좌 별칭 {alias}" if uses_profile else f"{selection.mode} mode"
|
|
87
|
+
return SelectionContext(
|
|
88
|
+
selection_source=selection.source,
|
|
89
|
+
mode=selection.mode,
|
|
90
|
+
profile=alias,
|
|
91
|
+
uses_profile=uses_profile,
|
|
92
|
+
target_label=target_label,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def resolve_mode(mode: str | None = None, *, profile: str | None = None) -> Mode:
|
|
97
|
+
return _resolve_selection(mode=mode, profile=profile).mode
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_auth(
|
|
101
|
+
mode: str | None = None,
|
|
102
|
+
*,
|
|
103
|
+
profile: str | None = None,
|
|
104
|
+
secret_provider: SecretProvider | None = None,
|
|
105
|
+
token_store_kind: TokenStoreKind = "file",
|
|
106
|
+
) -> KiwoomAuth:
|
|
107
|
+
selection = _resolve_selection(mode=mode, profile=profile)
|
|
108
|
+
profile_alias = selection.profile.alias if selection.profile else None
|
|
109
|
+
return KiwoomAuth(
|
|
110
|
+
mode=selection.mode,
|
|
111
|
+
profile=profile_alias,
|
|
112
|
+
secret_provider=secret_provider or default_secret_provider(profile=profile_alias),
|
|
113
|
+
token_store=_build_token_store(token_store_kind),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_client(
|
|
118
|
+
mode: str | None = None,
|
|
119
|
+
*,
|
|
120
|
+
profile: str | None = None,
|
|
121
|
+
auth: KiwoomAuth | None = None,
|
|
122
|
+
timeout_seconds: int = 30,
|
|
123
|
+
) -> KiwoomClient:
|
|
124
|
+
if auth is not None and (mode is not None or profile is not None):
|
|
125
|
+
raise ValueError("mode/profile and auth cannot be used together")
|
|
126
|
+
return KiwoomClient(auth or get_auth(mode, profile=profile), timeout_seconds=timeout_seconds)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_ws_client(
|
|
130
|
+
mode: str | None = None,
|
|
131
|
+
*,
|
|
132
|
+
profile: str | None = None,
|
|
133
|
+
auth: KiwoomAuth | None = None,
|
|
134
|
+
) -> KiwoomWebSocketClient:
|
|
135
|
+
if auth is not None and (mode is not None or profile is not None):
|
|
136
|
+
raise ValueError("mode/profile and auth cannot be used together")
|
|
137
|
+
return KiwoomWebSocketClient(auth or get_auth(mode, profile=profile))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_base_url(mode: str | None = None, *, profile: str | None = None) -> str:
|
|
141
|
+
return _auth_base_url(resolve_mode(mode, profile=profile))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_ws_base_url(mode: str | None = None, *, profile: str | None = None) -> str:
|
|
145
|
+
return _auth_ws_base_url(resolve_mode(mode, profile=profile))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _build_token_store(token_store_kind: TokenStoreKind):
|
|
149
|
+
if token_store_kind == "file":
|
|
150
|
+
return FileTokenStore()
|
|
151
|
+
if token_store_kind == "memory":
|
|
152
|
+
return MemoryTokenStore()
|
|
153
|
+
raise ValueError(f"unsupported token_store_kind: {token_store_kind}")
|