krx-openapi 0.1.0__tar.gz → 0.2.0__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 (40) hide show
  1. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/.github/workflows/reusable-check.yml +12 -7
  2. krx_openapi-0.2.0/Makefile +13 -0
  3. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/PKG-INFO +7 -2
  4. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/README.en.md +4 -0
  5. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/README.md +5 -1
  6. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/pyproject.toml +14 -8
  7. krx_openapi-0.2.0/src/krx_openapi/_config.py +80 -0
  8. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/src/krx_openapi/session.py +2 -2
  9. krx_openapi-0.2.0/tests/conftest.py +18 -0
  10. krx_openapi-0.2.0/tests/test_config.py +207 -0
  11. krx_openapi-0.1.0/src/krx_openapi/_config.py +0 -74
  12. krx_openapi-0.1.0/tests/test_config.py +0 -101
  13. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/.agents/plugins/marketplace.json +0 -0
  14. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/.claude-plugin/marketplace.json +0 -0
  15. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/.github/workflows/check.yml +0 -0
  16. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/.github/workflows/publish.yml +0 -0
  17. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/.gitignore +0 -0
  18. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/LICENSE +0 -0
  19. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/plugins/krx/.claude-plugin/plugin.json +0 -0
  20. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/plugins/krx/.codex-plugin/plugin.json +0 -0
  21. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/plugins/krx/skills/fetch/SKILL.md +0 -0
  22. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/plugins/krx/skills/fields/SKILL.md +0 -0
  23. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/plugins/krx/skills/list/SKILL.md +0 -0
  24. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/src/krx_openapi/__init__.py +0 -0
  25. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/src/krx_openapi/__main__.py +0 -0
  26. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/src/krx_openapi/_endpoint.py +0 -0
  27. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/src/krx_openapi/catalog.py +0 -0
  28. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/src/krx_openapi/cli.py +0 -0
  29. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/src/krx_openapi/client.py +0 -0
  30. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/src/krx_openapi/errors.py +0 -0
  31. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/src/krx_openapi/py.typed +0 -0
  32. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/src/krx_openapi/types.py +0 -0
  33. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/tests/__init__.py +0 -0
  34. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/tests/test_catalog.py +0 -0
  35. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/tests/test_cli.py +0 -0
  36. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/tests/test_client.py +0 -0
  37. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/tests/test_endpoints.py +0 -0
  38. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/tests/test_errors.py +0 -0
  39. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/tests/test_live.py +0 -0
  40. {krx_openapi-0.1.0 → krx_openapi-0.2.0}/tests/test_session.py +0 -0
@@ -1,6 +1,6 @@
1
1
  name: reusable check
2
2
 
3
- # The full check -- tests, lint, types, and the zero-dependency assertion -- factored
3
+ # The full check -- tests, lint, types, and the runtime-dependency assertion -- factored
4
4
  # into one reusable job so both `check.yml` (on push / PR) and any release gate run
5
5
  # the *identical* thing.
6
6
 
@@ -42,20 +42,25 @@ jobs:
42
42
  - name: Types
43
43
  run: .venv/bin/mypy
44
44
 
45
- - name: Confirm the package still has no runtime dependencies
46
- # The zero-dependency claim is in the README and pyproject; a claim nothing
47
- # checks quietly stops being true. Installed into a bare environment, the
48
- # package must import with nothing else present.
45
+ - name: Confirm the only runtime dependency is credbox
46
+ # The transport is dependency-free (urllib) and the sole runtime dependency is
47
+ # credbox, the credential store; documented in the pyproject comment. A claim
48
+ # nothing checks quietly stops being true, so a stray extra runtime dependency
49
+ # creeping into `dependencies` is caught here, not in a user's env.
49
50
  run: |
50
51
  uv venv /tmp/bare --python ${{ matrix.python-version }}
51
52
  uv pip install --python /tmp/bare/bin/python .
52
53
  /tmp/bare/bin/python -c "
54
+ import re
53
55
  import importlib.metadata as md
54
56
  requires = md.requires('krx-openapi') or []
55
57
  runtime = [r for r in requires if 'extra ==' not in r]
56
- assert not runtime, f'runtime dependencies appeared: {runtime}'
58
+ # Each requirement's bare name (before any version/marker/extra), PEP 503
59
+ # normalized; the runtime set must be exactly credbox.
60
+ names = sorted(re.split(r'[<>=!~;\[\s]', r, maxsplit=1)[0].replace('_', '-').lower() for r in runtime)
61
+ assert names == ['credbox'], f'unexpected runtime dependencies: {runtime}'
57
62
  import krx_openapi
