diff --git a/CHANGELOG.md b/CHANGELOG.md index 88cc98e..2721630 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 변경 이력 +## 2026-10-23 — typed pagination receipt + +- `ResponseMetadata.pagination`/`PaginationReceipt`로 페이지 완결 정보를 가산한다. 제공자 metadata/행 수 불일치와 중간 NO_DATA의 부분 성공을 거부한다. metadata 생략 응답의 기존 값·첫 NO_DATA03 빈 결과는 유지한다. + `python-kma-api`의 주요 변경 사항을 기록합니다. ## 0.1.0 - 미배포 diff --git a/README.md b/README.md index 6f0e611..a8ed3a8 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Korea Meteorological Administration(KMA, 기상청) 공공데이터포털과 API | 인증키 로딩 | `api_key_for_gateway`, `env_names_for_gateway`, `load_local_env` | | 위치 값 객체 | `LatLon`, `GridPoint`, `normalize_location` | | 좌표 변환 | `to_grid`, `to_latlon`, `wgs84_to_kma_grid`, `kma_grid_to_wgs84` | -| 응답 모델 | `WeatherSnapshot`, `ForecastItem`, `ForecastTimepoint`, `MidForecastItem`, `DataGoKrItem`, `BeachForecastItem`, `BeachWaveHeight`, `BeachWaterTemperature`, `BeachTideItem`, `BeachSunTime`, `ResponseMetadata` | +| 응답 모델 | `WeatherSnapshot`, `ForecastItem`, `ForecastTimepoint`, `MidForecastItem`, `DataGoKrItem`, `BeachForecastItem`, `BeachWaveHeight`, `BeachWaterTemperature`, `BeachTideItem`, `BeachSunTime`, `ResponseMetadata`, `PaginationReceipt` | | timeline/pagination/cache | `pivot_forecast_items`, `has_next_page`, `next_page_no`, `iter_pages`, `make_cache_key`, `base_available_at`, `cache_expire_at`, `latest_mid_fcst_base`, `latest_mid_fcst_time`, `sanitize_request_params` | | enum/라벨 | `KmaEndpoint`, `WeatherCategory`, `SkyCode`, `ObservedPrecipitationType`, `ForecastPrecipitationType`, `label_for`, `unit_for`, `parse_amount` | | 예외 | `KmaError`, `KmaAuthError`, `KmaRequestError`, `KmaServerError`, `KmaParseError` | @@ -935,3 +935,10 @@ KMA 원천 데이터의 저작권과 이용조건은 기상청 및 공공데이 ## 변경 이력 - `0.1.0`: 초기 패키지 구조, KMA 단기예보 클라이언트, 좌표 변환, 시간 계산, 문서 보강. + + +### typed 응답의 페이지 완결 증거 + +`now()`의 `snapshot.metadata.pagination` 또는 비어 있지 않은 예보 item의 `metadata.pagination`은 `PaginationReceipt`다. `expected_total_count`, `received_item_count`, `page_count`, `complete`를 제공한다. 제공자가 모든 페이지에 일관된 `pageNo/numOfRows/totalCount`를 명시하고 실제 행 수가 일치할 때만 `complete=True`다. 수치·페이지 순서·중간 빈 응답 불일치는 `KmaParseError`로 반환하여 부분 집합을 정상 전체로 내보내지 않는다. metadata가 생략된 기존 응답은 기존 값을 반환하지만 `complete=False`다. + +첫 `NO_DATA(03)`는 기존 정상 빈 결과이고 now snapshot에는 명시0 receipt가 남는다. 예보가 빈 list이면 item metadata도 없으므로 빈 list만으로 정상0 완결을 승인하지 않는다. 페이지 완결은 항목 유일성·의미·관측/예보 revision·downstream 데이터셋 전체 권위를 보장하지 않는다. `collected_at`은 실제 조회 시각이며 데이터 발표 revision으로 쓰지 않는다. diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 03176e6..6563d62 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -214,3 +214,8 @@ data.go.kr의 KMA REST API는 `http://apis.data.go.kr/1360000/{service}/{operati - **범용 클라이언트까지 포함하면 data.go.kr 임의 service/operation과 APIHub `/api/...` path를 호출할 수 있습니다.** - **APIHub 470개는 endpoint별 함수 이름을 제공하지만, 모든 응답을 endpoint별 Pydantic 모델로 강제 변환하지는 않습니다.** - **위치/코드 타입 계층은 endpoint 개수를 늘리는 항목은 아니며, 외부 프로그램에서 좌표계와 category 문자열을 안정적으로 다루기 위한 public API입니다.** + + +### typed pagination receipt (2026-10-23) + +기존 endpoint 개수는 그대로다. 공개 모델 `PaginationReceipt`와 `ResponseMetadata.pagination`을 가산해 typed 조회의 원천 총수·실제 행 수·페이지 수·완결 여부를 제공한다. metadata 없는 레거시는 complete=False, 명시 불일치는 KmaParseError다. 예보 빈 list에는 item metadata가 없으며 receipt만으로 source 전체 권위/의미 유일성은 보장하지 않는다. diff --git a/docs/decisions.md b/docs/decisions.md index a26332a..eafa153 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -45,3 +45,8 @@ 다른 API 라이브러리와 같은 AsyncTokenBucket 로직을 적용해 재시도·리다이렉트도 합산 제한한다. 파싱·모델·카탈로그와 CLI/UI 진입점은 일반 함수로 유지한다. 기존 동기 소비자는 await, async for, async with로 전환해야 한다. 자세한 설정은 async-tps.md에 기록한다. + + +## ADR-007: typed pagination receipt와 불일치 거부 (2026-10-23) + +전체 페이지 순회 종료를 완결 증거로 사용하지 않는다. typed 조회에 공개 `PaginationReceipt`를 가산하고 페이지 번호·총수/크기 일관성·실제 행 수를 검증한다. 명시 metadata가 틀리면 typed parse 오류이며, metadata가 없는 레거시는 값 호환을 유지하되 complete=False다. NO_DATA03 정상 빈 계약은 유지하고 중간03의 부분 집합은 거부한다. receipt는 transport pagination의 증거이며 데이터 의미/유일성·downstream authoritative replacement는 소비자가 별도로 검증한다. 기존 서비스키·async·TPS·endpoint·시각 유틸은 바꾸지 않는다. diff --git a/docs/journal.md b/docs/journal.md index 8612ebb..0a30de4 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -1,5 +1,12 @@ # JOURNAL — 작업 일지 +## 2026-10-23 — typed 페이지 완결 receipt (구현·검증 중) + +- Weather 성공 export가 부분 KMA 응답을 전체 집합으로 오인하지 않도록 `ResponseMetadata.pagination`과 공개 frozen `PaginationReceipt`를 추가한다. typed now/forecast는 페이지 echo·고정 totalCount/numOfRows·페이지별/누적 실제 행 수를 검증한다. 불일치는 typed KmaParseError이며 metadata 생략 레거시는 값을 유지하되 complete=False다. +- 첫 NO_DATA03은 기존 정상 빈 결과를 유지하며 now snapshot에는 명시0 receipt가 남는다. 중간 페이지03은 부분 성공으로 반환하지 않는다. 빈 예보 list에는 item metadata가 없으므로 그것만으로 정상0 완결을 승인하지 않는다. +- pagination complete는 원천 페이지/행 수 증거이며 항목 의미·유일성·발표 revision·downstream 전체 scope 권위를 대신하지 않는다. Weather의 collector/3B/실제API 배선·운영 적용은 별도다. +- Windows Python3.10 집중38 PASS·전체263 PASS/12 live deselected/14 subtests, Ruff·mypy26개파일 PASS. CI·2리뷰·live는 진행 중이다. 기존 로컬 .venv에 pytest-asyncio가 없어 거절된 환경 실패를 별도 전용 dev venv로 해소했고 기존 .venv/미커밋 .gitignore/.claude는 변경하지 않았다. + ## 2026-09-14 — 비동기 전용 API와 공통 TPS - 원격 최신 main 기준으로 Kma/DataGoKr/ApiHub와 생성 endpoint 470개를 비동기로 통합했다. diff --git a/docs/repeated-mistakes.md b/docs/repeated-mistakes.md index c54500a..0bdc9b8 100644 --- a/docs/repeated-mistakes.md +++ b/docs/repeated-mistakes.md @@ -281,3 +281,8 @@ Get-Content -Path docs/repeated-mistakes.md -Encoding UTF8 ``` **방지 테스트:** 한글이 깨져 보이면 먼저 UTF-8 명시 명령으로 다시 읽고, 파일 자체가 깨졌는지는 diff나 테스트 결과로 확인합니다. + + +## 2026-10-23 — 페이지 반복 종료와 완결을 혼동하지 않는다 + +증상: 요청 페이지가 끝났어도 실제 rows와 totalCount가 다른 부분 응답을 정상 typed 값으로 반환할 수 있었다. 규칙: echo/고정 metadata와 페이지별·누적 수를 검증하고 공개 receipt에 완결 여부를 남긴다. metadata 생략은 unknown이며 첫 NO_DATA와 중간 NO_DATA를 구분한다. `tests/test_typed_pagination_receipt.py`가 short page·수치/순서 변경·중간03·legacy·민감값 미노출을 검증한다. diff --git a/docs/resume.md b/docs/resume.md index d8f51e7..a6df939 100644 --- a/docs/resume.md +++ b/docs/resume.md @@ -1,5 +1,12 @@ # RESUME — 작업 재개 가이드 +## 2026-10-23 — typed 페이지 완결 receipt (구현·검증 중) + +- Weather 성공 export가 부분 KMA 응답을 전체 집합으로 오인하지 않도록 `ResponseMetadata.pagination`과 공개 frozen `PaginationReceipt`를 추가한다. typed now/forecast는 페이지 echo·고정 totalCount/numOfRows·페이지별/누적 실제 행 수를 검증한다. 불일치는 typed KmaParseError이며 metadata 생략 레거시는 값을 유지하되 complete=False다. +- 첫 NO_DATA03은 기존 정상 빈 결과를 유지하며 now snapshot에는 명시0 receipt가 남는다. 중간 페이지03은 부분 성공으로 반환하지 않는다. 빈 예보 list에는 item metadata가 없으므로 그것만으로 정상0 완결을 승인하지 않는다. +- pagination complete는 원천 페이지/행 수 증거이며 항목 의미·유일성·발표 revision·downstream 전체 scope 권위를 대신하지 않는다. Weather의 collector/3B/실제API 배선·운영 적용은 별도다. +- Windows Python3.10 집중38 PASS·전체263 PASS/12 live deselected/14 subtests, Ruff·mypy26개파일 PASS. CI·2리뷰·live는 진행 중이다. 기존 로컬 .venv에 pytest-asyncio가 없어 거절된 환경 실패를 별도 전용 dev venv로 해소했고 기존 .venv/미커밋 .gitignore/.claude는 변경하지 않았다. + 2026-09-14: 비동기 전용 및 공통 TPS 전환 완료. 검증/실제 API 결과는 verification-async-tps.md에 기록한다. 새 에이전트 세션이 시작될 때 "지금 어디까지 했고, 다음은 뭐 하면 되나"를 한 화면에서 답한다. diff --git a/kma-api.md b/kma-api.md index 269849e..60ec9cb 100644 --- a/kma-api.md +++ b/kma-api.md @@ -348,3 +348,10 @@ label_for(WeatherCategory.PRECIPITATION_TYPE, "4", endpoint=KmaEndpoint.VILAGE_F - [ ] `PCP`/`SNO`를 무조건 float로 변환하지 않는다. - [ ] `PTY` 매핑은 endpoint-aware로 처리한다. - [ ] 실제 네트워크 호출 없는 단위 테스트를 먼저 작성한다. + + +## typed 페이지 일관성과 receipt + +`KmaClient.now/forecast_short/forecast`의 내부 수집은 한 격자/base 선택에 대해 일관된 페이지 번호·크기·총건수를 검증한다. 페이지별 예상 행 수와 누적 수가 맞지 않거나 중간 NO_DATA면 `KmaParseError(failure_kind="parse",retryable=False)`다. 기존 페이지 상한을 유지하고 인증·rate limiter·시각 선택은 바꾸지 않는다. + +`ResponseMetadata.pagination`의 frozen `PaginationReceipt`는 선언된 totalCount, 실제 수, 페이지 수와 완결 여부를 보존한다. metadata 생략 응답은 기존 호환을 유지하면서 완결로 표시하지 않는다. 첫 NO_DATA03은 명시0 증거이며, 빈 예보 list의 반환형은 그대로라 별도 source 완결 증거 없이 downstream 전체 삭제에 쓰지 않는다. 이 증거만으로 데이터 row의 semantic uniqueness나 source 전체 권위를 승인하지 않는다. diff --git a/src/kma/__init__.py b/src/kma/__init__.py index 8db583f..2deb611 100644 --- a/src/kma/__init__.py +++ b/src/kma/__init__.py @@ -41,7 +41,7 @@ ) from .grid import kma_grid_to_wgs84, to_grid, to_latlon, wgs84_to_kma_grid from .locations import GridPoint, LatLon, normalize_location -from .metadata import ResponseMetadata, make_cache_key, sanitize_request_params +from .metadata import PaginationReceipt, ResponseMetadata, make_cache_key, sanitize_request_params from .missing import KMA_MISSING_ABS_THRESHOLD, is_missing from .models import ( AsosDailyItem, @@ -108,6 +108,7 @@ "LatLon", "MidForecastItem", "ObservedPrecipitationType", + "PaginationReceipt", "ResponseMetadata", "SENSITIVE_KEYS", "SkyCode", diff --git a/src/kma/client.py b/src/kma/client.py index e4a3b8f..8f8bc71 100644 --- a/src/kma/client.py +++ b/src/kma/client.py @@ -5,7 +5,7 @@ from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime -from typing import Any +from typing import Any, NoReturn import httpx @@ -30,7 +30,7 @@ from .exceptions import KmaError, KmaParseError from .grid import validate_grid from .locations import LocationInput, normalize_location -from .metadata import ResponseMetadata, make_response_metadata +from .metadata import PaginationReceipt, ResponseMetadata, make_response_metadata from .missing import is_missing from .models import ForecastItem, WeatherSnapshot from .pagination import has_next_page @@ -56,6 +56,7 @@ class _KmaBody: body: Mapping[str, Any] metadata: ResponseMetadata + no_data: bool = False @dataclass(frozen=True) @@ -163,12 +164,8 @@ async def now( ), humidity=_kma_int_or_none(by_category.get(WeatherCategory.HUMIDITY.value)), wind_speed=_kma_value_or_none(by_category.get(WeatherCategory.WIND_SPEED.value)), - wind_direction=_kma_int_or_none( - by_category.get(WeatherCategory.WIND_DIRECTION.value) - ), - precipitation=_observed_amount( - by_category.get(WeatherCategory.ONE_HOUR_RAIN.value) - ), + wind_direction=_kma_int_or_none(by_category.get(WeatherCategory.WIND_DIRECTION.value)), + precipitation=_observed_amount(by_category.get(WeatherCategory.ONE_HOUR_RAIN.value)), sky_label=label_for( WeatherCategory.SKY, by_category.get(WeatherCategory.SKY.value), @@ -288,20 +285,13 @@ async def _fetch_items( nx: int, ny: int, ) -> _FetchedItems: - """Fetch every page of one grid's response. - - `getVilageFcst` alone carries up to a dozen categories over a 3-day, - 3-hour-step forecast, and the item count that produces varies with how - many of those categories the office actually published for this base - time -- comfortably under our 1,000-row page most of the time, but not - always. Treating a second page as a hard, non-retryable parse error - (the previous behaviour here) turned an ordinary large response into a - failed run instead of one extra request, silently for however many - consecutive base times stayed over the line. - """ + """한 격자의 모든 페이지를 모으고 명시한 원천 개수와 대조한다.""" endpoint_name = enum_value(endpoint) items: list[Mapping[str, Any]] = [] metadata: ResponseMetadata | None = None + expected_total: int | None = None + expected_page_size: int | None = None + complete = True page_no = 1 while True: response = await self._request_with_metadata( @@ -315,26 +305,55 @@ async def _fetch_items( }, ) if metadata is None: - # The paginated request differs from page to page only in - # pageNo; base_date/base_time/nx/ny -- everything this - # metadata actually records -- stay fixed, so the first page - # describes the whole fetch. metadata = response.metadata - items.extend(_page_items(response.body, endpoint_name)) + page_items = _page_items(response.body, endpoint_name) + if response.no_data: + # 명시 NO_DATA(03)는 정상 빈 결과다. 중간 페이지 NO_DATA는 전체 일관성 오류다. + if page_no != 1 or items: + _pagination_error(endpoint_name, "중간 페이지가 NO_DATA로 바뀌었습니다") + expected_total = 0 + break + total = _pagination_integer(response.body, "totalCount", endpoint_name, minimum=0) + echoed_page = _pagination_integer(response.body, "pageNo", endpoint_name, minimum=1) + page_size = _pagination_integer(response.body, "numOfRows", endpoint_name, minimum=1) + if echoed_page is not None and echoed_page != page_no: + _pagination_error(endpoint_name, "응답 pageNo가 요청과 다릅니다") + if total is None or echoed_page is None or page_size is None: + complete = False + if total is not None: + if expected_total is not None and expected_total != total: + _pagination_error(endpoint_name, "페이지 사이 totalCount가 달라졌습니다") + expected_total = total + if page_size is not None: + if expected_page_size is not None and expected_page_size != page_size: + _pagination_error(endpoint_name, "페이지 사이 numOfRows가 달라졌습니다") + if len(page_items) > page_size: + _pagination_error(endpoint_name, "응답 행 수가 numOfRows를 넘었습니다") + expected_page_size = page_size + if total is not None and page_size is not None: + expected_rows = min(page_size, max(0, total - (page_no - 1) * page_size)) + if len(page_items) != expected_rows: + _pagination_error( + endpoint_name, "현재 페이지 행 수가 totalCount/numOfRows와 다릅니다" + ) + items.extend(page_items) + if expected_total is not None and len(items) > expected_total: + _pagination_error(endpoint_name, "실제 행 수가 totalCount를 넘었습니다") if not has_next_page(response.body): break page_no += 1 if page_no > _MAX_FETCH_PAGES: - raise KmaParseError( - f"KMA response did not finish paginating after " - f"{_MAX_FETCH_PAGES} pages", - provider="data.go.kr", - endpoint=endpoint_name, - failure_kind="parse", - retryable=False, - ) + _pagination_error(endpoint_name, "페이지 수집 상한에 도달했습니다") assert metadata is not None - return _FetchedItems(items, metadata) + if expected_total is not None and len(items) != expected_total: + _pagination_error(endpoint_name, "실제 행 수와 totalCount가 일치하지 않습니다") + receipt = PaginationReceipt( + expected_total_count=expected_total, + received_item_count=len(items), + page_count=page_no, + complete=complete and expected_total is not None, + ) + return _FetchedItems(items, metadata.model_copy(update={"pagination": receipt})) async def _request( self, @@ -505,7 +524,7 @@ def _parse_kma_body(response: Any, endpoint_name: str, metadata: ResponseMetadat endpoint=endpoint_name, label="KMA", ): - return _KmaBody(empty_kma_body(), metadata) + return _KmaBody(empty_kma_body(), metadata, no_data=True) raise KmaParseError( "KMA response was not valid JSON in the expected shape", provider="data.go.kr", @@ -528,7 +547,9 @@ def _parse_kma_body(response: Any, endpoint_name: str, metadata: ResponseMetadat message = str(header.get("resultMsg", "")) if code == NO_DATA_RESULT_CODE: # NO_DATA는 "조회 결과 없음" — 오류가 아니라 정상적인 빈 결과다. - return _KmaBody(empty_kma_body(body if isinstance(body, Mapping) else None), metadata) + return _KmaBody( + empty_kma_body(body if isinstance(body, Mapping) else None), metadata, no_data=True + ) if code != "00": _raise_for_result_code(code, message, endpoint=endpoint_name) if not isinstance(body, Mapping): @@ -543,6 +564,42 @@ def _parse_kma_body(response: Any, endpoint_name: str, metadata: ResponseMetadat return _KmaBody(body, metadata) +def _pagination_error(endpoint: str, message: str) -> NoReturn: + raise KmaParseError( + "KMA pagination: " + message, + provider="data.go.kr", + endpoint=endpoint, + failure_kind="parse", + retryable=False, + ) + + +def _pagination_integer( + body: Mapping[str, Any], + name: str, + endpoint: str, + *, + minimum: int, +) -> int | None: + value = body.get(name) + if value is None: + return None + if type(value) is int: + parsed = value + elif ( + isinstance(value, str) + and len(value.strip()) <= 20 + and value.strip().isascii() + and value.strip().isdigit() + ): + parsed = int(value.strip()) + else: + _pagination_error(endpoint, "페이지 metadata가 정수가 아닙니다: " + name) + if parsed < minimum: + _pagination_error(endpoint, "페이지 metadata 범위가 잘못됐습니다: " + name) + return parsed + + def _page_items(body: Mapping[str, Any], endpoint_name: str) -> list[Mapping[str, Any]]: """Extract one page's ``items.item`` list, raising the same parse errors ``_fetch_items`` always has for a shape it doesn't recognise.""" @@ -566,6 +623,8 @@ def _page_items(body: Mapping[str, Any], endpoint_name: str) -> list[Mapping[str failure_kind="parse", retryable=False, ) + if any(not isinstance(item, Mapping) for item in items): + _pagination_error(endpoint_name, "items.item에 객체가 아닌 행이 있습니다") return items diff --git a/src/kma/metadata.py b/src/kma/metadata.py index 050f221..fb7f68f 100644 --- a/src/kma/metadata.py +++ b/src/kma/metadata.py @@ -10,7 +10,7 @@ from typing import Any from urllib.parse import parse_qsl, urlsplit -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator _CREDENTIAL_PARAM_NAMES = { "apikey", @@ -26,6 +26,29 @@ ) +class PaginationReceipt(BaseModel): + """typed 조회의 페이지 순서와 원천 totalCount 일치 증거다. + + 항목 의미/유일성이나 downstream의 전체 데이터셋 권위를 대신하지 않는다. + metadata가 생략된 응답은 complete=False로만 표시한다. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + expected_total_count: int | None = Field(default=None, ge=0) + received_item_count: int = Field(ge=0) + page_count: int = Field(ge=1) + complete: bool + + @model_validator(mode="after") + def _complete_count_matches(self) -> PaginationReceipt: + if self.complete and ( + self.expected_total_count is None + or self.received_item_count != self.expected_total_count + ): + raise ValueError("완결 receipt에는 totalCount와 실제 행 수 일치가 필요합니다") + return self + + class ResponseMetadata(BaseModel): """`kma` 응답 모델의 제공자 출처 정보. @@ -43,6 +66,7 @@ class ResponseMetadata(BaseModel): base_date: str | None = None base_time: str | None = None reference_time: datetime | None = None + pagination: PaginationReceipt | None = None @field_validator("request_params", mode="before") @classmethod diff --git a/tests/test_async_tps.py b/tests/test_async_tps.py index dead978..fa772ee 100644 --- a/tests/test_async_tps.py +++ b/tests/test_async_tps.py @@ -58,7 +58,15 @@ def handler(request): return httpx.Response(302, headers={"Location": "/resolved"}) page = int(request.url.params.get("pageNo", 1)) total = 2 if request.url.path.endswith("/pages") else 1 - return httpx.Response(200, json=payload(page=page, total=total)) + # totalCount=1인 typed fixture도 실제1행을 포함해야 완결 검증과 일치한다. + return httpx.Response( + 200, + json=payload( + page=page, + total=total, + items=[{"category": "T1H", "obsrValue": "18.4"}], + ), + ) async with httpx.AsyncClient( transport=httpx.MockTransport(handler), follow_redirects=True diff --git a/tests/test_client.py b/tests/test_client.py index 2541050..51e4362 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -615,19 +615,16 @@ class NeverLastPageSession: def __init__(self) -> None: self.calls = 0 - async def get( - self, url: str, *, params: dict[str, Any], timeout: float - ) -> FakeResponse: + async def get(self, url: str, *, params: dict[str, Any], timeout: float) -> FakeResponse: self.calls += 1 page_no = int(params["pageNo"]) - # totalCount always claims one more row than this page reports, - # so has_next_page is true no matter how many pages are fetched. + # 상한보다 큰 고정 totalCount로, 일관성 오류가 아닌 실제 페이지 상한을 검증한다. return FakeResponse( _paged_payload( [{"category": "T1H", "obsrValue": "18.4"}], page_no=page_no, num_of_rows=1, - total_count=page_no + 1, + total_count=1000000, ) ) diff --git a/tests/test_typed_pagination_receipt.py b/tests/test_typed_pagination_receipt.py new file mode 100644 index 0000000..37970e1 --- /dev/null +++ b/tests/test_typed_pagination_receipt.py @@ -0,0 +1,137 @@ +from datetime import datetime + +import pytest +from pydantic import ValidationError +from test_client import FakeSession, PagedFakeSession, _error_payload, _paged_payload, _payload + +from kma import KmaClient, PaginationReceipt +from kma.exceptions import KmaParseError +from kma.time_utils import KST + +WHEN = datetime(2026, 4, 30, 14, 45, tzinfo=KST) +ROW = {"category": "T1H", "obsrValue": "18.4"} + + +async def test_complete_receipt_counts_every_page_and_keeps_secrets_out(): + session = PagedFakeSession( + { + 1: _paged_payload([ROW], page_no=1, num_of_rows=1, total_count=2), + 2: _paged_payload( + [{"category": "REH", "obsrValue": "52"}], page_no=2, num_of_rows=1, total_count=2 + ), + } + ) + snapshot = await KmaClient("opaque-secret", session=session).now(nx=60, ny=127, when=WHEN) + receipt = snapshot.metadata.pagination + assert receipt == PaginationReceipt( + expected_total_count=2, received_item_count=2, page_count=2, complete=True + ) + assert snapshot.temperature == 18.4 and snapshot.humidity == 52 + assert session.requested_pages == [1, 2] + assert "opaque-secret" not in snapshot.metadata.model_dump_json() + with pytest.raises(ValidationError): + receipt.page_count = 100 + + +async def test_missing_metadata_remains_usable_but_never_proves_completeness(): + snapshot = await KmaClient("key", session=FakeSession(_payload([ROW]))).now( + nx=60, ny=127, when=WHEN + ) + assert snapshot.temperature == 18.4 + assert snapshot.metadata.pagination == PaginationReceipt( + expected_total_count=None, + received_item_count=1, + page_count=1, + complete=False, + ) + + +async def test_missing_page_echo_does_not_claim_complete(): + payload = _paged_payload([ROW], page_no=1, num_of_rows=1, total_count=1) + del payload["response"]["body"]["pageNo"] + snapshot = await KmaClient("key", session=FakeSession(payload)).now(nx=60, ny=127, when=WHEN) + assert not snapshot.metadata.pagination.complete + assert snapshot.metadata.pagination.expected_total_count == 1 + + +@pytest.mark.parametrize( + "field,value", + [ + ("totalCount", True), + ("totalCount", -1), + ("totalCount", 1.1), + ("totalCount", "1.0"), + ("totalCount", "١"), + ("totalCount", "9" * 5000), + ("pageNo", 0), + ("numOfRows", 0), + ("numOfRows", "opaque-secret"), + ], +) +async def test_invalid_counter_metadata_is_a_typed_parse_failure(field, value): + payload = _paged_payload([ROW], page_no=1, num_of_rows=1, total_count=1) + payload["response"]["body"][field] = value + with pytest.raises(KmaParseError) as failure: + await KmaClient("opaque-secret", session=FakeSession(payload)).now(nx=60, ny=127, when=WHEN) + assert failure.value.failure_kind == "parse" and not failure.value.retryable + assert "opaque-secret" not in str(failure.value) + + +async def test_short_page_fails_before_wasting_another_request(): + session = FakeSession(_paged_payload([ROW], page_no=1, num_of_rows=1000, total_count=1001)) + with pytest.raises(KmaParseError): + await KmaClient("key", session=session).now(nx=60, ny=127, when=WHEN) + assert len(session.calls) == 1 + + +async def test_total_count_cannot_change_between_pages(): + session = PagedFakeSession( + { + 1: _paged_payload([ROW], page_no=1, num_of_rows=1, total_count=2), + 2: _paged_payload([ROW], page_no=2, num_of_rows=1, total_count=3), + } + ) + with pytest.raises(KmaParseError): + await KmaClient("key", session=session).now(nx=60, ny=127, when=WHEN) + assert session.requested_pages == [1, 2] + + +async def test_repeated_page_echo_is_rejected(): + session = PagedFakeSession( + { + 1: _paged_payload([ROW], page_no=1, num_of_rows=1, total_count=2), + 2: _paged_payload([ROW], page_no=1, num_of_rows=1, total_count=2), + } + ) + with pytest.raises(KmaParseError): + await KmaClient("key", session=session).now(nx=60, ny=127, when=WHEN) + + +async def test_mid_fetch_no_data_cannot_erase_partial_data_as_success(): + session = PagedFakeSession( + { + 1: _paged_payload([ROW], page_no=1, num_of_rows=1, total_count=2), + 2: _error_payload("03", "NO_DATA"), + } + ) + with pytest.raises(KmaParseError): + await KmaClient("key", session=session).now(nx=60, ny=127, when=WHEN) + + +async def test_first_page_no_data_has_explicit_zero_receipt(): + snapshot = await KmaClient("key", session=FakeSession(_error_payload("03", "NO_DATA"))).now( + nx=60, ny=127, when=WHEN + ) + assert snapshot.metadata.pagination == PaginationReceipt( + expected_total_count=0, + received_item_count=0, + page_count=1, + complete=True, + ) + + +def test_inconsistent_complete_receipt_cannot_be_constructed(): + with pytest.raises(ValidationError): + PaginationReceipt( + expected_total_count=2, received_item_count=1, page_count=1, complete=True + )