krx-openapi 0.2.0__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.0 → krx_openapi-0.2.2}/PKG-INFO +1 -1
  2. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/pyproject.toml +1 -1
  3. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/src/krx_openapi/errors.py +3 -1
  4. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/src/krx_openapi/session.py +62 -21
  5. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/tests/test_session.py +79 -15
  6. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/.agents/plugins/marketplace.json +0 -0
  7. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/.claude-plugin/marketplace.json +0 -0
  8. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/.github/workflows/check.yml +0 -0
  9. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/.github/workflows/publish.yml +0 -0
  10. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/.github/workflows/reusable-check.yml +0 -0
  11. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/.gitignore +0 -0
  12. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/LICENSE +0 -0
  13. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/Makefile +0 -0
  14. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/README.en.md +0 -0
  15. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/README.md +0 -0
  16. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/plugins/krx/.claude-plugin/plugin.json +0 -0
  17. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/plugins/krx/.codex-plugin/plugin.json +0 -0
  18. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/plugins/krx/skills/fetch/SKILL.md +0 -0
  19. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/plugins/krx/skills/fields/SKILL.md +0 -0
  20. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/plugins/krx/skills/list/SKILL.md +0 -0
  21. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/src/krx_openapi/__init__.py +0 -0
  22. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/src/krx_openapi/__main__.py +0 -0
  23. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/src/krx_openapi/_config.py +0 -0
  24. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/src/krx_openapi/_endpoint.py +0 -0
  25. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/src/krx_openapi/catalog.py +0 -0
  26. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/src/krx_openapi/cli.py +0 -0
  27. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/src/krx_openapi/client.py +0 -0
  28. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/src/krx_openapi/py.typed +0 -0
  29. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/src/krx_openapi/types.py +0 -0
  30. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/tests/__init__.py +0 -0
  31. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/tests/conftest.py +0 -0
  32. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/tests/test_catalog.py +0 -0
  33. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/tests/test_cli.py +0 -0
  34. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/tests/test_client.py +0 -0
  35. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/tests/test_config.py +0 -0
  36. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/tests/test_endpoints.py +0 -0
  37. {krx_openapi-0.2.0 → krx_openapi-0.2.2}/tests/test_errors.py +0 -0
  38. {krx_openapi-0.2.0 → 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.0
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.0"
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
 
@@ -21,7 +21,7 @@ from typing import Any
21
21
 
22
22
  from ._config import resolve_api_key
23
23
  from ._endpoint import SAMPLE_DATE, SAMPLE_KEY, KRXEndpoint
24
- from .errors import KRXNetworkError, KRXRateLimitError, _error_for
24
+ from .errors import KRXError, KRXNetworkError, KRXRateLimitError, _error_for
25
25
  from .types import Row
26
26
 
27
27
  _AUTH_HEADER = "AUTH_KEY"
@@ -99,51 +99,92 @@ class KRXSession:
99
99
  request = urllib.request.Request(
100
100
  full_url, headers={_AUTH_HEADER: api_key.strip(), "User-Agent": _USER_AGENT}
101
101
  )
102
+ # The key rides in the AUTH_KEY header, not the URL, so `url` is safe to name.
103
+ # Still, every failure is BUILT inside the except and RAISED after it
104
+ # (`from None`), so no transport exception is chained -- a reflecting server that
105
+ # echoed the request header into a response body could otherwise reach the key
106
+ # through, e.g., an IncompleteRead's partial body on the cause chain.
107
+ failure: KRXError
102
108
  try:
103
109
  with _OPENER.open(request, timeout=self.timeout) as response:
104
110
  raw = response.read()
105
111
  except urllib.error.HTTPError as err:
106
112
  with err: # an HTTPError is an unclosed response; release its socket
107
113
  if err.code == _RATE_LIMIT_STATUS:
108
- raise KRXRateLimitError(
114
+ failure = KRXRateLimitError(
109
115
  "KRX daily call quota exceeded (10,000/key/day); resets at "
110
- "midnight") from err
111
- hint = ""
112
- if err.code == _FORBIDDEN_STATUS:
113
- hint = (" -- check the key, that the URL is https and /svc/apis "
114
- "(not /svc/sample/apis with a real key)")
115
- raise KRXNetworkError(f"HTTP {err.code} from KRX for {url}{hint}") from err
116
+ "midnight")
117
+ else:
118
+ hint = ""
119
+ if err.code == _FORBIDDEN_STATUS:
120
+ hint = (" -- check the key, that the URL is https and /svc/apis "
121
+ "(not /svc/sample/apis with a real key)")
122
+ failure = KRXNetworkError(f"HTTP {err.code} from KRX for {url}{hint}")
116
123
  except urllib.error.URLError as err:
117
- raise KRXNetworkError(f"request to KRX failed: {err.reason}") from err
124
+ # A transport reason is external text; keep only its type, then detach below.
125
+ failure = KRXNetworkError(
126
+ f"request to KRX failed for {url}: {type(err.reason).__name__}")
118
127
  except (http.client.HTTPException, OSError) as err:
119
128
  # A failure during response.read() (IncompleteRead, a socket timeout or
120
- # reset) is not an HTTPError/URLError; surface it through KRXError too.
121
- raise KRXNetworkError(f"KRX response read failed for {url}: {err}") from err
122
- return _rows_from_body(raw, url)
129
+ # reset) is not an HTTPError/URLError; surface it through KRXError too. Name
130
+ # only the exception type -- str(err) can hold partially-read body bytes.
131
+ failure = KRXNetworkError(
132
+ f"KRX response read failed for {url}: {type(err).__name__}")
133
+ else:
134
+ return _rows_from_body(raw, url, api_key)
135
+ raise failure from None
136
+
137
+
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>")
123
151
 
124
152
 
125
- def _rows_from_body(raw: bytes, url: str) -> list[Row]:
153
+ def _rows_from_body(raw: bytes, url: str, api_key: str) -> list[Row]:
126
154
  """Apply the KRX status contract to a raw response body.
127
155
 
128
- A pure function of the bytes: parses JSON, raises on a non-empty ``respCode`` or a
129
- non-JSON body, and returns the single ``OutBlock_*`` array (``[]`` when the vendor
130
- 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.
131
160
  """
161
+ failure: KRXError | None = None
132
162
  try:
133
163
  # Decode as UTF-8 explicitly: json.loads(bytes) auto-detects UTF-16/32, which
134
164
  # would let a non-UTF-8 body slip through the "must be UTF-8 JSON" contract.
135
165
  body: Any = json.loads(raw.decode("utf-8"))
136
- except (json.JSONDecodeError, UnicodeDecodeError, RecursionError) as err:
166
+ except (json.JSONDecodeError, UnicodeDecodeError, RecursionError):
137
167
  # A 200 whose body is not JSON / not UTF-8 (a proxy or maintenance HTML page), or
138
168
  # is nested deep enough to blow the parser's recursion limit, must surface through
139
- # KRXError, not as a raw decode/recursion error.
140
- 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
+
141
176
  if not isinstance(body, dict):
142
- 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__}")
143
181
 
