diff --git a/src/polymarket/_internal/actions/gamma.py b/src/polymarket/_internal/actions/gamma.py index 45d836f..39220ed 100644 --- a/src/polymarket/_internal/actions/gamma.py +++ b/src/polymarket/_internal/actions/gamma.py @@ -38,6 +38,7 @@ TagReference, Team, ) +from polymarket.models.gamma.common import parse_string_sequence CommentParentEntityType = Literal["Event", "Series"] TagMatch = Literal["any", "all"] @@ -48,7 +49,7 @@ def _make_keyset_parser( items_key: str, - parse_item: Callable[[object], _T], + parse_item: Callable[[object], _T | None], ) -> Callable[[object], KeysetPagePayload[_T]]: def parse(data: object) -> KeysetPagePayload[_T]: if not isinstance(data, dict): @@ -63,7 +64,11 @@ def parse(data: object) -> KeysetPagePayload[_T]: if not isinstance(raw, list): raise UnexpectedResponseError(f"Expected '{items_key}' to be an array.") items_list = cast(list[Any], raw) - items = tuple(parse_item(item) for item in items_list) + items: list[_T] = [] + for item in items_list: + parsed = parse_item(item) + if parsed is not None: + items.append(parsed) if "next_cursor" not in data_dict: server_cursor: str | None = None @@ -82,11 +87,27 @@ def parse(data: object) -> KeysetPagePayload[_T]: f"'next_cursor' must be a string when present, got {type(nc).__name__}." ) - return KeysetPagePayload(items=items, server_next_cursor=server_cursor) + return KeysetPagePayload(items=tuple(items), server_next_cursor=server_cursor) return parse +def _parse_list_market(item: object) -> Market | None: + if not isinstance(item, dict): + return Market.parse_response(item) + + item_dict = cast(dict[str, Any], item) + try: + outcomes = parse_string_sequence(item_dict.get("outcomes")) + except ValueError as error: + raise UnexpectedResponseError("Market response did not match expected shape") from error + + if len(outcomes) != 2: + return None + + return Market.parse_response(item_dict) + + def _add_optional( params: dict[str, QueryParamValue], key: str, @@ -523,7 +544,7 @@ def list_markets_spec( return KeysetPaginatedSpec( service="gamma", path="/markets/keyset", - parse_page=_make_keyset_parser("markets", Market.parse_response), + parse_page=_make_keyset_parser("markets", _parse_list_market), base_params=params or None, ) diff --git a/src/polymarket/clients/async_public.py b/src/polymarket/clients/async_public.py index c4ecc62..b726372 100644 --- a/src/polymarket/clients/async_public.py +++ b/src/polymarket/clients/async_public.py @@ -306,7 +306,11 @@ async def get_market( include_tag: bool | None = None, locale: str | None = None, ) -> Market: - """Get a market by id, slug, or Polymarket URL.""" + """Get a market by id, slug, or Polymarket URL. + + Markets that cannot be represented by the binary Market model raise + UnexpectedResponseError. + """ return await async_dispatch( self._ctx, _gamma_actions.get_market_spec( @@ -858,6 +862,9 @@ def list_markets( ) -> AsyncPaginator[Market]: """List markets. + Markets that cannot be represented by the binary Market model are + omitted from results. + Returns: An async paginator over matching markets. diff --git a/src/polymarket/clients/async_secure.py b/src/polymarket/clients/async_secure.py index 95a251b..45a5e72 100644 --- a/src/polymarket/clients/async_secure.py +++ b/src/polymarket/clients/async_secure.py @@ -740,7 +740,11 @@ async def get_market( include_tag: bool | None = None, locale: str | None = None, ) -> Market: - """Get a market by id, slug, or Polymarket URL.""" + """Get a market by id, slug, or Polymarket URL. + + Markets that cannot be represented by the binary Market model raise + UnexpectedResponseError. + """ return await async_dispatch( self._ctx, _gamma_actions.get_market_spec( @@ -1292,6 +1296,9 @@ def list_markets( ) -> AsyncPaginator[Market]: """List markets. + Markets that cannot be represented by the binary Market model are + omitted from results. + Returns: An async paginator over matching markets. """ @@ -2338,6 +2345,8 @@ async def _resolve_market_position_context( ids=[parse_market_id(market_id)], page_size=1 ).first_page() markets = page.items + if not markets: + raise UserInputError(f"No market found for {context}") if len(markets) != 1: raise UserInputError(f"Expected exactly one market for {context}, got {len(markets)}") return normalize_market_position_context( diff --git a/src/polymarket/clients/public.py b/src/polymarket/clients/public.py index 9b33ce3..e18f3dc 100644 --- a/src/polymarket/clients/public.py +++ b/src/polymarket/clients/public.py @@ -151,7 +151,11 @@ def get_market( include_tag: bool | None = None, locale: str | None = None, ) -> Market: - """Get a market.""" + """Get a market. + + Markets that cannot be represented by the binary Market model raise + UnexpectedResponseError. + """ return sync_dispatch( self._ctx, _gamma_actions.get_market_spec( @@ -707,6 +711,9 @@ def list_markets( ) -> Paginator[Market]: """List markets. + Markets that cannot be represented by the binary Market model are + omitted from results. + Returns: A paginator over matching markets. diff --git a/src/polymarket/clients/secure.py b/src/polymarket/clients/secure.py index 8b724bc..8847869 100644 --- a/src/polymarket/clients/secure.py +++ b/src/polymarket/clients/secure.py @@ -473,7 +473,11 @@ def get_market( include_tag: bool | None = None, locale: str | None = None, ) -> Market: - """Get a market by id, slug, or Polymarket URL.""" + """Get a market by id, slug, or Polymarket URL. + + Markets that cannot be represented by the binary Market model raise + UnexpectedResponseError. + """ return sync_dispatch( self._ctx, _gamma_actions.get_market_spec( @@ -1025,6 +1029,9 @@ def list_markets( ) -> Paginator[Market]: """List markets. + Markets that cannot be represented by the binary Market model are + omitted from results. + Returns: A paginator over matching markets. """ @@ -2291,6 +2298,8 @@ def _resolve_market_position_context( context = f"market {market_id}" page = self.list_markets(ids=[parse_market_id(market_id)], page_size=1).first_page() markets = page.items + if not markets: + raise UserInputError(f"No market found for {context}") if len(markets) != 1: raise UserInputError(f"Expected exactly one market for {context}, got {len(markets)}") return normalize_market_position_context( diff --git a/tests/integration/test_gamma_paginated.py b/tests/integration/test_gamma_paginated.py index e61f852..f55ad51 100644 --- a/tests/integration/test_gamma_paginated.py +++ b/tests/integration/test_gamma_paginated.py @@ -62,6 +62,16 @@ def test_list_markets_returns_paginator() -> None: assert all(isinstance(market, Market) for market in first.items) +@pytest.mark.integration +def test_list_closed_markets_omits_legacy_multi_outcome_markets() -> None: + with PublicClient() as client: + first = client.list_markets(closed=True, page_size=100).first_page() + + assert first.items + assert all(isinstance(market, Market) for market in first.items) + assert all(market.outcomes.yes.label and market.outcomes.no.label for market in first.items) + + @pytest.mark.integration def test_async_list_markets_returns_paginator() -> None: async def run() -> None: diff --git a/tests/integration/test_markets.py b/tests/integration/test_markets.py index ff2fae6..d31d7f5 100644 --- a/tests/integration/test_markets.py +++ b/tests/integration/test_markets.py @@ -3,9 +3,11 @@ import pytest from polymarket import AsyncPublicClient, Market, PublicClient, TagReference +from polymarket.errors import UnexpectedResponseError MARKET_ID = "540816" MARKET_SLUG = "russia-ukraine-ceasefire-before-gta-vi-554" +LEGACY_MULTI_OUTCOME_MARKET_SLUG = "who-will-the-world-s-richest-person-be-on-february-27-2021" @pytest.mark.integration @@ -44,6 +46,12 @@ def test_get_market_by_url_returns_canonical_market() -> None: assert market.outcomes.no.label +@pytest.mark.integration +def test_get_market_rejects_legacy_multi_outcome_market_with_typed_error() -> None: + with PublicClient() as client, pytest.raises(UnexpectedResponseError): + client.get_market(slug=LEGACY_MULTI_OUTCOME_MARKET_SLUG) + + @pytest.mark.integration def test_get_market_tags_returns_tags() -> None: with PublicClient() as client: diff --git a/tests/unit/test_gamma_paginated_specs.py b/tests/unit/test_gamma_paginated_specs.py index 386b677..2930993 100644 --- a/tests/unit/test_gamma_paginated_specs.py +++ b/tests/unit/test_gamma_paginated_specs.py @@ -6,7 +6,20 @@ OffsetPaginatedSpec, PageBasedSpec, ) -from polymarket.errors import UserInputError +from polymarket.errors import UnexpectedResponseError, UserInputError + + +def _minimal_market_payload(**overrides: object) -> dict[str, object]: + payload: dict[str, object] = { + "id": "MARKET-1", + "outcomes": ["Yes", "No"], + "outcomePrices": ["0.6", "0.4"], + "clobTokenIds": ["TOKEN-YES", "TOKEN-NO"], + "positionIds": ["POSITION-YES", "POSITION-NO"], + "marketMakerAddress": "0xMM", + } + payload.update(overrides) + return payload def test_list_events_spec_defaults_to_open_events() -> None: @@ -72,6 +85,39 @@ def test_list_markets_spec_collects_array_params() -> None: } +def test_list_markets_parser_skips_non_binary_markets_and_keeps_cursor() -> None: + spec = gamma_actions.list_markets_spec() + + payload = spec.parse_page( + { + "markets": [ + _minimal_market_payload(id="MARKET-1"), + _minimal_market_payload( + id="MARKET-2", + outcomes=["Jeff Bezos", "Elon Musk", "Other"], + ), + ], + "next_cursor": "cursor-1", + } + ) + + assert [market.id for market in payload.items] == ["MARKET-1"] + assert payload.server_next_cursor == "cursor-1" + + +def test_list_markets_parser_rejects_malformed_outcomes() -> None: + spec = gamma_actions.list_markets_spec() + + with pytest.raises(UnexpectedResponseError, match="Market response"): + spec.parse_page( + { + "markets": [ + _minimal_market_payload(outcomes=["Yes", 1]), + ] + } + ) + + def test_list_series_spec_default_has_no_params() -> None: spec = gamma_actions.list_series_spec() diff --git a/tests/unit/test_relayer_split_position.py b/tests/unit/test_relayer_split_position.py index 54e9efb..d10304b 100644 --- a/tests/unit/test_relayer_split_position.py +++ b/tests/unit/test_relayer_split_position.py @@ -115,7 +115,7 @@ async def run() -> None: client = await make_deposit_client() client.list_markets = lambda **_: _StubPaginator(()) # type: ignore[method-assign] try: - with pytest.raises(UserInputError, match="exactly one market"): + with pytest.raises(UserInputError, match="No market found"): await client.split_position(condition_id=_CONDITION_ID, amount=1) finally: await client.close()