From b5ced2a6c6a22aebe25e6e63b8d8ae16aa7b6a2d Mon Sep 17 00:00:00 2001 From: digitie <964189+digitie@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:39:35 +0900 Subject: [PATCH 1/2] feat: expose shared offline rest area weather parser --- API_COVERAGE.md | 4 ++++ CHANGELOG.md | 7 +++++++ README.md | 5 +++++ docs/raw-replay.md | 20 ++++++++++++++++++++ endpoints.md | 5 +++++ src/krex/__init__.py | 3 ++- src/krex/client.py | 9 +++++++-- tests/runners.py | 4 ++-- tests/test_public_replay.py | 37 +++++++++++++++++++++++++++++++++++++ 9 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 docs/raw-replay.md create mode 100644 tests/test_public_replay.py diff --git a/API_COVERAGE.md b/API_COVERAGE.md index 960176e..132fb55 100644 --- a/API_COVERAGE.md +++ b/API_COVERAGE.md @@ -119,3 +119,7 @@ transport 내부의 연결 재시도는 라이브러리 밖의 동작이다. 전국휴게소 표준데이터는 활용 신청 후 HTTP 200 및 정상 코드 `00`을 반환했다. 최상위 `header`/`body` 응답과 기존 `response` 감싸기 응답을 모두 정규화한다. + +## 로컬 재해석 + +`parse_rest_area_weather`는휴게소 기상의 기존 행 파서를 공개한다. 새 endpoint/live 검증을 추가하지 않는다. [신뢰 경계](docs/raw-replay.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index b0eb512..58774af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 변경 기록 +## 2026-10-23 — 원문 재해석 API 공개 + +기존 파서를 `parse_rest_area_weather`로 공개하고 HTTP client도 같은 함수를 사용한다. +DTO·raw·코드·시각·기존 오류 의미를 유지하며 네트워크 호출은 추가하지 않는다. +[단일 행/장소 계약과 신뢰 경계](docs/raw-replay.md)를 명시하고 오프라인 회귀를 추가했다. + + ## 2026-10-10 — 기본 User-Agent - 라이브러리가 직접 만드는 HTTPX session이 `User-Agent: python-krex-api/`을 보낸다 diff --git a/README.md b/README.md index 0b4f150..add560d 100644 --- a/README.md +++ b/README.md @@ -648,3 +648,8 @@ transport 내부의 연결 재시도는 라이브러리 밖의 동작이다. 사용 예제: [docs/async-tps.md](docs/async-tps.md). + +## 저장 원문의 오프라인 재해석 + +`parse_rest_area_weather`은 기존 HTTP client와 같은 파서의 공개 함수다. +[사용법과 단일 행/장소 신뢰 경계](docs/raw-replay.md)를 따른다. diff --git a/docs/raw-replay.md b/docs/raw-replay.md new file mode 100644 index 0000000..6610810 --- /dev/null +++ b/docs/raw-replay.md @@ -0,0 +1,20 @@ +# RestAreaWeather 원문 재해석 + +```python +from krex import parse_rest_area_weather + +weather = parse_rest_area_weather(saved_row) +``` + +`row`는 휴게소 기상 provider 단일 행인 `dict[str, Any]`다. 원문 raw와 선행0 코드, +sdate/stdHour의 KST 시각, -99 이하 결측·좌표 변환을 기존대로 유지한다. +잘못된 필수값·날짜/시각은 기존 행 파서처럼ValueError를 낼 수 있다. HTTP 메서드는 +이 행 오류를 기존 페이지 경계에서KrexParseError로 포장하며 그 동작도 유지한다. + +## 원문 재해석의 신뢰 경계 + +이 API는 이미 확보한 provider record를 같은 SDK DTO로 다시 해석하는 로컬 함수다. +네트워크·인증키·현재 시각·원천 재조회 없이 기존 파서와 같은 변환을 사용한다. +HTTP header/resultCode, 전체 items 형태·byte/count 상한, 전국 집합의 완결성·신선도는 +이 단일 행/장소만으로 검증하지 않는다. 호출자는 원문 확보 때의 응답 검증·출처·수집 시각과 +대상 identity를 따로 보존해야 하며, 빈 결과를 전국 정상0건의 근거로 사용하면 안 된다. diff --git a/endpoints.md b/endpoints.md index 7745334..fc6ecc8 100644 --- a/endpoints.md +++ b/endpoints.md @@ -724,3 +724,8 @@ transport 내부의 연결 재시도는 라이브러리 밖의 동작이다. 공공데이터포털 응답은 `response.header`/`response.body`와 최상위 `header`/`body` 두 형태를 지원한다. 상태 코드 판정, 항목 정규화, 페이지 정보와 원문 보존은 동일하다. + +## 로컬 원문 재해석 + +`parse_rest_area_weather`을 패키지 root에서 제공한다. 네트워크 endpoint를 추가하지 않으며 +[공개 입력·DTO·예외와 신뢰 경계](docs/raw-replay.md)를 따른다. diff --git a/src/krex/__init__.py b/src/krex/__init__.py index 9362676..724f2e3 100644 --- a/src/krex/__init__.py +++ b/src/krex/__init__.py @@ -2,7 +2,7 @@ from ._ratelimit import AsyncTokenBucket from .catalog import api_catalog, api_catalog_item, get_api_catalog, get_api_catalog_item -from .client import KrexClient +from .client import KrexClient, parse_rest_area_weather from .codes import ( CarType, CongestionLevel, @@ -55,6 +55,7 @@ PROVIDER_NAME = "python-krex-api" __all__ = [ + "parse_rest_area_weather", "AsyncTokenBucket", "PROVIDER_NAME", "ApiCatalogItem", diff --git a/src/krex/client.py b/src/krex/client.py index 0b190b1..dc283e0 100644 --- a/src/krex/client.py +++ b/src/krex/client.py @@ -502,7 +502,7 @@ async def weather( return await self._client._page_ex( "/openapi/restinfo/restWeatherList", {"sdate": _format_sdate(sdate), "stdHour": _format_hour(std_hour)}, - _rest_area_weather, + parse_rest_area_weather, ) except KrexError as exc: self._client._http.protect_error(exc) @@ -976,7 +976,12 @@ def _rest_area_fuel_price(row: dict[str, Any]) -> RestAreaFuelPrice: ) -def _rest_area_weather(row: dict[str, Any]) -> RestAreaWeather: +def parse_rest_area_weather(row: dict[str, Any]) -> RestAreaWeather: + """휴게소 기상 원문 한 행을 HTTP client와 동일한 DTO로 다시 해석한다. + + 응답 code/list/페이지 완결성은 이 행만으로 검증하지 않는다. 원래 raw, + 선행 0 코드, KST 시각과 결측 처리 규칙을 유지하며 네트워크를 호출하지 않는다. + """ x = _weather_float_or_none(_get(row, "xValue", "x")) y = _weather_float_or_none(_get(row, "yValue", "y")) lon, lat = _wgs84_from_lon_lat(x, y) diff --git a/tests/runners.py b/tests/runners.py index eca9d72..c5ccda4 100644 --- a/tests/runners.py +++ b/tests/runners.py @@ -4,12 +4,12 @@ from typing import Any from krex._http import _normalize_ex_payload -from krex.client import _parse_page, _parse_traffic_flow_page, _rest_area_weather +from krex.client import _parse_page, _parse_traffic_flow_page, parse_rest_area_weather def _parse_restarea_weather(raw: dict[str, Any]) -> Any: payload = _normalize_ex_payload(raw, params={}) - return _parse_page(payload, _rest_area_weather) + return _parse_page(payload, parse_rest_area_weather) def _identity(value: Any) -> Any: diff --git a/tests/test_public_replay.py b/tests/test_public_replay.py new file mode 100644 index 0000000..7e13ee2 --- /dev/null +++ b/tests/test_public_replay.py @@ -0,0 +1,37 @@ +"""휴게소 기상 단일행 재해석은 기존 client parser를 공유한다.""" + +from copy import deepcopy +from datetime import timedelta + +import pytest + +from krex import KrexClient, parse_rest_area_weather +from tests.test_client import FakeSession, ex_payload, rest_weather_row + + +async def test_replay_equals_http_dto_without_network_or_input_mutation() -> None: + raw = rest_weather_row(providerExtra={"unknown": "original"}) + before = deepcopy(raw) + session = FakeSession(ex_payload([raw])) + client = KrexClient(ex_api_key="fixture-key", retry_backoff=0, session=session) + original = (await client.restarea.weather(sdate="20210507", std_hour=12)).items[0] + count = len(session.calls) + replay = parse_rest_area_weather(raw) + assert replay == original and replay.raw == before and raw == before + assert replay.unit_code == "002" and replay.route_no == "0010" + assert replay.observed_at.utcoffset() == timedelta(hours=9) + assert len(session.calls) == count + + +def test_existing_sentinel_and_coordinate_semantics_are_preserved() -> None: + replay = parse_rest_area_weather(rest_weather_row( + tempValue="-99.0", xValue="-99.0", yValue="-99.0", + )) + assert replay.temperature is None and replay.latitude is None and replay.longitude is None + assert replay.raw["tempValue"] == "-99.0" + + +@pytest.mark.parametrize("changes", [{"unitCode": ""}, {"sdate": "20260230"}, {"stdHour": "24"}]) +def test_invalid_required_source_value_keeps_existing_value_error(changes) -> None: + with pytest.raises(ValueError): + parse_rest_area_weather(rest_weather_row(**changes)) From bf5fc788f73772819665db2007707c35d001b8ea Mon Sep 17 00:00:00 2001 From: digitie <964189+digitie@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:46:02 +0900 Subject: [PATCH 2/2] docs: clarify partial success and row replay errors --- docs/raw-replay.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/raw-replay.md b/docs/raw-replay.md index 6610810..6d7aba1 100644 --- a/docs/raw-replay.md +++ b/docs/raw-replay.md @@ -8,8 +8,10 @@ weather = parse_rest_area_weather(saved_row) `row`는 휴게소 기상 provider 단일 행인 `dict[str, Any]`다. 원문 raw와 선행0 코드, sdate/stdHour의 KST 시각, -99 이하 결측·좌표 변환을 기존대로 유지한다. -잘못된 필수값·날짜/시각은 기존 행 파서처럼ValueError를 낼 수 있다. HTTP 메서드는 -이 행 오류를 기존 페이지 경계에서KrexParseError로 포장하며 그 동작도 유지한다. +잘못된 필수값·날짜/시각은 로컬 행 재해석에서 기존처럼 `ValueError`를 낼 수 있다. +HTTP 페이지 파서는 일부 행이 실패해도 정상 행이 하나 이상 있으면 실패 행을 제외한 +`Page`를 반환한다. 모든 행이 실패하고 첫 행 오류가 있을 때만 `KrexParseError`로 +포장한다. 빈 페이지는 기존처럼 빈 `Page`다. 이 동작은 공개 함수 추가 전후에 동일하다. ## 원문 재해석의 신뢰 경계