cluefin-openapi 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.
Files changed (35) hide show
  1. cluefin_openapi/__init__.py +2 -0
  2. cluefin_openapi/kiwoom/__init__.py +31 -0
  3. cluefin_openapi/kiwoom/_auth.py +96 -0
  4. cluefin_openapi/kiwoom/_auth_types.py +18 -0
  5. cluefin_openapi/kiwoom/_cache.py +130 -0
  6. cluefin_openapi/kiwoom/_client.py +386 -0
  7. cluefin_openapi/kiwoom/_domestic_account.py +1129 -0
  8. cluefin_openapi/kiwoom/_domestic_account_types.py +937 -0
  9. cluefin_openapi/kiwoom/_domestic_chart.py +587 -0
  10. cluefin_openapi/kiwoom/_domestic_chart_types.py +1072 -0
  11. cluefin_openapi/kiwoom/_domestic_credit_order.py +35 -0
  12. cluefin_openapi/kiwoom/_domestic_etf.py +322 -0
  13. cluefin_openapi/kiwoom/_domestic_etf_types.py +188 -0
  14. cluefin_openapi/kiwoom/_domestic_foreign.py +131 -0
  15. cluefin_openapi/kiwoom/_domestic_foreign_types.py +70 -0
  16. cluefin_openapi/kiwoom/_domestic_market_condition.py +909 -0
  17. cluefin_openapi/kiwoom/_domestic_market_condition_types.py +672 -0
  18. cluefin_openapi/kiwoom/_domestic_order.py +262 -0
  19. cluefin_openapi/kiwoom/_domestic_order_types.py +38 -0
  20. cluefin_openapi/kiwoom/_domestic_rank_info.py +1495 -0
  21. cluefin_openapi/kiwoom/_domestic_rank_info_types.py +554 -0
  22. cluefin_openapi/kiwoom/_domestic_realtime.py +22 -0
  23. cluefin_openapi/kiwoom/_domestic_sector.py +244 -0
  24. cluefin_openapi/kiwoom/_domestic_sector_types.py +192 -0
  25. cluefin_openapi/kiwoom/_domestic_stock_info.py +1280 -0
  26. cluefin_openapi/kiwoom/_domestic_stock_info_types.py +752 -0
  27. cluefin_openapi/kiwoom/_domestic_theme.py +101 -0
  28. cluefin_openapi/kiwoom/_domestic_theme_types.py +45 -0
  29. cluefin_openapi/kiwoom/_exceptions.py +77 -0
  30. cluefin_openapi/kiwoom/_model.py +28 -0
  31. cluefin_openapi/kiwoom/_rate_limiter.py +90 -0
  32. cluefin_openapi/kiwoom/py.typed +0 -0
  33. cluefin_openapi-0.1.0.dist-info/METADATA +65 -0
  34. cluefin_openapi-0.1.0.dist-info/RECORD +35 -0
  35. cluefin_openapi-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,2 @@
