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.
Files changed (38) hide show
  1. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/PKG-INFO +1 -1
  2. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/pyproject.toml +1 -1
  3. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/src/krx_openapi/errors.py +3 -1
  4. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/src/krx_openapi/session.py +38 -10
  5. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/tests/test_session.py +62 -15
  6. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/.agents/plugins/marketplace.json +0 -0
  7. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/.claude-plugin/marketplace.json +0 -0
  8. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/.github/workflows/check.yml +0 -0
  9. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/.github/workflows/publish.yml +0 -0
  10. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/.github/workflows/reusable-check.yml +0 -0
  11. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/.gitignore +0 -0
  12. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/LICENSE +0 -0
  13. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/Makefile +0 -0
  14. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/README.en.md +0 -0
  15. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/README.md +0 -0
  16. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/plugins/krx/.claude-plugin/plugin.json +0 -0
  17. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/plugins/krx/.codex-plugin/plugin.json +0 -0
  18. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/plugins/krx/skills/fetch/SKILL.md +0 -0
  19. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/plugins/krx/skills/fields/SKILL.md +0 -0
  20. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/plugins/krx/skills/list/SKILL.md +0 -0
  21. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/src/krx_openapi/__init__.py +0 -0
  22. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/src/krx_openapi/__main__.py +0 -0
  23. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/src/krx_openapi/_config.py +0 -0
  24. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/src/krx_openapi/_endpoint.py +0 -0
  25. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/src/krx_openapi/catalog.py +0 -0
  26. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/src/krx_openapi/cli.py +0 -0
  27. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/src/krx_openapi/client.py +0 -0
  28. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/src/krx_openapi/py.typed +0 -0
  29. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/src/krx_openapi/types.py +0 -0
  30. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/tests/__init__.py +0 -0
  31. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/tests/conftest.py +0 -0
  32. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/tests/test_catalog.py +0 -0
  33. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/tests/test_cli.py +0 -0
  34. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/tests/test_client.py +0 -0
  35. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/tests/test_config.py +0 -0
  36. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/tests/test_endpoints.py +0 -0
  37. {krx_openapi-0.2.1 → krx_openapi-0.2.2}/tests/test_errors.py +0 -0
  38. {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.1
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.1"
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 as ``__cause__``.
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 _rows_from_body(raw: bytes, url: str) -> list[Row]:
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
- A pure function of the bytes: parses JSON, raises on a non-empty ``respCode`` or a
142
- non-JSON body, and returns the single ``OutBlock_*`` array (``[]`` when the vendor
143
- sends an empty one for a no-data date).
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) as err:
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, not as a raw decode/recursion error.
153
- raise KRXNetworkError(f"non-JSON response from KRX for {url}") from err
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
- raise KRXNetworkError(f"unexpected KRX response for {url}: {body!r}")
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
- raise _error_for(str(code), str(body.get("respMsg", "")))
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
- assert "SECRETKEY123" not in str(exc.value.__cause__)
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