From 8a7a47b2c551de9ce3a6d0b66df0c6eda8b4cf3b Mon Sep 17 00:00:00 2001 From: Flummy1 <82732824+Flummy1@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:07:16 +0300 Subject: [PATCH 1/2] chore: fix ruff and mypy strict checks --- .gitattributes | 8 ++++ funpaybotengine/client/bot.py | 7 +++- .../exceptions/method_exceptions.py | 39 +++++++++---------- funpaybotengine/methods/__init__.py | 2 +- funpaybotengine/methods/raise_offers.py | 6 +-- funpaybotengine/methods/refund.py | 4 +- funpaybotengine/methods/save_offer_fields.py | 23 +++++------ funpaybotengine/runner/event_collector.py | 6 ++- funpaybotengine/runner/runner.py | 18 ++++----- funpaybotengine/types/__init__.py | 2 +- funpaybotengine/types/calc.py | 3 +- funpaybotengine/types/offers.py | 3 +- funpaybotengine/types/pages/__init__.py | 2 +- 13 files changed, 69 insertions(+), 54 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..94c0ef5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +* text=auto + +*.py text eol=lf +*.toml text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +*.md text eol=lf +Makefile text eol=lf diff --git a/funpaybotengine/client/bot.py b/funpaybotengine/client/bot.py index c8d902a..1189fe6 100644 --- a/funpaybotengine/client/bot.py +++ b/funpaybotengine/client/bot.py @@ -25,9 +25,9 @@ TransactionFilter, OrderPreviewsBatch, PrivateChatPreview, + RaiseOffersResponse, TransactionPreviewsBatch, CurrentlyViewingOfferInfo, - RaiseOffersResponse ) from funpaybotengine.utils import ( random_runner_tag, @@ -466,6 +466,8 @@ async def calc_lots(self, subcategory_id: int, price: float) -> CalcResult: async def logout(self) -> bool: if self._logout_token is None: await self.update() + if self._logout_token is None: + raise BotNotInitializedError(self) return (await Logout(logout_token=self._logout_token).execute(self)).response_obj @@ -602,7 +604,8 @@ async def get_chat_messages( :param args: Tuple of (chat_id, after_message_id) - :returns: A dictionary [chat_id, list] or a list of up to 100 ``Message`` objects, sorted from oldest to newest. + :returns: A dictionary [chat_id, list] or a list of up to 100 ``Message`` objects, + sorted from oldest to newest. """ if not args and chat_id is None: raise ValueError('Either `chat_id` or `args` must be provided.') diff --git a/funpaybotengine/exceptions/method_exceptions.py b/funpaybotengine/exceptions/method_exceptions.py index a545c8b..4351fa9 100644 --- a/funpaybotengine/exceptions/method_exceptions.py +++ b/funpaybotengine/exceptions/method_exceptions.py @@ -1,20 +1,19 @@ -__all__ = [ - 'MethodError', - 'InvalidOfferFieldsError' -] - - -from .base import FunPayBotEngineError - - -class MethodError(FunPayBotEngineError): - ... - - -class InvalidOfferFieldsError(MethodError): - def __init__(self, message: str, fields: dict[str, str] | None = None): - self.message = message - self.fields = fields - - def __str__(self): - return self.message +from __future__ import annotations + + +__all__ = ['MethodError', 'InvalidOfferFieldsError'] + + +from .base import FunPayBotEngineError + + +class MethodError(FunPayBotEngineError): ... + + +class InvalidOfferFieldsError(MethodError): + def __init__(self, message: str, fields: dict[str, str] | None = None): + self.message = message + self.fields = fields + + def __str__(self) -> str: + return self.message diff --git a/funpaybotengine/methods/__init__.py b/funpaybotengine/methods/__init__.py index 2c8e77b..100dba8 100644 --- a/funpaybotengine/methods/__init__.py +++ b/funpaybotengine/methods/__init__.py @@ -28,8 +28,8 @@ from .get_settings_page import * from .save_offer_fields import * from .set_offers_hidden import * -from .get_sras_info_page import * from .get_my_offers_page import * +from .get_sras_info_page import * from .get_subcategory_page import * from .get_transactions_page import * from .update_notice_channel import * diff --git a/funpaybotengine/methods/raise_offers.py b/funpaybotengine/methods/raise_offers.py index df48303..5b4243c 100644 --- a/funpaybotengine/methods/raise_offers.py +++ b/funpaybotengine/methods/raise_offers.py @@ -4,7 +4,7 @@ __all__ = ('RaiseOffers',) import json -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from collections.abc import Sequence from pydantic import Field @@ -12,8 +12,8 @@ from funpaybotengine.types.enums import Language from funpaybotengine.methods.base import FunPayMethod -from funpaybotengine.client.session.http_methods import HTTPMethod from funpaybotengine.types.common import RaiseOffersResponse +from funpaybotengine.client.session.http_methods import HTTPMethod if TYPE_CHECKING: @@ -47,4 +47,4 @@ def __init__( async def parse_result(self, response: RawResponse[bool]) -> dict[str, Any]: data = json.loads(response.raw_response) - return {'raw_source': response.raw_response} | data + return {'raw_source': response.raw_response} | cast(dict[str, Any], data) diff --git a/funpaybotengine/methods/refund.py b/funpaybotengine/methods/refund.py index fa43f11..d449622 100644 --- a/funpaybotengine/methods/refund.py +++ b/funpaybotengine/methods/refund.py @@ -42,11 +42,11 @@ def __init__(self, order_id: str, locale: Language | None = None): async def parse_result(self, response: RawResponse[Any]) -> bool: try: result = json.loads(response.raw_response) - except: + except json.decoder.JSONDecodeError: raise RefundError( order_id=self.order_id, message=f'Unable to refund order {self.order_id}', - ) + ) from None if result.get('error'): raise RefundError( diff --git a/funpaybotengine/methods/save_offer_fields.py b/funpaybotengine/methods/save_offer_fields.py index 5659182..f70f456 100644 --- a/funpaybotengine/methods/save_offer_fields.py +++ b/funpaybotengine/methods/save_offer_fields.py @@ -3,17 +3,17 @@ __all__ = ('SaveOfferFields',) -from typing import Any +import json +from typing import Any, cast from pydantic import BaseModel from funpayparsers.types import Language from funpayparsers.parsers import OfferFieldsParser -from funpaybotengine.exceptions.method_exceptions import InvalidOfferFieldsError from funpaybotengine.methods.base import FunPayMethod from funpaybotengine.types.offers import OfferFields from funpaybotengine.client.session import HTTPMethod, RawResponse -import json +from funpaybotengine.exceptions.method_exceptions import InvalidOfferFieldsError class SaveOfferFields(FunPayMethod[bool], BaseModel): @@ -43,27 +43,28 @@ def __init__( async def parse_result(self, response: RawResponse[Any]) -> bool | dict[str, Any]: try: - return json.loads(response.raw_response) + return cast(dict[str, Any], json.loads(response.raw_response)) except json.decoder.JSONDecodeError: return True async def transform_result( self, parsing_result: bool | dict[str, Any], - response: RawResponse[Any] + response: RawResponse[Any], ) -> bool: if isinstance(parsing_result, bool): return parsing_result - if not parsing_result.get('error'): return True error_msg = str(parsing_result.get('msg', '')) or str(parsing_result.get('error')) - fields: list[list[str, str]] = parsing_result.get('errors') - try: - fields_dict = {field: error for field, error in fields} - except Exception: - fields_dict = {} + raw_fields = parsing_result.get('errors') + fields_dict: dict[str, str] = {} + if isinstance(raw_fields, list): + for item in raw_fields: + if isinstance(item, list) and len(item) == 2: + field, error = item + fields_dict[str(field)] = str(error) raise InvalidOfferFieldsError(error_msg, fields_dict) diff --git a/funpaybotengine/runner/event_collector.py b/funpaybotengine/runner/event_collector.py index 9087b8f..21f4cc3 100644 --- a/funpaybotengine/runner/event_collector.py +++ b/funpaybotengine/runner/event_collector.py @@ -3,6 +3,7 @@ import time from typing import TYPE_CHECKING, Any, Type, Literal, TypeVar, cast from itertools import chain +from collections import ChainMap from collections.abc import Callable from funpaybotengine.types import PrivateChatPreview @@ -53,7 +54,6 @@ from funpaybotengine.storage.base import Storage from funpaybotengine.types.orders import OrderPreview from funpaybotengine.types.updates import RunnerResponse -from collections import ChainMap CHAT_EVENTS = ChatChangedEvent | NewMessageEvent @@ -103,6 +103,7 @@ async def inner(*args: Any, **kwargs: Any) -> Any: except UnexpectedHTTPStatusError: if not attempts: raise + return None return inner # type: ignore @@ -324,7 +325,8 @@ async def get_new_message_events(self, total: EventsPack) -> None: if from_id != 0: if from_id < message.id <= to_id: logger.debug( - 'New message in chat %r (%r): %r (from IDs difference: %r < %r <= %r).', + 'New message in chat %r (%r): %r ' + '(from IDs difference: %r < %r <= %r).', chat_event.chat_preview.username, chat_event.chat_preview.id, message.id, diff --git a/funpaybotengine/runner/runner.py b/funpaybotengine/runner/runner.py index e79bd85..0618891 100644 --- a/funpaybotengine/runner/runner.py +++ b/funpaybotengine/runner/runner.py @@ -9,7 +9,7 @@ import asyncio from typing import TYPE_CHECKING, Any from dataclasses import field, dataclass -from collections.abc import Iterator, AsyncGenerator +from collections.abc import Iterator, Sequence, AsyncGenerator from funpaybotengine.loggers import runner_logger from funpaybotengine.exceptions import UnauthorizedError, BotUnauthenticatedError @@ -41,7 +41,7 @@ def __getitem__(self, item: Any) -> Any: def __setitem__(self, item: Any, value: Any) -> None: self.data[item] = value - def __iter__(self) -> Iterator[RunnerEvent[Any]]: + def __iter__(self) -> Iterator[RunnerEvent[Any] | BotEngineEvent[Any]]: return iter(self.events) def get(self, value: str, fallback: Any = None) -> Any: @@ -60,7 +60,7 @@ async def listen( self, config: RunnerConfig | None = None, session_storage: Storage | None = None, - ) -> AsyncGenerator[tuple[RunnerEvent[Any], EventsStack], None]: + ) -> AsyncGenerator[tuple[RunnerEvent[Any] | BotEngineEvent[Any], EventsStack], None]: config = config or RunnerConfig() collector = EventCollector( self.bot, @@ -74,7 +74,7 @@ async def listen( sleep_time: float | None = None while True: start = time.time() - result: list[RunnerEvent[Any] | BotEngineEvent[Any]] = [] + result: Sequence[RunnerEvent[Any] | BotEngineEvent[Any]] = () try: result = await collector.get_events() @@ -82,7 +82,7 @@ async def listen( backoff.reset() runner_logger.info('Connection established. Continuing collecting events.') if config.on_unauthenticated_error_policy == 'event': - result.insert(0, BotAuthenticatedEvent(object=None)) + result = (BotAuthenticatedEvent(object=None), *result) except (BotUnauthenticatedError, UnauthorizedError) as e: runner_logger.warning( @@ -98,9 +98,9 @@ async def listen( 'Current attempt: %d. Delay: %f.', backoff.counter, backoff.current_delay ) if backoff.counter == 1 and config.on_unauthenticated_error_policy == 'event': - result = [ - BotUnauthenticatedEvent(object=None, delay=backoff.current_delay) - ] + result = ( + BotUnauthenticatedEvent(object=None, delay=backoff.current_delay), + ) elif config.on_unauthenticated_error_policy == 'stop': return @@ -116,7 +116,7 @@ async def listen( events_stack = EventsStack(events=()) if not backoff.counter: - result.insert(0, NewEventsPack(object=events_stack.id)) + result = (NewEventsPack(object=events_stack.id), *result) events_stack.events = tuple(result) for i in events_stack: diff --git a/funpaybotengine/types/__init__.py b/funpaybotengine/types/__init__.py index 0f13264..0cee384 100644 --- a/funpaybotengine/types/__init__.py +++ b/funpaybotengine/types/__init__.py @@ -3,6 +3,7 @@ from .base import * from .calc import * from .chat import * +from .sras import * from .enums import * from .common import * from .offers import * @@ -11,7 +12,6 @@ from .updates import * from .finances import * from .messages import * -from .sras import * from .settings import * from .categories import * from .common_page_elements import * diff --git a/funpaybotengine/types/calc.py b/funpaybotengine/types/calc.py index dd106e2..37a2594 100644 --- a/funpaybotengine/types/calc.py +++ b/funpaybotengine/types/calc.py @@ -16,7 +16,8 @@ class MethodResult(FunPayObject, BaseModel): """ - Represents a result of offer price calculation method (`lots/calc`) for a single payment method. + Represents a result of offer price calculation method (`lots/calc`) for a single + payment method. """ name: str = '' diff --git a/funpaybotengine/types/offers.py b/funpaybotengine/types/offers.py index 4e5ba75..a282226 100644 --- a/funpaybotengine/types/offers.py +++ b/funpaybotengine/types/offers.py @@ -126,7 +126,8 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> Any: if not obj.is_common: raise RuntimeError( f'Instance of {obj.__class__.__name__} is not describing a common lot fields.\n' - f'Use {obj.__class__.__name__}.convert_to_common to convert it to common lot fields.', + f'Use {obj.__class__.__name__}.convert_to_common to convert it to common lot ' + 'fields.', ) return func(*args, **kwargs) diff --git a/funpaybotengine/types/pages/__init__.py b/funpaybotengine/types/pages/__init__.py index 9f7ec7b..21a833e 100644 --- a/funpaybotengine/types/pages/__init__.py +++ b/funpaybotengine/types/pages/__init__.py @@ -9,6 +9,6 @@ from .my_chips_page import * from .settings_page import * from .my_offers_page import * -from .subcategory_page import * from .sras_info_page import * +from .subcategory_page import * from .transactions_page import * From c7cd5784f7f6bed2d542290430383cc2eddbc374 Mon Sep 17 00:00:00 2001 From: Valera <82732824+Flummy1@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:06:51 +0300 Subject: [PATCH 2/2] fix: preserve requested transaction filter across pagination (#40) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: preserve requested transaction filter across pagination `TransactionPreviewsBatch.next_batch()` re-derived the filter from `self.filter`, which is scraped from the response HTML. FunPay omits the hidden `filter` input in some responses, so `self.filter` became `None` and `self.filter or ''` silently widened the filter to `TransactionFilter.ALL` — every page after the first returned the entire transaction feed instead of the requested category. `TransactionFilter.ALL` is `''` and therefore falsy, so no `or`-based fallback on this enum can distinguish "no filter" from "unknown filter". Thread the requested filter through the method context, the same way `GetSales` threads `order_preview_type`, and let `next_batch()` fail loudly on an unknown filter instead of defaulting to the widest one — mirroring `OrderPreviewsBatch.next_batch()`. - `GetTransactions` now passes `context={'transaction_filter': ...}`. - `TransactionPreviewsBatch` prefers the requested filter over the scraped one. - `next_batch()` raises `ValueError` when the filter is unknown. - `GetTransactionsPage` marks its batch as `ALL` (the balance page is unfiltered). - `GetTransactions()` no longer fails validation when called without arguments (`from_transaction_id` defaulted to `None` against an `int` field). Adds a regression suite covering the end-to-end path with a response that has no hidden `filter` input. * chore: remove pagination filter tests Requested by maintainer. Co-Authored-By: Claude Opus 4.8 * chore: drop pytest config from pyproject Requested by maintainer. Co-Authored-By: Claude Opus 4.8 * refactor: stamp transaction filter on the batch instead of the validation context Passing the requested filter through the pydantic validation context leaked it to every nested model: pydantic hands the same context object to each nested `model_validate`, so `header`, `app_data`, every `MoneyValue` and every `TransactionPreview` saw `transaction_filter`, not just the batch it belongs to. Stamp the filter directly on the batch in the building method instead: - `GetTransactions.transform_result` sets `batch.filter` to the requested filter. - `GetTransactionsPage.transform_result` marks its nested batch as `ALL`. - Drop the context-reading `model_post_init` from `TransactionPreviewsBatch`; the HTML-scraped value stays as a fallback for hand-built objects. The validation context now carries only `bot`. Filter propagation is verified end-to-end against a response with no hidden `filter` input. * small improvements * moved filter logic to method init --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: qvvonk --- funpaybotengine/methods/get_transactions.py | 15 +++++++- .../methods/get_transactions_page.py | 18 +++++++++- funpaybotengine/types/enums.py | 2 ++ funpaybotengine/types/finances.py | 36 +++++++++++-------- 4 files changed, 54 insertions(+), 17 deletions(-) diff --git a/funpaybotengine/methods/get_transactions.py b/funpaybotengine/methods/get_transactions.py index 9a6c92d..f016ca8 100644 --- a/funpaybotengine/methods/get_transactions.py +++ b/funpaybotengine/methods/get_transactions.py @@ -16,6 +16,7 @@ if TYPE_CHECKING: from funpaybotengine.client import Bot + from funpaybotengine.client.session.base import RawResponse class GetTransactions(FunPayMethod[TransactionPreviewsBatch], BaseModel): @@ -33,7 +34,7 @@ class GetTransactions(FunPayMethod[TransactionPreviewsBatch], BaseModel): def __init__( self, filter: TransactionFilter | str = TransactionFilter.ALL, - from_transaction_id: int | None = None, + from_transaction_id: int = 0, locale: Language | None = None, ): super().__init__( @@ -48,6 +49,18 @@ def __init__( from_transaction_id=from_transaction_id, ) + if self.filter is TransactionFilter.UNKNOWN: + raise ValueError(f'Unknown filter.') + + async def transform_result( + self, + parsing_result: Any, + response: RawResponse[Any], + ) -> TransactionPreviewsBatch: + batch = await super().transform_result(parsing_result, response) + batch.filter = self.filter + return batch + async def make_data(method: GetTransactions, bot: Bot) -> dict[str, Any]: return { diff --git a/funpaybotengine/methods/get_transactions_page.py b/funpaybotengine/methods/get_transactions_page.py index 4e680ec..72d7b1e 100644 --- a/funpaybotengine/methods/get_transactions_page.py +++ b/funpaybotengine/methods/get_transactions_page.py @@ -4,15 +4,21 @@ __all__ = ('GetTransactionsPage',) +from typing import TYPE_CHECKING, Any + from pydantic import BaseModel from funpayparsers.parsers.page_parsers import TransactionsPageParser -from funpaybotengine.types.enums import Language +from funpaybotengine.types.enums import Language, TransactionFilter from funpaybotengine.types.pages import TransactionsPage from funpaybotengine.methods.base import FunPayMethod from funpaybotengine.client.session import HTTPMethod +if TYPE_CHECKING: + from funpaybotengine.client.session.base import RawResponse + + class GetTransactionsPage(FunPayMethod[TransactionsPage], BaseModel): """Get the transactions page (``https://funpay.com/account/balance``).""" @@ -27,3 +33,13 @@ def __init__(self, locale: Language | None = None): allow_anonymous=False, allow_uninitialized=True, ) + + async def transform_result( + self, + parsing_result: Any, + response: RawResponse[Any], + ) -> TransactionsPage: + page = await super().transform_result(parsing_result, response) + if page.transactions is not None: + page.transactions.filter = TransactionFilter.ALL + return page diff --git a/funpaybotengine/types/enums.py b/funpaybotengine/types/enums.py index 502ea59..daa62a7 100644 --- a/funpaybotengine/types/enums.py +++ b/funpaybotengine/types/enums.py @@ -31,6 +31,8 @@ class TransactionFilter(str, Enum): OTHER = 'other' """Other transactions.""" + UNKNOWN = 'unknown' + class OrderPreviewType(Enum): SALE = auto() diff --git a/funpaybotengine/types/finances.py b/funpaybotengine/types/finances.py index 16d6827..85de3ef 100644 --- a/funpaybotengine/types/finances.py +++ b/funpaybotengine/types/finances.py @@ -6,6 +6,7 @@ from types import MappingProxyType from collections.abc import Mapping +from typing import Any from pydantic import BaseModel, field_validator from funpayparsers.parsers.utils import parse_date_string @@ -83,21 +84,18 @@ class TransactionPreviewsBatch(FunPayObject, BaseModel): user_id: int | None """ID of the user to whom all transactions in this batch belong.""" - filter: TransactionFilter | None - """Transactions filter applied to the current batch.""" + filter: TransactionFilter + """ + Transactions filter applied to the current batch. - @field_validator('filter', mode='before') - @classmethod - def _coerce_transaction_filter( - cls, - value: str | TransactionFilter | None, - ) -> TransactionFilter | None: - if value is None or isinstance(value, TransactionFilter): - return value - try: - return TransactionFilter(value) - except ValueError: - return None + The building method stamps this with the filter that was actually requested + (see ``GetTransactions.transform_result``). The value scraped from the response + HTML is only a fallback for hand-built objects, since FunPay omits the hidden + ``filter`` input in some responses. + + ``None`` means the filter is unknown, which is not the same as + ``TransactionFilter.ALL``. + """ next_transaction_id: int | None """ @@ -109,11 +107,19 @@ def _coerce_transaction_filter( If ``None``, there are no more transactions to load. """ + @field_validator('filter', mode='before') + @classmethod + def _coerce_transaction_filter(cls, value: Any) -> TransactionFilter | None: + try: + return TransactionFilter(value) + except ValueError: + return TransactionFilter.UNKNOWN + async def next_batch(self) -> TransactionPreviewsBatch: if not self.next_transaction_id: raise ValueError('Last batch.') return await self.get_bound_bot().get_transactions( from_transaction_id=self.next_transaction_id, - filter=self.filter or '', + filter=self.filter, )