krx-openapi 0.2.1__tar.gz → 0.2.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.
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/PKG-INFO +1 -1
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/pyproject.toml +1 -1
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/src/krx_openapi/errors.py +3 -1
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/src/krx_openapi/session.py +38 -10
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/tests/test_session.py +62 -15
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/.agents/plugins/marketplace.json +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/.claude-plugin/marketplace.json +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/.github/workflows/check.yml +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/.github/workflows/publish.yml +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/.github/workflows/reusable-check.yml +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/.gitignore +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/LICENSE +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/Makefile +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/README.en.md +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/README.md +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/plugins/krx/.claude-plugin/plugin.json +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/plugins/krx/.codex-plugin/plugin.json +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/plugins/krx/skills/fetch/SKILL.md +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/plugins/krx/skills/fields/SKILL.md +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/plugins/krx/skills/list/SKILL.md +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/src/krx_openapi/__init__.py +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/src/krx_openapi/__main__.py +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/src/krx_openapi/_config.py +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/src/krx_openapi/_endpoint.py +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/src/krx_openapi/catalog.py +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/src/krx_openapi/cli.py +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/src/krx_openapi/client.py +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/src/krx_openapi/py.typed +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/src/krx_openapi/types.py +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/tests/__init__.py +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/tests/conftest.py +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/tests/test_catalog.py +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/tests/test_cli.py +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/tests/test_client.py +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/tests/test_config.py +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/tests/test_endpoints.py +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/tests/test_errors.py +0 -0
- {krx_openapi-0.2.1 → krx_openapi-0.2.2}/tests/test_live.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: krx-openapi
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Read the Korea Exchange (KRX) Open API: indices, stocks, ETP, bonds, derivatives, commodities, ESG.
|
|
5
5
|
Project-URL: Homepage, https://github.com/seokhoonj/krx-openapi
|
|
6
6
|
Project-URL: Repository, https://github.com/seokhoonj/krx-openapi
|
|
@@ -7,7 +7,7 @@ build-backend = "hatchling.build"
|
|
|
7
7
|
|
|
8
8
|
[project]
|
|
9
9
|
name = "krx-openapi"
|
|
10
|
-
version = "0.2.
|
|
10
|
+
version = "0.2.2"
|
|
11
11
|
description = "Read the Korea Exchange (KRX) Open API: indices, stocks, ETP, bonds, derivatives, commodities, ESG."
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.11"
|
|
@@ -36,7 +36,9 @@ class KRXNetworkError(KRXError):
|
|
|
36
36
|
|
|
37
37
|
A timeout, DNS failure, connection reset, an interrupted read, a non-success HTTP
|
|
38
38
|
status, or a 200 whose body is not JSON (a proxy/maintenance page). The underlying
|
|
39
|
-
exception is chained
|
|
39
|
+
transport/decode exception is deliberately NOT chained: it can hold the request URL
|
|
40
|
+
or the response body, so surfacing it on ``__cause__``/``__context__`` would expose
|
|
41
|
+
them to a logger that walks the chain.
|
|
40
42
|
"""
|
|
41
43
|
|
|
42
44
|
|
|
@@ -131,32 +131,60 @@ class KRXSession:
|
|
|
131
131
|
failure = KRXNetworkError(
|
|
132
132
|
f"KRX response read failed for {url}: {type(err).__name__}")
|
|
133
133
|
else:
|
|
134
|
-
return _rows_from_body(raw, url)
|
|
134
|
+
return _rows_from_body(raw, url, api_key)
|
|
135
135
|
raise failure from None
|
|
136
136
|
|
|
137
137
|
|
|
138
|
-
def
|
|
138
|
+
def _redact_key(text: str, api_key: str) -> str:
|
|
139
|
+
"""Blank the API key out of server-authored text (a vendor message, or a body a
|
|
140
|
+
reflecting server echoed the ``AUTH_KEY`` header into).
|
|
141
|
+
|
|
142
|
+
krx carries the key in the request header, not the URL, so this is defense-in-depth:
|
|
143
|
+
a server that reflected the header into a response field would otherwise surface it in
|
|
144
|
+
an error message. Blank the raw and url-encoded forms of the value actually sent
|
|
145
|
+
(``api_key.strip()``). (An empty key would splice ``<key>`` between every character.)
|
|
146
|
+
"""
|
|
147
|
+
key = api_key.strip()
|
|
148
|
+
if not key:
|
|
149
|
+
return text
|
|
150
|
+
return text.replace(key, "<key>").replace(urllib.parse.quote_plus(key), "<key>")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _rows_from_body(raw: bytes, url: str, api_key: str) -> list[Row]:
|
|
139
154
|
"""Apply the KRX status contract to a raw response body.
|
|
140
155
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
156
|
+
Parses JSON, raises on a non-empty ``respCode`` or a non-JSON body, and returns the
|
|
157
|
+
single ``OutBlock_*`` array (``[]`` when the vendor sends an empty one for a no-data
|
|
158
|
+
date). ``api_key`` is used only to redact it from an error message, in case a server
|
|
159
|
+
echoes the request header into the body.
|
|
144
160
|
"""
|
|
161
|
+
failure: KRXError | None = None
|
|
145
162
|
try:
|
|
146
163
|
# Decode as UTF-8 explicitly: json.loads(bytes) auto-detects UTF-16/32, which
|
|
147
164
|
# would let a non-UTF-8 body slip through the "must be UTF-8 JSON" contract.
|
|
148
165
|
body: Any = json.loads(raw.decode("utf-8"))
|
|
149
|
-
except (json.JSONDecodeError, UnicodeDecodeError, RecursionError)
|
|
166
|
+
except (json.JSONDecodeError, UnicodeDecodeError, RecursionError):
|
|
150
167
|
# A 200 whose body is not JSON / not UTF-8 (a proxy or maintenance HTML page), or
|
|
151
168
|
# is nested deep enough to blow the parser's recursion limit, must surface through
|
|
152
|
-
# KRXError
|
|
153
|
-
|
|
169
|
+
# KRXError. Built here but raised OUTSIDE the except (`from None` below): the decode
|
|
170
|
+
# error's .doc/.object is the whole body -- which a reflecting server could make
|
|
171
|
+
# echo the AUTH_KEY header -- so it must not ride __cause__/__context__.
|
|
172
|
+
failure = KRXNetworkError(f"non-JSON response from KRX for {url}")
|
|
173
|
+
if failure is not None:
|
|
174
|
+
raise failure from None
|
|
175
|
+
|
|
154
176
|
if not isinstance(body, dict):
|
|
155
|
-
|
|
177
|
+
# Name only the shape -- body is server-authored and could echo the header key.
|
|
178
|
+
raise KRXNetworkError(
|
|
179
|
+
f"unexpected KRX response for {url}: expected a JSON object, "
|
|
180
|
+
f"got {type(body).__name__}")
|
|
156
181
|
|
|
157
182
|
code = body.get("respCode")
|
|
158
183
|
if code: # present only on failure
|
|
159
|
-
|
|
184
|
+
# respCode/respMsg are server-authored; redact in case they echo the header key.
|
|
185
|
+
raise _error_for(
|
|
186
|
+
_redact_key(str(code), api_key),
|
|
187
|
+
_redact_key(str(body.get("respMsg", "")), api_key))
|
|
160
188
|
|
|
161
189
|
blocks = [
|
|
162
190
|
value for key, value in body.items()
|
|
@@ -31,69 +31,69 @@ def _body(payload):
|
|
|
31
31
|
def test_extracts_the_outblock_array():
|
|
32
32
|
rows = _rows_from_body(
|
|
33
33
|
_body({"OutBlock_1": [{"BAS_DD": "20200414"}, {"BAS_DD": "20200414"}]}),
|
|
34
|
-
"http://x")
|
|
34
|
+
"http://x", "")
|
|
35
35
|
assert rows == [{"BAS_DD": "20200414"}, {"BAS_DD": "20200414"}]
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
def test_empty_outblock_is_empty_list():
|
|
39
39
|
# a no-data date: KRX still returns the OutBlock, just empty
|
|
40
|
-
assert _rows_from_body(_body({"OutBlock_1": []}), "http://x") == []
|
|
40
|
+
assert _rows_from_body(_body({"OutBlock_1": []}), "http://x", "") == []
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
def test_no_outblock_array_raises():
|
|
44
44
|
with pytest.raises(KRXNetworkError):
|
|
45
|
-
_rows_from_body(_body({"other": "1"}), "http://x")
|
|
45
|
+
_rows_from_body(_body({"other": "1"}), "http://x", "")
|
|
46
46
|
|
|
47
47
|
|
|
48
48
|
def test_multiple_outblock_arrays_raise():
|
|
49
49
|
with pytest.raises(KRXNetworkError):
|
|
50
|
-
_rows_from_body(_body({"OutBlock_1": [], "OutBlock_2": []}), "http://x")
|
|
50
|
+
_rows_from_body(_body({"OutBlock_1": [], "OutBlock_2": []}), "http://x", "")
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
def test_outblock_prefix_requires_the_underscore():
|
|
54
54
|
# The block key must be OutBlock_<n>. A bare "OutBlock" is not a data array, so a
|
|
55
55
|
# body carrying only that is no recognized block -> raises.
|
|
56
56
|
with pytest.raises(KRXNetworkError):
|
|
57
|
-
_rows_from_body(_body({"OutBlock": []}), "http://x")
|
|
57
|
+
_rows_from_body(_body({"OutBlock": []}), "http://x", "")
|
|
58
58
|
# A near-prefix like "OutBlockMeta" must be ignored, not mistaken for a second data
|
|
59
59
|
# block: with the real OutBlock_1 present, exactly one block is found and returned.
|
|
60
60
|
# (Under the looser startswith("OutBlock") this would be two blocks and would raise,
|
|
61
61
|
# so this pins the underscore.)
|
|
62
|
-
rows = _rows_from_body(_body({"OutBlockMeta": [], "OutBlock_1": [{"a": "1"}]}), "http://x")
|
|
62
|
+
rows = _rows_from_body(_body({"OutBlockMeta": [], "OutBlock_1": [{"a": "1"}]}), "http://x", "")
|
|
63
63
|
assert rows == [{"a": "1"}]
|
|
64
64
|
|
|
65
65
|
|
|
66
66
|
def test_respcode_401_raises_auth_error():
|
|
67
67
|
with pytest.raises(KRXAuthError):
|
|
68
|
-
_rows_from_body(_body({"respCode": "401", "respMsg": "not applied"}), "http://x")
|
|
68
|
+
_rows_from_body(_body({"respCode": "401", "respMsg": "not applied"}), "http://x", "")
|
|
69
69
|
|
|
70
70
|
|
|
71
71
|
def test_respcode_other_raises_response_error():
|
|
72
72
|
with pytest.raises(KRXResponseError):
|
|
73
|
-
_rows_from_body(_body({"respCode": "500", "respMsg": "boom"}), "http://x")
|
|
73
|
+
_rows_from_body(_body({"respCode": "500", "respMsg": "boom"}), "http://x", "")
|
|
74
74
|
|
|
75
75
|
|
|
76
76
|
def test_non_json_raises_network_error():
|
|
77
77
|
with pytest.raises(KRXNetworkError):
|
|
78
|
-
_rows_from_body(b"<html>maintenance</html>", "http://x")
|
|
78
|
+
_rows_from_body(b"<html>maintenance</html>", "http://x", "")
|
|
79
79
|
|
|
80
80
|
|
|
81
81
|
def test_invalid_utf8_raises_network_error():
|
|
82
82
|
# json.loads on non-UTF-8 bytes raises UnicodeDecodeError, not JSONDecodeError
|
|
83
83
|
with pytest.raises(KRXNetworkError):
|
|
84
|
-
_rows_from_body(b"\xff\xfe not utf-8", "http://x")
|
|
84
|
+
_rows_from_body(b"\xff\xfe not utf-8", "http://x", "")
|
|
85
85
|
|
|
86
86
|
|
|
87
87
|
def test_valid_utf16_json_raises_network_error():
|
|
88
88
|
# Well-formed JSON, but UTF-16: json.loads(bytes) would auto-detect and accept it;
|
|
89
89
|
# the contract is UTF-8 only, so decoding explicitly must reject it.
|
|
90
90
|
with pytest.raises(KRXNetworkError):
|
|
91
|
-
_rows_from_body('{"OutBlock_1": []}'.encode("utf-16"), "http://x")
|
|
91
|
+
_rows_from_body('{"OutBlock_1": []}'.encode("utf-16"), "http://x", "")
|
|
92
92
|
|
|
93
93
|
|
|
94
94
|
def test_non_object_json_raises_network_error():
|
|
95
95
|
with pytest.raises(KRXNetworkError):
|
|
96
|
-
_rows_from_body(b"[1, 2, 3]", "http://x")
|
|
96
|
+
_rows_from_body(b"[1, 2, 3]", "http://x", "")
|
|
97
97
|
|
|
98
98
|
|
|
99
99
|
def test_recursion_error_becomes_network_error(monkeypatch):
|
|
@@ -106,12 +106,12 @@ def test_recursion_error_becomes_network_error(monkeypatch):
|
|
|
106
106
|
|
|
107
107
|
monkeypatch.setattr("krx_openapi.session.json.loads", boom)
|
|
108
108
|
with pytest.raises(KRXNetworkError):
|
|
109
|
-
_rows_from_body(b'{"OutBlock_1": []}', "http://x")
|
|
109
|
+
_rows_from_body(b'{"OutBlock_1": []}', "http://x", "")
|
|
110
110
|
|
|
111
111
|
|
|
112
112
|
def test_non_dict_row_raises():
|
|
113
113
|
with pytest.raises(KRXNetworkError):
|
|
114
|
-
_rows_from_body(_body({"OutBlock_1": [{"a": "1"}, "junk"]}), "http://x")
|
|
114
|
+
_rows_from_body(_body({"OutBlock_1": [{"a": "1"}, "junk"]}), "http://x", "")
|
|
115
115
|
|
|
116
116
|
|
|
117
117
|
# --- _get (offline opener fake) ---------------------------------------------
|
|
@@ -216,6 +216,51 @@ def test_read_failure_echoing_the_key_never_leaks_it(monkeypatch):
|
|
|
216
216
|
assert exc.value.__context__ is None and exc.value.__cause__ is None
|
|
217
217
|
|
|
218
218
|
|
|
219
|
+
# --- parse-path secret safety (the key must not ride the decode-error chain) --
|
|
220
|
+
|
|
221
|
+
def _assert_no_key(exc, key):
|
|
222
|
+
seen, pending = set(), [exc]
|
|
223
|
+
while pending:
|
|
224
|
+
current = pending.pop()
|
|
225
|
+
if current is None or id(current) in seen:
|
|
226
|
+
continue
|
|
227
|
+
seen.add(id(current))
|
|
228
|
+
blob = str(current) + repr(current) + repr(current.args)
|
|
229
|
+
assert key not in blob
|
|
230
|
+
pending.extend([current.__cause__, current.__context__])
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_non_utf8_body_echoing_the_key_never_leaks_it():
|
|
234
|
+
# A reflecting server could echo the AUTH_KEY header into a non-UTF-8 200 body; the
|
|
235
|
+
# decode error (whose .object is the whole body) must not ride the raised error's
|
|
236
|
+
# chain. This is the sibling of the read-phase test, for the body-parse path.
|
|
237
|
+
key = "SECRETKEY123"
|
|
238
|
+
body = ("bad " + key).encode() + b"\xff" # invalid UTF-8 tail, echoes the key
|
|
239
|
+
with pytest.raises(KRXNetworkError) as exc:
|
|
240
|
+
_rows_from_body(body, "http://x", key)
|
|
241
|
+
assert exc.value.__cause__ is None and exc.value.__context__ is None
|
|
242
|
+
_assert_no_key(exc.value, key)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_non_dict_body_echoing_the_key_never_leaks_it():
|
|
246
|
+
# A non-dict JSON body is server-authored; the "unexpected response" error must name
|
|
247
|
+
# only the shape, never the body content (which could echo the header key).
|
|
248
|
+
key = "SECRETKEY123"
|
|
249
|
+
body = _body([f"{key} reflected"]) # a JSON list echoing the key
|
|
250
|
+
with pytest.raises(KRXNetworkError) as exc:
|
|
251
|
+
_rows_from_body(body, "http://x", key)
|
|
252
|
+
_assert_no_key(exc.value, key)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_vendor_error_message_echoing_the_key_never_leaks_it():
|
|
256
|
+
# respCode/respMsg are server-authored; a message echoing the header key is redacted.
|
|
257
|
+
key = "SECRETKEY123"
|
|
258
|
+
body = _body({"respCode": "99", "respMsg": f"rejected {key}"})
|
|
259
|
+
with pytest.raises(KRXResponseError) as exc:
|
|
260
|
+
_rows_from_body(body, "http://x", key)
|
|
261
|
+
_assert_no_key(exc.value, key)
|
|
262
|
+
|
|
263
|
+
|
|
219
264
|
# --- redirects must never carry the key -------------------------------------
|
|
220
265
|
|
|
221
266
|
def test_no_redirect_handler_refuses_and_never_follows():
|
|
@@ -271,4 +316,6 @@ def test_error_message_never_shows_the_key(monkeypatch):
|
|
|
271
316
|
with pytest.raises(KRXNetworkError) as exc:
|
|
272
317
|
KRXSession(api_key="SECRETKEY123").fetch(ENDPOINTS["kospi_dd_trd"], basDd="20200414")
|
|
273
318
|
assert "SECRETKEY123" not in str(exc.value)
|
|
274
|
-
|
|
319
|
+
# The HTTPError branch also detaches: the key-bearing transport error must not ride
|
|
320
|
+
# the chain (str(__cause__) alone would pass even if it were chained, so assert None).
|
|
321
|
+
assert exc.value.__cause__ is None and exc.value.__context__ is None
|
|
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
|