144
182
  code = body.get("respCode")
145
183
  if code: # present only on failure
146
- 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))
147
188
 
148
189
  blocks = [
149
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) ---------------------------------------------
@@ -199,6 +199,68 @@ def test_get_read_failure_raises_network(monkeypatch):
199
199
  KRXSession(api_key="k").fetch(ENDPOINTS["kospi_dd_trd"], basDd="20200414")
200
200
 
201
201
 
202
+ def test_read_failure_echoing_the_key_never_leaks_it(monkeypatch):
203
+ # A reflecting server could echo the AUTH_KEY header into the partial body of an
204
+ # interrupted read; the read-phase error must not surface it or chain the raw
205
+ # exception (whose partial body would carry it on the cause chain).
206
+ key = "SECRETKEY123"
207
+
208
+ class _EchoingRead(_FakeResponse):
209
+ def read(self) -> bytes:
210
+ raise http.client.IncompleteRead(f"partial Auth_key={key}".encode())
211
+
212
+ _patch_opener(monkeypatch, lambda request, timeout=None: _EchoingRead(b""))
213
+ with pytest.raises(KRXNetworkError) as exc:
214
+ KRXSession(api_key=key).fetch(ENDPOINTS["kospi_dd_trd"], basDd="20200414")
215
+ assert key not in str(exc.value)
216
+ assert exc.value.__context__ is None and exc.value.__cause__ is None
217
+
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
+
202
264
  # --- redirects must never carry the key -------------------------------------
203
265
 
204
266
  def test_no_redirect_handler_refuses_and_never_follows():
@@ -254,4 +316,6 @@ def test_error_message_never_shows_the_key(monkeypatch):
254
316
  with pytest.raises(KRXNetworkError) as exc:
255
317
  KRXSession(api_key="SECRETKEY123").fetch(ENDPOINTS["kospi_dd_trd"], basDd="20200414")
256
318
  assert "SECRETKEY123" not in str(exc.value)
257
- 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