ecos-reader 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.
ecos/__init__.py ADDED
@@ -0,0 +1,109 @@
1
+ """
2
+ ecos-reader: 한국은행 ECOS Open API Python 클라이언트
3
+
4
+ 한국은행 ECOS Open API를 Python에서 쉽고 일관된 방식으로 사용할 수 있는 라이브러리입니다.
5
+
6
+ Examples
7
+ --------
8
+ >>> import ecos
9
+ >>> ecos.set_api_key("your_api_key")
10
+
11
+ # 기준금리 조회
12
+ >>> df = ecos.get_base_rate()
13
+ >>> df.head()
14
+ date value unit
15
+ 0 2024-01-01 3.50 %
16
+
17
+ # 소비자물가지수 조회
18
+ >>> df = ecos.get_cpi(start_date="202301", end_date="202312")
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ __version__ = "0.1.0"
24
+ __author__ = "yeonguk"
25
+
26
+ # Config API
27
+ # Cache API
28
+ from .cache import clear_cache, disable_cache, enable_cache
29
+
30
+ # Client
31
+ from .client import EcosClient, get_client, reset_client, set_client
32
+ from .config import clear_api_key, get_api_key, load_env, set_api_key
33
+
34
+ # Exceptions
35
+ from .exceptions import (
36
+ EcosAPIError,
37
+ EcosConfigError,
38
+ EcosError,
39
+ EcosNetworkError,
40
+ EcosRateLimitError,
41
+ )
42
+
43
+ # Indicator APIs
44
+ from .indicators import (
45
+ get_bank_lending,
46
+ # 금리 지표
47
+ get_base_rate,
48
+ get_core_cpi,
49
+ # 물가 지표
50
+ get_cpi,
51
+ # 성장 지표
52
+ get_gdp,
53
+ get_gdp_deflator,
54
+ # 통화 지표
55
+ get_money_supply,
56
+ get_ppi,
57
+ get_treasury_yield,
58
+ get_yield_spread,
59
+ )
60
+
61
+ # Logging API
62
+ from .logging import setup_logging
63
+
64
+ # Metrics API
65
+ from .metrics import get_metrics_summary, reset_metrics
66
+
67
+ __all__ = [
68
+ # Version
69
+ "__version__",
70
+ # Config
71
+ "set_api_key",
72
+ "get_api_key",
73
+ "clear_api_key",
74
+ "load_env",
75
+ # Cache
76
+ "clear_cache",
77
+ "disable_cache",
78
+ "enable_cache",
79
+ # Client
80
+ "EcosClient",
81
+ "get_client",
82
+ "set_client",
83
+ "reset_client",
84
+ # Exceptions
85
+ "EcosError",
86
+ "EcosAPIError",
87
+ "EcosConfigError",
88
+ "EcosNetworkError",
89
+ "EcosRateLimitError",
90
+ # 금리 지표
91
+ "get_base_rate",
92
+ "get_treasury_yield",
93
+ "get_yield_spread",
94
+ # 물가 지표
95
+ "get_cpi",
96
+ "get_core_cpi",
97
+ "get_ppi",
98
+ # 성장 지표
99
+ "get_gdp",
100
+ "get_gdp_deflator",
101
+ # 통화 지표
102
+ "get_money_supply",
103
+ "get_bank_lending",
104
+ # Metrics
105
+ "get_metrics_summary",
106
+ "reset_metrics",
107
+ # Logging
108
+ "setup_logging",
109
+ ]
ecos/cache.py ADDED
@@ -0,0 +1,212 @@
1
+ """
2
+ ecos-reader 캐시 레이어
3
+
4
+ 동일 요청에 대한 응답을 캐싱하여 API 호출을 최소화합니다.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import time
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ from .logging import log_cache_operation
16
+ from .metrics import record_cache_clear, record_cache_hit, record_cache_miss, record_cache_set
17
+
18
+
19
+ @dataclass
20
+ class CacheEntry:
21
+ """캐시 항목"""
22
+
23
+ value: Any
24
+ created_at: float
25
+ ttl: int
26
+
27
+ def is_expired(self) -> bool:
28
+ """캐시 만료 여부 확인"""
29
+ return time.time() - self.created_at > self.ttl
30
+
31
+
32
+ class Cache:
33
+ """
34
+ 인메모리 LRU 캐시
35
+
36
+ TTL(Time-To-Live) 기반으로 캐시를 관리합니다.
37
+
38
+ Parameters
39
+ ----------
40
+ ttl : int
41
+ 캐시 유효 시간 (초), 기본값 3600 (1시간)
42
+ maxsize : int
43
+ 최대 캐시 항목 수, 기본값 100
44
+
45
+ Examples
46
+ --------
47
+ >>> cache = Cache(ttl=3600, maxsize=100)
48
+ >>> cache.set("key", {"data": "value"})
49
+ >>> cache.get("key")
50
+ {'data': 'value'}
51
+ """
52
+
53
+ def __init__(self, ttl: int = 3600, maxsize: int = 100):
54
+ self.ttl = ttl
55
+ self.maxsize = maxsize
56
+ self._cache: dict[str, CacheEntry] = {}
57
+ self._access_order: list[str] = []
58
+ self._enabled: bool = True
59
+
60
+ @property
61
+ def enabled(self) -> bool:
62
+ """캐시 활성화 상태"""
63
+ return self._enabled
64
+
65
+ @enabled.setter
66
+ def enabled(self, value: bool) -> None:
67
+ """캐시 활성화 설정"""
68
+ self._enabled = value
69
+
70
+ def _make_key(self, *args: Any, **kwargs: Any) -> str:
71
+ """
72
+ 요청 파라미터로부터 캐시 키를 생성합니다.
73
+
74
+ Parameters
75
+ ----------
76
+ *args : Any
77
+ 위치 인자
78
+ **kwargs : Any
79
+ 키워드 인자
80
+
81
+ Returns
82
+ -------
83
+ str
84
+ 해시된 캐시 키 (SHA256 전체 해시 사용으로 충돌 방지)
85
+ """
86
+ key_data = json.dumps({"args": args, "kwargs": kwargs}, sort_keys=True)
87
+ return hashlib.sha256(key_data.encode()).hexdigest()
88
+
89
+ def get(self, key: str) -> Any | None:
90
+ """
91
+ 캐시에서 값을 조회합니다.
92
+
93
+ Parameters
94
+ ----------
95
+ key : str
96
+ 캐시 키
97
+
98
+ Returns
99
+ -------
100
+ Optional[Any]
101
+ 캐시된 값, 없거나 만료된 경우 None
102
+ """
103
+ if not self._enabled:
104
+ return None
105
+
106
+ entry = self._cache.get(key)
107
+ if entry is None:
108
+ record_cache_miss()
109
+ log_cache_operation("get", key, hit=False)
110
+ return None
111
+
112
+ if entry.is_expired():
113
+ self.invalidate(key)
114
+ record_cache_miss()
115
+ log_cache_operation("get", key, hit=False)
116
+ return None
117
+
118
+ # LRU: 접근된 항목을 최신으로 이동
119
+ if key in self._access_order:
120
+ self._access_order.remove(key)
121
+ self._access_order.append(key)
122
+
123
+ record_cache_hit()
124
+ log_cache_operation("get", key, hit=True)
125
+ return entry.value
126
+
127
+ def set(self, key: str, value: Any) -> None:
128
+ """
129
+ 캐시에 값을 저장합니다.
130
+
131
+ Parameters
132
+ ----------
133
+ key : str
134
+ 캐시 키
135
+ value : Any
136
+ 저장할 값
137
+ """
138
+ if not self._enabled:
139
+ return
140
+
141
+ # 최대 크기 초과 시 가장 오래된 항목 제거
142
+ while len(self._cache) >= self.maxsize and self._access_order:
143
+ oldest_key = self._access_order.pop(0)
144
+ self._cache.pop(oldest_key, None)
145
+
146
+ self._cache[key] = CacheEntry(value=value, created_at=time.time(), ttl=self.ttl)
147
+
148
+ if key in self._access_order:
149
+ self._access_order.remove(key)
150
+ self._access_order.append(key)
151
+
152
+ record_cache_set()
153
+ log_cache_operation("set", key)
154
+
155
+ def invalidate(self, key: str) -> None:
156
+ """
157
+ 특정 캐시 항목을 무효화합니다.
158
+
159
+ Parameters
160
+ ----------
161
+ key : str
162
+ 무효화할 캐시 키
163
+ """
164
+ self._cache.pop(key, None)
165
+ if key in self._access_order:
166
+ self._access_order.remove(key)
167
+
168
+ def clear(self) -> None:
169
+ """모든 캐시를 삭제합니다."""
170
+ self._cache.clear()
171
+ self._access_order.clear()
172
+ record_cache_clear()
173
+ log_cache_operation("clear", "")
174
+
175
+ def __len__(self) -> int:
176
+ """캐시된 항목 수"""
177
+ return len(self._cache)
178
+
179
+ def __contains__(self, key: str) -> bool:
180
+ """캐시 키 존재 여부"""
181
+ return key in self._cache and not self._cache[key].is_expired()
182
+
183
+
184
+ # 전역 캐시 인스턴스
185
+ _global_cache: Cache | None = None
186
+
187
+
188
+ def get_cache() -> Cache:
189
+ """전역 캐시 인스턴스를 반환합니다."""
190
+ global _global_cache
191
+ if _global_cache is None:
192
+ from .config import Settings
193
+
194
+ _global_cache = Cache(ttl=Settings.CACHE_TTL, maxsize=Settings.CACHE_MAXSIZE)
195
+ return _global_cache
196
+
197
+
198
+ def clear_cache() -> None:
199
+ """전역 캐시를 초기화합니다."""
200
+ global _global_cache
201
+ if _global_cache is not None:
202
+ _global_cache.clear()
203
+
204
+
205
+ def disable_cache() -> None:
206
+ """캐시를 비활성화합니다."""
207
+ get_cache().enabled = False
208
+
209
+
210
+ def enable_cache() -> None:
211
+ """캐시를 활성화합니다."""
212
+ get_cache().enabled = True
ecos/client.py ADDED
@@ -0,0 +1,394 @@
1
+ """
2
+ ecos-reader API 클라이언트
3
+
4
+ ECOS API와 HTTP 통신을 담당하는 핵심 클라이언트입니다.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import time
11
+ from typing import Any, cast
12
+ from urllib.parse import quote
13
+
14
+ import requests
15
+
16
+ from .cache import Cache, get_cache
17
+ from .config import Settings, get_api_key
18
+ from .exceptions import (
19
+ RETRYABLE_ERROR_CODES,
20
+ EcosAPIError,
21
+ EcosConfigError,
22
+ EcosNetworkError,
23
+ EcosRateLimitError,
24
+ )
25
+ from .logging import log_api_request, log_error_response, log_retry_attempt, logger
26
+ from .types import EcosService
27
+
28
+
29
+ class EcosClient:
30
+ """
31
+ ECOS API 클라이언트
32
+
33
+ 한국은행 ECOS Open API와 HTTP 통신을 담당합니다.
34
+
35
+ Parameters
36
+ ----------
37
+ api_key : str, optional
38
+ ECOS API 인증키. 미제공 시 환경 변수에서 로드
39
+ timeout : int, optional
40
+ 요청 타임아웃(초), 기본값 30
41
+ max_retries : int, optional
42
+ 최대 재시도 횟수, 기본값 3
43
+ use_cache : bool, optional
44
+ 캐시 사용 여부, 기본값 True
45
+
46
+ Examples
47
+ --------
48
+ >>> client = EcosClient()
49
+ >>> result = client.get_statistic_search(
50
+ ... stat_code="722Y001",
51
+ ... period="M",
52
+ ... start_date="202301",
53
+ ... end_date="202312",
54
+ ... item_code1="0101000"
55
+ ... )
56
+ """
57
+
58
+ BASE_URL = Settings.BASE_URL
59
+
60
+ def __init__(
61
+ self,
62
+ api_key: str | None = None,
63
+ timeout: int = Settings.DEFAULT_TIMEOUT,
64
+ max_retries: int = Settings.MAX_RETRIES,
65
+ use_cache: bool = True,
66
+ ):
67
+ self.api_key = api_key
68
+ self.timeout = timeout
69
+ self.max_retries = max_retries
70
+ self.use_cache = use_cache
71
+ self.session = requests.Session()
72
+ self._cache: Cache | None = get_cache() if use_cache else None
73
+
74
+ def _get_api_key(self) -> str:
75
+ """API Key를 반환합니다."""
76
+ if self.api_key:
77
+ return self.api_key
78
+ return get_api_key()
79
+
80
+ def _build_url(
81
+ self,
82
+ service: EcosService,
83
+ start: int,
84
+ end: int,
85
+ *path_params: str,
86
+ ) -> str:
87
+ """
88
+ ECOS API 요청 URL을 구성합니다.
89
+
90
+ URL 형식: {BASE_URL}/{service}/{api_key}/{format}/{lang}/{start}/{end}/{params...}/
91
+ """
92
+ api_key = self._get_api_key()
93
+ parts = [
94
+ self.BASE_URL.rstrip("/"),
95
+ service,
96
+ api_key,
97
+ Settings.DEFAULT_FORMAT,
98
+ Settings.DEFAULT_LANG,
99
+ str(start),
100
+ str(end),
101
+ ]
102
+
103
+ # 추가 파라미터 (통계코드, 주기, 날짜 등)
104
+ for param in path_params:
105
+ if param: # 빈 문자열은 제외
106
+ parts.append(quote(str(param), safe=""))
107
+
108
+ return "/".join(parts)
109
+
110
+ @log_api_request
111
+ def _make_request(self, url: str) -> dict[str, Any]:
112
+ """
113
+ HTTP GET 요청을 수행합니다.
114
+
115
+ Parameters
116
+ ----------
117
+ url : str
118
+ 요청 URL
119
+
120
+ Returns
121
+ -------
122
+ dict
123
+ JSON 응답
124
+
125
+ Raises
126
+ ------
127
+ EcosNetworkError
128
+ 네트워크 에러 발생 시
129
+ EcosAPIError
130
+ API 에러 응답 시
131
+ EcosRateLimitError
132
+ Rate Limit 초과 시
133
+ """
134
+ last_exception: Exception | None = None
135
+
136
+ for attempt in range(self.max_retries):
137
+ try:
138
+ logger.debug(f"API 요청 전송: {url}")
139
+ response = self.session.get(url, timeout=self.timeout)
140
+ response.raise_for_status()
141
+ data = cast(dict[str, Any], response.json())
142
+
143
+ # 에러 응답 확인
144
+ self._check_error_response(data, url)
145
+
146
+ logger.debug(f"API 응답 성공: {len(data)} 바이트 수신")
147
+ return data
148
+
149
+ except requests.exceptions.Timeout:
150
+ last_exception = EcosNetworkError(f"요청 타임아웃 ({self.timeout}초)")
151
+ if attempt < self.max_retries - 1:
152
+ log_retry_attempt(attempt + 1, self.max_retries, last_exception)
153
+
154
+ except requests.exceptions.ConnectionError as e:
155
+ last_exception = EcosNetworkError(f"네트워크 연결 오류: {e}")
156
+ if attempt < self.max_retries - 1:
157
+ log_retry_attempt(attempt + 1, self.max_retries, last_exception)
158
+
159
+ except requests.exceptions.HTTPError as e:
160
+ last_exception = EcosNetworkError(f"HTTP 오류: {e}")
161
+ if attempt < self.max_retries - 1:
162
+ log_retry_attempt(attempt + 1, self.max_retries, last_exception)
163
+
164
+ except (EcosAPIError, EcosRateLimitError) as e:
165
+ # 재시도 가능한 에러인지 확인
166
+ error_key = f"ERROR-{e.code}" if hasattr(e, "code") else ""
167
+ if error_key in RETRYABLE_ERROR_CODES:
168
+ last_exception = e
169
+ if attempt < self.max_retries - 1:
170
+ log_retry_attempt(attempt + 1, self.max_retries, last_exception)
171
+ else:
172
+ raise
173
+
174
+ # 재시도 전 대기 (exponential backoff)
175
+ if attempt < self.max_retries - 1:
176
+ wait_time = Settings.RETRY_BACKOFF_FACTOR * (2**attempt)
177
+ logger.debug(f"재시도 전 대기: {wait_time:.2f}초")
178
+ time.sleep(wait_time)
179
+
180
+ # 모든 재시도 실패
181
+ if last_exception:
182
+ raise last_exception
183
+
184
+ raise EcosNetworkError("알 수 없는 네트워크 오류")
185
+
186
+ def _check_error_response(self, data: dict[str, Any], url: str = "") -> None:
187
+ """
188
+ API 응답에서 에러를 확인합니다.
189
+
190
+ Parameters
191
+ ----------
192
+ data : dict
193
+ API 응답 데이터
194
+ url : str, optional
195
+ 요청 URL (로깅용)
196
+ """
197
+ # RESULT 키 확인
198
+ result = data.get("RESULT")
199
+ if not result:
200
+ return
201
+
202
+ code = result.get("CODE", "")
203
+ message = result.get("MESSAGE", "")
204
+
205
+ # 정보 코드 200: 데이터 없음 - 정상 처리 (빈 결과)
206
+ if code == "INFO-200":
207
+ return
208
+
209
+ # 정보 코드 100: 인증키 오류
210
+ if code == "INFO-100":
211
+ log_error_response(code, message, url)
212
+ raise EcosConfigError(message)
213
+
214
+ # 에러 코드
215
+ if code.startswith("ERROR"):
216
+ error_num = code.split("-")[-1] if "-" in code else code.replace("ERROR", "")
217
+ log_error_response(code, message, url)
218
+
219
+ if error_num == "602":
220
+ raise EcosRateLimitError(message)
221
+
222
+ raise EcosAPIError(error_num, message)
223
+
224
+ def get_statistic_search(
225
+ self,
226
+ stat_code: str,
227
+ period: str,
228
+ start_date: str,
229
+ end_date: str,
230
+ item_code1: str = "",
231
+ item_code2: str = "",
232
+ item_code3: str = "",
233
+ item_code4: str = "",
234
+ start: int = 1,
235
+ end: int = 100000,
236
+ ) -> dict[str, Any]:
237
+ """
238
+ 통계 조회 (StatisticSearch)
239
+
240
+ Parameters
241
+ ----------
242
+ stat_code : str
243
+ 통계표코드
244
+ period : str
245
+ 주기 (D: 일, M: 월, Q: 분기, A: 연)
246
+ start_date : str
247
+ 조회 시작일
248
+ end_date : str
249
+ 조회 종료일
250
+ item_code1 : str, optional
251
+ 통계항목코드1
252
+ item_code2 : str, optional
253
+ 통계항목코드2
254
+ item_code3 : str, optional
255
+ 통계항목코드3
256
+ item_code4 : str, optional
257
+ 통계항목코드4
258
+ start : int, optional
259
+ 시작 건수, 기본값 1
260
+ end : int, optional
261
+ 종료 건수, 기본값 100000
262
+
263
+ Returns
264
+ -------
265
+ dict
266
+ API 응답 데이터
267
+ """
268
+ # 캐시 확인
269
+ if self._cache and self.use_cache:
270
+ cache_key = self._cache._make_key(
271
+ "StatisticSearch",
272
+ stat_code,
273
+ period,
274
+ start_date,
275
+ end_date,
276
+ item_code1,
277
+ item_code2,
278
+ item_code3,
279
+ item_code4,
280
+ )
281
+ cached = self._cache.get(cache_key)
282
+ if cached is not None:
283
+ return cast(dict[str, Any], cached)
284
+
285
+ url = self._build_url(
286
+ "StatisticSearch",
287
+ start,
288
+ end,
289
+ stat_code,
290
+ period,
291
+ start_date,
292
+ end_date,
293
+ item_code1,
294
+ item_code2,
295
+ item_code3,
296
+ item_code4,
297
+ )
298
+
299
+ result = self._make_request(url)
300
+
301
+ # 캐시 저장
302
+ if self._cache and self.use_cache:
303
+ self._cache.set(cache_key, result)
304
+
305
+ return result
306
+
307
+ def get_statistic_item_list(
308
+ self,
309
+ stat_code: str,
310
+ start: int = 1,
311
+ end: int = 10000,
312
+ ) -> dict[str, Any]:
313
+ """
314
+ 통계 항목 목록 조회 (StatisticItemList)
315
+
316
+ Parameters
317
+ ----------
318
+ stat_code : str
319
+ 통계표코드
320
+ start : int, optional
321
+ 시작 건수, 기본값 1
322
+ end : int, optional
323
+ 종료 건수, 기본값 10000
324
+
325
+ Returns
326
+ -------
327
+ dict
328
+ API 응답 데이터
329
+ """
330
+ url = self._build_url("StatisticItemList", start, end, stat_code)
331
+ return self._make_request(url)
332
+
333
+ def get_statistic_table_list(
334
+ self,
335
+ stat_code: str = "",
336
+ start: int = 1,
337
+ end: int = 10000,
338
+ ) -> dict[str, Any]:
339
+ """
340
+ 통계표 목록 조회 (StatisticTableList)
341
+
342
+ Parameters
343
+ ----------
344
+ stat_code : str, optional
345
+ 통계표코드 (검색어로 활용)
346
+ start : int, optional
347
+ 시작 건수, 기본값 1
348
+ end : int, optional
349
+ 종료 건수, 기본값 10000
350
+
351
+ Returns
352
+ -------
353
+ dict
354
+ API 응답 데이터
355
+ """
356
+ url = self._build_url("StatisticTableList", start, end, stat_code)
357
+ return self._make_request(url)
358
+
359
+
360
+ # 전역 클라이언트 인스턴스
361
+ _global_client: EcosClient | None = None
362
+
363
+
364
+ def get_client() -> EcosClient:
365
+ """전역 클라이언트 인스턴스를 반환합니다."""
366
+ global _global_client
367
+ if _global_client is None:
368
+ _global_client = EcosClient()
369
+ return _global_client
370
+
371
+
372
+ def set_client(client: EcosClient | None) -> None:
373
+ """
374
+ 전역 기본 클라이언트를 설정합니다.
375
+
376
+ Notes
377
+ -----
378
+ - indicator 함수들은 기본적으로 이 전역 클라이언트를 사용합니다.
379
+ - 기존 전역 클라이언트가 존재하면 세션을 close()한 뒤 교체합니다.
380
+ """
381
+ global _global_client
382
+ if _global_client is not None and _global_client is not client:
383
+ with contextlib.suppress(Exception):
384
+ _global_client.session.close()
385
+ _global_client = client
386
+
387
+
388
+ def reset_client() -> None:
389
+ """전역 클라이언트를 초기화합니다."""
390
+ global _global_client
391
+ if _global_client is not None:
392
+ with contextlib.suppress(Exception):
393
+ _global_client.session.close()
394
+ _global_client = None