pyecos 0.1.0__py3-none-any.whl
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.
- pyecos/__init__.py +56 -0
- pyecos/__main__.py +8 -0
- pyecos/_cache.py +75 -0
- pyecos/_config.py +63 -0
- pyecos/_parse.py +110 -0
- pyecos/_transport.py +176 -0
- pyecos/catalog.py +81 -0
- pyecos/cli.py +245 -0
- pyecos/client.py +231 -0
- pyecos/curation/__init__.py +11 -0
- pyecos/curation/_generated.py +1937 -0
- pyecos/curation/_indicator.py +155 -0
- pyecos/data/catalog.tsv.gz +0 -0
- pyecos/exceptions.py +68 -0
- pyecos/py.typed +0 -0
- pyecos/types.py +133 -0
- pyecos-0.1.0.dist-info/METADATA +538 -0
- pyecos-0.1.0.dist-info/RECORD +21 -0
- pyecos-0.1.0.dist-info/WHEEL +4 -0
- pyecos-0.1.0.dist-info/entry_points.txt +2 -0
- pyecos-0.1.0.dist-info/licenses/LICENSE +21 -0
pyecos/__init__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""pyecos -- a Python client for the Bank of Korea ECOS Open API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
from . import catalog
|
|
8
|
+
from .client import ECOS
|
|
9
|
+
from .curation import Indicator, IndicatorSpec
|
|
10
|
+
from .exceptions import (
|
|
11
|
+
ECOSAuthError,
|
|
12
|
+
ECOSConfigError,
|
|
13
|
+
ECOSError,
|
|
14
|
+
ECOSNetworkError,
|
|
15
|
+
ECOSRateLimitError,
|
|
16
|
+
ECOSResponseError,
|
|
17
|
+
)
|
|
18
|
+
from .types import (
|
|
19
|
+
CatalogRow,
|
|
20
|
+
Cycle,
|
|
21
|
+
ItemRow,
|
|
22
|
+
KeyStatRow,
|
|
23
|
+
Language,
|
|
24
|
+
MetaRow,
|
|
25
|
+
StatRow,
|
|
26
|
+
TableRow,
|
|
27
|
+
WordRow,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
__version__ = version("pyecos")
|
|
32
|
+
except PackageNotFoundError: # running from a source tree without an install
|
|
33
|
+
__version__ = "0.0.0"
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"ECOS",
|
|
37
|
+
"catalog",
|
|
38
|
+
"Indicator",
|
|
39
|
+
"IndicatorSpec",
|
|
40
|
+
"Cycle",
|
|
41
|
+
"Language",
|
|
42
|
+
"StatRow",
|
|
43
|
+
"CatalogRow",
|
|
44
|
+
"TableRow",
|
|
45
|
+
"ItemRow",
|
|
46
|
+
"KeyStatRow",
|
|
47
|
+
"WordRow",
|
|
48
|
+
"MetaRow",
|
|
49
|
+
"ECOSError",
|
|
50
|
+
"ECOSConfigError",
|
|
51
|
+
"ECOSAuthError",
|
|
52
|
+
"ECOSResponseError",
|
|
53
|
+
"ECOSRateLimitError",
|
|
54
|
+
"ECOSNetworkError",
|
|
55
|
+
"__version__",
|
|
56
|
+
]
|
pyecos/__main__.py
ADDED
pyecos/_cache.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""An opt-in, TTL-bounded store of prior fetch results, keyed by the logical query.
|
|
2
|
+
|
|
3
|
+
Off unless :class:`ECOS` is given a ``cache_ttl``. When on, a repeated query returns
|
|
4
|
+
the stored rows without a network round trip -- cutting calls so a burst stays under
|
|
5
|
+
the ECOS rate cap (~300 in three minutes), and repeating work for free.
|
|
6
|
+
|
|
7
|
+
The store holds *complete* results (a whole paginated, mapped series), never a single
|
|
8
|
+
page: a page is a partial intermediate, and caching answers rather than ingredients is
|
|
9
|
+
what keeps a cache honest. Entries expire after ``ttl`` seconds, so the staleness a
|
|
10
|
+
caller accepts is exactly the bound they chose -- a series whose latest observation has
|
|
11
|
+
since updated is re-fetched once its entry expires. Least-recently-used entries are
|
|
12
|
+
evicted past ``maxsize`` so the store cannot grow without bound.
|
|
13
|
+
|
|
14
|
+
Each entry is copied on store and on retrieval, row by row, so a caller mutating a
|
|
15
|
+
returned row cannot corrupt the stored entry or another caller's copy; the rows carry
|
|
16
|
+
only scalar values, so a per-row ``dict`` copy fully isolates them.
|
|
17
|
+
|
|
18
|
+
Not thread-safe: the entries are mutable instance state with no lock, like the client
|
|
19
|
+
that owns it -- use one client per thread.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import time
|
|
25
|
+
from collections import OrderedDict
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
# (service, resolved language, request tail) -- the logical query, minus the paging
|
|
29
|
+
# window and the API key, so kr and en cache apart and two clients never collide.
|
|
30
|
+
_CacheKey = tuple[str, str, tuple[str, ...]]
|
|
31
|
+
|
|
32
|
+
_Rows = list[dict[str, Any]]
|
|
33
|
+
|
|
34
|
+
_DEFAULT_MAXSIZE = 256
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _isolate_rows(rows: _Rows) -> _Rows:
|
|
38
|
+
"""A copy of ``rows`` sharing none of its dicts (values are scalars)."""
|
|
39
|
+
return [dict(row) for row in rows]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _Cache:
|
|
43
|
+
"""A TTL + LRU store mapping a query key to its rows."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, *, ttl: float, maxsize: int = _DEFAULT_MAXSIZE) -> None:
|
|
46
|
+
self._ttl = ttl
|
|
47
|
+
self._maxsize = maxsize
|
|
48
|
+
self._entries: OrderedDict[_CacheKey, tuple[float, _Rows]] = OrderedDict()
|
|
49
|
+
|
|
50
|
+
def get(self, key: _CacheKey) -> _Rows | None:
|
|
51
|
+
"""The cached rows for ``key`` if present and unexpired, else ``None``.
|
|
52
|
+
|
|
53
|
+
Returns an isolated copy, so a caller mutating the rows cannot corrupt the
|
|
54
|
+
entry.
|
|
55
|
+
"""
|
|
56
|
+
entry = self._entries.get(key)
|
|
57
|
+
if entry is None:
|
|
58
|
+
return None
|
|
59
|
+
expires_at, rows = entry
|
|
60
|
+
if time.monotonic() >= expires_at:
|
|
61
|
+
del self._entries[key]
|
|
62
|
+
return None
|
|
63
|
+
self._entries.move_to_end(key) # mark most-recently-used
|
|
64
|
+
return _isolate_rows(rows)
|
|
65
|
+
|
|
66
|
+
def set(self, key: _CacheKey, rows: _Rows) -> None:
|
|
67
|
+
"""Store an isolated copy of ``rows``, evicting the LRU entry past maxsize."""
|
|
68
|
+
self._entries[key] = (time.monotonic() + self._ttl, _isolate_rows(rows))
|
|
69
|
+
self._entries.move_to_end(key)
|
|
70
|
+
while len(self._entries) > self._maxsize:
|
|
71
|
+
self._entries.popitem(last=False) # drop the least-recently-used
|
|
72
|
+
|
|
73
|
+
def clear(self) -> None:
|
|
74
|
+
"""Drop every entry."""
|
|
75
|
+
self._entries.clear()
|
pyecos/_config.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Resolve the ECOS 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 ``ECOS(...)``
|
|
7
|
+
2. the ``ECOS_API_KEY`` environment variable
|
|
8
|
+
3. ``"ECOS_API_KEY"`` in ``$XDG_CONFIG_HOME/pyecos/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 .exceptions import ECOSConfigError
|
|
23
|
+
|
|
24
|
+
_ENV_VAR = "ECOS_API_KEY"
|
|
25
|
+
_CONFIG_DIR = "pyecos"
|
|
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 os.environ.get(_ENV_VAR) or _key_from_file()
|
|
32
|
+
if not key:
|
|
33
|
+
raise ECOSConfigError(
|
|
34
|
+
f"no ECOS API key: pass api_key=, set the {_ENV_VAR} environment "
|
|
35
|
+
f"variable, or put it in {credentials_path()}"
|
|
36
|
+
)
|
|
37
|
+
return key
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def credentials_path() -> Path:
|
|
41
|
+
"""The path pyecos reads a stored key from (honoring ``$XDG_CONFIG_HOME``)."""
|
|
42
|
+
config_home = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
43
|
+
return Path(config_home) / _CONFIG_DIR / _CONFIG_FILE
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _key_from_file() -> str | None:
|
|
47
|
+
path = credentials_path()
|
|
48
|
+
try:
|
|
49
|
+
text = path.read_text(encoding="utf-8")
|
|
50
|
+
except FileNotFoundError:
|
|
51
|
+
return None
|
|
52
|
+
except OSError as err:
|
|
53
|
+
raise ECOSConfigError(f"could not read {path}: {err}") from err
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
data = json.loads(text)
|
|
57
|
+
except json.JSONDecodeError as err:
|
|
58
|
+
raise ECOSConfigError(f"{path} is not valid JSON: {err}") from err
|
|
59
|
+
if not isinstance(data, dict):
|
|
60
|
+
raise ECOSConfigError(f"{path} must contain a JSON object")
|
|
61
|
+
|
|
62
|
+
key = data.get(_ENV_VAR)
|
|
63
|
+
return key if isinstance(key, str) and key else None
|
pyecos/_parse.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Turn ECOS's paged, upper-cased rows into a full list of snake_cased dicts.
|
|
2
|
+
|
|
3
|
+
Two jobs live here, both above a single wire request (``_transport``):
|
|
4
|
+
|
|
5
|
+
* **paginate** -- ECOS caps a response at :data:`PAGE_SIZE` rows, so walk the
|
|
6
|
+
``start_row``/``end_row`` window until ``list_total_count`` is exhausted and
|
|
7
|
+
return every row, never a first page silently mistaken for the whole answer.
|
|
8
|
+
* **map** -- rename the vendor's ``UPPER_CASE`` keys to the snake_case fields the
|
|
9
|
+
``TypedDict``s document, and parse the one genuinely numeric field
|
|
10
|
+
(``DATA_VALUE``) to ``float``. Every key the vendor sends is kept; an
|
|
11
|
+
unrecognized one simply lowercases and passes through.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ._transport import PAGE_SIZE, _Transport
|
|
19
|
+
from .exceptions import ECOSResponseError
|
|
20
|
+
|
|
21
|
+
# No real ECOS series approaches a million rows; the cap turns a broken server
|
|
22
|
+
# that returns full pages forever into a clear error instead of an endless loop.
|
|
23
|
+
_MAX_PAGES = 10_000
|
|
24
|
+
|
|
25
|
+
# Vendor keys whose snake_case is not a plain lowercase of the original.
|
|
26
|
+
_FIELD_BY_VENDOR_KEY = {
|
|
27
|
+
"P_STAT_CODE": "parent_stat_code",
|
|
28
|
+
"P_ITEM_CODE": "parent_item_code",
|
|
29
|
+
"P_ITEM_NAME": "parent_item_name",
|
|
30
|
+
"GRP_CODE": "group_code",
|
|
31
|
+
"GRP_NAME": "group_name",
|
|
32
|
+
"ORG_NAME": "org_name",
|
|
33
|
+
"SRCH_YN": "searchable",
|
|
34
|
+
"WGT": "weight",
|
|
35
|
+
"DATA_CNT": "data_count",
|
|
36
|
+
"DATA_VALUE": "data_value",
|
|
37
|
+
"KEYSTAT_NAME": "keystat_name",
|
|
38
|
+
"CLASS_NAME": "class_name",
|
|
39
|
+
"LVL": "level",
|
|
40
|
+
"P_CONT_CODE": "parent_content_code",
|
|
41
|
+
"CONT_CODE": "content_code",
|
|
42
|
+
"CONT_NAME": "content_name",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def collect(
|
|
47
|
+
transport: _Transport,
|
|
48
|
+
*,
|
|
49
|
+
service: str,
|
|
50
|
+
api_key: str,
|
|
51
|
+
lang: str,
|
|
52
|
+
tail: list[str],
|
|
53
|
+
) -> list[dict[str, Any]]:
|
|
54
|
+
"""Fetch every page of a service call and return the mapped rows."""
|
|
55
|
+
return [_map_row(row) for row in _paginate(
|
|
56
|
+
transport, service=service, api_key=api_key, lang=lang, tail=tail,
|
|
57
|
+
)]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _paginate(
|
|
61
|
+
transport: _Transport,
|
|
62
|
+
*,
|
|
63
|
+
service: str,
|
|
64
|
+
api_key: str,
|
|
65
|
+
lang: str,
|
|
66
|
+
tail: list[str],
|
|
67
|
+
) -> list[dict[str, Any]]:
|
|
68
|
+
rows: list[dict[str, Any]] = []
|
|
69
|
+
start_row = 1
|
|
70
|
+
for _ in range(_MAX_PAGES):
|
|
71
|
+
page = transport.request_page(
|
|
72
|
+
service=service,
|
|
73
|
+
api_key=api_key,
|
|
74
|
+
lang=lang,
|
|
75
|
+
start_row=start_row,
|
|
76
|
+
end_row=start_row + PAGE_SIZE - 1,
|
|
77
|
+
tail=tail,
|
|
78
|
+
)
|
|
79
|
+
batch = page.get("row") or []
|
|
80
|
+
rows.extend(batch)
|
|
81
|
+
try:
|
|
82
|
+
total = int(page.get("list_total_count") or 0)
|
|
83
|
+
except (TypeError, ValueError):
|
|
84
|
+
# A garbage total must not truncate the series: push it past what we
|
|
85
|
+
# have so the loop keeps paging and the empty-batch guard ends it.
|
|
86
|
+
total = len(rows) + PAGE_SIZE
|
|
87
|
+
if not batch or len(rows) >= total:
|
|
88
|
+
return rows
|
|
89
|
+
start_row += PAGE_SIZE
|
|
90
|
+
raise ECOSResponseError("UNKNOWN", f"pagination exceeded {_MAX_PAGES} pages")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _map_row(raw: dict[str, Any]) -> dict[str, Any]:
|
|
94
|
+
row = {_FIELD_BY_VENDOR_KEY.get(key, key.lower()): value
|
|
95
|
+
for key, value in raw.items()}
|
|
96
|
+
if "data_value" in row:
|
|
97
|
+
row["data_value"] = _to_float(row["data_value"])
|
|
98
|
+
return row
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _to_float(text: Any) -> float | None:
|
|
102
|
+
# ECOS marks a missing observation with an empty string or a lone dash; any
|
|
103
|
+
# other unparseable value also becomes None (the vendor sends plain numbers,
|
|
104
|
+
# so this only fires on genuinely malformed data, reported as missing).
|
|
105
|
+
if text in (None, "", "-"):
|
|
106
|
+
return None
|
|
107
|
+
try:
|
|
108
|
+
return float(text)
|
|
109
|
+
except (TypeError, ValueError):
|
|
110
|
+
return None
|
pyecos/_transport.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""One request over the wire: build the ECOS path, GET it, surface errors.
|
|
2
|
+
|
|
3
|
+
ECOS is a path-positional API -- every argument is a slash segment in a fixed
|
|
4
|
+
order, up to fifteen of them:
|
|
5
|
+
|
|
6
|
+
/api/{service}/{key}/{format}/{lang}/{start_row}/{end_row}/{tail...}
|
|
7
|
+
|
|
8
|
+
This module is the only place that order is written down. ``_Transport`` holds the
|
|
9
|
+
HTTP client and the pacing clock: it spaces consecutive requests (``delay_seconds``)
|
|
10
|
+
and retries a transient failure (timeout, connection reset, 5xx) with backoff, but a
|
|
11
|
+
rate limit (ERROR-602) is an answer to respect, raised straight through.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any
|
|
19
|
+
from urllib.parse import quote
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from .exceptions import (
|
|
24
|
+
ECOSAuthError,
|
|
25
|
+
ECOSNetworkError,
|
|
26
|
+
ECOSRateLimitError,
|
|
27
|
+
ECOSResponseError,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
BASE_URL = "https://ecos.bok.or.kr/api"
|
|
31
|
+
|
|
32
|
+
# ECOS serves at most this many rows per request; the parser pages past it.
|
|
33
|
+
PAGE_SIZE = 100
|
|
34
|
+
|
|
35
|
+
# The vendor's rate-limit code -- in the RESULT body, and mirrored for an HTTP 429.
|
|
36
|
+
_RATE_LIMIT_CODE = "ERROR-602"
|
|
37
|
+
|
|
38
|
+
# A transient failure (timeout, reset, 5xx) is a glitch worth retrying; a rate
|
|
39
|
+
# limit is not (see _Transport.request_page). This counts total attempts, not
|
|
40
|
+
# retries -- 3 is one try plus two retries.
|
|
41
|
+
_MAX_ATTEMPTS = 3
|
|
42
|
+
_RETRY_BACKOFF_SECONDS = 1.0
|
|
43
|
+
_RETRY_BACKOFF_FACTOR = 2 # each retry waits this many times the last
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _Transport:
|
|
47
|
+
"""The HTTP client plus its pacing clock -- one per :class:`ECOS`.
|
|
48
|
+
|
|
49
|
+
``delay_seconds`` spaces consecutive requests so a burst (a long paginated
|
|
50
|
+
series, or many indicators in a loop) stays under the ECOS rate cap of ~300
|
|
51
|
+
calls in three minutes; the default is 0 because a handful of calls never
|
|
52
|
+
reaches it, and pacing every page would only slow the common case. A bulk
|
|
53
|
+
caller sets it (0.6s keeps one client under the cap indefinitely).
|
|
54
|
+
|
|
55
|
+
Not thread-safe: the pacing clock (``_next_request_at``) is shared mutable
|
|
56
|
+
state, so use one client -- hence one transport -- per thread.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
client: httpx.Client,
|
|
62
|
+
*,
|
|
63
|
+
delay_seconds: float = 0.0,
|
|
64
|
+
max_attempts: int = _MAX_ATTEMPTS,
|
|
65
|
+
) -> None:
|
|
66
|
+
self._client = client
|
|
67
|
+
self._delay_seconds = delay_seconds
|
|
68
|
+
self._max_attempts = max_attempts
|
|
69
|
+
self._next_request_at = 0.0
|
|
70
|
+
|
|
71
|
+
def request_page(
|
|
72
|
+
self,
|
|
73
|
+
*,
|
|
74
|
+
service: str,
|
|
75
|
+
api_key: str,
|
|
76
|
+
lang: str,
|
|
77
|
+
start_row: int,
|
|
78
|
+
end_row: int,
|
|
79
|
+
tail: list[str],
|
|
80
|
+
) -> dict[str, Any]:
|
|
81
|
+
"""Fetch one page and return its ``{list_total_count, row}`` body.
|
|
82
|
+
|
|
83
|
+
Retries a transient transport failure (timeout, connection reset, 5xx) with
|
|
84
|
+
backoff. Raises :class:`ECOSNetworkError` if it never completes,
|
|
85
|
+
:class:`ECOSRateLimitError` on a rate limit (ERROR-602, not retried),
|
|
86
|
+
:class:`ECOSAuthError` on a rejected key, and :class:`ECOSResponseError` on
|
|
87
|
+
any other vendor error. A "no data" response (INFO-200) returns as empty.
|
|
88
|
+
"""
|
|
89
|
+
url = _build_url(service, api_key, lang, start_row, end_row, tail)
|
|
90
|
+
last_error: ECOSNetworkError | None = None
|
|
91
|
+
last_cause: Exception | None = None
|
|
92
|
+
for attempt in range(self._max_attempts):
|
|
93
|
+
self._wait_for_next_slot()
|
|
94
|
+
try:
|
|
95
|
+
response = self._client.get(url)
|
|
96
|
+
response.raise_for_status()
|
|
97
|
+
payload = response.json()
|
|
98
|
+
except httpx.HTTPStatusError as err:
|
|
99
|
+
status = err.response.status_code
|
|
100
|
+
if status == 429: # an HTTP-level rate limit, should ECOS send one
|
|
101
|
+
raise ECOSRateLimitError(_RATE_LIMIT_CODE, str(err)) from err
|
|
102
|
+
if status < 500: # any other 4xx is the server's answer
|
|
103
|
+
raise ECOSNetworkError(str(err)) from err
|
|
104
|
+
last_error, last_cause = ECOSNetworkError(str(err)), err # 5xx -- retry
|
|
105
|
+
except httpx.HTTPError as err: # timeout, connection reset, ...
|
|
106
|
+
last_error, last_cause = ECOSNetworkError(str(err)), err
|
|
107
|
+
except json.JSONDecodeError as err:
|
|
108
|
+
# A 200 whose body is not JSON (a proxy/maintenance HTML page) must
|
|
109
|
+
# surface through the ECOSError hierarchy, not as a raw decode error.
|
|
110
|
+
raise ECOSResponseError(
|
|
111
|
+
"UNKNOWN", f"non-JSON response from ECOS: {err}") from err
|
|
112
|
+
else:
|
|
113
|
+
return _extract_body(payload, service)
|
|
114
|
+
if attempt + 1 < self._max_attempts:
|
|
115
|
+
time.sleep(_RETRY_BACKOFF_SECONDS * _RETRY_BACKOFF_FACTOR**attempt)
|
|
116
|
+
if last_error is not None:
|
|
117
|
+
# Chain the httpx cause the ECOSNetworkError docstring promises.
|
|
118
|
+
raise last_error from last_cause
|
|
119
|
+
raise ECOSNetworkError("request failed")
|
|
120
|
+
|
|
121
|
+
def _wait_for_next_slot(self) -> None:
|
|
122
|
+
if self._delay_seconds <= 0:
|
|
123
|
+
return
|
|
124
|
+
now = time.monotonic()
|
|
125
|
+
if now < self._next_request_at:
|
|
126
|
+
time.sleep(self._next_request_at - now)
|
|
127
|
+
self._next_request_at = time.monotonic() + self._delay_seconds
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _build_url(
|
|
131
|
+
service: str,
|
|
132
|
+
api_key: str,
|
|
133
|
+
lang: str,
|
|
134
|
+
start_row: int,
|
|
135
|
+
end_row: int,
|
|
136
|
+
tail: list[str],
|
|
137
|
+
) -> str:
|
|
138
|
+
segments = [service, api_key, "json", lang, str(start_row), str(end_row), *tail]
|
|
139
|
+
while segments and segments[-1] == "": # trailing optional args ECOS omits
|
|
140
|
+
segments.pop()
|
|
141
|
+
if "" in segments:
|
|
142
|
+
# A remaining empty is an interior positional gap (end without start, or
|
|
143
|
+
# item_code2 without item_code1); it would shift every later argument.
|
|
144
|
+
raise ValueError(
|
|
145
|
+
"cannot build a request with a gap between positional arguments"
|
|
146
|
+
)
|
|
147
|
+
path = "/".join(quote(segment, safe="") for segment in segments)
|
|
148
|
+
return f"{BASE_URL}/{path}"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _extract_body(payload: Any, service: str) -> dict[str, Any]:
|
|
152
|
+
if not isinstance(payload, dict):
|
|
153
|
+
raise ECOSResponseError("UNKNOWN", f"unexpected ECOS response: {payload!r}")
|
|
154
|
+
if service in payload:
|
|
155
|
+
body = payload[service]
|
|
156
|
+
if not isinstance(body, dict): # a non-object under the service key is bad
|
|
157
|
+
raise ECOSResponseError("UNKNOWN", f"unexpected ECOS response: {payload!r}")
|
|
158
|
+
if "row" not in body and "list_total_count" not in body:
|
|
159
|
+
# A dict under the service key that carries no page fields (e.g. a
|
|
160
|
+
# nested RESULT error) must surface, not read as an empty series.
|
|
161
|
+
raise ECOSResponseError("UNKNOWN", f"unexpected ECOS response: {payload!r}")
|
|
162
|
+
return body
|
|
163
|
+
|
|
164
|
+
result = payload.get("RESULT")
|
|
165
|
+
if not isinstance(result, dict):
|
|
166
|
+
raise ECOSResponseError("UNKNOWN", f"unexpected ECOS response: {payload!r}")
|
|
167
|
+
|
|
168
|
+
code = result.get("CODE", "UNKNOWN")
|
|
169
|
+
message = result.get("MESSAGE", "")
|
|
170
|
+
if code == "INFO-200": # no matching data -- an empty result, not a failure
|
|
171
|
+
return {"list_total_count": "0", "row": []}
|
|
172
|
+
if code == "INFO-100": # invalid authentication key
|
|
173
|
+
raise ECOSAuthError(message or "invalid ECOS API key")
|
|
174
|
+
if code == _RATE_LIMIT_CODE: # too many calls -- rate limited, back off
|
|
175
|
+
raise ECOSRateLimitError(code, message)
|
|
176
|
+
raise ECOSResponseError(code, message)
|
pyecos/catalog.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Offline search over a bundled snapshot of the ECOS table catalog.
|
|
2
|
+
|
|
3
|
+
The hard part of using ECOS is finding the stat code you need -- and
|
|
4
|
+
:meth:`~pyecos.ECOS.fetch_tables` costs a network call every time. This module
|
|
5
|
+
ships a snapshot of the table hierarchy inside the package, so you can search it
|
|
6
|
+
by name or code with **no key and no network**::
|
|
7
|
+
|
|
8
|
+
from pyecos import catalog
|
|
9
|
+
|
|
10
|
+
for table in catalog.search("소비자물가"):
|
|
11
|
+
print(table["stat_code"], table["stat_name"]) # 901Y009 소비자물가지수
|
|
12
|
+
|
|
13
|
+
catalog.table("901Y009") # one table's row, or None
|
|
14
|
+
|
|
15
|
+
The snapshot is a point-in-time copy (refresh it with ``tools/gen_catalog.py``);
|
|
16
|
+
for the live hierarchy, or a table's detail items, use ``ECOS.fetch_tables`` /
|
|
17
|
+
``ECOS.fetch_items``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import csv
|
|
23
|
+
import gzip
|
|
24
|
+
import io
|
|
25
|
+
from collections.abc import Iterable
|
|
26
|
+
from functools import cache
|
|
27
|
+
from importlib.resources import files
|
|
28
|
+
from typing import cast
|
|
29
|
+
|
|
30
|
+
from .types import CatalogRow
|
|
31
|
+
|
|
32
|
+
_DATA = files("pyecos").joinpath("data", "catalog.tsv.gz")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@cache
|
|
36
|
+
def _tables() -> tuple[CatalogRow, ...]:
|
|
37
|
+
text = gzip.decompress(_DATA.read_bytes()).decode("utf-8")
|
|
38
|
+
return tuple(
|
|
39
|
+
CatalogRow(
|
|
40
|
+
stat_code=row["stat_code"],
|
|
41
|
+
stat_name=row["stat_name"],
|
|
42
|
+
cycle=row["cycle"],
|
|
43
|
+
searchable=row["searchable"] == "Y",
|
|
44
|
+
)
|
|
45
|
+
for row in csv.DictReader(io.StringIO(text), delimiter="\t")
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _copies(rows: Iterable[CatalogRow]) -> list[CatalogRow]:
|
|
50
|
+
# Hand out fresh row dicts so a caller mutating a result can't corrupt the
|
|
51
|
+
# shared snapshot -- the same isolation _Cache applies to fetched rows.
|
|
52
|
+
return [cast("CatalogRow", dict(row)) for row in rows]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def tables() -> list[CatalogRow]:
|
|
56
|
+
"""Every table in the bundled catalog snapshot."""
|
|
57
|
+
return _copies(_tables())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def table(stat_code: str) -> CatalogRow | None:
|
|
61
|
+
"""The catalog row for ``stat_code``, or ``None`` if the snapshot lacks it."""
|
|
62
|
+
row = next((row for row in _tables() if row["stat_code"] == stat_code), None)
|
|
63
|
+
return cast("CatalogRow", dict(row)) if row is not None else None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def search(query: str, *, searchable_only: bool = True) -> list[CatalogRow]:
|
|
67
|
+
"""Tables whose code or name contains ``query``, case-insensitively, offline.
|
|
68
|
+
|
|
69
|
+
``searchable_only`` (the default) keeps only tables you can actually query
|
|
70
|
+
with :meth:`~pyecos.ECOS.fetch_series`, dropping the category headers that
|
|
71
|
+
organize the hierarchy; pass ``False`` to search those too.
|
|
72
|
+
"""
|
|
73
|
+
needle = query.strip().lower()
|
|
74
|
+
hits = [
|
|
75
|
+
row
|
|
76
|
+
for row in _tables()
|
|
77
|
+
if needle in row["stat_code"].lower() or needle in row["stat_name"].lower()
|
|
78
|
+
]
|
|
79
|
+
if searchable_only:
|
|
80
|
+
hits = [row for row in hits if row["searchable"]]
|
|
81
|
+
return _copies(hits)
|