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 @@
## 수집 승인용 주차장 값 검증 추가

- parse_parking_snapshot_values/ParkingSnapshotValues로 원문 오류를 현재시각/0으로 바꾸지 않는다. 기존 API는 유지한다.

# 주차장 완결 raw 집합 추가

- public parking_raw_set과 ParkingRawSet으로 total·페이지·수신량·중복·상한을 검증한다. 기존 API는 유지한다.
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,3 +471,11 @@ tests/
`await client.parking_raw_set(provider="kac", page_size=100, max_pages=10)`은 원천 total과
실제 페이지 수신을 검증한 `ParkingRawSet`을 반환한다. IIAC는 provider="iiac"를 쓴다.
기존 목록 API는 그대로다. [완결성·오류·상한 계약](docs/parking-full-set.md)을 참고한다.

## 수집 승인용 엄격한 현황 값

`parse_parking_snapshot_values(raw, provider="kac"|"iiac")`는 `ParkingSnapshotValues`로
총면수·점유면수·timezone-aware KST 관측시각을 반환한다. 숫자의 오류문자열/음수/bool/
소수/상한초과와 없거나 유효하지 않은 원천시각을 원문 없는 KrairportParseError로 거부한다.
기존 status API의 느슨한 숫자·시각 변환은 바꾸지 않는다. HTTP를 호출하지 않는 public
원천검증 함수이므로 수집 승인 전에 raw와 canonical 값의 대응을 확인할 수 있다.
8 changes: 8 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,11 @@ Streamlit과 UI 전용 의존성은 `krairport` package 밖에 둔다. `Krairpor
`await client.parking_raw_set(provider="kac", page_size=100, max_pages=10)`은 원천 total과
실제 페이지 수신을 검증한 `ParkingRawSet`을 반환한다. IIAC는 provider="iiac"를 쓴다.
기존 목록 API는 그대로다. [완결성·오류·상한 계약](docs/parking-full-set.md)을 참고한다.

## 수집 승인용 엄격한 현황 값

`parse_parking_snapshot_values(raw, provider="kac"|"iiac")`는 `ParkingSnapshotValues`로
총면수·점유면수·timezone-aware KST 관측시각을 반환한다. 숫자의 오류문자열/음수/bool/
소수/상한초과와 없거나 유효하지 않은 원천시각을 원문 없는 KrairportParseError로 거부한다.
기존 status API의 느슨한 숫자·시각 변환은 바꾸지 않는다. HTTP를 호출하지 않는 public
원천검증 함수이므로 수집 승인 전에 raw와 canonical 값의 대응을 확인할 수 있다.
8 changes: 8 additions & 0 deletions docs/parking-full-set.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,11 @@ Transport는 별도로 주차장 identity·관측시각·범위·급감 여부
적대 리뷰 회귀로 malformed nonempty items/예상 밖 키를 빈 성공으로 승인하지 않으며, 메타 숫자는 변환 전에 5자리로 제한한다. 정상 빈 XML items와 JSON empty만 빈 receipt 후보가 된다.

신규 full-set은 header.resultCode의 명시 성공과 items 키 존재를 추가로 요구한다. resultMsg만 있는 응답을 성공으로 해석하지 않는다. 비정상 JSON 값/Unicode/직렬화 오류는 원문 없는 ParseError로 변환한다. 기존 HTTP의 느슨한 응답 호환 정책은 유지한다.

## 수집 승인용 엄격한 현황 값

`parse_parking_snapshot_values(raw, provider="kac"|"iiac")`는 `ParkingSnapshotValues`로
총면수·점유면수·timezone-aware KST 관측시각을 반환한다. 숫자의 오류문자열/음수/bool/
소수/상한초과와 없거나 유효하지 않은 원천시각을 원문 없는 KrairportParseError로 거부한다.
기존 status API의 느슨한 숫자·시각 변환은 바꾸지 않는다. HTTP를 호출하지 않는 public
원천검증 함수이므로 수집 승인 전에 raw와 canonical 값의 대응을 확인할 수 있다.
8 changes: 8 additions & 0 deletions krairport-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -554,3 +554,11 @@ Live 테스트:
`await client.parking_raw_set(provider="kac", page_size=100, max_pages=10)`은 원천 total과
실제 페이지 수신을 검증한 `ParkingRawSet`을 반환한다. IIAC는 provider="iiac"를 쓴다.
기존 목록 API는 그대로다. [완결성·오류·상한 계약](docs/parking-full-set.md)을 참고한다.

## 수집 승인용 엄격한 현황 값

`parse_parking_snapshot_values(raw, provider="kac"|"iiac")`는 `ParkingSnapshotValues`로
총면수·점유면수·timezone-aware KST 관측시각을 반환한다. 숫자의 오류문자열/음수/bool/
소수/상한초과와 없거나 유효하지 않은 원천시각을 원문 없는 KrairportParseError로 거부한다.
기존 status API의 느슨한 숫자·시각 변환은 바꾸지 않는다. HTTP를 호출하지 않는 public
원천검증 함수이므로 수집 승인 전에 raw와 canonical 값의 대응을 확인할 수 있다.
4 changes: 4 additions & 0 deletions src/krairport/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,13 @@
ParkingAreaStatus,
ParkingFee,
ParkingRawSet,
ParkingSnapshotValues,
PassengerForecast,
ServiceDestination,
TaxiStatus,
WorldWeather,
)
from krairport.parking import parse_parking_snapshot_values
from krairport.types import (
AirportCodeLike,
AirportCodeText,
Expand Down Expand Up @@ -107,6 +109,8 @@
"KrairportServerError",
"ParkingAreaStatus",
"ParkingRawSet",
"ParkingSnapshotValues",
"parse_parking_snapshot_values",
"ParkingFee",
"PaginatedResult",
"PassengerForecast",
Expand Down
8 changes: 8 additions & 0 deletions src/krairport/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ def extend(self, items: Sequence[T]) -> None:
self.items.extend(items)


class ParkingSnapshotValues(KrairportModel):
"""원문 숫자와 KST 관측시각을 엄격히 해석한 값이다. 현재시각 fallback은 없다."""

total_spaces: int
occupied_spaces: int
observed_at: datetime


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

Expand Down
71 changes: 71 additions & 0 deletions src/krairport/parking.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""수집 승인용 주차장 원문 값 검증. 기존 느슨한 status API와 분리한다."""

from __future__ import annotations

import re
from collections.abc import Mapping
from typing import Any

from krairport._time import parse_kst_datetime
from krairport.enums import Provider, normalize_provider
from krairport.exceptions import KrairportParseError
from krairport.models import ParkingSnapshotValues
from krairport.types import ProviderLike


def _count(value: Any) -> int:
if isinstance(value, bool) or not isinstance(value, (str, int)):
raise KrairportParseError("parking count must be a nonnegative integer")
if isinstance(value, int) and not 0 <= value <= 999999999:
raise KrairportParseError("parking count must be a bounded nonnegative integer")
text = str(value).strip()
if not 1 <= len(text) <= 9 or not text.isascii() or not text.isdigit():
raise KrairportParseError("parking count must be a bounded nonnegative integer")
return int(text)


def parse_parking_snapshot_values(
raw: Mapping[str, Any],
*,
provider: ProviderLike,
) -> ParkingSnapshotValues:
"""기관 원문의 현황 숫자와 실제 KST 시각을 반환한다. 원문은 오류에 포함하지 않는다."""
if not isinstance(raw, Mapping):
raise KrairportParseError("parking observation must be an object")
selected = normalize_provider(provider)
if selected is Provider.KAC:
capacity = _count(raw.get("parkingFullSpace"))
occupied = _count(raw.get("parkingIstay"))
day, clock = raw.get("parkingGetdate"), raw.get("parkingGettime")
if (
not isinstance(day, str)
or not isinstance(clock, str)
or re.fullmatch(r"(?:[0-9]{8}|[0-9]{4}-[0-9]{2}-[0-9]{2})", day.strip()) is None
or re.fullmatch(r"(?:[0-9]{6}|[0-9]{2}:[0-9]{2}:[0-9]{2})", clock.strip()) is None
):
raise KrairportParseError("parking KAC observation timestamp is malformed")
text = day.strip().replace("-", "") + clock.strip().replace(":", "")
else:
capacity = _count(raw.get("parkingarea"))
occupied = _count(raw.get("parking"))
value = raw.get("datetm")
if (
not isinstance(value, str)
or re.fullmatch(
r"(?:[0-9]{12}|[0-9]{14}|[0-9]{4}-[0-9]{2}-[0-9]{2} "
r"[0-9]{2}:[0-9]{2}(?::[0-9]{2})?)",
value.strip(),
)
is None
):
raise KrairportParseError("parking IIAC observation timestamp is malformed")
text = value.strip()
try:
observed_at = parse_kst_datetime(text, require_time=True)
except (TypeError, ValueError, OverflowError):
raise KrairportParseError("parking observation timestamp is invalid") from None
if observed_at is None:
raise KrairportParseError("parking observation timestamp is missing")
return ParkingSnapshotValues(
total_spaces=capacity, occupied_spaces=occupied, observed_at=observed_at
)
67 changes: 67 additions & 0 deletions tests/test_parking_values.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""수집 승인 경계는 오류 문자열/잘못된 시각을 0/현재시각으로 바꾸지 않는다."""

import pytest

from krairport import KrairportParseError, parse_parking_snapshot_values

KAC = {
"parkingFullSpace": "640",
"parkingIstay": "012",
"parkingGetdate": "2026-10-23",
"parkingGettime": "09:10:20",
}
IIAC = {"parkingarea": "640", "parking": "12", "datetm": "2026-10-23 09:10"}


@pytest.mark.parametrize("provider, raw", [("kac", KAC), ("iiac", IIAC)])
async def test_valid_strict_values_are_aware_kst(provider, raw):
value = parse_parking_snapshot_values(raw, provider=provider)
assert value.total_spaces == 640 and value.occupied_spaces == 12
assert value.observed_at.utcoffset().total_seconds() == 9 * 3600
assert value.observed_at.year == 2026 and value.observed_at.hour == 9


@pytest.mark.parametrize(
"provider,base,key",
[
("kac", KAC, "parkingIstay"),
("iiac", IIAC, "parking"),
("kac", KAC, "parkingFullSpace"),
("iiac", IIAC, "parkingarea"),
],
)
@pytest.mark.parametrize(
"bad",
[
"error",
"",
None,
-1,
True,
"1.5",
"1e3",
"1" * 4301,
pytest.param(10**5000, id="huge-integer"),
],
)
async def test_bad_counts_fail_without_original_payload(provider, base, key, bad):
with pytest.raises(KrairportParseError, match="parking count"):
parse_parking_snapshot_values({**base, key: bad}, provider=provider)


@pytest.mark.parametrize(
"provider,raw",
[
("kac", {**KAC, "parkingGetdate": "bad"}),
("kac", {**KAC, "parkingGettime": "error"}),
("kac", {**KAC, "parkingGetdate": "2026-02-31"}),
("kac", {**KAC, "parkingGettime": "99:00:00"}),
("iiac", {**IIAC, "datetm": "bad"}),
("iiac", {**IIAC, "datetm": "2026-02-31 09:00"}),
("iiac", {**IIAC, "datetm": "error202610230910"}),
("iiac", {**IIAC, "datetm": None}),
],
)
async def test_bad_or_missing_timestamp_never_uses_now(provider, raw):
with pytest.raises(KrairportParseError, match="timestamp"):
parse_parking_snapshot_values(raw, provider=provider)
Loading