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 API_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,7 @@ transport 내부의 연결 재시도는 라이브러리 밖의 동작이다.

전국휴게소 표준데이터는 활용 신청 후 HTTP 200 및 정상 코드 `00`을 반환했다.
최상위 `header`/`body` 응답과 기존 `response` 감싸기 응답을 모두 정규화한다.

## 로컬 재해석

`parse_rest_area_weather`는휴게소 기상의 기존 행 파서를 공개한다. 새 endpoint/live 검증을 추가하지 않는다. [신뢰 경계](docs/raw-replay.md).
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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/<version>`을 보낸다
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -648,3 +648,8 @@ transport 내부의 연결 재시도는 라이브러리 밖의 동작이다.


사용 예제: [docs/async-tps.md](docs/async-tps.md).

## 저장 원문의 오프라인 재해석

`parse_rest_area_weather`은 기존 HTTP client와 같은 파서의 공개 함수다.
[사용법과 단일 행/장소 신뢰 경계](docs/raw-replay.md)를 따른다.
22 changes: 22 additions & 0 deletions docs/raw-replay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# 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 페이지 파서는 일부 행이 실패해도 정상 행이 하나 이상 있으면 실패 행을 제외한
`Page`를 반환한다. 모든 행이 실패하고 첫 행 오류가 있을 때만 `KrexParseError`로
포장한다. 빈 페이지는 기존처럼 빈 `Page`다. 이 동작은 공개 함수 추가 전후에 동일하다.

## 원문 재해석의 신뢰 경계

이 API는 이미 확보한 provider record를 같은 SDK DTO로 다시 해석하는 로컬 함수다.
네트워크·인증키·현재 시각·원천 재조회 없이 기존 파서와 같은 변환을 사용한다.
HTTP header/resultCode, 전체 items 형태·byte/count 상한, 전국 집합의 완결성·신선도는
이 단일 행/장소만으로 검증하지 않는다. 호출자는 원문 확보 때의 응답 검증·출처·수집 시각과
대상 identity를 따로 보존해야 하며, 빈 결과를 전국 정상0건의 근거로 사용하면 안 된다.
5 changes: 5 additions & 0 deletions endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -724,3 +724,8 @@ transport 내부의 연결 재시도는 라이브러리 밖의 동작이다.

공공데이터포털 응답은 `response.header`/`response.body`와 최상위 `header`/`body`
두 형태를 지원한다. 상태 코드 판정, 항목 정규화, 페이지 정보와 원문 보존은 동일하다.

## 로컬 원문 재해석

`parse_rest_area_weather`을 패키지 root에서 제공한다. 네트워크 endpoint를 추가하지 않으며
[공개 입력·DTO·예외와 신뢰 경계](docs/raw-replay.md)를 따른다.
3 changes: 2 additions & 1 deletion src/krex/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -55,6 +55,7 @@
PROVIDER_NAME = "python-krex-api"

__all__ = [
"parse_rest_area_weather",
"AsyncTokenBucket",
"PROVIDER_NAME",
"ApiCatalogItem",
Expand Down
9 changes: 7 additions & 2 deletions src/krex/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions tests/runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
37 changes: 37 additions & 0 deletions tests/test_public_replay.py
Original file line number Diff line number Diff line change
@@ -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))
Loading