1
+ def main() -> None:
2
+ print("Hello from cluefin-openapi!")
@@ -0,0 +1,31 @@
1
+ """Cluefin Kiwoom API Client Package."""
2
+
3
+ from ._auth import Auth
4
+ from ._client import Client
5
+ from ._exceptions import (
6
+ KiwoomAPIError,
7
+ KiwoomAuthenticationError,
8
+ KiwoomAuthorizationError,
9
+ KiwoomNetworkError,
10
+ KiwoomRateLimitError,
11
+ KiwoomServerError,
12
+ KiwoomTimeoutError,
13
+ KiwoomValidationError,
14
+ )
15
+
16
+ __all__ = [
17
+ "Auth",
18
+ "Client",
19
+ "KiwoomAPIError",
20
+ "KiwoomAuthenticationError",
21
+ "KiwoomAuthorizationError",
22
+ "KiwoomNetworkError",
23
+ "KiwoomRateLimitError",
24
+ "KiwoomServerError",
25
+ "KiwoomTimeoutError",
26
+ "KiwoomValidationError",
27
+ ]
28
+
29
+
30
+ def hello() -> str:
31
+ return "Hello from cluefin-kiwoom!"
@@ -0,0 +1,96 @@
1
+ """Authentication module for Kiwoom API.
2
+
3
+ This module provides functionality for generating and revoking API tokens
4
+ using client credentials authentication flow.
5
+ """
6
+
7
+ from typing import Literal
8
+
9
+ import requests
10
+
11
+ from ._auth_types import TokenResponse
12
+
13
+
14
+ class Auth:
15
+ """Initialize the Auth client.
16
+
17
+ Args:
18
+ app_key: The application key provided by Kiwoom.
19
+ secret_key: The secret key provided by Kiwoom.
20
+ env: The environment to use. Either "dev" or "prod".
21
+ Defaults to "dev".
22
+
23
+ Raises:
24
+ ValueError: If an invalid environment is provided.
25
+ """
26
+
27
+ def __init__(self, app_key: str, secret_key: str, env: Literal["dev", "prod"] = "dev") -> None:
28
+ self.app_key = app_key
29
+ self.secret_key = secret_key
30
+
31
+ if env == "dev":
32
+ self.url = "https://mockapi.kiwoom.com"
33
+ elif env == "prod":
34
+ self.url = "https://api.kiwoom.com"
35
+ else:
36
+ raise ValueError("Invalid environment. Must be either 'dev' or 'prod'.")
37
+
38
+ @property
39
+ def token(self) -> TokenResponse:
40
+ """Get the current token data.
41
+
42
+ Returns:
43
+ TokenResponse: The current token data.
44
+
45
+ Raises:
46
+ AttributeError: If no token has been generated yet.
47
+ """
48
+ return self._token_data
49
+
50
+ def generate_token(self) -> TokenResponse:
51
+ """Generate a new access token.
52
+
53
+ Calls the Kiwoom OAuth2 token endpoint to generate a new access token
54
+ using the client credentials flow.
55
+
56
+ Returns:
57
+ TokenResponse: The generated token data including access token
58
+ and expiration.
59
+
60
+ Raises:
61
+ requests.exceptions.HTTPError: If the API request fails.
62
+ """
63
+ headers = {
64
+ "Content-Type": "application/json;charset=UTF-8",
65
+ }
66
+ data = {"grant_type": "client_credentials", "appkey": self.app_key, "secretkey": self.secret_key}
67
+
68
+ response = requests.post(f"{self.url}/oauth2/token", headers=headers, json=data)
69
+ response.raise_for_status()
70
+
71
+ token_data = TokenResponse(**response.json())
72
+ self._token_data = token_data
73
+
74
+ return self._token_data
75
+
76
+ def revoke_token(self, token: str) -> bool:
77
+ """Revoke an access token.
78
+
79
+ Args:
80
+ token: The token to revoke.
81
+ Returns:
82
+ bool: True if the token was successfully revoked.
83
+
84
+ Raises:
85
+ requests.exceptions.HTTPError: If the API request fails.
86
+ """
87
+ headers = {
88
+ "Content-Type": "application/json;charset=UTF-8",
89
+ }
90
+
91
+ data = {"appkey": self.app_key, "secretkey": self.secret_key, "token": token}
92
+
93
+ response = requests.post(f"{self.url}/oauth2/revoke", headers=headers, json=data)
94
+ response.raise_for_status()
95
+
96
+ return True
@@ -0,0 +1,18 @@
1
+ from datetime import datetime
2
+
3
+ from pydantic import BaseModel, field_validator
4
+
5
+
6
+ class TokenResponse(BaseModel):
7
+ expires_dt: datetime
8
+ token_type: str
9
+ token: str
10
+
11
+ @field_validator("expires_dt", mode="before")
12
+ def parse_expires_dt(cls, v):
13
+ if isinstance(v, str):
14
+ try:
15
+ return datetime.strptime(v, "%Y%m%d%H%M%S")
16
+ except ValueError:
17
+ pass # 다른 형식도 추가 가능
18
+ return v
@@ -0,0 +1,130 @@
1
+ """Caching utilities for Kiwoom API client."""
2
+
3
+ import hashlib
4
+ import time
5
+ from typing import Any, Dict, Optional
6
+
7
+
8
+ class SimpleCache:
9
+ """Simple in-memory cache with TTL support."""
10
+
11
+ def __init__(self, default_ttl: int = 300):
12
+ """
13
+ Initialize cache.
14
+
15
+ Args:
16
+ default_ttl: Default time-to-live in seconds (default: 5 minutes)
17
+ """
18
+ self.default_ttl = default_ttl
19
+ self._cache: Dict[str, Dict[str, Any]] = {}
20
+
21
+ def get(self, key: str) -> Optional[Any]:
22
+ """
23
+ Get value from cache.
24
+
25
+ Args:
26
+ key: Cache key
27
+
28
+ Returns:
29
+ Cached value or None if not found/expired
30
+ """
31
+ if key in self._cache:
32
+ entry = self._cache[key]
33
+ if time.time() < entry["expires_at"]:
34
+ return entry["value"]
35
+ else:
36
+ # Remove expired entry
37
+ del self._cache[key]
38
+ return None
39
+
40
+ def set(self, key: str, value: Any, ttl: Optional[int] = None) -> None:
41
+ """
42
+ Set value in cache.
43
+
44
+ Args:
45
+ key: Cache key
46
+ value: Value to cache
47
+ ttl: Time-to-live in seconds (uses default if None)
48
+ """
49
+ if ttl is None:
50
+ ttl = self.default_ttl
51
+
52
+ self._cache[key] = {
53
+ "value": value,
54
+ "expires_at": time.time() + ttl,
55
+ "created_at": time.time(),
56
+ }
57
+
58
+ def delete(self, key: str) -> bool:
59
+ """
60
+ Delete key from cache.
61
+
62
+ Args:
63
+ key: Cache key
64
+
65
+ Returns:
66
+ True if key was deleted, False if not found
67
+ """
68
+ if key in self._cache:
69
+ del self._cache[key]
70
+ return True
71
+ return False
72
+
73
+ def clear(self) -> None:
74
+ """Clear all cached entries."""
75
+ self._cache.clear()
76
+
77
+ def cleanup_expired(self) -> int:
78
+ """
79
+ Remove expired entries from cache.
80
+
81
+ Returns:
82
+ Number of entries removed
83
+ """
84
+ current_time = time.time()
85
+ expired_keys = [key for key, entry in self._cache.items() if current_time >= entry["expires_at"]]
86
+
87
+ for key in expired_keys:
88
+ del self._cache[key]
89
+
90
+ return len(expired_keys)
91
+
92
+ def cache_info(self) -> Dict[str, Any]:
93
+ """Get cache statistics."""
94
+ current_time = time.time()
95
+ valid_entries = sum(1 for entry in self._cache.values() if current_time < entry["expires_at"])
96
+
97
+ return {
98
+ "total_entries": len(self._cache),
99
+ "valid_entries": valid_entries,
100
+ "expired_entries": len(self._cache) - valid_entries,
101
+ }
102
+
103
+
104
+ def create_cache_key(url: str, headers: Dict[str, str], body: Dict[str, Any]) -> str:
105
+ """
106
+ Create a cache key from request parameters.
107
+
108
+ Args:
109
+ url: Request URL
110
+ headers: Request headers (excluding auth and dynamic headers)
111
+ body: Request body
112
+
113
+ Returns:
114
+ Cache key string
115
+ """
116
+ # Filter out dynamic headers that shouldn't affect caching
117
+ cacheable_headers = {
118
+ k: v for k, v in headers.items() if k.lower() not in ["authorization", "user-agent", "content-length"]
119
+ }
120
+
121
+ # Create a deterministic string representation
122
+ cache_data = {
123
+ "url": url,
124
+ "headers": sorted(cacheable_headers.items()),
125
+ "body": sorted(body.items()) if isinstance(body, dict) else body,
126
+ }
127
+
128
+ # Create hash of the cache data
129
+ cache_string = str(cache_data)
130
+ return hashlib.sha256(cache_string.encode()).hexdigest()[:16] # First 16 chars for brevity
@@ -0,0 +1,386 @@
1
+ import json
2
+ import time
3
+ from typing import Dict, List, Literal, Optional, Tuple
4
+
5
+ import requests
6
+ from loguru import logger
7
+
8
+ from ._cache import SimpleCache, create_cache_key
9
+ from ._exceptions import (
10
+ KiwoomAPIError,
11
+ KiwoomAuthenticationError,
12
+ KiwoomAuthorizationError,
13
+ KiwoomNetworkError,
14
+ KiwoomRateLimitError,
15
+ KiwoomServerError,
16
+ KiwoomTimeoutError,
17
+ KiwoomValidationError,
18
+ )
19
+ from ._rate_limiter import TokenBucket
20
+
21
+
22
+ class MockResponse:
23
+ """Mock response object for caching."""
24
+
25
+ def __init__(self, status_code: int, headers: dict, content: bytes, json_data: Optional[dict] = None):
26
+ self.status_code = status_code
27
+ self.headers = headers
28
+ self.content = content
29
+ self._json_data = json_data
30
+
31
+ def json(self):
32
+ if self._json_data is not None:
33
+ return self._json_data
34
+ return json.loads(self.content.decode())
35
+
36
+ @property
37
+ def text(self):
38
+ return self.content.decode()
39
+
40
+
41
+ class Client(object):
42
+ def __init__(
43
+ self,
44
+ token: str,
45
+ env: Literal["dev", "prod"],
46
+ timeout: int = 30,
47
+ max_retries: int = 3,
48
+ debug: bool = False,
49
+ rate_limit_requests_per_second: float = 10.0,
50
+ rate_limit_burst: int = 20,
51
+ enable_caching: bool = False,
52
+ cache_ttl: int = 300,
53
+ ):
54
+ self.token = token
55
+ self.timeout = timeout
56
+ self.max_retries = max_retries
57
+ self.debug = debug
58
+ self.enable_caching = enable_caching
59
+
60
+ if env == "dev":
61
+ self.url = "https://mockapi.kiwoom.com"
62
+ elif env == "prod":
63
+ self.url = "https://api.kiwoom.com"
64
+ else:
65
+ raise ValueError("Invalid environment")
66
+
67
+ # Create a reusable session for connection pooling
68
+ self._session = requests.Session()
69
+
70
+ # Set common headers for all requests
71
+ self._session.headers.update(
72
+ {
73
+ "Content-Type": "application/json",
74
+ "Accept": "application/json",
75
+ "User-Agent": "cluefin-kiwoom/0.1.0",
76
+ }
77
+ )
78
+
79
+ # Initialize rate limiter
80
+ self._rate_limiter = TokenBucket(capacity=rate_limit_burst, refill_rate=rate_limit_requests_per_second)
81
+
82
+ # Initialize cache if enabled
83
+ self._cache = SimpleCache(default_ttl=cache_ttl) if enable_caching else None
84
+
85
+ # Configure logging
86
+ if self.debug:
87
+ logger.enable("cluefin_openapi.kiwoom")
88
+ else:
89
+ logger.disable("cluefin_openapi.kiwoom")
90
+
91
+ @property
92
+ def account(self):
93
+ from ._domestic_account import DomesticAccount
94
+
95
+ return DomesticAccount(self)
96
+
97
+ @property
98
+ def chart(self):
99
+ from ._domestic_chart import DomesticChart
100
+
101
+ return DomesticChart(self)
102
+
103
+ @property
104
+ def credit_order(self):
105
+ from ._domestic_credit_order import DomesticCreditOrder
106
+
107
+ return DomesticCreditOrder(self)
108
+
109
+ @property
110
+ def etf(self):
111
+ from ._domestic_etf import DomesticETF
112
+
113
+ return DomesticETF(self)
114
+
115
+ @property
116
+ def foreign(self):
117
+ from ._domestic_foreign import DomesticForeign
118
+
119
+ return DomesticForeign(self)
120
+
121
+ @property
122
+ def market_conditions(self):
123
+ from ._domestic_market_condition import DomesticMarketCondition
124
+
125
+ return DomesticMarketCondition(self)
126
+
127
+ @property
128
+ def order(self):
129
+ from ._domestic_order import DomesticOrder
130
+
131
+ return DomesticOrder(self)
132
+
133
+ @property
134
+ def rank_info(self):
135
+ from ._domestic_rank_info import DomesticRankInfo
136
+
137
+ return DomesticRankInfo(self)
138
+
139
+ @property
140
+ def sector(self):
141
+ from ._domestic_sector import DomesticSector
142
+
143
+ return DomesticSector(self)
144
+
145
+ @property
146
+ def stock_info(self):
147
+ from ._domestic_stock_info import DomesticStockInfo
148
+
149
+ return DomesticStockInfo(self)
150
+
151
+ @property
152
+ def theme(self):
153
+ from ._domestic_theme import DomesticTheme
154
+
155
+ return DomesticTheme(self)
156
+
157
+ def _post(self, path: str, headers: dict, body: dict, use_cache: bool = True):
158
+ """Make a POST request with improved error handling and logging."""
159
+ # Check cache first if enabled
160
+ cache_key = None
161
+ if self._cache and use_cache:
162
+ cache_key = create_cache_key(f"{self.url}{path}", headers, body)
163
+ cached_response = self._cache.get(cache_key)
164
+ if cached_response:
165
+ if self.debug:
166
+ logger.debug(f"Cache hit for {path}")
167
+ return cached_response
168
+
169
+ # Apply rate limiting
170
+ if not self._rate_limiter.wait_for_tokens(timeout=self.timeout):
171
+ raise KiwoomRateLimitError(
172
+ "Rate limit timeout - could not acquire token within timeout period",
173
+ request_context={"url": f"{self.url}{path}", "path": path},
174
+ )
175
+
176
+ url = f"{self.url}{path}"
177
+
178
+ # Merge headers with authentication
179
+ merged_headers = headers.copy()
180
+ merged_headers["Authorization"] = f"Bearer {self.token}"
181
+
182
+ # Log request details in debug mode
183
+ if self.debug:
184
+ logger.debug(f"Making POST request to {url}")
185
+ logger.debug(f"Headers: {merged_headers}")
186
+ logger.debug(f"Body: {body}")
187
+ logger.debug(f"Rate limiter tokens available: {self._rate_limiter.available_tokens:.2f}")
188
+
189
+ request_context = {
190
+ "url": url,
191
+ "path": path,
192
+ "method": "POST",
193
+ "headers": merged_headers,
194
+ "body": body,
195
+ }
196
+
197
+ for attempt in range(self.max_retries + 1):
198
+ try:
199
+ start_time = time.time()
200
+
201
+ response = self._session.post(
202
+ url=url,
203
+ headers=merged_headers,
204
+ data=json.dumps(body),
205
+ timeout=self.timeout,
206
+ )
207
+
208
+ duration = time.time() - start_time
209
+
210
+ # Log response details in debug mode
211
+ if self.debug:
212
+ logger.debug(f"Response received in {duration:.3f}s - Status: {response.status_code}")
213
+ logger.debug(f"Response headers: {dict(response.headers)}")
214
+
215
+ # Handle different HTTP status codes
216
+ if response.status_code == 200:
217
+ # Cache successful responses if caching is enabled
218
+ if self._cache and use_cache and cache_key:
219
+ # Create a mock response object to cache
220
+ cached_response = MockResponse(
221
+ status_code=response.status_code,
222
+ headers=dict(response.headers),
223
+ content=response.content,
224
+ json_data=self._safe_json(response),
225
+ )
226
+ self._cache.set(cache_key, cached_response)
227
+ if self.debug:
228
+ logger.debug(f"Cached response for {path}")
229
+
230
+ return response
231
+ elif response.status_code == 400:
232
+ raise KiwoomValidationError(
233
+ f"Bad request: {response.text}",
234
+ status_code=response.status_code,
235
+ response_data=self._safe_json(response),
236
+ request_context=request_context,
237
+ )
238
+ elif response.status_code == 401:
239
+ raise KiwoomAuthenticationError(
240
+ "Authentication failed - invalid or expired token",
241
+ status_code=response.status_code,
242
+ response_data=self._safe_json(response),
243
+ request_context=request_context,
244
+ )
245
+ elif response.status_code == 403:
246
+ raise KiwoomAuthorizationError(
247
+ "Access forbidden - insufficient permissions",
248
+ status_code=response.status_code,
249
+ response_data=self._safe_json(response),
250
+ request_context=request_context,
251
+ )
252
+ elif response.status_code == 429:
253
+ retry_after = self._get_retry_after(response)
254
+ if attempt < self.max_retries:
255
+ wait_time = retry_after or (2**attempt)
256
+ logger.warning(
257
+ f"Rate limit hit, waiting {wait_time}s before retry {attempt + 1}/{self.max_retries}"
258
+ )
259
+ time.sleep(wait_time)
260
+ continue
261
+ else:
262
+ raise KiwoomRateLimitError(
263
+ f"Rate limit exceeded after {self.max_retries} retries",
264
+ status_code=response.status_code,
265
+ response_data=self._safe_json(response),
266
+ request_context=request_context,
267
+ retry_after=retry_after,
268
+ )
269
+ elif 500 <= response.status_code < 600:
270
+ if attempt < self.max_retries:
271
+ wait_time = 2**attempt
272
+ logger.warning(f"Server error {response.status_code}, retrying in {wait_time}s")
273
+ time.sleep(wait_time)
274
+ continue
275
+ else:
276
+ raise KiwoomServerError(
277
+ f"Server error: {response.text}",
278
+ status_code=response.status_code,
279
+ response_data=self._safe_json(response),
280
+ request_context=request_context,
281
+ )
282
+ else:
283
+ raise KiwoomAPIError(
284
+ f"Unexpected status code {response.status_code}: {response.text}",
285
+ status_code=response.status_code,
286
+ response_data=self._safe_json(response),
287
+ request_context=request_context,
288
+ )
289
+
290
+ except requests.exceptions.Timeout as e:
291
+ if attempt < self.max_retries:
292
+ wait_time = 2**attempt
293
+ logger.warning(f"Request timeout, retrying in {wait_time}s")
294
+ time.sleep(wait_time)
295
+ continue
296
+ else:
297
+ raise KiwoomTimeoutError(
298
+ f"Request timeout after {self.max_retries} retries",
299
+ request_context=request_context,
300
+ ) from e
301
+ except requests.exceptions.ConnectionError as e:
302
+ if attempt < self.max_retries:
303
+ wait_time = 2**attempt
304
+ logger.warning(f"Connection error, retrying in {wait_time}s")
305
+ time.sleep(wait_time)
306
+ continue
307
+ else:
308
+ raise KiwoomNetworkError(
309
+ f"Network connection failed: {str(e)}",
310
+ request_context=request_context,
311
+ ) from e
312
+ except requests.exceptions.RequestException as e:
313
+ raise KiwoomNetworkError(
314
+ f"Request failed: {str(e)}",
315
+ request_context=request_context,
316
+ ) from e
317
+
318
+ # This should never be reached, but just in case
319
+ raise KiwoomAPIError("Maximum retries exceeded", request_context=request_context)
320
+
321
+ def _safe_json(self, response: requests.Response) -> Optional[Dict]:
322
+ """Safely parse JSON response, returning None if parsing fails."""
323
+ try:
324
+ return response.json()
325
+ except (ValueError, json.JSONDecodeError):
326
+ return None
327
+
328
+ def _get_retry_after(self, response: requests.Response) -> Optional[int]:
329
+ """Extract retry-after value from response headers."""
330
+ retry_after = response.headers.get("Retry-After")
331
+ if retry_after:
332
+ try:
333
+ return int(retry_after)
334
+ except ValueError:
335
+ pass
336
+ return None
337
+
338
+ def close(self):
339
+ """Close the HTTP session."""
340
+ if hasattr(self, "_session"):
341
+ self._session.close()
342
+
343
+ def batch_post(self, requests_data: List[Tuple[str, dict, dict]]) -> List[requests.Response]:
344
+ """
345
+ Execute multiple POST requests with rate limiting.
346
+
347
+ Args:
348
+ requests_data: List of tuples (path, headers, body)
349
+
350
+ Returns:
351
+ List of response objects
352
+ """
353
+ responses = []
354
+ for path, headers, body in requests_data:
355
+ try:
356
+ response = self._post(path, headers, body)
357
+ responses.append(response)
358
+ except Exception as e:
359
+ # For batch requests, we collect errors instead of raising immediately
360
+ error_response = MockResponse(
361
+ status_code=0,
362
+ headers={},
363
+ content=json.dumps({"error": str(e)}).encode(),
364
+ json_data={"error": str(e), "exception_type": type(e).__name__},
365
+ )
366
+ responses.append(error_response)
367
+ return responses
368
+
369
+ def clear_cache(self):
370
+ """Clear the request cache if caching is enabled."""
371
+ if self._cache:
372
+ self._cache.clear()
373
+ if self.debug:
374
+ logger.debug("Cache cleared")
375
+
376
+ def cache_info(self) -> Optional[Dict]:
377
+ """Get cache statistics if caching is enabled."""
378
+ if self._cache:
379
+ return self._cache.cache_info()
380
+ return None
381
+
382
+ def cleanup_cache(self) -> int:
383
+ """Remove expired entries from cache if caching is enabled."""
384
+ if self._cache:
385
+ return self._cache.cleanup_expired()
386
+ return 0