58
- print('krx_openapi', krx_openapi.__version__, 'imports with no third-party packages')
63
+ print('krx_openapi', krx_openapi.__version__, 'imports with only credbox present')
59
64
  "
60
65
  # The console script runs, and its top-level --version prints (not shoved
61
66
  # into a required subcommand).
@@ -0,0 +1,13 @@
1
+ # Run the standard CI checks (test, lint, types) locally. Dev extras: uv pip install -e ".[dev]"
2
+ .PHONY: check test lint types
3
+
4
+ check: test lint types
5
+
6
+ test:
7
+ pytest -q
8
+
9
+ lint:
10
+ ruff check src tests
11
+
12
+ types:
13
+ mypy
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: krx-openapi
3
- Version: 0.1.0
3
+ Version: 0.2.0
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
@@ -20,6 +20,7 @@ Classifier: Programming Language :: Python :: 3.13
20
20
  Classifier: Programming Language :: Python :: 3.14
21
21
  Classifier: Topic :: Office/Business :: Financial
22
22
  Requires-Python: >=3.11
23
+ Requires-Dist: credbox>=0.4
23
24
  Provides-Extra: dev
24
25
  Requires-Dist: mypy>=1.11; extra == 'dev'
25
26
  Requires-Dist: pytest>=8.0; extra == 'dev'
@@ -175,6 +176,8 @@ krx fetch bond treasury 20200414
175
176
 
176
177
  ### 5.1 Claude Code
177
178
 
179
+ Claude Code 채팅창에서 마켓플레이스를 추가하고 설치합니다:
180
+
178
181
  ```
179
182
  /plugin marketplace add seokhoonj/krx-openapi
180
183
  /plugin install krx@krx-openapi
@@ -185,6 +188,8 @@ krx fetch bond treasury 20200414
185
188
 
186
189
  ### 5.2 Codex
187
190
 
191
+ 터미널에서 마켓플레이스를 추가하고 설치합니다:
192
+
188
193
  ```
189
194
  codex plugin marketplace add seokhoonj/krx-openapi
190
195
  codex plugin add krx@krx-openapi
@@ -232,5 +237,5 @@ Claude Code는 바로 인식하고, Codex는 재시작해야 로딩됩니다.
232
237
 
233
238
  ## 8. 라이선스
234
239
 
235
- MIT. 이 패키지의 코드에 대한 라이선스이며, KRX 데이터 자체의 이용 조건은 위 KRX
240
+ [MIT](LICENSE). 이 패키지의 코드에 대한 라이선스이며, KRX 데이터 자체의 이용 조건은 위 KRX
236
241
  이용약관을 따릅니다.
@@ -149,6 +149,8 @@ krx fetch bond treasury 20200414
149
149
 
150
150
  ### 5.1 Claude Code
151
151
 
152
+ In the Claude Code chat, add the marketplace and install:
153
+
152
154
  ```
153
155
  /plugin marketplace add seokhoonj/krx-openapi
154
156
  /plugin install krx@krx-openapi
@@ -159,6 +161,8 @@ or call a skill directly — `/krx:list`, `/krx:fields index kospi`, `/krx:fetch
159
161
 
160
162
  ### 5.2 Codex
161
163
 
164
+ In your terminal, add the marketplace and install:
165
+
162
166
  ```
163
167
  codex plugin marketplace add seokhoonj/krx-openapi
164
168
  codex plugin add krx@krx-openapi
@@ -147,6 +147,8 @@ krx fetch bond treasury 20200414
147
147
 
148
148
  ### 5.1 Claude Code
149
149
 
150
+ Claude Code 채팅창에서 마켓플레이스를 추가하고 설치합니다:
151
+
150
152
  ```
151
153
  /plugin marketplace add seokhoonj/krx-openapi
152
154
  /plugin install krx@krx-openapi
@@ -157,6 +159,8 @@ krx fetch bond treasury 20200414
157
159
 
158
160
  ### 5.2 Codex
159
161
 
