Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# 주차장 완결 raw 집합 추가

- public parking_raw_set과 ParkingRawSet으로 total·페이지·수신량·중복·상한을 검증한다. 기존 API는 유지한다.

# Changelog

## 미배포 — KAC 운항 GW
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -465,3 +465,9 @@ tests/
## 변경 이력

- `0.1.0`: KAC/IIAC 통합 클라이언트, provider adapter, KST 시간/타입 변환, XML/JSON 파서, CLI, fixture 기반 테스트와 문서 보강.

## 주차장 완결 집합 API

`await client.parking_raw_set(provider="kac", page_size=100, max_pages=10)`은 원천 total과
실제 페이지 수신을 검증한 `ParkingRawSet`을 반환한다. IIAC는 provider="iiac"를 쓴다.
기존 목록 API는 그대로다. [완결성·오류·상한 계약](docs/parking-full-set.md)을 참고한다.
6 changes: 6 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,9 @@ Optional live test는 `live_kac` 또는 `live_iiac` marker를 사용하고 match
## Debug UI fixture 규칙

Streamlit과 UI 전용 의존성은 `krairport` package 밖에 둔다. `KrairportClient.debug()` 또는 `debug_*` helper로 `DebugRun`을 만들고, 생성된 case는 `tests/fixtures/{function}/{case}.json`에 저장한다. 기본 pytest replay는 외부 API를 호출하지 않고 `tests/runners.py`를 사용한다.

## 주차장 완결 집합 API

`await client.parking_raw_set(provider="kac", page_size=100, max_pages=10)`은 원천 total과
실제 페이지 수신을 검증한 `ParkingRawSet`을 반환한다. IIAC는 provider="iiac"를 쓴다.
기존 목록 API는 그대로다. [완결성·오류·상한 계약](docs/parking-full-set.md)을 참고한다.
25 changes: 25 additions & 0 deletions docs/parking-full-set.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# 주차장 전체 원시 집합의 완결성

`await KrairportClient.parking_raw_set(provider="kac"|"iiac", page_size=100, max_pages=10)`는
기관 전체 주차장 현재 현황을 읽는다. KAC는 AirportParking/airportparkingRT XML,
IIAC는 StatusOfParking/getTrackingParking JSON을 사용한다. 공항 필터나 요금 호출을 섞지 않는다.
기존 parking_status/raw_items의 반환·요청 계약은 바꾸지 않는다.

`ParkingRawSet`은 items, provider, total_count, page_count, page_size, wire_page_metadata를 보존한다.
source totalCount는 필수이며 누락·음수·bool·변동·전체수신량 불일치는 KrairportParseError다.
pageNo/numOfRows가 존재하면 요청과 같아야 한다. 누락 시 요청값을 원문 echo로 주장하지 않고
wire_page_metadata=false로 표시한다. 정상 total=0은 완결된 빈 응답의 증거일 뿐이다.
소비자는 삭제 승인 전에 원천 재확인 및 이전 성공집합 보존 정책을 별도로 적용해야 한다.

page_size 1~1000, max_pages 1~20, 누적 raw JSON 8MiB로 제한한다. 상한을 넘는 total은
다음 페이지를 호출하기 전에 거부한다. 동일 raw행/반복페이지와 중간 빈 페이지도 거부한다.
HTTP·재시도·redirect·공통 TPS 및 resultCode 판정은 기존 SDK 경계를 그대로 사용한다.
페이지 사이 total이 같더라도 원천 전체 snapshot의 강한 동일성을 보장하지는 않는다.
Transport는 별도로 주차장 identity·관측시각·범위·급감 여부를 검증해야 한다.

기본 테스트는 fixture로 KAC XML/IIAC JSON 두 실제 public 경로와 페이지 메타를 검증하며,
실제 API 키를 사용하거나 운영 배포하지 않는다.

적대 리뷰 회귀로 malformed nonempty items/예상 밖 키를 빈 성공으로 승인하지 않으며, 메타 숫자는 변환 전에 5자리로 제한한다. 정상 빈 XML items와 JSON empty만 빈 receipt 후보가 된다.

신규 full-set은 header.resultCode의 명시 성공과 items 키 존재를 추가로 요구한다. resultMsg만 있는 응답을 성공으로 해석하지 않는다. 비정상 JSON 값/Unicode/직렬화 오류는 원문 없는 ParseError로 변환한다. 기존 HTTP의 느슨한 응답 호환 정책은 유지한다.
6 changes: 6 additions & 0 deletions krairport-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -548,3 +548,9 @@ Live 테스트:
- IIAC 입국장현황: https://www.data.go.kr/data/15095061/openapi.do
- IIAC 승객예고: https://www.data.go.kr/data/15095066/openapi.do
- OurAirports data downloads: https://ourairports.com/data/

