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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ assembly-client = assembly_client.cli:app
@@ -0,0 +1,5 @@
1
+ """
2
+ Assembly API Client Library
3
+ """
4
+
5
+ __version__ = "0.1.0"
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)