162
+ 터미널에서 마켓플레이스를 추가하고 설치합니다:
163
+
160
164
  ```
161
165
  codex plugin marketplace add seokhoonj/krx-openapi
162
166
  codex plugin add krx@krx-openapi
@@ -204,5 +208,5 @@ Claude Code는 바로 인식하고, Codex는 재시작해야 로딩됩니다.
204
208
 
205
209
  ## 8. 라이선스
206
210
 
207
- MIT. 이 패키지의 코드에 대한 라이선스이며, KRX 데이터 자체의 이용 조건은 위 KRX
211
+ [MIT](LICENSE). 이 패키지의 코드에 대한 라이선스이며, KRX 데이터 자체의 이용 조건은 위 KRX
208
212
  이용약관을 따릅니다.
@@ -1,10 +1,13 @@
1
1
  [build-system]
2
- requires = ["hatchling"]
2
+ # Cap <1.32: hatchling 1.32 emits Metadata-Version 2.5, which the twine bundled in the pinned
3
+ # PyPI-publish action rejects (pypa/twine#1327). 2.4 is accepted by PyPI and that twine; the
4
+ # 1.27 floor is PEP 639 (the SPDX license expression and license-files). Lift once twine updates.
5
+ requires = ["hatchling>=1.27,<1.32"]
3
6
  build-backend = "hatchling.build"
4
7
 
5
8
  [project]
6
9
  name = "krx-openapi"
7
- version = "0.1.0"
10
+ version = "0.2.0"
8
11
  description = "Read the Korea Exchange (KRX) Open API: indices, stocks, ETP, bonds, derivatives, commodities, ESG."
9
12
  readme = "README.md"
10
13
  requires-python = ">=3.11"
@@ -24,12 +27,15 @@ classifiers = [
24
27
  "Programming Language :: Python :: 3.14",
25
28
  "Topic :: Office/Business :: Financial",
26
29
  ]
27
- # No runtime dependencies on purpose: urllib and json from the standard library
28
- # carry the whole client. A data client that never breaks on a third party's release
29
- # is worth more than any convenience they would add. Rows come back as ``list[dict]``,
30
- # which ``pandas.DataFrame`` / ``polars.DataFrame`` accept directly, so no DataFrame
31
- # library is bundled or wrapped.
32
- dependencies = []
30
+ # The transport is dependency-free on purpose: urllib and json from the standard library
31
+ # carry the whole client, and a data client that never breaks on a transport library's
32
+ # release is worth more than any convenience one would add. Rows come back as
33
+ # ``list[dict]``, which ``pandas.DataFrame`` / ``polars.DataFrame`` accept directly, so no
34
+ # DataFrame library is bundled. The one runtime dependency is credbox, the shared
35
+ # credential store the API key is read from.
36
+ dependencies = [
37
+ "credbox>=0.4",
38
+ ]
33
39
 
34
40
  [project.optional-dependencies]
35
41
  dev = [
@@ -0,0 +1,80 @@
1
+ """Resolve the KRX API key from the caller, the environment, or the config file.
2
+
3
+ The key is looked up in a fixed order, so an explicit value always wins and a set
4
+ environment variable beats a file on disk:
5
+
6
+ 1. the ``api_key`` passed to ``KRX(...)``
7
+ 2. the ``KRX_API_KEY`` environment variable
8
+ 3. ``"KRX_API_KEY"`` in ``$XDG_CONFIG_HOME/krx-openapi/credentials.json``
9
+ (``$XDG_CONFIG_HOME`` defaults to ``~/.config``)
10
+
11
+ The resolution, the whitespace trimming, the permission handling (a group/other-readable
12
+ file is warned about, not refused), and the storage backend are delegated to credbox. The
13
+ store binding is not hardcoded: ``Credentials.for_app("krx-openapi")`` lets a host
14
+ embedding krx-openapi redirect it via ``KRX_OPENAPI_STORE_APP`` / ``KRX_OPENAPI_NAMESPACE``;
15
+ standalone it is exactly the flat ``~/.config/krx-openapi/credentials.json`` krx-openapi has
16
+ always read. A file that is present but unreadable, not JSON, or not a JSON object is still
17
+ an error rather than a silent skip.
18
+
19
+ Once a key is found, it is still checked for characters it cannot send as an HTTP header
20
+ here -- credbox trims surrounding whitespace but keeps an embedded newline/tab or a
21
+ non-ASCII character, any of which would make an invalid header and let urllib echo part of
22
+ the key while encoding it. That check is krx-openapi's own concern, not something the
23
+ credential store knows about.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from functools import lru_cache
29
+
30
+ from credbox import CredBoxError, Credentials
31
+
32
+ from .errors import KRXConfigError
33
+
34
+ _ENV_VAR = "KRX_API_KEY"
35
+ _STORE_APP = "krx-openapi"
36
+
37
+
38
+ def resolve_api_key(explicit: str | None) -> str:
39
+ """Return the first key found across the three sources, or raise if none exists."""
40
+ try:
41
+ found = _get_credentials().secret(_ENV_VAR, override=explicit)
42
+ except CredBoxError as err:
43
+ # credbox's message already names the store path + fault; don't prepend a static
44
+ # path (wrong under a KRX_OPENAPI_STORE_APP redirect). credbox detaches
45
+ # secret-bearing context, so chaining `from err` keeps the key out of any traceback.
46
+ raise KRXConfigError(f"could not read the credential store: {err}") from err
47
+ if found is None:
48
+ # Binding validated cleanly above (None, not error), so store_location() is safe
49
+ # and gives the real store (the host's under a redirect).
50
+ raise KRXConfigError(
51
+ f"no KRX API key: pass api_key=, set the {_ENV_VAR} environment "
52
+ f"variable, or put it in {_get_credentials().store_location()}"
53
+ )
54
+ key = found.reveal()
55
+ if any(not (0x20 <= ord(ch) < 0x7f) for ch in key):
56
+ # The key rides an HTTP header (AUTH_KEY). Any byte outside printable ASCII -- a
57
+ # stray newline/tab from a copy-paste, or a non-ASCII character -- makes an invalid
58
+ # header, and urllib raises *while encoding it*, echoing the offending character
59
+ # (part of the key). Reject it here, before it becomes a request, and never echo
60
+ # it. KRX keys are ASCII tokens, so no legitimate key is lost.
61
+ raise KRXConfigError(
62
+ "the KRX API key contains a character it cannot send as an HTTP header "
63
+ "(a stray newline, tab, or non-ASCII character?)")
64
+ return key
65
+
66
+
67
+ @lru_cache(maxsize=1)
68
+ def _get_credentials() -> Credentials:
69
+ """krx-openapi's credbox credential store, built on first use and cached.
70
+
71
+ Built via ``for_app`` (not the bare ``Credentials(...)``) so a host embedding
72
+ krx-openapi can redirect the binding with ``KRX_OPENAPI_STORE_APP`` /
73
+ ``KRX_OPENAPI_NAMESPACE`` before the first lookup. credbox re-resolves the store
74
+ *path* per call (honouring a later ``XDG_CONFIG_HOME``); the binding is read from the
75
+ environment once, when this facade is built. A malformed binding surfaces as
76
+ ``KRXConfigError`` on the first lookup that actually reaches the store -- an explicit
77
+ argument or ``KRX_API_KEY`` resolves first, so a bad binding with the env var set
78
+ never raises.
79
+ """
80
+ return Credentials.for_app(_STORE_APP)
@@ -2,8 +2,8 @@
2
2
 
3
3
  The session holds the API key and turns an endpoint + params into rows: a
4
4
  ``list[dict[str, str]]``, the vendor's ``OutBlock_*`` array passed through with its
5
- own field names. No third-party HTTP client -- ``urllib`` carries it, so the package
6
- has zero runtime dependencies.
5
+ own field names. No third-party HTTP client -- ``urllib`` carries the transport; the
6
+ credential store (credbox) the key is read from is the package's only runtime dependency.
7
7
 
8
8
  KRX answers with HTTP 200 even on failure and marks the failure with a non-empty
9
9
  ``respCode`` (+ ``respMsg``) in the JSON body; :meth:`fetch` applies that contract,
@@ -0,0 +1,18 @@
1
+ """Shared test fixtures for krx-openapi."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from krx_openapi import _config
8
+
9
+
10
+ @pytest.fixture(autouse=True)
11
+ def _reset_credbox_binding(monkeypatch):
12
+ """Reset the cached credbox facade and the store-binding env vars around every test,
13
+ so a binding one test sets (or a dev's shell) cannot leak into another."""
14
+ monkeypatch.delenv("KRX_OPENAPI_STORE_APP", raising=False)
15
+ monkeypatch.delenv("KRX_OPENAPI_NAMESPACE", raising=False)
16
+ _config._get_credentials.cache_clear()
17
+ yield
18
+ _config._get_credentials.cache_clear()
@@ -0,0 +1,207 @@
1
+ """Config resolution -- explicit > env > file, the header-safety guard (printable ASCII)
2
+ applied to whatever is found, and the secret-safety invariant that the key never rides
3
+ along in an error message or its cause chain."""
4
+
5
+ import json
6
+ import os
7
+
8
+ import pytest
9
+
10
+ from krx_openapi._config import resolve_api_key
11
+ from krx_openapi.errors import KRXConfigError
12
+
13
+ VALID_API_KEY = "krx-service-key-0123456789" # a well-formed KRX key (printable ASCII)
14
+
15
+
16
+ def _point_config_at(tmp_path, monkeypatch):
17
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
18
+ monkeypatch.delenv("KRX_API_KEY", raising=False)
19
+ return tmp_path / "krx-openapi" / "credentials.json"
20
+
21
+
22
+ def _write_credentials_file(path, contents):
23
+ path.parent.mkdir(parents=True, exist_ok=True)
24
+ path.write_text(contents, encoding="utf-8")
25
+
26
+
27
+ def _assert_secret_safe(error, secret):
28
+ seen = set()
29
+ pending = [error]
30
+ while pending:
31
+ current = pending.pop()
32
+ if current is None or id(current) in seen:
33
+ continue
34
+ seen.add(id(current))
35
+ assert secret not in str(current)
36
+ assert secret not in repr(current)
37
+ pending.extend([current.__cause__, current.__context__])
38
+
39
+
40
+ def test_explicit_key_wins(tmp_path, monkeypatch):
41
+ path = _point_config_at(tmp_path, monkeypatch)
42
+ _write_credentials_file(path, json.dumps({"KRX_API_KEY": "from-file"}))
43
+ monkeypatch.setenv("KRX_API_KEY", "from-env")
44
+ assert resolve_api_key("from-arg") == "from-arg"
45
+
46
+
47
+ def test_env_beats_file(tmp_path, monkeypatch):
48
+ path = _point_config_at(tmp_path, monkeypatch)
49
+ _write_credentials_file(path, json.dumps({"KRX_API_KEY": "from-file"}))
50
+ monkeypatch.setenv("KRX_API_KEY", "from-env")
51
+ assert resolve_api_key(None) == "from-env"
52
+
53
+
54
+ def test_file_is_last_resort(tmp_path, monkeypatch):
55
+ path = _point_config_at(tmp_path, monkeypatch)
56
+ _write_credentials_file(path, json.dumps({"KRX_API_KEY": "from-file"}))
57
+ assert resolve_api_key(None) == "from-file"
58
+
59
+
60
+ def test_missing_everywhere_raises(tmp_path, monkeypatch):
61
+ _point_config_at(tmp_path, monkeypatch) # no file written
62
+ with pytest.raises(KRXConfigError, match="no KRX API key"):
63
+ resolve_api_key(None)
64
+
65
+
66
+ def test_blank_key_is_not_a_key(tmp_path, monkeypatch):
67
+ path = _point_config_at(tmp_path, monkeypatch)
68
+ _write_credentials_file(path, json.dumps({"KRX_API_KEY": " "}))
69
+ with pytest.raises(KRXConfigError, match="no KRX API key"):
70
+ resolve_api_key(" ")
71
+
72
+
73
+ @pytest.mark.parametrize("blank_source", ["explicit", "environment"])
74
+ def test_blank_higher_tier_falls_through_to_the_file(tmp_path, monkeypatch, blank_source):
75
+ # A blank explicit argument or env var is "absent" (credbox trims to empty), so
76
+ # resolution falls through to the file rather than returning empty.
77
+ path = _point_config_at(tmp_path, monkeypatch)
78
+ _write_credentials_file(path, json.dumps({"KRX_API_KEY": VALID_API_KEY}))
79
+ explicit = None
80
+ if blank_source == "explicit":
81
+ explicit = " "
82
+ else:
83
+ monkeypatch.setenv("KRX_API_KEY", " ")
84
+
85
+ assert resolve_api_key(explicit) == VALID_API_KEY
86
+
87
+
88
+ @pytest.mark.parametrize("contents", ["{not json", "[1, 2, 3]", json.dumps({"KRX_API_KEY": 1})])
89
+ def test_malformed_credentials_file_is_rejected(tmp_path, monkeypatch, contents):
90
+ # Not-JSON, a non-object, or a non-string value all reach the caller as a config
91
+ # error rather than a silent skip -- credbox validates the store and its fault is
92
+ # translated to KRXConfigError.
93
+ path = _point_config_at(tmp_path, monkeypatch)
94
+ _write_credentials_file(path, contents)
95
+ with pytest.raises(KRXConfigError, match="could not read"):
96
+ resolve_api_key(None)
97
+
98
+
99
+ def test_credentials_path_that_is_a_directory_is_rejected(tmp_path, monkeypatch):
100
+ # A present-but-unreadable store (here a directory at the file path) is an error,
101
+ # not a silent "no key here".
102
+ path = _point_config_at(tmp_path, monkeypatch)
103
+ path.mkdir(parents=True)
104
+ with pytest.raises(KRXConfigError, match="could not read"):
105
+ resolve_api_key(None)
106
+
107
+
108
+ @pytest.mark.parametrize("source", ["explicit", "environment", "stored"])
109
+ def test_key_is_trimmed_at_every_tier(tmp_path, monkeypatch, source):
110
+ # credbox strips surrounding whitespace at every tier (a pasted trailing newline no
111
+ # longer breaks auth); pinned so a future change cannot silently return padding.
112
+ path = _point_config_at(tmp_path, monkeypatch)
113
+ explicit = None
114
+ if source == "explicit":
115
+ explicit = " " + VALID_API_KEY + " "
116
+ elif source == "environment":
117
+ monkeypatch.setenv("KRX_API_KEY", " " + VALID_API_KEY + " ")
118
+ else:
119
+ _write_credentials_file(path, json.dumps({"KRX_API_KEY": " " + VALID_API_KEY + " "}))
120
+
121
+ assert resolve_api_key(explicit) == VALID_API_KEY
122
+
123
+
124
+ @pytest.mark.parametrize("bad_key", ["prefix\nSECRETTAIL", "prefix한SECRETTAIL"])
125
+ def test_key_outside_printable_ascii_raises_and_never_echoes_the_key(monkeypatch, bad_key):
126
+ # credbox trims surrounding whitespace but keeps an embedded newline or a non-ASCII
127
+ # character; either makes an invalid HTTP header and lets urllib echo the value (in
128
+ # whole or in part) while encoding it. Reject it as config, echoing nothing -- not in
129
+ # the message, not anywhere in the cause chain. (The non-ASCII case would otherwise
130
+ # leak one character + its offset through an uncaught UnicodeEncodeError in session.)
131
+ monkeypatch.delenv("KRX_API_KEY", raising=False)
132
+ with pytest.raises(KRXConfigError, match="cannot send as an HTTP header") as exc:
133
+ resolve_api_key(bad_key)
134
+ _assert_secret_safe(exc.value, "prefix")
135
+ _assert_secret_safe(exc.value, "SECRETTAIL")
136
+ _assert_secret_safe(exc.value, "한")
137
+
138
+
139
+ def test_store_binding_redirects_to_a_host_namespace(tmp_path, monkeypatch):
140
+ # A host embedding krx-openapi redirects the store via KRX_OPENAPI_STORE_APP +
141
+ # KRX_OPENAPI_NAMESPACE, so krx-openapi's key lives in the host's store under a
142
+ # krx-openapi section.
143
+ _point_config_at(tmp_path, monkeypatch)
144
+ monkeypatch.setenv("KRX_OPENAPI_STORE_APP", "host")
145
+ monkeypatch.setenv("KRX_OPENAPI_NAMESPACE", "krx")
146
+ host = tmp_path / "host"
147
+ host.mkdir(parents=True)
148
+ (host / "credentials.json").write_text(
149
+ json.dumps({"krx": {"KRX_API_KEY": VALID_API_KEY}}), encoding="utf-8")
150
+
151
+ assert resolve_api_key(None) == VALID_API_KEY
152
+
153
+
154
+ @pytest.mark.parametrize("source", ["explicit", "environment"])
155
+ def test_higher_tier_wins_before_an_invalid_binding_is_validated(monkeypatch, source):
156
+ # An explicit argument or KRX_API_KEY resolves before the binding is validated
157
+ # (lazy), so a bad binding never raises when a higher tier supplies the key.
158
+ monkeypatch.delenv("KRX_API_KEY", raising=False)
159
+ monkeypatch.setenv("KRX_OPENAPI_STORE_APP", "../invalid")
160
+ explicit = None
161
+ if source == "explicit":
162
+ explicit = VALID_API_KEY
163
+ else:
164
+ monkeypatch.setenv("KRX_API_KEY", VALID_API_KEY)
165
+
166
+ assert resolve_api_key(explicit) == VALID_API_KEY
167
+
168
+
169
+ def test_invalid_store_binding_raises_config_error(monkeypatch):
170
+ # A malformed binding surfaces as krx-openapi's KRXConfigError on first store touch.
171
+ monkeypatch.delenv("KRX_API_KEY", raising=False)
172
+ monkeypatch.setenv("KRX_OPENAPI_STORE_APP", "../invalid")
173
+ with pytest.raises(KRXConfigError, match="could not read"):
174
+ resolve_api_key(None)
175
+
176
+
177
+ @pytest.mark.parametrize(
178
+ "contents",
179
+ [
180
+ lambda secret: secret.encode() + b"\xff", # not valid UTF-8
181
+ lambda secret: (secret + "{").encode(), # not valid JSON
182
+ lambda secret: json.dumps([secret]).encode(), # a JSON array, not an object
183
+ lambda secret: json.dumps({"KRX_API_KEY": [secret]}).encode(), # non-string value
184
+ ],
185
+ )
186
+ def test_malformed_store_detaches_secret_bearing_context(tmp_path, monkeypatch, contents):
187
+ # Whatever the fault, the key bytes must not ride along in the error, its repr, or
188
+ # the cause chain -- credbox detaches secret-bearing content and `from err` preserves
189
+ # that, the load-bearing secret-safety invariant.
190
+ secret = "krx-secret-value-abcdef"
191
+ path = _point_config_at(tmp_path, monkeypatch)
192
+ path.parent.mkdir(parents=True, exist_ok=True)
193
+ path.write_bytes(contents(secret))
194
+ with pytest.raises(KRXConfigError, match="could not read") as caught:
195
+ resolve_api_key(None)
196
+ _assert_secret_safe(caught.value, secret)
197
+
198
+
199
+ @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits required")
200
+ def test_loose_permission_file_warns_and_still_reads(tmp_path, monkeypatch, capsys):
201
+ # A group/other-readable file is warned about (chmod 600 nudge), not refused.
202
+ path = _point_config_at(tmp_path, monkeypatch)
203
+ _write_credentials_file(path, json.dumps({"KRX_API_KEY": VALID_API_KEY}))
204
+ path.chmod(0o644)
205
+
206
+ assert resolve_api_key(None) == VALID_API_KEY
207
+ assert "chmod 600" in capsys.readouterr().err
@@ -1,74 +0,0 @@
1
- """Resolve the KRX API key from the caller, the environment, or the config file.
2
-
3
- The key is looked up in a fixed order, so an explicit value always wins and a set
4
- environment variable beats a file on disk:
5
-
6
- 1. the ``api_key`` passed to ``KRX(...)``
7
- 2. the ``KRX_API_KEY`` environment variable
8
- 3. ``"KRX_API_KEY"`` in ``$XDG_CONFIG_HOME/krx-openapi/credentials.json``
9
- (``$XDG_CONFIG_HOME`` defaults to ``~/.config``)
10
-
11
- The file is optional -- its absence just means "no key here." But a file that is
12
- present and unreadable, not JSON, or not a JSON object is an error, because a caller
13
- who wrote one meant it to be used and a silent skip would hide the mistake.
14
- """
15
-
16
- from __future__ import annotations
17
-
18
- import json
19
- import os
20
- from pathlib import Path
21
-
22
- from .errors import KRXConfigError
23
-
24
- _ENV_VAR = "KRX_API_KEY"
25
- _CONFIG_DIR = "krx-openapi"
26
- _CONFIG_FILE = "credentials.json"
27
-
28
-
29
- def resolve_api_key(explicit: str | None) -> str:
30
- """Return the first key found across the three sources, or raise if none exists."""
31
- key = (explicit or "").strip() or os.environ.get(_ENV_VAR, "").strip() or _key_from_file()
32
- if not key:
33
- raise KRXConfigError(
34
- f"no KRX API key: pass api_key=, set the {_ENV_VAR} environment "
35
- f"variable, or put it in {credentials_path()}"
36
- )
37
- if any(ord(ch) < 0x20 or ord(ch) == 0x7f for ch in key):
38
- # A control character (a stray newline/tab, often from a copy-paste) would make
39
- # an invalid HTTP header: urllib raises ValueError echoing the whole value -- the
40
- # key. Reject it as a config error, before it becomes a request, and never echo it.
41
- raise KRXConfigError(
42
- "the KRX API key contains a control character (a stray newline or tab?)")
43
- return key
44
-
45
-
46
- def credentials_path() -> Path:
47
- """The path krx-openapi reads a stored key from (honoring ``$XDG_CONFIG_HOME``)."""
48
- config_home = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
49
- return Path(config_home) / _CONFIG_DIR / _CONFIG_FILE
50
-
51
-
52
- def _key_from_file() -> str:
53
- path = credentials_path()
54
- try:
55
- text = path.read_text(encoding="utf-8")
56
- except FileNotFoundError:
57
- return ""
58
- except UnicodeDecodeError as err:
59
- # A present file that is not UTF-8 is "unreadable" in the same sense as an
60
- # OSError: the caller wrote it meaning it to be used, so surface it, not a
61
- # raw decode error. (UnicodeDecodeError is a ValueError, not an OSError.)
62
- raise KRXConfigError(f"{path} is not valid UTF-8: {err}") from err
63
- except OSError as err:
64
- raise KRXConfigError(f"could not read {path}: {err}") from err
65
-
66
- try:
67
- data = json.loads(text)
68
- except json.JSONDecodeError as err:
69
- raise KRXConfigError(f"{path} is not valid JSON: {err}") from err
70
- if not isinstance(data, dict):
71
- raise KRXConfigError(f"{path} must contain a JSON object")
72
-
73
- key = data.get(_ENV_VAR)
74
- return key.strip() if isinstance(key, str) else ""
@@ -1,101 +0,0 @@
1
- """Config resolution -- explicit > env > file, and the failure modes."""
2
-
3
- import json
4
- from pathlib import Path
5
-
6
- import pytest
7
-
8
- from krx_openapi._config import credentials_path, resolve_api_key
9
- from krx_openapi.errors import KRXConfigError
10
-
11
-
12
- def _point_config_at(tmp_path, monkeypatch):
13
- monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
14
- monkeypatch.delenv("KRX_API_KEY", raising=False)
15
- return tmp_path / "krx-openapi" / "credentials.json"
16
-
17
-
18
- def _write(path, data):
19
- path.parent.mkdir(parents=True, exist_ok=True)
20
- path.write_text(data, encoding="utf-8")
21
-
22
-
23
- def test_explicit_key_wins(tmp_path, monkeypatch):
24
- path = _point_config_at(tmp_path, monkeypatch)
25
- _write(path, json.dumps({"KRX_API_KEY": "from-file"}))
26
- monkeypatch.setenv("KRX_API_KEY", "from-env")
27
- assert resolve_api_key("from-arg") == "from-arg"
28
-
29
-
30
- def test_env_beats_file(tmp_path, monkeypatch):
31
- path = _point_config_at(tmp_path, monkeypatch)
32
- _write(path, json.dumps({"KRX_API_KEY": "from-file"}))
33
- monkeypatch.setenv("KRX_API_KEY", "from-env")
34
- assert resolve_api_key(None) == "from-env"
35
-
36
-
37
- def test_file_is_last_resort(tmp_path, monkeypatch):
38
- path = _point_config_at(tmp_path, monkeypatch)
39
- _write(path, json.dumps({"KRX_API_KEY": "from-file"}))
40
- assert resolve_api_key(None) == "from-file"
41
-
42
-
43
- def test_missing_everywhere_raises(tmp_path, monkeypatch):
44
- _point_config_at(tmp_path, monkeypatch) # no file written
45
- with pytest.raises(KRXConfigError):
46
- resolve_api_key(None)
47
-
48
-
49
- def test_whitespace_key_is_not_a_key(tmp_path, monkeypatch):
50
- path = _point_config_at(tmp_path, monkeypatch)
51
- _write(path, json.dumps({"KRX_API_KEY": " "}))
52
- with pytest.raises(KRXConfigError):
53
- resolve_api_key(" ")
54
-
55
-
56
- def test_present_but_invalid_json_raises(tmp_path, monkeypatch):
57
- path = _point_config_at(tmp_path, monkeypatch)
58
- _write(path, "{not json")
59
- with pytest.raises(KRXConfigError):
60
- resolve_api_key(None)
61
-
62
-
63
- def test_non_object_json_raises(tmp_path, monkeypatch):
64
- path = _point_config_at(tmp_path, monkeypatch)
65
- _write(path, "[1, 2, 3]")
66
- with pytest.raises(KRXConfigError):
67
- resolve_api_key(None)
68
-
69
-
70
- def test_present_but_invalid_utf8_raises(tmp_path, monkeypatch):
71
- path = _point_config_at(tmp_path, monkeypatch)
72
- path.parent.mkdir(parents=True, exist_ok=True)
73
- path.write_bytes(b"\xff\xfe not utf-8") # present but undecodable
74
- with pytest.raises(KRXConfigError):
75
- resolve_api_key(None)
76
-
77
-
78
- def test_key_with_control_char_raises_and_never_echoes_the_key():
79
- # A stray newline inside the key would make an invalid HTTP header, and urllib's
80
- # ValueError would echo the whole value. Reject it as config, without echoing it.
81
- with pytest.raises(KRXConfigError) as exc:
82
- resolve_api_key("prefix\nSECRETTAIL")
83
- assert "SECRETTAIL" not in str(exc.value)
84
- assert "prefix" not in str(exc.value)
85
-
86
-
87
- def test_present_but_unreadable_raises(tmp_path, monkeypatch):
88
- path = _point_config_at(tmp_path, monkeypatch)
89
- _write(path, json.dumps({"KRX_API_KEY": "x"}))
90
-
91
- def boom(self, *args, **kwargs):
92
- raise PermissionError("locked")
93
-
94
- monkeypatch.setattr(Path, "read_text", boom) # file exists but cannot be read
95
- with pytest.raises(KRXConfigError):
96
- resolve_api_key(None)
97
-
98
-
99
- def test_credentials_path_honors_xdg(tmp_path, monkeypatch):
100
- monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
101
- assert credentials_path() == tmp_path / "krx-openapi" / "credentials.json"
File without changes
File without changes