kwcli 1.0.0__tar.gz → 1.0.2__tar.gz
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.
- {kwcli-1.0.0 → kwcli-1.0.2}/PKG-INFO +2 -2
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/core/auth.py +13 -2
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/core/client.py +13 -2
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/core/errors.py +55 -2
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/core/runtime.py +86 -11
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/core/secrets.py +23 -0
- kwcli-1.0.2/kiwoom/core/settings.py +114 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/core/types.py +12 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/core/ws_client.py +2 -4
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/README.md +36 -55
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/maps/README.md +5 -6
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/setup.py +3 -18
- {kwcli-1.0.0 → kwcli-1.0.2}/pyproject.toml +1 -1
- kwcli-1.0.0/kiwoom/core/settings.py +0 -64
- {kwcli-1.0.0 → kwcli-1.0.2}/LICENSE.md +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/README.md +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/__init__.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/_data/kiwoom_api_spec.json +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/core/__init__.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/core/platform_paths.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/core/profiles.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/core/token_store.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/realtime/__init__.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/realtime/decoders.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/realtime/events.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/realtime/packets.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/realtime/schemas.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/realtime/stream.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom/specs.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/__init__.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/__main__.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/argument_maps.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/arguments.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/auth_context.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/banner.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/commands/__init__.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/commands/auth.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/commands/exchange.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/commands/groups.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/commands/mapped.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/commands/orders.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/commands/spec.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/commands/stocks.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/commands/streams.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/doctor.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/errors.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/executor/__init__.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/executor/condition.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/executor/rest.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/executor/waits.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/executor/websocket.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/main.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/maps/api_commands.csv +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/maps/arguments.csv +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/maps/coupled_arguments.csv +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/maps/order_confirmation_commands.csv +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/maps/order_confirmation_fields.csv +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/maps/order_price_policies.csv +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/maps/order_value_labels.csv +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/maps/positional_arguments.csv +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/order_confirmation.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/output.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/registry.py +0 -0
- {kwcli-1.0.0 → kwcli-1.0.2}/kiwoom_cli/safety.py +0 -0
|
@@ -328,8 +328,19 @@ class KiwoomAuth:
|
|
|
328
328
|
|
|
329
329
|
@staticmethod
|
|
330
330
|
def _credential_fingerprint(credentials: CredentialSet) -> str:
|
|
331
|
-
|
|
332
|
-
|
|
331
|
+
return credential_fingerprint(credentials)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def credential_fingerprint(credentials: CredentialSet) -> str:
|
|
335
|
+
"""Non-reversible identity of an appkey/secret pair.
|
|
336
|
+
|
|
337
|
+
Stored beside a cached token so a token issued for one key pair is never
|
|
338
|
+
reused after the credentials change (`_load_valid_token`), and used by the
|
|
339
|
+
runtime to seed a pre-issued token (`KIWOOM_ACCESS_TOKEN`) with the
|
|
340
|
+
fingerprint the auth object will check it against.
|
|
341
|
+
"""
|
|
342
|
+
raw = f"{credentials.appkey}:{credentials.secretkey}".encode("utf-8")
|
|
343
|
+
return hashlib.sha256(raw).hexdigest()
|
|
333
344
|
|
|
334
345
|
|
|
335
346
|
def _safe_json(response: requests.Response) -> dict:
|
|
@@ -6,10 +6,11 @@ import requests
|
|
|
6
6
|
|
|
7
7
|
from kiwoom.core.auth import KiwoomAuth, get_base_url
|
|
8
8
|
from kiwoom.core.errors import (
|
|
9
|
-
AUTH_RETRY_RETURN_CODES,
|
|
10
9
|
AuthenticationError,
|
|
11
10
|
HTTPRequestError,
|
|
11
|
+
auth_retry_code,
|
|
12
12
|
normalize_return_code,
|
|
13
|
+
raise_for_error_code,
|
|
13
14
|
)
|
|
14
15
|
from kiwoom.core.types import Continuation, KiwoomResponse
|
|
15
16
|
|
|
@@ -81,7 +82,9 @@ class KiwoomClient:
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
return_code = normalize_return_code(data.get("return_code"))
|
|
84
|
-
|
|
85
|
+
# 키움은 만료/무효 토큰을 top-level return_code=8005로도, return_code=3 +
|
|
86
|
+
# return_msg "[8005:...]"로도 돌려준다 — 어느 쪽이든 한 번 재발급 후 재시도.
|
|
87
|
+
if retry_on_auth_failure and auth_retry_code(return_code, data.get("return_msg")) is not None:
|
|
85
88
|
self.auth.recover_from_auth_failure()
|
|
86
89
|
return self.request(
|
|
87
90
|
api_id=resolved_api_id,
|
|
@@ -97,6 +100,14 @@ class KiwoomClient:
|
|
|
97
100
|
if return_code not in (None, 0):
|
|
98
101
|
message = f"{message} (return_code={data['return_code']})"
|
|
99
102
|
raise HTTPRequestError(response.status_code, message)
|
|
103
|
+
|
|
104
|
+
# 키움은 업무 오류도 HTTP 200으로 내려보내고 실패 여부는 본문 return_code에만 담는다.
|
|
105
|
+
if return_code not in (None, 0):
|
|
106
|
+
raise_for_error_code(
|
|
107
|
+
return_code,
|
|
108
|
+
str(data.get("return_msg") or "API 요청에 실패했습니다."),
|
|
109
|
+
)
|
|
110
|
+
|
|
100
111
|
return _build_response(data, response.headers)
|
|
101
112
|
|
|
102
113
|
def fetch_page(
|
|
@@ -1,9 +1,31 @@
|
|
|
1
|
+
import re
|
|
1
2
|
from pathlib import Path
|
|
2
3
|
|
|
3
4
|
# Kiwoom auth-expiry return codes that warrant a one-shot credential
|
|
4
5
|
# recovery + retry (shared by the REST client and the WebSocket client).
|
|
5
6
|
AUTH_RETRY_RETURN_CODES = frozenset({8005, 8031, 8103})
|
|
6
7
|
|
|
8
|
+
# Kiwoom often reports a generic top-level ``return_code`` (e.g. 3 = "인증에
|
|
9
|
+
# 실패했습니다") and puts the specific code inside ``return_msg`` as
|
|
10
|
+
# ``[8005:Token이 유효하지 않습니다]`` (REST) or ``CODE=8005`` (WebSocket).
|
|
11
|
+
_EMBEDDED_CODE_RE = re.compile(r"\[(\d{3,5}):|CODE=(\d{3,5})")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def embedded_return_code(return_msg: object) -> int | None:
|
|
15
|
+
"""Return the specific code embedded in a Kiwoom ``return_msg``, if any."""
|
|
16
|
+
match = _EMBEDDED_CODE_RE.search(str(return_msg or ""))
|
|
17
|
+
if match is None:
|
|
18
|
+
return None
|
|
19
|
+
return int(match.group(1) or match.group(2))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def auth_retry_code(return_code: int | None, return_msg: object) -> int | None:
|
|
23
|
+
"""The auth-expiry code (top-level or embedded) that warrants recovery, else None."""
|
|
24
|
+
if return_code in AUTH_RETRY_RETURN_CODES:
|
|
25
|
+
return return_code
|
|
26
|
+
embedded = embedded_return_code(return_msg)
|
|
27
|
+
return embedded if embedded in AUTH_RETRY_RETURN_CODES else None
|
|
28
|
+
|
|
7
29
|
|
|
8
30
|
def normalize_return_code(value: object) -> int | None:
|
|
9
31
|
"""Normalize a Kiwoom ``return_code`` payload value.
|
|
@@ -137,6 +159,13 @@ class DeviceAuthenticationError(AuthenticationError):
|
|
|
137
159
|
self.return_msg = return_msg
|
|
138
160
|
|
|
139
161
|
|
|
162
|
+
class DemoUnsupportedError(APIError):
|
|
163
|
+
"""Raised when the API exists but is not served in demo (모의투자) mode."""
|
|
164
|
+
|
|
165
|
+
def __init__(self, return_code: int, return_msg: str):
|
|
166
|
+
super().__init__(return_code, f"모의투자에서 지원하지 않는 API입니다. ({return_msg})")
|
|
167
|
+
|
|
168
|
+
|
|
140
169
|
INPUT_VALIDATION_CODES = {
|
|
141
170
|
1501,
|
|
142
171
|
1504,
|
|
@@ -151,15 +180,35 @@ INPUT_VALIDATION_CODES = {
|
|
|
151
180
|
1687,
|
|
152
181
|
8020,
|
|
153
182
|
}
|
|
154
|
-
RATE_LIMIT_CODES = {1700}
|
|
155
|
-
SYMBOL_NOT_FOUND_CODES = {1901, 1902}
|
|
183
|
+
RATE_LIMIT_CODES = {1700, 1701, 1702}
|
|
184
|
+
SYMBOL_NOT_FOUND_CODES = {1901, 1902, 1903}
|
|
156
185
|
INVALID_CREDENTIAL_CODES = {8001, 8002, 8011, 8012}
|
|
157
186
|
INVALID_TOKEN_CODES = {8003, 8005, 8006, 8009, 8015, 8016}
|
|
158
187
|
MODE_MISMATCH_CODES = {8030, 8031}
|
|
159
188
|
DEVICE_AUTH_CODES = {8010, 8040, 8050, 8103}
|
|
189
|
+
DEMO_UNSUPPORTED_CODES = {8104}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
_CLASSIFIED_CODES = (
|
|
193
|
+
INPUT_VALIDATION_CODES
|
|
194
|
+
| RATE_LIMIT_CODES
|
|
195
|
+
| SYMBOL_NOT_FOUND_CODES
|
|
196
|
+
| INVALID_CREDENTIAL_CODES
|
|
197
|
+
| INVALID_TOKEN_CODES
|
|
198
|
+
| MODE_MISMATCH_CODES
|
|
199
|
+
| DEVICE_AUTH_CODES
|
|
200
|
+
| DEMO_UNSUPPORTED_CODES
|
|
201
|
+
)
|
|
160
202
|
|
|
161
203
|
|
|
162
204
|
def raise_for_error_code(return_code: int, return_msg: str) -> None:
|
|
205
|
+
# A generic top-level code (3, 5, ...) with a specific code embedded in the
|
|
206
|
+
# message is classified by the embedded code, so callers get the same
|
|
207
|
+
# error class either way; the message keeps Kiwoom's original text.
|
|
208
|
+
if return_code not in _CLASSIFIED_CODES:
|
|
209
|
+
embedded = embedded_return_code(return_msg)
|
|
210
|
+
if embedded in _CLASSIFIED_CODES:
|
|
211
|
+
return_code = embedded
|
|
163
212
|
if return_code in INPUT_VALIDATION_CODES:
|
|
164
213
|
raise InputValidationError(return_code, return_msg)
|
|
165
214
|
if return_code in RATE_LIMIT_CODES:
|
|
@@ -174,4 +223,8 @@ def raise_for_error_code(return_code: int, return_msg: str) -> None:
|
|
|
174
223
|
raise ModeMismatchError(return_code, return_msg)
|
|
175
224
|
if return_code in DEVICE_AUTH_CODES:
|
|
176
225
|
raise DeviceAuthenticationError(return_code, return_msg)
|
|
226
|
+
if return_code in DEMO_UNSUPPORTED_CODES:
|
|
227
|
+
raise DemoUnsupportedError(return_code, return_msg)
|
|
228
|
+
# 1999(예기치 못한 오류)·8200(법인 미지원)처럼 분류해도 대응이 달라지지 않는 코드는
|
|
229
|
+
# 원문 메시지를 그대로 노출하는 APIError로 남긴다.
|
|
177
230
|
raise APIError(return_code, return_msg)
|
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
from dataclasses import dataclass
|
|
2
|
+
from datetime import UTC, datetime
|
|
2
3
|
from typing import Literal
|
|
3
4
|
|
|
4
|
-
from kiwoom.core.auth import
|
|
5
|
+
from kiwoom.core.auth import (
|
|
6
|
+
KiwoomAuth,
|
|
7
|
+
credential_fingerprint,
|
|
8
|
+
get_base_url as _auth_base_url,
|
|
9
|
+
get_ws_base_url as _auth_ws_base_url,
|
|
10
|
+
)
|
|
5
11
|
from kiwoom.core.client import KiwoomClient
|
|
6
12
|
from kiwoom.core.errors import ModeNotConfiguredError
|
|
7
13
|
from kiwoom.core.profiles import AuthProfile, get_current_profile, get_profile
|
|
8
14
|
from kiwoom.core.secrets import SecretProvider, default_secret_provider
|
|
9
|
-
from kiwoom.core.settings import
|
|
10
|
-
|
|
11
|
-
|
|
15
|
+
from kiwoom.core.settings import (
|
|
16
|
+
ACCESS_TOKEN_ENV_VAR,
|
|
17
|
+
TOKEN_STORE_ENV_VAR,
|
|
18
|
+
PreissuedToken,
|
|
19
|
+
get_mode_from_env,
|
|
20
|
+
get_preissued_token_from_env,
|
|
21
|
+
get_profile_from_env,
|
|
22
|
+
get_token_store_kind_from_env,
|
|
23
|
+
)
|
|
24
|
+
from kiwoom.core.token_store import FileTokenStore, MemoryTokenStore, TokenRecord
|
|
25
|
+
from kiwoom.core.types import Mode, TokenStoreKind, normalize_mode
|
|
12
26
|
from kiwoom.core.ws_client import KiwoomWebSocketClient
|
|
13
27
|
|
|
14
|
-
TokenStoreKind = Literal["file", "memory"]
|
|
15
|
-
|
|
16
28
|
SelectionSource = Literal[
|
|
17
29
|
"--profile",
|
|
18
30
|
"KIWOOM_PROFILE",
|
|
@@ -102,15 +114,28 @@ def get_auth(
|
|
|
102
114
|
*,
|
|
103
115
|
profile: str | None = None,
|
|
104
116
|
secret_provider: SecretProvider | None = None,
|
|
105
|
-
token_store_kind: TokenStoreKind =
|
|
117
|
+
token_store_kind: TokenStoreKind | None = None,
|
|
106
118
|
) -> KiwoomAuth:
|
|
119
|
+
"""Build the auth object for the selected target.
|
|
120
|
+
|
|
121
|
+
`token_store_kind=None` (the default) resolves via `resolve_token_store_kind`,
|
|
122
|
+
i.e. `KIWOOM_TOKEN_STORE` when set, else the on-disk file cache. Pass an
|
|
123
|
+
explicit kind to override the environment (setup does this for its
|
|
124
|
+
throwaway validation auth).
|
|
125
|
+
"""
|
|
107
126
|
selection = _resolve_selection(mode=mode, profile=profile)
|
|
108
127
|
profile_alias = selection.profile.alias if selection.profile else None
|
|
128
|
+
provider = secret_provider or default_secret_provider(profile=profile_alias)
|
|
109
129
|
return KiwoomAuth(
|
|
110
130
|
mode=selection.mode,
|
|
111
131
|
profile=profile_alias,
|
|
112
|
-
secret_provider=
|
|
113
|
-
token_store=_build_token_store(
|
|
132
|
+
secret_provider=provider,
|
|
133
|
+
token_store=_build_token_store(
|
|
134
|
+
resolve_token_store_kind(token_store_kind),
|
|
135
|
+
mode=selection.mode,
|
|
136
|
+
profile=profile_alias,
|
|
137
|
+
secret_provider=provider,
|
|
138
|
+
),
|
|
114
139
|
)
|
|
115
140
|
|
|
116
141
|
|
|
@@ -145,9 +170,59 @@ def get_ws_base_url(mode: str | None = None, *, profile: str | None = None) -> s
|
|
|
145
170
|
return _auth_ws_base_url(resolve_mode(mode, profile=profile))
|
|
146
171
|
|
|
147
172
|
|
|
148
|
-
def
|
|
173
|
+
def resolve_token_store_kind(token_store_kind: TokenStoreKind | None = None) -> TokenStoreKind:
|
|
174
|
+
"""Explicit argument > `KIWOOM_TOKEN_STORE` > `file`."""
|
|
175
|
+
if token_store_kind is not None:
|
|
176
|
+
return token_store_kind
|
|
177
|
+
return get_token_store_kind_from_env() or "file"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _build_token_store(
|
|
181
|
+
token_store_kind: TokenStoreKind,
|
|
182
|
+
*,
|
|
183
|
+
mode: Mode,
|
|
184
|
+
profile: str | None,
|
|
185
|
+
secret_provider: SecretProvider,
|
|
186
|
+
):
|
|
187
|
+
preissued = get_preissued_token_from_env()
|
|
149
188
|
if token_store_kind == "file":
|
|
189
|
+
if preissued is not None:
|
|
190
|
+
raise ValueError(f"{ACCESS_TOKEN_ENV_VAR} requires {TOKEN_STORE_ENV_VAR}=memory.")
|
|
150
191
|
return FileTokenStore()
|
|
151
192
|
if token_store_kind == "memory":
|
|
152
|
-
|
|
193
|
+
store = MemoryTokenStore()
|
|
194
|
+
if preissued is not None:
|
|
195
|
+
_seed_preissued_token(store, preissued, mode=mode, profile=profile, secret_provider=secret_provider)
|
|
196
|
+
return store
|
|
153
197
|
raise ValueError(f"unsupported token_store_kind: {token_store_kind}")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _seed_preissued_token(
|
|
201
|
+
store: MemoryTokenStore,
|
|
202
|
+
preissued: PreissuedToken,
|
|
203
|
+
*,
|
|
204
|
+
mode: Mode,
|
|
205
|
+
profile: str | None,
|
|
206
|
+
secret_provider: SecretProvider,
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Place a token handed in via env into the in-memory store.
|
|
209
|
+
|
|
210
|
+
The record carries the fingerprint of the credentials this process resolves,
|
|
211
|
+
so `KiwoomAuth._load_valid_token` accepts it exactly as it would a token it
|
|
212
|
+
issued itself; if the credentials are missing the token is not seeded and the
|
|
213
|
+
usual credentials error surfaces on first use.
|
|
214
|
+
"""
|
|
215
|
+
credentials = secret_provider.get_credentials(mode)
|
|
216
|
+
if credentials is None:
|
|
217
|
+
return
|
|
218
|
+
store.save(
|
|
219
|
+
TokenRecord(
|
|
220
|
+
access_token=preissued.access_token,
|
|
221
|
+
token_type="bearer",
|
|
222
|
+
expires_at=preissued.expires_at.astimezone(UTC),
|
|
223
|
+
mode=mode,
|
|
224
|
+
profile=profile,
|
|
225
|
+
credential_fingerprint=credential_fingerprint(credentials),
|
|
226
|
+
saved_at=datetime.now(UTC),
|
|
227
|
+
)
|
|
228
|
+
)
|
|
@@ -25,6 +25,29 @@ class SecretProvider(Protocol):
|
|
|
25
25
|
def clear_credentials(self, mode: Mode) -> bool: ...
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
class StaticSecretProvider:
|
|
29
|
+
"""Read-only provider holding one appkey/secret pair in memory.
|
|
30
|
+
|
|
31
|
+
Used where credentials arrive for a single operation and must not be
|
|
32
|
+
persisted: setup's validation call before anything is stored, and a
|
|
33
|
+
server issuing a token for credentials received on a request.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, appkey: str, secretkey: str, *, source: str = "static") -> None:
|
|
37
|
+
self.appkey = appkey
|
|
38
|
+
self.secretkey = secretkey
|
|
39
|
+
self.source = source
|
|
40
|
+
|
|
41
|
+
def get_credentials(self, mode: Mode) -> CredentialSet | None:
|
|
42
|
+
return CredentialSet(appkey=self.appkey, secretkey=self.secretkey, source=self.source)
|
|
43
|
+
|
|
44
|
+
def set_credentials(self, mode: Mode, appkey: str, secretkey: str) -> None:
|
|
45
|
+
raise RuntimeError("정적 자격 증명 공급자는 읽기 전용입니다.")
|
|
46
|
+
|
|
47
|
+
def clear_credentials(self, mode: Mode) -> bool:
|
|
48
|
+
raise RuntimeError("정적 자격 증명 공급자는 읽기 전용입니다.")
|
|
49
|
+
|
|
50
|
+
|
|
28
51
|
class EnvSecretProvider:
|
|
29
52
|
def get_credentials(self, mode: Mode) -> CredentialSet | None:
|
|
30
53
|
appkey_var, secretkey_var = env_var_names(mode)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from tempfile import NamedTemporaryFile
|
|
6
|
+
|
|
7
|
+
from kiwoom.core.errors import InvalidModeError, SettingsError
|
|
8
|
+
from kiwoom.core.platform_paths import config_dir, ensure_private_directory, protect_file
|
|
9
|
+
from kiwoom.core.types import Mode, TokenStoreKind, normalize_mode, normalize_token_store_kind
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
SETTINGS_FILE_NAME = "settings.json"
|
|
13
|
+
MODE_ENV_VAR = "KIWOOM_MODE"
|
|
14
|
+
PROFILE_ENV_VAR = "KIWOOM_PROFILE"
|
|
15
|
+
TOKEN_STORE_ENV_VAR = "KIWOOM_TOKEN_STORE"
|
|
16
|
+
ACCESS_TOKEN_ENV_VAR = "KIWOOM_ACCESS_TOKEN"
|
|
17
|
+
ACCESS_TOKEN_EXPIRES_AT_ENV_VAR = "KIWOOM_ACCESS_TOKEN_EXPIRES_AT"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_mode_from_env() -> Mode | None:
|
|
21
|
+
value = os.getenv(MODE_ENV_VAR)
|
|
22
|
+
if not value:
|
|
23
|
+
return None
|
|
24
|
+
try:
|
|
25
|
+
return normalize_mode(value)
|
|
26
|
+
except ValueError as exc:
|
|
27
|
+
raise InvalidModeError(value) from exc
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_profile_from_env() -> str | None:
|
|
31
|
+
value = os.getenv(PROFILE_ENV_VAR)
|
|
32
|
+
if not value:
|
|
33
|
+
return None
|
|
34
|
+
return value.strip() or None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_token_store_kind_from_env() -> TokenStoreKind | None:
|
|
38
|
+
"""Return the token store kind selected by KIWOOM_TOKEN_STORE, or None.
|
|
39
|
+
|
|
40
|
+
`file` (default when unset) caches the access token under the user cache
|
|
41
|
+
directory; `memory` keeps it in-process only, so a short-lived process
|
|
42
|
+
(e.g. one MCP request → one kiwoomcli subprocess) leaves no token on disk.
|
|
43
|
+
"""
|
|
44
|
+
value = os.getenv(TOKEN_STORE_ENV_VAR)
|
|
45
|
+
if not value:
|
|
46
|
+
return None
|
|
47
|
+
return normalize_token_store_kind(value.strip().lower())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class PreissuedToken:
|
|
52
|
+
access_token: str
|
|
53
|
+
expires_at: datetime
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_preissued_token_from_env() -> PreissuedToken | None:
|
|
57
|
+
"""Return the token in KIWOOM_ACCESS_TOKEN + KIWOOM_ACCESS_TOKEN_EXPIRES_AT, or None.
|
|
58
|
+
|
|
59
|
+
A caller that already holds a valid token for the same credentials (e.g. a
|
|
60
|
+
server that issued it once and keeps it in memory) hands it to a short-lived
|
|
61
|
+
process this way, so the process does not spend a `/oauth2/token` call of
|
|
62
|
+
its own. Both variables must be set together; expires_at is ISO 8601 with a
|
|
63
|
+
timezone. Only meaningful with KIWOOM_TOKEN_STORE=memory (see runtime).
|
|
64
|
+
"""
|
|
65
|
+
token = os.getenv(ACCESS_TOKEN_ENV_VAR)
|
|
66
|
+
expires_raw = os.getenv(ACCESS_TOKEN_EXPIRES_AT_ENV_VAR)
|
|
67
|
+
if not token and not expires_raw:
|
|
68
|
+
return None
|
|
69
|
+
if not token or not expires_raw:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"{ACCESS_TOKEN_ENV_VAR} and {ACCESS_TOKEN_EXPIRES_AT_ENV_VAR} must be set together."
|
|
72
|
+
)
|
|
73
|
+
try:
|
|
74
|
+
expires_at = datetime.fromisoformat(expires_raw.strip())
|
|
75
|
+
except ValueError as exc:
|
|
76
|
+
raise ValueError(f"{ACCESS_TOKEN_EXPIRES_AT_ENV_VAR} must be an ISO 8601 datetime.") from exc
|
|
77
|
+
if expires_at.tzinfo is None:
|
|
78
|
+
raise ValueError(f"{ACCESS_TOKEN_EXPIRES_AT_ENV_VAR} must include a timezone offset.")
|
|
79
|
+
return PreissuedToken(access_token=token.strip(), expires_at=expires_at)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def settings_path() -> Path:
|
|
83
|
+
return config_dir() / SETTINGS_FILE_NAME
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def snapshot_settings() -> str | None:
|
|
87
|
+
path = settings_path()
|
|
88
|
+
if not path.exists():
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
return path.read_text(encoding="utf-8")
|
|
92
|
+
except OSError as exc:
|
|
93
|
+
raise SettingsError("settings.json 파일을 읽을 수 없습니다.") from exc
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def restore_settings(snapshot: str | None) -> None:
|
|
97
|
+
path = settings_path()
|
|
98
|
+
if snapshot is None:
|
|
99
|
+
path.unlink(missing_ok=True)
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
directory = ensure_private_directory(config_dir(), strict=False)
|
|
103
|
+
with NamedTemporaryFile(
|
|
104
|
+
"w",
|
|
105
|
+
encoding="utf-8",
|
|
106
|
+
dir=directory,
|
|
107
|
+
delete=False,
|
|
108
|
+
) as tmp:
|
|
109
|
+
tmp.write(snapshot)
|
|
110
|
+
temp_path = Path(tmp.name)
|
|
111
|
+
|
|
112
|
+
protect_file(temp_path, strict=False)
|
|
113
|
+
temp_path.replace(path)
|
|
114
|
+
protect_file(path, strict=False)
|
|
@@ -7,6 +7,10 @@ Mode = Literal["real", "demo"]
|
|
|
7
7
|
|
|
8
8
|
VALID_MODES = ("real", "demo")
|
|
9
9
|
|
|
10
|
+
TokenStoreKind = Literal["file", "memory"]
|
|
11
|
+
|
|
12
|
+
VALID_TOKEN_STORE_KINDS = ("file", "memory")
|
|
13
|
+
|
|
10
14
|
|
|
11
15
|
def normalize_mode(value: str) -> Mode:
|
|
12
16
|
if value not in VALID_MODES:
|
|
@@ -14,6 +18,14 @@ def normalize_mode(value: str) -> Mode:
|
|
|
14
18
|
return value # type: ignore[return-value]
|
|
15
19
|
|
|
16
20
|
|
|
21
|
+
def normalize_token_store_kind(value: str) -> TokenStoreKind:
|
|
22
|
+
if value not in VALID_TOKEN_STORE_KINDS:
|
|
23
|
+
raise ValueError(
|
|
24
|
+
f"Unsupported token store kind: {value!r}. Expected one of {VALID_TOKEN_STORE_KINDS}."
|
|
25
|
+
)
|
|
26
|
+
return value # type: ignore[return-value]
|
|
27
|
+
|
|
28
|
+
|
|
17
29
|
@dataclass(frozen=True)
|
|
18
30
|
class Continuation:
|
|
19
31
|
has_next: bool
|
|
@@ -5,7 +5,7 @@ from collections.abc import AsyncIterator, Callable
|
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
7
|
from kiwoom.core.auth import KiwoomAuth, get_ws_base_url
|
|
8
|
-
from kiwoom.core.errors import
|
|
8
|
+
from kiwoom.core.errors import auth_retry_code, normalize_return_code
|
|
9
9
|
|
|
10
10
|
logger = logging.getLogger(__name__)
|
|
11
11
|
|
|
@@ -194,9 +194,7 @@ class KiwoomWebSocketClient:
|
|
|
194
194
|
|
|
195
195
|
|
|
196
196
|
def _is_auth_retry_login_error(exc: WebSocketLoginError) -> bool:
|
|
197
|
-
|
|
198
|
-
return True
|
|
199
|
-
return any(f"CODE={code}" in exc.return_msg for code in AUTH_RETRY_RETURN_CODES)
|
|
197
|
+
return auth_retry_code(exc.return_code, exc.return_msg) is not None
|
|
200
198
|
|
|
201
199
|
|
|
202
200
|
async def _default_connect(uri: str, timeout_seconds: int) -> Any:
|
|
@@ -16,8 +16,9 @@ actions. Runtime/auth logic stays in `kiwoom/core/`; CLI use cases stay under
|
|
|
16
16
|
## Quickstart
|
|
17
17
|
|
|
18
18
|
The everyday flow for both humans and AI agents is discovery first, then a
|
|
19
|
-
single command.
|
|
20
|
-
|
|
19
|
+
single command. The user manual is `docs/NOTICE_CLI.md` in this delivery tree
|
|
20
|
+
(the same text becomes the PyPI package README). Use `-h` on any command for
|
|
21
|
+
its live, mapped contract.
|
|
21
22
|
|
|
22
23
|
```sh
|
|
23
24
|
1. kiwoomcli setup # onboard: alias, demo/real, keys, verify
|
|
@@ -554,7 +555,7 @@ separate account/`.env` for 금현물 where the demo account differs. All order
|
|
|
554
555
|
writes (domestic stock/credit/gold and overseas) are `guarded` and submit with
|
|
555
556
|
`--confirm`; no rows remain `preview-only`.
|
|
556
557
|
|
|
557
|
-
See [`docs/
|
|
558
|
+
See [`docs/NOTICE_CLI.md`](../docs/NOTICE_CLI.md) for installation, command
|
|
558
559
|
groups, output formats, streaming, and order safety guidance.
|
|
559
560
|
|
|
560
561
|
Policy-design API families:
|
|
@@ -625,7 +626,7 @@ Rules:
|
|
|
625
626
|
- New CLI behavior must acquire runtime objects through the package facade:
|
|
626
627
|
`get_auth`, `get_client`, or `get_ws_client`.
|
|
627
628
|
- New implemented CLI behavior must update this README and
|
|
628
|
-
`docs/
|
|
629
|
+
`docs/NOTICE_CLI.md`.
|
|
629
630
|
|
|
630
631
|
Preferred runtime pattern:
|
|
631
632
|
|
|
@@ -638,40 +639,46 @@ response = client.fetch_page(api_id=api_id, path=path, body=body, method=method)
|
|
|
638
639
|
|
|
639
640
|
## Internal Layout
|
|
640
641
|
|
|
641
|
-
Current CLI implementation layout:
|
|
642
|
+
Current CLI implementation layout (see [`docs/DEVELOPER_CLI.md`](../docs/DEVELOPER_CLI.md)):
|
|
642
643
|
|
|
643
644
|
```text
|
|
644
645
|
kiwoom_cli/
|
|
645
646
|
main.py # router only: parse args, dispatch to args.handler, error funnel
|
|
646
647
|
arguments.py
|
|
647
|
-
|
|
648
|
+
argument_maps.py
|
|
648
649
|
output.py
|
|
649
650
|
registry.py
|
|
650
651
|
safety.py
|
|
651
652
|
setup.py # setup command parser/handler + onboarding logic
|
|
652
653
|
doctor.py # doctor command parser/handler
|
|
653
654
|
order_confirmation.py
|
|
655
|
+
banner.py
|
|
656
|
+
errors.py
|
|
657
|
+
auth_context.py
|
|
654
658
|
README.md
|
|
655
|
-
|
|
659
|
+
executor/ # REST, WebSocket, condition search, waits
|
|
660
|
+
rest.py
|
|
661
|
+
websocket.py
|
|
662
|
+
condition.py
|
|
663
|
+
waits.py
|
|
664
|
+
commands/
|
|
665
|
+
groups.py # MappedGroup table for pure mapped groups
|
|
666
|
+
mapped.py # shared mapped REST parser/runner
|
|
667
|
+
stocks.py # stocks-only parser (domestic/overseas)
|
|
668
|
+
orders.py # order writes + --confirm
|
|
669
|
+
streams.py
|
|
670
|
+
exchange.py
|
|
656
671
|
auth.py
|
|
657
672
|
spec.py
|
|
658
|
-
accounts.py
|
|
659
|
-
candles.py
|
|
660
|
-
elws.py
|
|
661
|
-
etfs.py
|
|
662
|
-
investors.py
|
|
663
|
-
orderbooks.py
|
|
664
|
-
orders.py
|
|
665
|
-
quotes.py
|
|
666
|
-
rankings.py
|
|
667
|
-
securities_lending.py
|
|
668
|
-
short_selling.py
|
|
669
|
-
stocks.py
|
|
670
|
-
streams.py
|
|
671
|
-
themes.py
|
|
672
673
|
maps/
|
|
673
674
|
api_commands.csv
|
|
674
675
|
arguments.csv
|
|
676
|
+
positional_arguments.csv
|
|
677
|
+
coupled_arguments.csv
|
|
678
|
+
order_price_policies.csv
|
|
679
|
+
order_confirmation_commands.csv
|
|
680
|
+
order_confirmation_fields.csv
|
|
681
|
+
order_value_labels.csv
|
|
675
682
|
README.md
|
|
676
683
|
```
|
|
677
684
|
|
|
@@ -679,39 +686,11 @@ kiwoom_cli/
|
|
|
679
686
|
domestic stock 206, and US stock 129. All rows are implemented; coverage is
|
|
680
687
|
`public` (252) or `guarded` (85).
|
|
681
688
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
arguments.py
|
|
688
|
-
executor.py
|
|
689
|
-
output.py
|
|
690
|
-
safety.py
|
|
691
|
-
commands/
|
|
692
|
-
accounts.py
|
|
693
|
-
stocks.py
|
|
694
|
-
quotes.py
|
|
695
|
-
orderbooks.py
|
|
696
|
-
candles.py
|
|
697
|
-
rankings.py
|
|
698
|
-
etfs.py
|
|
699
|
-
elws.py
|
|
700
|
-
investors.py
|
|
701
|
-
short_selling.py
|
|
702
|
-
securities_lending.py
|
|
703
|
-
themes.py
|
|
704
|
-
orders.py
|
|
705
|
-
streams.py
|
|
706
|
-
maps/
|
|
707
|
-
api_commands.csv
|
|
708
|
-
arguments.csv
|
|
709
|
-
positional_arguments.csv
|
|
710
|
-
order_price_policies.csv
|
|
711
|
-
order_confirmation_commands.csv
|
|
712
|
-
order_confirmation_fields.csv
|
|
713
|
-
order_value_labels.csv
|
|
714
|
-
```
|
|
689
|
+
New commands go into `maps/*.csv` and `commands/groups.py` (`MappedGroup`)
|
|
690
|
+
when the behavior is a pure mapping. Only stocks/orders/streams/exchange keep
|
|
691
|
+
dedicated modules. Do not add a new `commands/<group>.py` per API family.
|
|
692
|
+
See [`docs/DEVELOPER_CLI.md`](../docs/DEVELOPER_CLI.md) and
|
|
693
|
+
[`docs/DEVELOPER_EXTEND.md`](../docs/DEVELOPER_EXTEND.md).
|
|
715
694
|
|
|
716
695
|
Domain modules should register command groups and delegate to shared execution.
|
|
717
696
|
They should not call `requests` directly, issue tokens directly, construct
|
|
@@ -719,7 +698,9 @@ ad-hoc request bodies, or bypass common safety checks.
|
|
|
719
698
|
|
|
720
699
|
## Documentation Contract
|
|
721
700
|
|
|
722
|
-
- [`docs/
|
|
701
|
+
- [`docs/NOTICE_CLI.md`](../docs/NOTICE_CLI.md) is the Korean user manual.
|
|
702
|
+
- [`docs/DEVELOPER_CLI.md`](../docs/DEVELOPER_CLI.md) is the architecture /
|
|
703
|
+
maintenance guide (command pipeline, safety gate, how to add commands).
|
|
723
704
|
- This README documents the package structure and complete command inventory.
|
|
724
705
|
- `kiwoom_cli/maps/README.md` documents map files and current API counts.
|
|
725
706
|
- Maps and documentation must remain synchronized with
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
This folder contains the runtime maps bundled with the full Kiwoom CLI.
|
|
4
4
|
The current map covers all 337 APIs: OAuth 2, domestic stock 206, and US stock
|
|
5
|
-
129.
|
|
6
|
-
|
|
5
|
+
129. The delivery pipeline copies generated maps from the development registry
|
|
6
|
+
and validates them against the bundled specification.
|
|
7
7
|
|
|
8
8
|
## Files
|
|
9
9
|
|
|
@@ -76,16 +76,15 @@ coverage is either `public` (252) or `guarded` (85).
|
|
|
76
76
|
|
|
77
77
|
## Validation Scope
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
replacing the Active Project output:
|
|
79
|
+
The bundled maps are validated against the packaged spec before they ship:
|
|
81
80
|
|
|
82
81
|
- bundled spec and `api_commands.csv` contain the same 337 API IDs;
|
|
83
82
|
- category counts are OAuth 2, domestic stock 206, and US stock 129;
|
|
84
83
|
- domestic and overseas command paths use their corresponding parser roots;
|
|
85
84
|
- every map row is implemented and Python sources compile;
|
|
86
85
|
- `exchange.py` and `coupled_arguments.csv` remain available;
|
|
87
|
-
-
|
|
88
|
-
|
|
86
|
+
- CLI prove evidence, when present, is stored as
|
|
87
|
+
`{domestic|overseas}/{api_id}/{command}.{txt|png}`.
|
|
89
88
|
|
|
90
89
|
Domestic and overseas order writes remain guarded: without `--confirm`, the CLI
|
|
91
90
|
prints a 미전송 주문 확인 and does not submit the order.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import argparse
|
|
2
|
-
from dataclasses import
|
|
2
|
+
from dataclasses import replace
|
|
3
3
|
from datetime import timedelta, timezone
|
|
4
4
|
from getpass import getpass
|
|
5
5
|
import sys
|
|
@@ -16,8 +16,8 @@ from kiwoom.core.profiles import (
|
|
|
16
16
|
validate_profile_alias,
|
|
17
17
|
)
|
|
18
18
|
from kiwoom.core.secrets import (
|
|
19
|
-
CredentialSet,
|
|
20
19
|
EnvSecretProvider,
|
|
20
|
+
StaticSecretProvider,
|
|
21
21
|
default_secret_provider,
|
|
22
22
|
)
|
|
23
23
|
from kiwoom.core.settings import restore_settings, snapshot_settings
|
|
@@ -85,21 +85,6 @@ def handle_setup(args: argparse.Namespace) -> None:
|
|
|
85
85
|
)
|
|
86
86
|
|
|
87
87
|
|
|
88
|
-
@dataclass
|
|
89
|
-
class _EphemeralSecretProvider:
|
|
90
|
-
appkey: str
|
|
91
|
-
secretkey: str
|
|
92
|
-
|
|
93
|
-
def get_credentials(self, mode: Mode) -> CredentialSet | None:
|
|
94
|
-
return CredentialSet(appkey=self.appkey, secretkey=self.secretkey, source="prompt")
|
|
95
|
-
|
|
96
|
-
def set_credentials(self, mode: Mode, appkey: str, secretkey: str) -> None:
|
|
97
|
-
raise RuntimeError("임시 자격 증명 공급자는 읽기 전용입니다.")
|
|
98
|
-
|
|
99
|
-
def clear_credentials(self, mode: Mode) -> bool:
|
|
100
|
-
raise RuntimeError("임시 자격 증명 공급자는 읽기 전용입니다.")
|
|
101
|
-
|
|
102
|
-
|
|
103
88
|
def run_setup(*, alias: str | None = None, mode: str | None = None) -> None:
|
|
104
89
|
print_banner()
|
|
105
90
|
print()
|
|
@@ -327,7 +312,7 @@ def _validate_credentials(mode: Mode, appkey: str, secretkey: str) -> tuple[Toke
|
|
|
327
312
|
_wz_note("입력한 자격 증명을 확인하는 중입니다…")
|
|
328
313
|
auth = get_auth(
|
|
329
314
|
mode=mode,
|
|
330
|
-
secret_provider=
|
|
315
|
+
secret_provider=StaticSecretProvider(appkey, secretkey, source="prompt"),
|
|
331
316
|
token_store_kind="memory",
|
|
332
317
|
)
|
|
333
318
|
client = get_client(auth=auth)
|
|
@@ -1,64 +0,0 @@
|
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|