From 6e1b156667bf0bbb54e07ab5bfd8df75c2c4bfb0 Mon Sep 17 00:00:00 2001 From: Flummy1 Date: Thu, 16 Jul 2026 12:40:54 +0300 Subject: [PATCH 1/2] fix: preserve requested review filter and add ReviewsBatch.next_batch Stamp the requested filter and user_id onto ReviewsBatch in GetReviews.transform_result, since FunPay omits the hidden filter/user_id inputs in some responses. Add ReviewsBatch.next_batch() to paginate while preserving the filter, mirroring the transactions pagination fix. Co-Authored-By: Claude Opus 4.8 --- funpaybotengine/methods/get_reviews.py | 19 +++++++++++++++++++ funpaybotengine/types/reviews.py | 18 ++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/funpaybotengine/methods/get_reviews.py b/funpaybotengine/methods/get_reviews.py index d4d8f19..17acc24 100644 --- a/funpaybotengine/methods/get_reviews.py +++ b/funpaybotengine/methods/get_reviews.py @@ -4,6 +4,8 @@ __all__ = ('GetReviews',) +from typing import TYPE_CHECKING, Any + from pydantic import BaseModel from funpayparsers.types import Language from funpayparsers.parsers import ReviewsParser @@ -14,6 +16,10 @@ from funpaybotengine.client.session.http_methods import HTTPMethod +if TYPE_CHECKING: + from funpaybotengine.client.session.base import RawResponse + + STATE_FILTERS = { OrderStatus.COMPLETED: 'closed', OrderStatus.PAID: 'paid', @@ -51,3 +57,16 @@ def __init__( from_review_id=from_review_id, filter=filter, ) + + async def transform_result( + self, + parsing_result: Any, + response: RawResponse[Any], + ) -> ReviewsBatch: + batch = await super().transform_result(parsing_result, response) + # FunPay omits the hidden ``filter`` and ``user_id`` inputs in some responses, + # so the scraped values are unreliable. Stamp the ones actually requested to + # keep the filter (and the profile id needed for pagination) across batches. + batch.filter = self.filter + batch.user_id = self.user_id + return batch diff --git a/funpaybotengine/types/reviews.py b/funpaybotengine/types/reviews.py index e8f4d9e..65af96e 100644 --- a/funpaybotengine/types/reviews.py +++ b/funpaybotengine/types/reviews.py @@ -126,3 +126,21 @@ class ReviewsBatch(FunPayObject, BaseModel): If present, this value should be included in the next request to fetch the following batch of reviews. If ``None``, there are no more reviews to load. """ + + async def next_batch(self) -> ReviewsBatch: + if not self.next_review_id: + raise ValueError('Last batch.') + + if self.user_id is None: + raise ValueError('Unknown user id.') + + # Imported lazily to avoid a circular import between types and methods. + from funpaybotengine.methods.get_reviews import GetReviews + + return ( + await GetReviews( + user_id=self.user_id, + from_review_id=self.next_review_id, + filter=self.filter or '', + ).execute(self.get_bound_bot()) + ).response_obj From b58277764b92803241e38e38184005b3c4f42e62 Mon Sep 17 00:00:00 2001 From: Flummy1 Date: Wed, 9 Sep 2026 13:24:42 +0300 Subject: [PATCH 2/2] refactor: stamp requested filters in parse_result instead of the model context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback: passing the filters through `context` makes it hard to see where the values on the model came from. The parsers return plain mutable dataclasses, so the methods can set the fields right after parsing — the requested value sits next to the field it overrides, and both models go back to being untouched except for the new `ReviewsBatch.next_batch()`. Co-Authored-By: Claude Opus 5 --- funpaybotengine/methods/get_reviews.py | 18 +++- funpaybotengine/methods/get_transactions.py | 15 ++- funpaybotengine/types/finances.py | 10 -- funpaybotengine/types/reviews.py | 15 --- tests/pagination_filters_test.py | 101 +++++++++++--------- 5 files changed, 86 insertions(+), 73 deletions(-) diff --git a/funpaybotengine/methods/get_reviews.py b/funpaybotengine/methods/get_reviews.py index a5169f2..7cf8278 100644 --- a/funpaybotengine/methods/get_reviews.py +++ b/funpaybotengine/methods/get_reviews.py @@ -4,6 +4,9 @@ __all__ = ['GetReviews'] +from typing import TYPE_CHECKING, Any + +from funpayparsers.types.reviews import ReviewsBatch as ParsedReviewsBatch from funpayparsers.parsers.reviews_parser import ReviewsParser from funpaybotengine.types import ReviewsBatch @@ -11,6 +14,10 @@ from funpaybotengine.client.session.http_methods import HTTPMethod +if TYPE_CHECKING: + from funpaybotengine.client import RawResponse + + class GetReviews(FunPayMethod[ReviewsBatch]): """ Get a reviews list of a user (``https://funpay.com/users/reviews``). @@ -21,10 +28,19 @@ class GetReviews(FunPayMethod[ReviewsBatch]): url = 'users/reviews' method = HTTPMethod.POST data = lambda m, *_: {'user_id': m.user_id, 'continue': m.from_review_id, 'filter': m.filter} - context = lambda m, *_: {'reviews_user_id': m.user_id, 'reviews_filter': m.filter} parser_cls = ReviewsParser model_to_build = ReviewsBatch user_id: int from_review_id: str = '' filter: str = '' + + async def parse_result(self, response: RawResponse[Any]) -> ParsedReviewsBatch: + result: ParsedReviewsBatch = await super().parse_result(response) + + # FunPay omits the hidden ``user_id`` / ``filter`` inputs in a part of the + # ``users/reviews`` responses, so the parsed values are unreliable. The requested + # ones are known here and are what the next batch has to be asked with. + result.user_id = self.user_id + result.filter = self.filter + return result diff --git a/funpaybotengine/methods/get_transactions.py b/funpaybotengine/methods/get_transactions.py index 9471135..dc08f94 100644 --- a/funpaybotengine/methods/get_transactions.py +++ b/funpaybotengine/methods/get_transactions.py @@ -6,6 +6,9 @@ from typing import TYPE_CHECKING, Any from funpayparsers.parsers import TransactionPreviewsParser +from funpayparsers.types.finances import ( + TransactionPreviewsBatch as ParsedTransactionPreviewsBatch, +) from funpaybotengine.types import TransactionPreviewsBatch from funpaybotengine.types.enums import TransactionFilter @@ -14,7 +17,7 @@ if TYPE_CHECKING: - from funpaybotengine.client import Bot + from funpaybotengine.client import Bot, RawResponse def make_data(m: GetTransactions, bot: Bot) -> dict[str, Any]: @@ -35,9 +38,17 @@ class GetTransactions(FunPayMethod[TransactionPreviewsBatch]): url = 'users/transactions' method = HTTPMethod.POST data = make_data - context = lambda m, *_: {'transactions_filter': m.filter} parser_cls = TransactionPreviewsParser model_to_build = TransactionPreviewsBatch filter: TransactionFilter = TransactionFilter.ALL from_transaction_id: int = 0 + + async def parse_result(self, response: RawResponse[Any]) -> ParsedTransactionPreviewsBatch: + result: ParsedTransactionPreviewsBatch = await super().parse_result(response) + + # FunPay omits the hidden ``filter`` input in a part of the ``users/transactions`` + # responses, so the parsed value is unreliable. The requested filter is known here + # and is what the next batch has to be asked with. + result.filter = self.filter.value + return result diff --git a/funpaybotengine/types/finances.py b/funpaybotengine/types/finances.py index 1c301db..16d6827 100644 --- a/funpaybotengine/types/finances.py +++ b/funpaybotengine/types/finances.py @@ -4,7 +4,6 @@ __all__ = ('TransactionPreview', 'TransactionInfo', 'TransactionPreviewsBatch') -from typing import Any from types import MappingProxyType from collections.abc import Mapping @@ -78,15 +77,6 @@ class TransactionPreviewsBatch(FunPayObject, BaseModel): along with metadata required to fetch the next batch. """ - def model_post_init(self, context: dict[Any, Any]) -> None: - super().model_post_init(context) - - # FunPay omits the hidden ``filter`` input in a part of the ``users/transactions`` - # responses, so the parsed value is unreliable. When the batch comes from - # ``GetTransactions``, the requested filter is known and is authoritative. - if context and isinstance(context.get('transactions_filter'), TransactionFilter): - self.filter = context['transactions_filter'] - transactions: tuple[TransactionPreview, ...] """List of transaction previews included in this batch.""" diff --git a/funpaybotengine/types/reviews.py b/funpaybotengine/types/reviews.py index 11194ec..19768ad 100644 --- a/funpaybotengine/types/reviews.py +++ b/funpaybotengine/types/reviews.py @@ -4,8 +4,6 @@ __all__ = ('Review', 'ReviewsBatch') -from typing import Any - from pydantic import BaseModel from funpayparsers.parsers.utils import parse_date_string @@ -101,19 +99,6 @@ class ReviewsBatch(FunPayObject, BaseModel): along with metadata required to fetch the next batch. """ - def model_post_init(self, context: dict[Any, Any]) -> None: - super().model_post_init(context) - if not context: - return - - # FunPay omits the hidden ``user_id`` / ``filter`` inputs in a part of the - # ``users/reviews`` responses, so the parsed values are unreliable. When the batch - # comes from ``GetReviews``, the requested ones are known and are authoritative. - if isinstance(context.get('reviews_user_id'), int): - self.user_id = context['reviews_user_id'] - if isinstance(context.get('reviews_filter'), str): - self.filter = context['reviews_filter'] - reviews: tuple[Review, ...] """List of reviews included in this batch.""" diff --git a/tests/pagination_filters_test.py b/tests/pagination_filters_test.py index 5a52488..1d2de3a 100644 --- a/tests/pagination_filters_test.py +++ b/tests/pagination_filters_test.py @@ -1,13 +1,16 @@ from __future__ import annotations import asyncio -from typing import Any +from typing import Any, cast +from http import HTTPStatus import pytest -from funpayparsers.parsers import ReviewsParser, TransactionPreviewsParser from funpaybotengine.types.enums import TransactionFilter from funpaybotengine.types.reviews import ReviewsBatch from funpaybotengine.types.finances import TransactionPreviewsBatch +from funpaybotengine.client.session.base import RawResponse +from funpaybotengine.methods.get_reviews import GetReviews +from funpaybotengine.methods.get_transactions import GetTransactions # Both ``users/reviews`` and ``users/transactions`` may answer without the hidden @@ -31,62 +34,75 @@ """ -def parse_reviews(context: dict[str, Any] | None = None) -> ReviewsBatch: - return ReviewsBatch.model_validate(ReviewsParser(REVIEWS_HTML).parse(), context=context) +def raw_response(method: Any, html: str) -> RawResponse[Any]: + return RawResponse( + url='https://funpay.com/', + status_code=HTTPStatus.OK, + raw_response=html, + headers={}, + cookies={}, + method_obj=method, + context={}, + # Neither ``parse_result`` nor the parsers touch the bot. + executed_as=cast(Any, None), + ) -def parse_transactions(context: dict[str, Any] | None = None) -> TransactionPreviewsBatch: - return TransactionPreviewsBatch.model_validate( - TransactionPreviewsParser(TRANSACTIONS_HTML).parse(), - context=context, - ) +def parse(method: Any, html: str) -> Any: + return asyncio.run(method.parse_result(raw_response(method, html))) def test_reviews_response_carries_no_filter_and_user_id() -> None: # Guards the premise of the fix: without the hidden inputs there is nothing to parse. - batch = parse_reviews() + from funpayparsers.parsers import ReviewsParser - assert batch.user_id is None - assert batch.filter is None + parsed = ReviewsParser(REVIEWS_HTML).parse() + assert parsed.user_id is None + assert parsed.filter is None -def test_reviews_batch_prefers_requested_filter_and_user_id() -> None: - batch = parse_reviews({'reviews_user_id': 1234, 'reviews_filter': '5'}) - assert batch.user_id == 1234 - assert batch.filter == '5' +def test_get_reviews_stamps_requested_filter_and_user_id() -> None: + parsed = parse(GetReviews(user_id=1234, filter='5'), REVIEWS_HTML) + assert parsed.user_id == 1234 + assert parsed.filter == '5' -def test_reviews_batch_keeps_parsed_values_without_context() -> None: - # A batch nested in a profile page gets the page context, not a ``GetReviews`` one. - batch = ReviewsBatch.model_validate( - ReviewsParser(REVIEWS_HTML).parse(), - context={'unrelated': 'context'}, - ) - assert batch.user_id is None - assert batch.filter is None +def test_get_transactions_stamps_requested_filter() -> None: + parsed = parse(GetTransactions(filter=TransactionFilter.WITHDRAW), TRANSACTIONS_HTML) + + assert parsed.filter == TransactionFilter.WITHDRAW.value + + +def build_reviews_batch(**kwargs: Any) -> ReviewsBatch: + return ReviewsBatch.model_validate( + { + 'reviews': (), + 'user_id': None, + 'filter': None, + 'next_review_id': None, + **kwargs, + }, + ) def test_reviews_next_batch_rejects_last_batch() -> None: - batch = parse_reviews({'reviews_user_id': 1234, 'reviews_filter': '5'}) + batch = build_reviews_batch(user_id=1234, filter='5') with pytest.raises(ValueError, match='Last batch.'): asyncio.run(batch.next_batch()) def test_reviews_next_batch_rejects_unknown_user() -> None: - batch = parse_reviews() - batch.next_review_id = '42' + batch = build_reviews_batch(next_review_id='42') with pytest.raises(ValueError, match='Unknown user id.'): asyncio.run(batch.next_batch()) def test_reviews_next_batch_passes_cursor_and_filter() -> None: - batch = parse_reviews({'reviews_user_id': 1234, 'reviews_filter': '5'}) - batch.next_review_id = '42' - + batch = build_reviews_batch(user_id=1234, filter='5', next_review_id='42') calls: list[dict[str, Any]] = [] class BotStub: @@ -94,26 +110,21 @@ async def get_reviews(self, **kwargs: Any) -> ReviewsBatch: calls.append(kwargs) return batch - batch.bind_to(BotStub()) # type: ignore[arg-type] # only ``get_reviews`` is used + batch.bind_to(cast(Any, BotStub())) asyncio.run(batch.next_batch()) assert calls == [{'user_id': 1234, 'from_review_id': '42', 'filter': '5'}] -def test_transactions_response_carries_no_filter() -> None: - assert parse_transactions().filter is None - - -def test_transactions_batch_prefers_requested_filter() -> None: - batch = parse_transactions({'transactions_filter': TransactionFilter.WITHDRAW}) - - assert batch.filter is TransactionFilter.WITHDRAW - - def test_transactions_next_batch_keeps_filter() -> None: - batch = parse_transactions({'transactions_filter': TransactionFilter.WITHDRAW}) - batch.next_transaction_id = 42 - + batch = TransactionPreviewsBatch.model_validate( + { + 'transactions': (), + 'user_id': None, + 'filter': TransactionFilter.WITHDRAW.value, + 'next_transaction_id': 42, + }, + ) calls: list[dict[str, Any]] = [] class BotStub: @@ -121,7 +132,7 @@ async def get_transactions(self, **kwargs: Any) -> TransactionPreviewsBatch: calls.append(kwargs) return batch - batch.bind_to(BotStub()) # type: ignore[arg-type] # only ``get_transactions`` is used + batch.bind_to(cast(Any, BotStub())) asyncio.run(batch.next_batch()) assert calls == [{'from_transaction_id': 42, 'filter': TransactionFilter.WITHDRAW}]