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