assembly-api-client 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.
- assembly_api_client-0.1.0.dist-info/METADATA +142 -0
- assembly_api_client-0.1.0.dist-info/RECORD +14 -0
- assembly_api_client-0.1.0.dist-info/WHEEL +4 -0
- assembly_api_client-0.1.0.dist-info/entry_points.txt +2 -0
- assembly_client/__init__.py +5 -0
- assembly_client/api.py +265 -0
- assembly_client/cli.py +115 -0
- assembly_client/codegen/generator.py +120 -0
- assembly_client/errors.py +37 -0
- assembly_client/generated/__init__.py +555 -0
- assembly_client/generated/models.py +5860 -0
- assembly_client/generated/services.py +283 -0
- assembly_client/parser.py +466 -0
- assembly_client/sync.py +171 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: assembly-api-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A robust Python client for the Korean National Assembly Open API
|
|
5
|
+
Project-URL: Homepage, https://github.com/statpan/assembly-api-client
|
|
6
|
+
Project-URL: Repository, https://github.com/statpan/assembly-api-client
|
|
7
|
+
Author-email: Statpan <statpan@example.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: api,assembly,client,korea,open-api
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: httpx>=0.25.0
|
|
19
|
+
Requires-Dist: openpyxl>=3.1.0
|
|
20
|
+
Requires-Dist: platformdirs>=3.0.0
|
|
21
|
+
Requires-Dist: pydantic>=2.0.0
|
|
22
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
23
|
+
Requires-Dist: tenacity>=8.0.0
|
|
24
|
+
Requires-Dist: typer>=0.9.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pre-commit; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
29
|
+
Requires-Dist: python-semantic-release>=9.0.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: rich; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# Assembly API Client
|
|
35
|
+
# Assembly API Client (국회 오픈 API 클라이언트)
|
|
36
|
+
|
|
37
|
+
대한민국 국회 오픈 API(Open API)를 위한 강력하고 유연한 비동기 Python 클라이언트입니다.
|
|
38
|
+
|
|
39
|
+
## 주요 기능 (Features)
|
|
40
|
+
|
|
41
|
+
- **동적 스펙 파싱 (Dynamic Spec Parsing)**: 엑셀 명세서를 자동으로 다운로드하고 파싱하여 API 엔드포인트를 동적으로 해결합니다.
|
|
42
|
+
- **타입 안정성 (Type Safety)**: Pydantic 모델을 사용하여 데이터 타입을 검증하고 자동 완성 기능을 제공합니다. API의 불규칙한 데이터 타입(문자열/숫자 혼용)에도 유연하게 대응합니다.
|
|
43
|
+
- **강력한 복원력 (Resilience)**: 내장된 재시도(Retry) 로직과 에러 핸들링으로 안정적인 데이터 수집이 가능합니다.
|
|
44
|
+
- **자동화된 업데이트 (Automated Updates)**: 매주 자동으로 최신 API 명세를 동기화하고 코드를 재생성하는 CI/CD 파이프라인이 포함되어 있습니다.
|
|
45
|
+
- **CLI 도구**: API 명세 동기화 및 검색을 위한 커맨드라인 도구를 제공합니다.
|
|
46
|
+
|
|
47
|
+
## 설치 (Installation)
|
|
48
|
+
|
|
49
|
+
이 프로젝트는 [uv](https://github.com/astral-sh/uv)를 사용하여 의존성을 관리하는 것을 권장합니다.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# uv 설치 (없는 경우)
|
|
53
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
54
|
+
|
|
55
|
+
# 가상환경 생성 및 패키지 설치
|
|
56
|
+
uv venv
|
|
57
|
+
source .venv/bin/activate
|
|
58
|
+
uv pip install assembly-api-client
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## 사용법 (Usage)
|
|
62
|
+
|
|
63
|
+
### 1. API 키 설정 (API Key Configuration)
|
|
64
|
+
|
|
65
|
+
API 키는 두 가지 방식으로 설정할 수 있습니다.
|
|
66
|
+
|
|
67
|
+
**방법 A: 환경 변수 사용 (권장)**
|
|
68
|
+
`.env` 파일을 생성하거나 환경 변수를 설정합니다.
|
|
69
|
+
```bash
|
|
70
|
+
export ASSEMBLY_API_KEY="YOUR_API_KEY"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**방법 B: 클라이언트 직접 주입**
|
|
74
|
+
```python
|
|
75
|
+
client = AssemblyAPIClient(api_key="YOUR_API_KEY")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### 2. 기본 데이터 조회
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
import asyncio
|
|
82
|
+
from assembly_client.api import AssemblyAPIClient
|
|
83
|
+
from assembly_client.generated import Service
|
|
84
|
+
|
|
85
|
+
async def main():
|
|
86
|
+
# 환경 변수가 설정되어 있다면 api_key 생략 가능
|
|
87
|
+
async with AssemblyAPIClient() as client:
|
|
88
|
+
|
|
89
|
+
# 서비스 ID 또는 Enum을 사용하여 데이터 조회
|
|
90
|
+
# 예: 국회의원 발의법률안 조회
|
|
91
|
+
data = await client.get_data(Service.국회의원_발의법률안, params={"AGE": "21"})
|
|
92
|
+
|
|
93
|
+
for item in data:
|
|
94
|
+
print(f"법안명: {item.BILL_NAME}, 발의자: {item.PROPOSER}")
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
asyncio.run(main())
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### 3. CLI 사용 (uv 기반)
|
|
101
|
+
|
|
102
|
+
API 명세 동기화:
|
|
103
|
+
```bash
|
|
104
|
+
uv run python -m assembly_client.cli sync
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
사용 가능한 API 목록 조회:
|
|
108
|
+
```bash
|
|
109
|
+
uv run python -m assembly_client.cli list
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## 유지보수 (Maintenance)
|
|
113
|
+
|
|
114
|
+
### API 명세 및 Fixture 업데이트
|
|
115
|
+
국회 API는 수시로 변경되거나 새로운 서비스가 추가될 수 있습니다.
|
|
116
|
+
새로운 API가 추가되면 `sync` 명령어로 명세를 업데이트하고, 테스트를 위한 Fixture도 새로 받아야 합니다.
|
|
117
|
+
|
|
118
|
+
이 프로젝트는 매일 자동으로 변경사항을 확인하도록 설정되어 있습니다. 수동으로 업데이트하려면:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
# 1. 명세 동기화 및 코드 재생성
|
|
122
|
+
./scripts/update_client.sh
|
|
123
|
+
|
|
124
|
+
# 2. (필요시) 새로운 Fixture 생성
|
|
125
|
+
# 새로운 서비스가 추가되었다면 해당 서비스의 샘플 데이터를 받아 테스트에 추가해야 합니다.
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## 개발 및 기여 (Development)
|
|
129
|
+
|
|
130
|
+
### 테스트 실행
|
|
131
|
+
```bash
|
|
132
|
+
pytest
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### 코드 재생성 (수동)
|
|
136
|
+
```bash
|
|
137
|
+
./scripts/update_client.sh
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## 라이선스 (License)
|
|
141
|
+
|
|
142
|
+
MIT License
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
assembly_client/__init__.py,sha256=3QC1owaTDkmC_gZrMAVhSSR7QuglZlX0Od0B0jrJgyA,59
|
|
2
|
+
assembly_client/api.py,sha256=QD5h-AKBCJwpDPIJ0AIibSGbvRRRrdobg6kck4_ZTZk,10442
|
|
3
|
+
assembly_client/cli.py,sha256=_VInR2cLVnT5uf0MFZfWgHyqIQKz1j5JgmRpIw42b9w,3264
|
|
4
|
+
assembly_client/errors.py,sha256=118R1Bv3fpmZsF7aH_QKzQJYWuU9PJo14_nvDhTPWdA,1080
|
|
5
|
+
assembly_client/parser.py,sha256=Gij2WN_cqnQZw2EjpvboiqhEXoWt7Dy-3cGPSxLDIu8,19163
|
|
6
|
+
assembly_client/sync.py,sha256=tEPCOWiv4lf7ZX2NoxgqWbMH5-otbbAtotGqEFg-_bM,5782
|
|
7
|
+
assembly_client/codegen/generator.py,sha256=ostZwMODW2L4KdPPLd4hk7Ru6UKahslb4e8taLO_LZQ,4008
|
|
8
|
+
assembly_client/generated/__init__.py,sha256=-P-msuatqprRkO8qpBKK41M97SewX1ECV_DilmgU37M,29922
|
|
9
|
+
assembly_client/generated/models.py,sha256=o_2rPuKPWs9NAlMlFNsOOXD95ezIZn9VmqpCfiWzls8,348376
|
|
10
|
+
assembly_client/generated/services.py,sha256=3vtcTazE1xR37HsORTU2ua0Fnpm1DgPOnUvbA1OO0lc,17029
|
|
11
|
+
assembly_api_client-0.1.0.dist-info/METADATA,sha256=-75SUV8vS_YOGA02cSwH9OR-8f7-moQM6v8vRo_AXoA,4897
|
|
12
|
+
assembly_api_client-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
13
|
+
assembly_api_client-0.1.0.dist-info/entry_points.txt,sha256=_o-55CfTOZJ6s-W2rhS3sLauTiDFfIif1g2bXoCNjdQ,60
|
|
14
|
+
assembly_api_client-0.1.0.dist-info/RECORD,,
|
assembly_client/api.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any, Union
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
|
|
8
|
+
|
|
9
|
+
from .errors import AssemblyAPIError, SpecParseError
|
|
10
|
+
from .parser import APISpec, SpecParser, load_service_map, load_service_metadata
|
|
11
|
+
|
|
12
|
+
# Try to import generated types, but don't fail if not generated yet
|
|
13
|
+
try:
|
|
14
|
+
from .generated import MODEL_MAP, Service
|
|
15
|
+
|
|
16
|
+
HAS_GENERATED_TYPES = True
|
|
17
|
+
except ImportError:
|
|
18
|
+
HAS_GENERATED_TYPES = False
|
|
19
|
+
Service = None
|
|
20
|
+
MODEL_MAP = {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Configure logging
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_retryable_error(exception):
|
|
28
|
+
"""Check if the exception is retryable."""
|
|
29
|
+
if isinstance(exception, (httpx.NetworkError, httpx.TimeoutException)):
|
|
30
|
+
return True
|
|
31
|
+
if isinstance(exception, httpx.HTTPStatusError):
|
|
32
|
+
return exception.response.status_code in [429, 500, 502, 503, 504]
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AssemblyAPIClient:
|
|
37
|
+
"""Client for Korean National Assembly Open API."""
|
|
38
|
+
|
|
39
|
+
BASE_URL = "https://open.assembly.go.kr/portal/openapi"
|
|
40
|
+
|
|
41
|
+
def __init__(self, api_key: str | None = None, spec_parser: SpecParser | None = None):
|
|
42
|
+
"""
|
|
43
|
+
Initialize the Assembly API Client.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
api_key: API Key. If None, tries to read from ASSEMBLY_API_KEY env var.
|
|
47
|
+
spec_parser: Instance of SpecParser. If None, creates a default one.
|
|
48
|
+
"""
|
|
49
|
+
self.api_key = api_key or os.getenv("ASSEMBLY_API_KEY")
|
|
50
|
+
|
|
51
|
+
if not self.api_key:
|
|
52
|
+
logger.warning("ASSEMBLY_API_KEY is not set. Some APIs may fail.")
|
|
53
|
+
|
|
54
|
+
self.client = httpx.AsyncClient(timeout=30.0, follow_redirects=True)
|
|
55
|
+
self.spec_parser = spec_parser or SpecParser()
|
|
56
|
+
self.parsed_specs: dict[str, APISpec] = {}
|
|
57
|
+
|
|
58
|
+
# Load service map (ID -> Name) for name resolution
|
|
59
|
+
self.service_map = load_service_map(self.spec_parser.cache_dir)
|
|
60
|
+
# Create reverse map (Name -> ID)
|
|
61
|
+
self.name_to_id = {name: sid for sid, name in self.service_map.items()}
|
|
62
|
+
|
|
63
|
+
# Load comprehensive service metadata
|
|
64
|
+
self.service_metadata = load_service_metadata(self.spec_parser.cache_dir)
|
|
65
|
+
|
|
66
|
+
def search_services(self, keyword: str) -> dict[str, str]:
|
|
67
|
+
"""
|
|
68
|
+
Search for services by name or ID.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
keyword: Search term (case-insensitive).
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Dictionary of {service_id: service_name}
|
|
75
|
+
"""
|
|
76
|
+
results = {}
|
|
77
|
+
keyword = keyword.lower()
|
|
78
|
+
for sid, name in self.service_map.items():
|
|
79
|
+
if keyword in sid.lower() or keyword in name.lower():
|
|
80
|
+
results[sid] = name
|
|
81
|
+
return results
|
|
82
|
+
|
|
83
|
+
def _resolve_service_id(self, service_id_or_name: str) -> str:
|
|
84
|
+
"""Resolve a string to a Service ID."""
|
|
85
|
+
# 1. Check if it's a known ID
|
|
86
|
+
if service_id_or_name in self.service_map:
|
|
87
|
+
return service_id_or_name
|
|
88
|
+
|
|
89
|
+
# 2. Check if it's a known Name
|
|
90
|
+
if service_id_or_name in self.name_to_id:
|
|
91
|
+
return self.name_to_id[service_id_or_name]
|
|
92
|
+
|
|
93
|
+
# 3. If it looks like an ID (alphanumeric, long), assume it's an ID
|
|
94
|
+
# (Even if not in our cache, maybe it's new?)
|
|
95
|
+
if len(service_id_or_name) > 10 and service_id_or_name.isalnum():
|
|
96
|
+
return service_id_or_name
|
|
97
|
+
|
|
98
|
+
raise AssemblyAPIError("INVALID_ID", f"Could not resolve service: {service_id_or_name}")
|
|
99
|
+
|
|
100
|
+
async def close(self):
|
|
101
|
+
"""Close the underlying HTTP client."""
|
|
102
|
+
await self.client.aclose()
|
|
103
|
+
|
|
104
|
+
async def __aenter__(self):
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
108
|
+
await self.close()
|
|
109
|
+
|
|
110
|
+
async def get_endpoint(self, service_id: str) -> str:
|
|
111
|
+
"""
|
|
112
|
+
Get the actual API endpoint for a service ID.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
service_id: The service ID
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
The endpoint string
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
SpecParseError: If spec parsing fails
|
|
122
|
+
"""
|
|
123
|
+
if service_id not in self.parsed_specs:
|
|
124
|
+
logger.debug(f"Resolving endpoint for {service_id}")
|
|
125
|
+
|
|
126
|
+
# We don't have the master list loaded here to check for infSeq.
|
|
127
|
+
# But SpecParser defaults to infSeq=2 which works for most.
|
|
128
|
+
# If we need robust infSeq handling, we might need to look it up from a master list.
|
|
129
|
+
# For now, we'll stick to the default behavior of the original client
|
|
130
|
+
# which tried to look it up from self.specs (which was loaded from all_apis.json).
|
|
131
|
+
|
|
132
|
+
# TODO: Consider loading all_apis.json if it exists to get hints like infSeq.
|
|
133
|
+
|
|
134
|
+
spec = await self.spec_parser.parse_spec(service_id)
|
|
135
|
+
self.parsed_specs[service_id] = spec
|
|
136
|
+
|
|
137
|
+
return self.parsed_specs[service_id].endpoint
|
|
138
|
+
|
|
139
|
+
@retry(
|
|
140
|
+
stop=stop_after_attempt(3),
|
|
141
|
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
|
142
|
+
retry=retry_if_exception(_is_retryable_error),
|
|
143
|
+
)
|
|
144
|
+
async def get_data(
|
|
145
|
+
self, service_id_or_name: str | Service, params: dict[str, Any] | BaseModel = None, fmt: str = "json"
|
|
146
|
+
) -> Union[dict[str, Any], str, BaseModel, list[BaseModel]]:
|
|
147
|
+
"""
|
|
148
|
+
Fetch data from the API using dynamic endpoint resolution.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
service_id_or_name: The API service ID, Service Name, or Service Enum member.
|
|
152
|
+
params: Query parameters.
|
|
153
|
+
fmt: Response format ('json' or 'xml').
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Parsed JSON dict, raw XML string, or Pydantic Model (if available).
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
SpecParseError: If endpoint resolution fails
|
|
160
|
+
AssemblyAPIError: If API returns an error
|
|
161
|
+
"""
|
|
162
|
+
# Resolve ID
|
|
163
|
+
if HAS_GENERATED_TYPES and isinstance(service_id_or_name, Service):
|
|
164
|
+
service_id = service_id_or_name.value
|
|
165
|
+
else:
|
|
166
|
+
service_id = self._resolve_service_id(service_id_or_name)
|
|
167
|
+
|
|
168
|
+
# Handle Pydantic Params
|
|
169
|
+
if isinstance(params, BaseModel):
|
|
170
|
+
# Convert to dict using aliases (to match API param names)
|
|
171
|
+
# exclude_none=True because optional params shouldn't be sent if not set
|
|
172
|
+
params = params.model_dump(by_alias=True, exclude_none=True)
|
|
173
|
+
|
|
174
|
+
# Get actual endpoint from Excel spec
|
|
175
|
+
try:
|
|
176
|
+
endpoint = await self.get_endpoint(service_id)
|
|
177
|
+
except SpecParseError as e:
|
|
178
|
+
logger.error(f"Failed to get endpoint for {service_id}: {e}")
|
|
179
|
+
raise
|
|
180
|
+
|
|
181
|
+
# Build URL with actual endpoint
|
|
182
|
+
url = f"{self.BASE_URL}/{endpoint}"
|
|
183
|
+
|
|
184
|
+
# Add format parameter using Type param (not URL path)
|
|
185
|
+
default_params = {
|
|
186
|
+
"KEY": self.api_key,
|
|
187
|
+
"Type": fmt.lower(),
|
|
188
|
+
"pIndex": 1,
|
|
189
|
+
"pSize": 100,
|
|
190
|
+
}
|
|
191
|
+
merged_params = {**default_params, **(params or {})}
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
response = await self.client.get(url, params=merged_params)
|
|
195
|
+
response.raise_for_status()
|
|
196
|
+
|
|
197
|
+
if fmt.lower() == "json":
|
|
198
|
+
data = response.json()
|
|
199
|
+
self._check_api_error(data, endpoint)
|
|
200
|
+
|
|
201
|
+
# Try to convert to Pydantic model
|
|
202
|
+
if HAS_GENERATED_TYPES and service_id in MODEL_MAP:
|
|
203
|
+
try:
|
|
204
|
+
model_cls = MODEL_MAP[service_id]
|
|
205
|
+
# The data structure is usually {endpoint: [{head: ...}, {row: [...]}]}
|
|
206
|
+
# We want to parse the rows into a list of models?
|
|
207
|
+
# Or return a wrapper model?
|
|
208
|
+
# The generated model is for a SINGLE row item.
|
|
209
|
+
# So we should probably return a list of models.
|
|
210
|
+
# Extract rows
|
|
211
|
+
# The API returns a dict with a key equal to the service ID
|
|
212
|
+
# e.g. {"OK7XM...": [{"head": ...}, {"row": ...}]}
|
|
213
|
+
target_key = service_id
|
|
214
|
+
if service_id not in data:
|
|
215
|
+
# Fallback: look for a key that has the expected structure
|
|
216
|
+
# Expected: { "KEY": [ { "head": ... }, { "row": ... } ] }
|
|
217
|
+
for key, val in data.items():
|
|
218
|
+
if isinstance(val, list) and len(val) >= 2 and "row" in val[1]:
|
|
219
|
+
target_key = key
|
|
220
|
+
break
|
|
221
|
+
|
|
222
|
+
if target_key in data:
|
|
223
|
+
items = data[target_key][1]["row"]
|
|
224
|
+
return [model_cls(**row) for row in items]
|
|
225
|
+
else:
|
|
226
|
+
# If we can't find the rows, return raw data
|
|
227
|
+
return data
|
|
228
|
+
except Exception as e:
|
|
229
|
+
logger.warning(f"Failed to parse response into model {service_id}: {e}")
|
|
230
|
+
# Fallback to raw dict
|
|
231
|
+
|
|
232
|
+
return data
|
|
233
|
+
else:
|
|
234
|
+
return response.text
|
|
235
|
+
|
|
236
|
+
except httpx.HTTPStatusError as e:
|
|
237
|
+
logger.error(f"HTTP error: {e.response.status_code} - {e.response.text}")
|
|
238
|
+
raise AssemblyAPIError(str(e.response.status_code), str(e)) from e
|
|
239
|
+
except Exception as e:
|
|
240
|
+
logger.error(f"API request failed: {e}")
|
|
241
|
+
raise AssemblyAPIError("UNKNOWN", str(e)) from e
|
|
242
|
+
|
|
243
|
+
def _check_api_error(self, data: dict[str, Any], endpoint: str):
|
|
244
|
+
"""Check for API specific error codes."""
|
|
245
|
+
# The response key is usually the endpoint name (e.g. "nzmimeepazxkubdpn")
|
|
246
|
+
|
|
247
|
+
if endpoint in data:
|
|
248
|
+
items = data[endpoint]
|
|
249
|
+
for item in items:
|
|
250
|
+
if "head" in item:
|
|
251
|
+
for head_item in item["head"]:
|
|
252
|
+
if "RESULT" in head_item:
|
|
253
|
+
result = head_item["RESULT"]
|
|
254
|
+
code = result.get("CODE")
|
|
255
|
+
message = result.get("MESSAGE")
|
|
256
|
+
|
|
257
|
+
# Check for specific error codes
|
|
258
|
+
if code in ["INFO-200", "INFO-290", "INFO-300", "INFO-337"]:
|
|
259
|
+
logger.info(f"API Result: {code} - {message}")
|
|
260
|
+
if code == "INFO-200":
|
|
261
|
+
return # No data is valid result
|
|
262
|
+
raise AssemblyAPIError(code, message)
|
|
263
|
+
|
|
264
|
+
if code != "INFO-000":
|
|
265
|
+
raise AssemblyAPIError(code, message)
|
assembly_client/cli.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command Line Interface for Assembly API Client.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from .parser import SpecParser
|
|
14
|
+
from .sync import load_service_map, sync_all_services
|
|
15
|
+
|
|
16
|
+
# Configure logging
|
|
17
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
18
|
+
logger = logging.getLogger("assembly_client")
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(help="Assembly API Client CLI")
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_parser() -> SpecParser:
|
|
25
|
+
"""Get a configured SpecParser."""
|
|
26
|
+
return SpecParser()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command()
|
|
30
|
+
def sync(
|
|
31
|
+
api_key: str = typer.Option(..., envvar="ASSEMBLY_API_KEY", help="API Key"),
|
|
32
|
+
limit: Optional[int] = typer.Option(None, help="Limit number of services to sync"),
|
|
33
|
+
force: bool = typer.Option(False, help="Force update master list"),
|
|
34
|
+
):
|
|
35
|
+
"""
|
|
36
|
+
Synchronize API specifications.
|
|
37
|
+
Downloads the master list and individual service specs.
|
|
38
|
+
"""
|
|
39
|
+
parser = get_parser()
|
|
40
|
+
console.print(f"[bold green]Starting sync...[/bold green] (Cache: {parser.cache_dir})")
|
|
41
|
+
|
|
42
|
+
async def run_sync():
|
|
43
|
+
stats = await sync_all_services(api_key=api_key, parser=parser, limit=limit, force_update_list=force)
|
|
44
|
+
return stats
|
|
45
|
+
|
|
46
|
+
stats = asyncio.run(run_sync())
|
|
47
|
+
|
|
48
|
+
console.print("\n[bold]Sync Complete[/bold]")
|
|
49
|
+
console.print(f"Updated: [green]{stats['updated']}[/green]")
|
|
50
|
+
console.print(f"Failed: [red]{stats['failed']}[/red]")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command("list")
|
|
54
|
+
def list_apis(
|
|
55
|
+
search: Optional[str] = typer.Option(None, help="Filter by name or ID"),
|
|
56
|
+
):
|
|
57
|
+
"""
|
|
58
|
+
List available APIs from the cached master list.
|
|
59
|
+
"""
|
|
60
|
+
parser = get_parser()
|
|
61
|
+
service_map = load_service_map(parser.cache_dir)
|
|
62
|
+
|
|
63
|
+
if not service_map:
|
|
64
|
+
console.print("[yellow]No APIs found. Run 'sync' first.[/yellow]")
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
table = Table(title="Available APIs")
|
|
68
|
+
table.add_column("Service ID", style="cyan")
|
|
69
|
+
table.add_column("Name", style="green")
|
|
70
|
+
|
|
71
|
+
count = 0
|
|
72
|
+
for sid, name in sorted(service_map.items()):
|
|
73
|
+
if search and (search.lower() not in sid.lower() and search.lower() not in name.lower()):
|
|
74
|
+
continue
|
|
75
|
+
table.add_row(sid, name)
|
|
76
|
+
count += 1
|
|
77
|
+
|
|
78
|
+
console.print(table)
|
|
79
|
+
console.print(f"Total: {count} APIs")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command()
|
|
83
|
+
def info(service_id: str):
|
|
84
|
+
"""
|
|
85
|
+
Show details for a specific API service.
|
|
86
|
+
"""
|
|
87
|
+
parser = get_parser()
|
|
88
|
+
|
|
89
|
+
async def get_spec():
|
|
90
|
+
return await parser.parse_spec(service_id)
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
spec = asyncio.run(get_spec())
|
|
94
|
+
|
|
95
|
+
console.print(f"[bold]Service ID:[/bold] {spec.service_id}")
|
|
96
|
+
console.print(f"[bold]Endpoint:[/bold] {spec.endpoint}")
|
|
97
|
+
console.print(f"[bold]URL:[/bold] {spec.endpoint_url}")
|
|
98
|
+
|
|
99
|
+
table = Table(title="Request Parameters")
|
|
100
|
+
table.add_column("Name", style="cyan")
|
|
101
|
+
table.add_column("Type", style="magenta")
|
|
102
|
+
table.add_column("Required", style="red")
|
|
103
|
+
table.add_column("Description")
|
|
104
|
+
|
|
105
|
+
for p in spec.request_params:
|
|
106
|
+
table.add_row(p.name, p.type, "Yes" if p.required else "No", p.description)
|
|
107
|
+
|
|
108
|
+
console.print(table)
|
|
109
|
+
|
|
110
|
+
except Exception as e:
|
|
111
|
+
console.print(f"[red]Error fetching spec for {service_id}: {e}[/red]")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
app()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Code generation utilities for Assembly API Client.
|
|
3
|
+
"""
|
|
4
|
+
import keyword
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..parser import APISpec, load_service_map
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def sanitize_name(name: str) -> str:
|
|
12
|
+
"""Sanitize a string to be a valid Python identifier."""
|
|
13
|
+
# Remove invalid characters
|
|
14
|
+
name = re.sub(r"[^a-zA-Z0-9_]", "", name)
|
|
15
|
+
# Ensure it doesn't start with a number
|
|
16
|
+
if name[0].isdigit():
|
|
17
|
+
name = f"_{name}"
|
|
18
|
+
# Check for keywords
|
|
19
|
+
if keyword.iskeyword(name):
|
|
20
|
+
name = f"{name}_"
|
|
21
|
+
return name
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def generate_services_enum(cache_dir: Path) -> str:
|
|
25
|
+
"""Generate the Service enum code."""
|
|
26
|
+
service_map = load_service_map(cache_dir)
|
|
27
|
+
|
|
28
|
+
lines = [
|
|
29
|
+
"from enum import StrEnum",
|
|
30
|
+
"",
|
|
31
|
+
"class Service(StrEnum):",
|
|
32
|
+
' """Enumeration of all available Assembly API Services."""',
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
# Sort by name for stability
|
|
36
|
+
# We need to create valid enum member names from the service names
|
|
37
|
+
# e.g. "국회의원발의법률안" -> "BILL_INFO"? No, that's hard to automate perfectly.
|
|
38
|
+
# Maybe we just use the Service ID as the value, and try to make a readable key?
|
|
39
|
+
# Or just use the ID as the key if we can't make a good name?
|
|
40
|
+
# User wants "readable".
|
|
41
|
+
# Let's try to transliterate or just use the Korean name if Python 3 supports unicode identifiers (it does!)
|
|
42
|
+
# But usually English is preferred.
|
|
43
|
+
# Since we don't have English names, maybe we can use the Korean name as the key?
|
|
44
|
+
# Python 3 allows unicode variable names.
|
|
45
|
+
# Class attributes can be unicode.
|
|
46
|
+
|
|
47
|
+
# Let's try to make English-like keys if possible, or just use the Korean name sanitized?
|
|
48
|
+
# "국회의원발의법률안" -> valid python identifier? Yes.
|
|
49
|
+
|
|
50
|
+
for service_id, name in sorted(service_map.items(), key=lambda x: x[1]):
|
|
51
|
+
# Sanitize name for Python identifier
|
|
52
|
+
# Remove spaces, special chars
|
|
53
|
+
safe_name = re.sub(r"[^a-zA-Z0-9가-힣]", "_", name)
|
|
54
|
+
|
|
55
|
+
# If it starts with digit, prefix
|
|
56
|
+
if safe_name[0].isdigit():
|
|
57
|
+
safe_name = f"_{safe_name}"
|
|
58
|
+
|
|
59
|
+
lines.append(f' {safe_name} = "{service_id}"')
|
|
60
|
+
|
|
61
|
+
return "\n".join(lines)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def generate_model_code(spec: APISpec) -> str:
|
|
65
|
+
"""Generate Pydantic model code for a single spec."""
|
|
66
|
+
class_name = f"Model_{spec.service_id}"
|
|
67
|
+
|
|
68
|
+
lines = [
|
|
69
|
+
f"class {class_name}(BaseModel):",
|
|
70
|
+
f' """Response model for {spec.service_id}"""',
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
if not spec.response_fields:
|
|
74
|
+
lines.append(" pass")
|
|
75
|
+
return "\n".join(lines)
|
|
76
|
+
|
|
77
|
+
for field in spec.response_fields:
|
|
78
|
+
field_name = sanitize_name(field.name)
|
|
79
|
+
# Determine type (default to Union[str, int, float, None] for safety)
|
|
80
|
+
# API often returns numbers as strings or vice versa, so we must be lenient.
|
|
81
|
+
py_type = "Union[str, int, float, None]"
|
|
82
|
+
|
|
83
|
+
lines.append(
|
|
84
|
+
f' {field_name}: {py_type} = Field(None, description="{field.description}", alias="{field.name}")'
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return "\n".join(lines)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def generate_params_model_code(spec: APISpec) -> str:
|
|
91
|
+
"""Generate Pydantic model code for request parameters."""
|
|
92
|
+
class_name = f"Params_{spec.service_id}"
|
|
93
|
+
|
|
94
|
+
lines = [
|
|
95
|
+
f"class {class_name}(BaseModel):",
|
|
96
|
+
f' """Request parameters for {spec.service_id}"""',
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
if not spec.request_params:
|
|
100
|
+
lines.append(" pass")
|
|
101
|
+
return "\n".join(lines)
|
|
102
|
+
|
|
103
|
+
for param in spec.request_params:
|
|
104
|
+
field_name = sanitize_name(param.name)
|
|
105
|
+
|
|
106
|
+
# Determine type
|
|
107
|
+
# Most params are strings in this API, even numbers are often passed as strings
|
|
108
|
+
# But we can try to be smarter if needed. For now, str is safest.
|
|
109
|
+
py_type = "str"
|
|
110
|
+
|
|
111
|
+
default_val = "..." if param.required else "None"
|
|
112
|
+
if not param.required:
|
|
113
|
+
py_type += " | None"
|
|
114
|
+
|
|
115
|
+
lines.append(
|
|
116
|
+
f" {field_name}: {py_type} = Field({default_val}, "
|
|
117
|
+
f'description="{param.description}", alias="{param.name}")'
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return "\n".join(lines)
|