opendart-fss 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.
@@ -0,0 +1,255 @@
1
+ """검증 결과 리포트 생성."""
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime
6
+ from typing import Literal
7
+
8
+ from opendart_fss.verification.config import (
9
+ CATEGORY_DESCRIPTIONS,
10
+ VerificationResult,
11
+ VerificationStatus,
12
+ )
13
+
14
+
15
+ @dataclass
16
+ class VerificationReport:
17
+ """검증 리포트."""
18
+
19
+ timestamp: str
20
+ duration_seconds: float
21
+ total_endpoints: int
22
+ success_count: int
23
+ no_data_count: int
24
+ failed_count: int
25
+ skipped_count: int
26
+ results: list[VerificationResult] = field(default_factory=list)
27
+
28
+ @property
29
+ def success_rate(self) -> float:
30
+ """성공률 (SUCCESS + NO_DATA)."""
31
+ if self.total_endpoints == 0:
32
+ return 0.0
33
+ return ((self.success_count + self.no_data_count) / self.total_endpoints) * 100
34
+
35
+ @classmethod
36
+ def from_results(
37
+ cls,
38
+ results: list[VerificationResult],
39
+ duration_seconds: float,
40
+ ) -> "VerificationReport":
41
+ """검증 결과로부터 리포트 생성.
42
+
43
+ Args:
44
+ results: 검증 결과 목록
45
+ duration_seconds: 총 소요 시간 (초)
46
+
47
+ Returns:
48
+ 검증 리포트
49
+ """
50
+ success_count = sum(
51
+ 1 for r in results if r.status == VerificationStatus.SUCCESS
52
+ )
53
+ no_data_count = sum(
54
+ 1 for r in results if r.status == VerificationStatus.NO_DATA
55
+ )
56
+ failed_count = sum(1 for r in results if r.status == VerificationStatus.FAILED)
57
+ skipped_count = sum(
58
+ 1 for r in results if r.status == VerificationStatus.SKIPPED
59
+ )
60
+
61
+ return cls(
62
+ timestamp=datetime.now().isoformat(timespec="seconds"),
63
+ duration_seconds=duration_seconds,
64
+ total_endpoints=len(results),
65
+ success_count=success_count,
66
+ no_data_count=no_data_count,
67
+ failed_count=failed_count,
68
+ skipped_count=skipped_count,
69
+ results=results,
70
+ )
71
+
72
+
73
+ def generate_report(
74
+ results: list[VerificationResult],
75
+ duration_seconds: float,
76
+ format: Literal["console", "json", "markdown"] = "console",
77
+ ) -> str:
78
+ """검증 결과 리포트 생성.
79
+
80
+ Args:
81
+ results: 검증 결과 목록
82
+ duration_seconds: 총 소요 시간 (초)
83
+ format: 출력 형식 (console/json/markdown)
84
+
85
+ Returns:
86
+ 포맷팅된 리포트 문자열
87
+ """
88
+ report = VerificationReport.from_results(results, duration_seconds)
89
+
90
+ if format == "json":
91
+ return _generate_json(report)
92
+ elif format == "markdown":
93
+ return _generate_markdown(report)
94
+ else:
95
+ return _generate_console(report)
96
+
97
+
98
+ def _generate_console(report: VerificationReport) -> str:
99
+ """콘솔 출력 형식 리포트 생성."""
100
+ lines = [
101
+ "=" * 60,
102
+ "OpenDART API Endpoint Verification Report",
103
+ "=" * 60,
104
+ f"Timestamp: {report.timestamp}",
105
+ f"Duration: {report.duration_seconds:.1f}s",
106
+ "",
107
+ "Summary:",
108
+ f" Total Endpoints: {report.total_endpoints}",
109
+ f" Successful: {report.success_count} "
110
+ f"({report.success_count / report.total_endpoints * 100:.1f}%)"
111
+ if report.total_endpoints > 0
112
+ else " Successful: 0",
113
+ f" No Data: {report.no_data_count} "
114
+ f"({report.no_data_count / report.total_endpoints * 100:.1f}%)"
115
+ if report.total_endpoints > 0
116
+ else " No Data: 0",
117
+ f" Failed: {report.failed_count} "
118
+ f"({report.failed_count / report.total_endpoints * 100:.1f}%)"
119
+ if report.total_endpoints > 0
120
+ else " Failed: 0",
121
+ f" Skipped: {report.skipped_count} "
122
+ f"({report.skipped_count / report.total_endpoints * 100:.1f}%)"
123
+ if report.total_endpoints > 0
124
+ else " Skipped: 0",
125
+ "",
126
+ ]
127
+
128
+ # 카테고리별 결과 출력
129
+ current_category = None
130
+ for result in report.results:
131
+ if result.category != current_category:
132
+ current_category = result.category
133
+ desc = CATEGORY_DESCRIPTIONS.get(current_category, "")
134
+ lines.append(f"{current_category} - {desc}:")
135
+
136
+ status_icon = _get_status_icon(result.status)
137
+ time_str = (
138
+ f"({result.response_time_ms:.0f}ms)" if result.response_time_ms else ""
139
+ )
140
+
141
+ if result.error_message:
142
+ lines.append(f" [{status_icon}] {result.endpoint_name} {time_str}")
143
+ lines.append(f" {result.error_message}")
144
+ else:
145
+ lines.append(f" [{status_icon}] {result.endpoint_name} {time_str}")
146
+
147
+ lines.append("")
148
+ lines.append("=" * 60)
149
+
150
+ return "\n".join(lines)
151
+
152
+
153
+ def _generate_json(report: VerificationReport) -> str:
154
+ """JSON 형식 리포트 생성."""
155
+ data = {
156
+ "timestamp": report.timestamp,
157
+ "duration_seconds": report.duration_seconds,
158
+ "summary": {
159
+ "total": report.total_endpoints,
160
+ "success": report.success_count,
161
+ "no_data": report.no_data_count,
162
+ "failed": report.failed_count,
163
+ "skipped": report.skipped_count,
164
+ "success_rate": report.success_rate,
165
+ },
166
+ "results": [
167
+ {
168
+ "id": r.endpoint_id,
169
+ "name": r.endpoint_name,
170
+ "category": r.category,
171
+ "status": r.status.value,
172
+ "response_time_ms": r.response_time_ms,
173
+ "error_message": r.error_message,
174
+ "response_data": r.response_data,
175
+ }
176
+ for r in report.results
177
+ ],
178
+ }
179
+ return json.dumps(data, indent=2, ensure_ascii=False)
180
+
181
+
182
+ def _generate_markdown(report: VerificationReport) -> str:
183
+ """마크다운 형식 리포트 생성."""
184
+ lines = [
185
+ "# OpenDART API Endpoint Verification Report",
186
+ "",
187
+ f"- **Timestamp:** {report.timestamp}",
188
+ f"- **Duration:** {report.duration_seconds:.1f}s",
189
+ "",
190
+ "## Summary",
191
+ "",
192
+ "| Metric | Count | Percentage |",
193
+ "|--------|-------|------------|",
194
+ ]
195
+
196
+ if report.total_endpoints > 0:
197
+ lines.extend(
198
+ [
199
+ f"| Total | {report.total_endpoints} | 100% |",
200
+ f"| Success | {report.success_count} | "
201
+ f"{report.success_count / report.total_endpoints * 100:.1f}% |",
202
+ f"| No Data | {report.no_data_count} | "
203
+ f"{report.no_data_count / report.total_endpoints * 100:.1f}% |",
204
+ f"| Failed | {report.failed_count} | "
205
+ f"{report.failed_count / report.total_endpoints * 100:.1f}% |",
206
+ f"| Skipped | {report.skipped_count} | "
207
+ f"{report.skipped_count / report.total_endpoints * 100:.1f}% |",
208
+ ]
209
+ )
210
+ else:
211
+ lines.append("| Total | 0 | 0% |")
212
+
213
+ lines.extend(["", "## Details", ""])
214
+
215
+ # 카테고리별 결과
216
+ current_category = None
217
+ for result in report.results:
218
+ if result.category != current_category:
219
+ current_category = result.category
220
+ desc = CATEGORY_DESCRIPTIONS.get(current_category, "")
221
+ lines.extend(["", f"### {current_category} - {desc}", ""])
222
+ lines.append("| Status | Endpoint | Time (ms) | Notes |")
223
+ lines.append("|--------|----------|-----------|-------|")
224
+
225
+ status_badge = _get_status_badge(result.status)
226
+ time_str = f"{result.response_time_ms:.0f}" if result.response_time_ms else "-"
227
+ notes = result.error_message or "-"
228
+
229
+ lines.append(
230
+ f"| {status_badge} | {result.endpoint_name} | {time_str} | {notes} |"
231
+ )
232
+
233
+ return "\n".join(lines)
234
+
235
+
236
+ def _get_status_icon(status: VerificationStatus) -> str:
237
+ """상태에 따른 아이콘 반환."""
238
+ icons = {
239
+ VerificationStatus.SUCCESS: "OK",
240
+ VerificationStatus.NO_DATA: "NO_DATA",
241
+ VerificationStatus.FAILED: "FAIL",
242
+ VerificationStatus.SKIPPED: "SKIP",
243
+ }
244
+ return icons.get(status, "?")
245
+
246
+
247
+ def _get_status_badge(status: VerificationStatus) -> str:
248
+ """상태에 따른 마크다운 배지 반환."""
249
+ badges = {
250
+ VerificationStatus.SUCCESS: "SUCCESS",
251
+ VerificationStatus.NO_DATA: "NO_DATA",
252
+ VerificationStatus.FAILED: "FAILED",
253
+ VerificationStatus.SKIPPED: "SKIPPED",
254
+ }
255
+ return badges.get(status, "UNKNOWN")
@@ -0,0 +1,326 @@
1
+ """엔드포인트 검증 실행기."""
2
+
3
+ import time
4
+ from typing import Any
5
+
6
+ from opendart_fss.client import OpenDartClient
7
+ from opendart_fss.exceptions import (
8
+ APIError,
9
+ NotFoundError,
10
+ RateLimitError,
11
+ ValidationError,
12
+ )
13
+ from opendart_fss.verification.config import (
14
+ DEFAULT_TEST_DATA,
15
+ ENDPOINT_CONFIGS,
16
+ EndpointConfig,
17
+ VerificationResult,
18
+ VerificationStatus,
19
+ )
20
+ from opendart_fss.verification.rate_limiter import AdaptiveRateLimiter
21
+
22
+
23
+ class EndpointVerifier:
24
+ """OpenDART API 엔드포인트 검증기.
25
+
26
+ Example:
27
+ ```python
28
+ async with EndpointVerifier() as verifier:
29
+ # 모든 엔드포인트 검증
30
+ results = await verifier.verify_all()
31
+
32
+ # 특정 카테고리만 검증
33
+ results = await verifier.verify_category("DS001")
34
+
35
+ # 특정 엔드포인트만 검증
36
+ result = await verifier.verify_endpoint("DS001-01")
37
+ ```
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ api_key: str | None = None,
43
+ test_data: dict[str, str] | None = None,
44
+ max_retries: int = 3,
45
+ ) -> None:
46
+ """검증기 초기화.
47
+
48
+ Args:
49
+ api_key: OpenDART API 키 (환경변수에서 자동 로드 가능)
50
+ test_data: 테스트 데이터 (기본값 사용 가능)
51
+ max_retries: Rate limit 시 최대 재시도 횟수
52
+ """
53
+ self._api_key = api_key
54
+ self._test_data = {**DEFAULT_TEST_DATA, **(test_data or {})}
55
+ self._max_retries = max_retries
56
+ self._rate_limiter = AdaptiveRateLimiter()
57
+ self._client: OpenDartClient | None = None
58
+ self._rcept_no: str | None = None # 공시 검색에서 획득
59
+
60
+ async def __aenter__(self) -> "EndpointVerifier":
61
+ """컨텍스트 매니저 진입."""
62
+ self._client = OpenDartClient(api_key=self._api_key)
63
+ await self._client.__aenter__()
64
+ return self
65
+
66
+ async def __aexit__(self, *args: Any) -> None:
67
+ """컨텍스트 매니저 종료."""
68
+ if self._client:
69
+ await self._client.__aexit__(*args)
70
+
71
+ async def verify_all(
72
+ self,
73
+ category: str | None = None,
74
+ ) -> list[VerificationResult]:
75
+ """모든 엔드포인트 검증.
76
+
77
+ Args:
78
+ category: 특정 카테고리만 검증 (예: "DS001")
79
+
80
+ Returns:
81
+ 검증 결과 목록
82
+ """
83
+ configs = ENDPOINT_CONFIGS
84
+ if category:
85
+ configs = [c for c in configs if c.category == category]
86
+
87
+ results: list[VerificationResult] = []
88
+ for config in configs:
89
+ result = await self._verify_with_retry(config)
90
+ results.append(result)
91
+
92
+ return results
93
+
94
+ async def verify_category(self, category: str) -> list[VerificationResult]:
95
+ """특정 카테고리의 모든 엔드포인트 검증.
96
+
97
+ Args:
98
+ category: 카테고리 ID (예: "DS001")
99
+
100
+ Returns:
101
+ 검증 결과 목록
102
+ """
103
+ return await self.verify_all(category=category)
104
+
105
+ async def verify_endpoint(self, endpoint_id: str) -> VerificationResult:
106
+ """특정 엔드포인트 검증.
107
+
108
+ Args:
109
+ endpoint_id: 엔드포인트 ID (예: "DS001-01")
110
+
111
+ Returns:
112
+ 검증 결과
113
+ """
114
+ config = next(
115
+ (c for c in ENDPOINT_CONFIGS if c.id == endpoint_id),
116
+ None,
117
+ )
118
+ if not config:
119
+ return VerificationResult(
120
+ endpoint_id=endpoint_id,
121
+ endpoint_name="unknown",
122
+ category="unknown",
123
+ status=VerificationStatus.FAILED,
124
+ error_message=f"Unknown endpoint: {endpoint_id}",
125
+ )
126
+
127
+ return await self._verify_with_retry(config)
128
+
129
+ async def _verify_with_retry(
130
+ self,
131
+ config: EndpointConfig,
132
+ ) -> VerificationResult:
133
+ """재시도 로직을 포함한 엔드포인트 검증.
134
+
135
+ Args:
136
+ config: 엔드포인트 설정
137
+
138
+ Returns:
139
+ 검증 결과
140
+ """
141
+ for attempt in range(self._max_retries + 1):
142
+ await self._rate_limiter.wait()
143
+
144
+ result = await self._verify_single(config)
145
+
146
+ if result.status == VerificationStatus.FAILED:
147
+ if "rate limit" in (result.error_message or "").lower():
148
+ self._rate_limiter.on_rate_limit()
149
+ if attempt < self._max_retries:
150
+ continue
151
+ result.error_message = (
152
+ f"{result.error_message} (after {attempt + 1} retries)"
153
+ )
154
+ else:
155
+ self._rate_limiter.on_success()
156
+
157
+ return result
158
+
159
+ return result
160
+
161
+ async def _verify_single(self, config: EndpointConfig) -> VerificationResult:
162
+ """단일 엔드포인트 검증.
163
+
164
+ Args:
165
+ config: 엔드포인트 설정
166
+
167
+ Returns:
168
+ 검증 결과
169
+ """
170
+ if not self._client:
171
+ return VerificationResult(
172
+ endpoint_id=config.id,
173
+ endpoint_name=config.name,
174
+ category=config.category,
175
+ status=VerificationStatus.FAILED,
176
+ error_message="Client not initialized. Use async with.",
177
+ )
178
+
179
+ # rcept_no가 필요한데 없으면 스킵
180
+ if config.requires_rcept_no and not self._rcept_no:
181
+ return VerificationResult(
182
+ endpoint_id=config.id,
183
+ endpoint_name=config.name,
184
+ category=config.category,
185
+ status=VerificationStatus.SKIPPED,
186
+ error_message="Requires rcept_no from disclosure.search",
187
+ )
188
+
189
+ # 파라미터 치환
190
+ params = self._resolve_params(config.params)
191
+
192
+ start_time = time.perf_counter()
193
+ try:
194
+ # 서비스와 메서드 가져오기
195
+ service = getattr(self._client, config.service)
196
+ method = getattr(service, config.method)
197
+
198
+ # API 호출
199
+ result = await method(**params)
200
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
201
+
202
+ # 결과 분석
203
+ status = self._analyze_result(result)
204
+
205
+ # 공시 검색 결과에서 rcept_no 저장
206
+ if config.method == "search" and result:
207
+ self._rcept_no = result[0].rcept_no
208
+
209
+ return VerificationResult(
210
+ endpoint_id=config.id,
211
+ endpoint_name=config.name,
212
+ category=config.category,
213
+ status=status,
214
+ response_time_ms=elapsed_ms,
215
+ response_data=self._summarize_result(result),
216
+ )
217
+
218
+ except NotFoundError as e:
219
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
220
+ return VerificationResult(
221
+ endpoint_id=config.id,
222
+ endpoint_name=config.name,
223
+ category=config.category,
224
+ status=VerificationStatus.NO_DATA,
225
+ response_time_ms=elapsed_ms,
226
+ error_message=str(e),
227
+ )
228
+
229
+ except RateLimitError as e:
230
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
231
+ return VerificationResult(
232
+ endpoint_id=config.id,
233
+ endpoint_name=config.name,
234
+ category=config.category,
235
+ status=VerificationStatus.FAILED,
236
+ response_time_ms=elapsed_ms,
237
+ error_message=f"Rate limit: {e}",
238
+ )
239
+
240
+ except ValidationError as e:
241
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
242
+ return VerificationResult(
243
+ endpoint_id=config.id,
244
+ endpoint_name=config.name,
245
+ category=config.category,
246
+ status=VerificationStatus.FAILED,
247
+ response_time_ms=elapsed_ms,
248
+ error_message=f"Validation error: {e}",
249
+ )
250
+
251
+ except APIError as e:
252
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
253
+ return VerificationResult(
254
+ endpoint_id=config.id,
255
+ endpoint_name=config.name,
256
+ category=config.category,
257
+ status=VerificationStatus.FAILED,
258
+ response_time_ms=elapsed_ms,
259
+ error_message=f"API error ({e.status}): {e}",
260
+ )
261
+
262
+ except Exception as e:
263
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
264
+ return VerificationResult(
265
+ endpoint_id=config.id,
266
+ endpoint_name=config.name,
267
+ category=config.category,
268
+ status=VerificationStatus.FAILED,
269
+ response_time_ms=elapsed_ms,
270
+ error_message=str(e),
271
+ )
272
+
273
+ def _resolve_params(self, params: dict[str, str]) -> dict[str, Any]:
274
+ """파라미터 템플릿을 실제 값으로 치환.
275
+
276
+ Args:
277
+ params: 템플릿 파라미터
278
+
279
+ Returns:
280
+ 치환된 파라미터
281
+ """
282
+ resolved = {}
283
+ for key, value in params.items():
284
+ if value.startswith("{") and value.endswith("}"):
285
+ param_name = value[1:-1]
286
+ if param_name == "rcept_no":
287
+ resolved[key] = self._rcept_no
288
+ else:
289
+ resolved[key] = self._test_data.get(param_name, value)
290
+ else:
291
+ resolved[key] = value
292
+ return resolved
293
+
294
+ def _analyze_result(self, result: Any) -> VerificationStatus:
295
+ """결과를 분석하여 상태 반환.
296
+
297
+ Args:
298
+ result: API 응답
299
+
300
+ Returns:
301
+ 검증 상태
302
+ """
303
+ if result is None:
304
+ return VerificationStatus.NO_DATA
305
+ if isinstance(result, (list, tuple)):
306
+ return VerificationStatus.SUCCESS if result else VerificationStatus.NO_DATA
307
+ if isinstance(result, bytes):
308
+ return VerificationStatus.SUCCESS if result else VerificationStatus.NO_DATA
309
+ return VerificationStatus.SUCCESS
310
+
311
+ def _summarize_result(self, result: Any) -> dict[str, Any]:
312
+ """결과 요약.
313
+
314
+ Args:
315
+ result: API 응답
316
+
317
+ Returns:
318
+ 요약된 결과
319
+ """
320
+ if result is None:
321
+ return {"type": "null"}
322
+ if isinstance(result, bytes):
323
+ return {"type": "bytes", "size": len(result)}
324
+ if isinstance(result, (list, tuple)):
325
+ return {"type": "list", "count": len(result)}
326
+ return {"type": type(result).__name__}