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
29 changes: 25 additions & 4 deletions src/polymarket/_internal/actions/gamma.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
TagReference,
Team,
)
from polymarket.models.gamma.common import parse_string_sequence

CommentParentEntityType = Literal["Event", "Series"]
TagMatch = Literal["any", "all"]
Expand 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):
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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,
)

Expand Down
9 changes: 8 additions & 1 deletion src/polymarket/clients/async_public.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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.

Expand Down
11 changes: 10 additions & 1 deletion src/polymarket/clients/async_secure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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(
Expand Down
9 changes: 8 additions & 1 deletion src/polymarket/clients/public.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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.

Expand Down
11 changes: 10 additions & 1 deletion src/polymarket/clients/secure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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(
Expand Down
10 changes: 10 additions & 0 deletions tests/integration/test_gamma_paginated.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions tests/integration/test_markets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
48 changes: 47 additions & 1 deletion tests/unit/test_gamma_paginated_specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()

Expand Down
2 changes: 1 addition & 1 deletion tests/unit/test_relayer_split_position.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading