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.
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/.github/workflows/reusable-check.yml +12 -7
- krx_openapi-0.2.0/Makefile +13 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/PKG-INFO +7 -2
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/README.en.md +4 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/README.md +5 -1
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/pyproject.toml +14 -8
- krx_openapi-0.2.0/src/krx_openapi/_config.py +80 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/src/krx_openapi/session.py +2 -2
- krx_openapi-0.2.0/tests/conftest.py +18 -0
- krx_openapi-0.2.0/tests/test_config.py +207 -0
- krx_openapi-0.1.0/src/krx_openapi/_config.py +0 -74
- krx_openapi-0.1.0/tests/test_config.py +0 -101
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/.agents/plugins/marketplace.json +0 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/.claude-plugin/marketplace.json +0 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/.github/workflows/check.yml +0 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/.github/workflows/publish.yml +0 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/.gitignore +0 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/LICENSE +0 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/plugins/krx/.claude-plugin/plugin.json +0 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/plugins/krx/.codex-plugin/plugin.json +0 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/plugins/krx/skills/fetch/SKILL.md +0 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/plugins/krx/skills/fields/SKILL.md +0 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/plugins/krx/skills/list/SKILL.md +0 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/src/krx_openapi/__init__.py +0 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/src/krx_openapi/__main__.py +0 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/src/krx_openapi/_endpoint.py +0 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/src/krx_openapi/catalog.py +0 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/src/krx_openapi/cli.py +0 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/src/krx_openapi/client.py +0 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/src/krx_openapi/errors.py +0 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/src/krx_openapi/py.typed +0 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/src/krx_openapi/types.py +0 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/tests/__init__.py +0 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/tests/test_catalog.py +0 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/tests/test_cli.py +0 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/tests/test_client.py +0 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/tests/test_endpoints.py +0 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/tests/test_errors.py +0 -0
- {krx_openapi-0.1.0 → krx_openapi-0.2.0}/tests/test_live.py +0 -0
- {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
|
|
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
|
|
46
|
-
# The
|
|
47
|
-
#
|
|
48
|
-
#
|
|
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
|
-
|
|
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
|
|
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).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: krx-openapi
|
|
3
|
-
Version: 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
|
-
|
|
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.
|
|
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
|
-
#
|
|
28
|
-
# carry the whole client
|
|
29
|
-
# is worth more than any convenience
|
|
30
|
-
# which ``pandas.DataFrame`` / ``polars.DataFrame`` accept directly, so no
|
|
31
|
-
# library is bundled
|
|
32
|
-
|
|
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
|
|
6
|
-
|
|
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
|
|
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
|