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
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
@@ -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}")