From 6c9aa9ae2ad77cbef3fb538d23f5da1f362e0482 Mon Sep 17 00:00:00 2001 From: digitie <964189+digitie@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:59:15 +0900 Subject: [PATCH] feat: expose strict parking source values for collection approval --- CHANGELOG.md | 4 ++ README.md | 8 ++++ SKILL.md | 8 ++++ docs/parking-full-set.md | 8 ++++ krairport-api.md | 8 ++++ src/krairport/__init__.py | 4 ++ src/krairport/models.py | 8 ++++ src/krairport/parking.py | 71 ++++++++++++++++++++++++++++++++++++ tests/test_parking_values.py | 67 ++++++++++++++++++++++++++++++++++ 9 files changed, 186 insertions(+) create mode 100644 src/krairport/parking.py create mode 100644 tests/test_parking_values.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 44043f5..f9003c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 수집 승인용 주차장 값 검증 추가 + +- parse_parking_snapshot_values/ParkingSnapshotValues로 원문 오류를 현재시각/0으로 바꾸지 않는다. 기존 API는 유지한다. + # 주차장 완결 raw 집합 추가 - public parking_raw_set과 ParkingRawSet으로 total·페이지·수신량·중복·상한을 검증한다. 기존 API는 유지한다. diff --git a/README.md b/README.md index bebabea..e6bd510 100644 --- a/README.md +++ b/README.md @@ -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 값의 대응을 확인할 수 있다. diff --git a/SKILL.md b/SKILL.md index 3e18459..fb61938 100644 --- a/SKILL.md +++ b/SKILL.md @@ -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 값의 대응을 확인할 수 있다. diff --git a/docs/parking-full-set.md b/docs/parking-full-set.md index f965110..d6d44dd 100644 --- a/docs/parking-full-set.md +++ b/docs/parking-full-set.md @@ -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 값의 대응을 확인할 수 있다. diff --git a/krairport-api.md b/krairport-api.md index 399fcdc..c3ff5f7 100644 --- a/krairport-api.md +++ b/krairport-api.md @@ -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 값의 대응을 확인할 수 있다. diff --git a/src/krairport/__init__.py b/src/krairport/__init__.py index 1ab24ef..54cbc6a 100644 --- a/src/krairport/__init__.py +++ b/src/krairport/__init__.py @@ -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, @@ -107,6 +109,8 @@ "KrairportServerError", "ParkingAreaStatus", "ParkingRawSet", + "ParkingSnapshotValues", + "parse_parking_snapshot_values", "ParkingFee", "PaginatedResult", "PassengerForecast", diff --git a/src/krairport/models.py b/src/krairport/models.py index c3863f1..0582892 100644 --- a/src/krairport/models.py +++ b/src/krairport/models.py @@ -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과 실제 페이지를 모두 검증한 주차장 원시 집합이다.""" diff --git a/src/krairport/parking.py b/src/krairport/parking.py new file mode 100644 index 0000000..3dbdef7 --- /dev/null +++ b/src/krairport/parking.py @@ -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 + ) diff --git a/tests/test_parking_values.py b/tests/test_parking_values.py new file mode 100644 index 0000000..344ee72 --- /dev/null +++ b/tests/test_parking_values.py @@ -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)