## 주차장 완결 집합 API

`await client.parking_raw_set(provider="kac", page_size=100, max_pages=10)`은 원천 total과
실제 페이지 수신을 검증한 `ParkingRawSet`을 반환한다. IIAC는 provider="iiac"를 쓴다.
기존 목록 API는 그대로다. [완결성·오류·상한 계약](docs/parking-full-set.md)을 참고한다.
2 changes: 2 additions & 0 deletions src/krairport/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
PaginatedResult,
ParkingAreaStatus,
ParkingFee,
ParkingRawSet,
PassengerForecast,
ServiceDestination,
TaxiStatus,
Expand Down Expand Up @@ -105,6 +106,7 @@
"KrairportRequestError",
"KrairportServerError",
"ParkingAreaStatus",
"ParkingRawSet",
"ParkingFee",
"PaginatedResult",
"PassengerForecast",
Expand Down
115 changes: 115 additions & 0 deletions src/krairport/_parking_pages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""주차장 원천 페이지의 유한 완결성 검증. 원천 total 누락은 실패한다."""

from __future__ import annotations

import hashlib
import json
from collections.abc import Awaitable, Callable, Mapping
from typing import Any

from krairport.enums import Provider
from krairport.exceptions import KrairportParseError
from krairport.models import ParkingRawSet


def _integer(value: Any, name: str) -> int:
if isinstance(value, bool) or value is None:
raise KrairportParseError(f"parking {name} metadata is missing or invalid")
text = str(value)
if len(text) > 5 or not text.isascii() or not text.isdigit():
raise KrairportParseError(f"parking {name} metadata is not a nonnegative integer")
return int(text)


def _validate_items_container(value: Any) -> None:
"""기존의 느슨한 추출기가 malformed nonempty를 빈 집합으로 숨기지 못하게 한다."""
if value is None or value == "":
return
if isinstance(value, Mapping):
if set(value) - {"item"}:
raise KrairportParseError("parking items contains unexpected keys")
rows = value.get("item")
elif isinstance(value, list):
rows = value
else:
raise KrairportParseError("parking items container is malformed")
if rows is None or rows == "":
return
if isinstance(rows, Mapping):
return
if not isinstance(rows, list) or any(not isinstance(row, Mapping) for row in rows):
raise KrairportParseError("parking item container is malformed")


async def collect_parking_pages(
fetch_page: Callable[[int, int], Awaitable[Mapping[str, Any]]],
extract_items: Callable[[Mapping[str, Any]], list[dict[str, Any]]],
*,
provider: Provider,
page_size: int,
max_pages: int,
) -> ParkingRawSet:
if type(page_size) is not int or not 1 <= page_size <= 1000:
raise ValueError("page_size must be 1..1000")
if type(max_pages) is not int or not 1 <= max_pages <= 20:
raise ValueError("max_pages must be 1..20")
rows: list[dict[str, Any]] = []
total: int | None = None
seen: set[bytes] = set()
byte_count = 0
wire_page_metadata = True
for page_no in range(1, max_pages + 1):
data = await fetch_page(page_no, page_size)
response = data.get("response", data)
header = response.get("header") if isinstance(response, Mapping) else None
if not isinstance(header, Mapping) or str(header.get("resultCode", "")).strip() not in {
"00",
"0",
"NORMAL_CODE",
}:
raise KrairportParseError("parking response lacks explicit success code")
body = response.get("body") if isinstance(response, Mapping) else None
if not isinstance(body, Mapping) or "items" not in body:
raise KrairportParseError("parking response body is missing")
observed_total = _integer(body.get("totalCount"), "totalCount")
if total is None:
total = observed_total
if total > page_size * max_pages:
raise KrairportParseError("parking total exceeds bounded page budget")
elif observed_total != total:
raise KrairportParseError("parking total changed between pages")
for name, expected in (("pageNo", page_no), ("numOfRows", page_size)):
if name in body:
if _integer(body[name], name) != expected:
raise KrairportParseError(f"parking {name} does not match request")
else:
wire_page_metadata = False
_validate_items_container(body.get("items"))
items = extract_items(data)
if len(items) != min(page_size, max(total - len(rows), 0)):
raise KrairportParseError("parking page size does not match source total")
for item in items:
try:
body_bytes = json.dumps(
item, sort_keys=True, ensure_ascii=False, separators=(",", ":"), allow_nan=False
).encode()
except (ValueError, TypeError, UnicodeError):
raise KrairportParseError("parking row cannot be safely serialized") from None
byte_count += len(body_bytes)
if byte_count > 8 * 1024 * 1024:
raise KrairportParseError("parking raw set exceeds byte budget")
digest = hashlib.sha256(body_bytes).digest()
if digest in seen:
raise KrairportParseError("parking response repeats an identical row/page")
seen.add(digest)
rows.extend(items)
if len(rows) == total:
return ParkingRawSet(
provider=provider,
items=tuple(rows),
total_count=total,
page_count=page_no,
page_size=page_size,
wire_page_metadata=wire_page_metadata,
)
raise KrairportParseError("parking page budget exhausted before source total")
11 changes: 11 additions & 0 deletions src/krairport/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
PaginatedResult,
ParkingAreaStatus,
ParkingFee,
ParkingRawSet,
PassengerForecast,
ServiceDestination,
TaxiStatus,
Expand Down Expand Up @@ -430,6 +431,16 @@ async def service_destinations(
num_of_rows=num_of_rows,
)

async def parking_raw_set(
self, *, provider: ProviderLike, page_size: int = 100, max_pages: int = 10,
) -> ParkingRawSet:
"""기관 전체의 완결 주차장 원시 집합. 공항별 검색·주차요금과 분리한다."""
from krairport.enums import normalize_provider

selected = normalize_provider(provider)
client = self.kac if selected is Provider.KAC else self.iiac
return await client.parking_raw_set(page_size=page_size, max_pages=max_pages)

async def kac_raw_items(
self,
service: str,
Expand Down
11 changes: 11 additions & 0 deletions src/krairport/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ def extend(self, items: Sequence[T]) -> None:
self.items.extend(items)


class ParkingRawSet(KrairportModel):
"""원천 total과 실제 페이지를 모두 검증한 주차장 원시 집합이다."""

provider: Provider
items: tuple[dict[str, Any], ...]
total_count: int
page_count: int
page_size: int
wire_page_metadata: bool


class Flight(KrairportModel):
provider: Provider
airport_code: str
Expand Down
17 changes: 17 additions & 0 deletions src/krairport/providers/iiac.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
to_int_or_none,
)
from krairport._http import HttpClient, SessionLike
from krairport._parking_pages import collect_parking_pages
from krairport._ratelimit import AsyncTokenBucket
from krairport._routing import ensure_iiac_airport
from krairport._time import parse_kst_datetime
Expand All @@ -28,6 +29,7 @@
Flight,
FlightSchedule,
ParkingAreaStatus,
ParkingRawSet,
PassengerForecast,
ServiceDestination,
TaxiStatus,
Expand Down Expand Up @@ -348,6 +350,21 @@ async def facilities(
data = await self._http.get_json(f"{FACILITY_BASE}/getFacilityKR", params)
return [_build_facility(row) for row in _extract_items(data)]

async def parking_raw_set(
self, *, page_size: int = 100, max_pages: int = 10,
) -> ParkingRawSet:
"""원천 total·페이지 수신이 일치하는 전체 주차장 원시 집합을 반환한다."""

async def fetch(page: int, size: int) -> Mapping[str, Any]:
return await self._http.get_json(
f"{PARKING_BASE}/getTrackingParking",
{"type": "json", "pageNo": page, "numOfRows": size},
)

return await collect_parking_pages(
fetch, _extract_items, provider=Provider.IIAC, page_size=page_size, max_pages=max_pages,
)

async def raw_items(
self,
service: str,
Expand Down
17 changes: 17 additions & 0 deletions src/krairport/providers/kac.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from krairport._convert import first_value, strip_or_none, to_bool_or_none, to_int_or_none
from krairport._http import HttpClient, SessionLike
from krairport._parking_pages import collect_parking_pages
from krairport._ratelimit import AsyncTokenBucket
from krairport._routing import ensure_kac_airport
from krairport._time import parse_kst_datetime
Expand All @@ -24,6 +25,7 @@
FlightSchedule,
ParkingAreaStatus,
ParkingFee,
ParkingRawSet,
TaxiStatus,
)

Expand Down Expand Up @@ -342,6 +344,21 @@ async def jeju_taxi_wait(
for row in extract_items(data)
]

async def parking_raw_set(
self, *, page_size: int = 100, max_pages: int = 10,
) -> ParkingRawSet:
"""원천 total·페이지 수신이 일치하는 전체 주차장 원시 집합을 반환한다."""

async def fetch(page: int, size: int) -> Mapping[str, Any]:
return await self._http.get_xml(
f"{AIRPORT_PARKING_BASE}/airportparkingRT",
{"pageNo": page, "numOfRows": size},
)

return await collect_parking_pages(
fetch, extract_items, provider=Provider.KAC, page_size=page_size, max_pages=max_pages,
)

async def raw_items(
self,
service: str,
Expand Down
Loading
Loading