Skip to content

Commit a279ea6

Browse files
committed
Implemented OrdersEndpoint and OrderResponse.
Major updates: ____________ Implemented OrdersEndpoint and OrderResponse. Implemented get_request rate limiter to all endpoints. Minor updates: ____________ EventResponse: added missing is_private as an optional response attribute. Refactor: passed parsing of http response responsibility to the BaseClient ABC class. Hotfix: prevent json() from raising exception when http_response is empty Hotfix: removed token_id as a possible order_by parameter for Asset endpoint.
1 parent ee2e172 commit a279ea6

File tree

17 files changed

+627
-34
lines changed

17 files changed

+627
-34
lines changed

open_sea_v1/endpoints/abc.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from abc import ABC, abstractmethod
2-
from typing import Optional, Union, Generator
2+
from typing import Union, Generator
33

44
from requests import Response
55

open_sea_v1/endpoints/assets.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from dataclasses import dataclass
22
from enum import Enum
3-
from typing import Optional, Generator
3+
from typing import Optional
44

55
from open_sea_v1.endpoints.abc import BaseEndpoint
66
from open_sea_v1.endpoints.client import BaseClient, ClientParams
@@ -12,12 +12,16 @@ class AssetsOrderBy(str, Enum):
1212
"""
1313
Helper Enum for remembering the possible values for the order_by param of the AssetsEndpoint class.
1414
"""
15-
TOKEN_ID = "token_id"
1615
SALE_DATE = "sale_date"
1716
SALE_COUNT = "sale_count"
1817
VISITOR_COUNT = "visitor_count"
1918
SALE_PRICE = "sale_price"
2019

20+
@classmethod
21+
def list(cls) -> list[str]:
22+
"""Returns list of values of each attribute of this String Enum."""
23+
return list(map(lambda c: c.value, cls))
24+
2125

2226
@dataclass
2327
class AssetsEndpoint(BaseClient, BaseEndpoint):
@@ -70,9 +74,7 @@ def url(self):
7074

7175
@property
7276
def parsed_http_response(self) -> list[AssetResponse]:
73-
assets_json = self._http_response.json()['assets']
74-
assets = [AssetResponse(asset_json) for asset_json in assets_json]
75-
return assets
77+
return self.parse_http_response(AssetResponse, 'assets')
7678

7779
def _get_request(self, **kwargs):
7880
params = dict(
@@ -129,7 +131,7 @@ def _validate_order_by(self) -> None:
129131
if self.order_by is None:
130132
return
131133

132-
if self.order_by not in (AssetsOrderBy.TOKEN_ID, AssetsOrderBy.SALE_COUNT, AssetsOrderBy.SALE_DATE, AssetsOrderBy.SALE_PRICE, AssetsOrderBy.VISITOR_COUNT):
134+
if self.order_by not in AssetsOrderBy.list():
133135
raise ValueError(
134136
f"order_by param value ({self.order_by}) is invalid. "
135137
f"Must be a value from {AssetsOrderBy.list()}, case sensitive."

open_sea_v1/endpoints/client.py

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import logging
22
from abc import ABC
33
from dataclasses import dataclass
4-
from typing import Optional, Generator
4+
from typing import Optional, Generator, Union, Type
55

6+
from ratelimit import limits, sleep_and_retry
67
from requests import Response, request
78

89
from open_sea_v1.responses.abc import BaseResponse
910

1011
logger = logging.getLogger(__name__)
1112

13+
MAX_CALLS_PER_SECOND = 2 # gets overriden if API key is passed to ClientParams instance
14+
RATE_LIMIT = 1 # second
15+
1216
@dataclass
1317
class ClientParams:
1418
"""Common OpenSea Endpoint parameters to pass in."""
@@ -21,6 +25,7 @@ class ClientParams:
2125
def __post_init__(self):
2226
self._validate_attrs()
2327
self._decrement_max_pages_attr()
28+
self._set_max_rate_limit()
2429

2530
def _validate_attrs(self) -> None:
2631
if self.limit is not None and not 0 < self.limit <= 300:
@@ -38,14 +43,35 @@ def _decrement_max_pages_attr(self) -> None:
3843
if self.max_pages is not None:
3944
self.max_pages -= 1
4045

46+
def _set_max_rate_limit(self) -> None:
47+
global MAX_CALLS_PER_SECOND
48+
MAX_CALLS_PER_SECOND = 2 # per second
49+
if self.api_key:
50+
raise NotImplementedError("I don't know what the rate limit is for calls with an API key is yet.")
4151

52+
@dataclass
4253
class BaseClient(ABC):
54+
"""
55+
Parameters
56+
----------
57+
client_params:
58+
ClientParams instance.
59+
60+
rate_limiting: bool
61+
If True, will throttle the amount of requests per second to the OpenSea API.
62+
If you pass an API key into the client_params instance, the rate limiting will change accordingly.
63+
If False, will not throttle.
64+
"""
65+
4366
client_params: ClientParams
44-
processed_pages: int = 0
45-
response = None
46-
parsed_http_response = None
4767
url = None
48-
_http_response = None
68+
rate_limiting: bool = True
69+
70+
def __post_init__(self):
71+
self.processed_pages: int = 0
72+
self.response = None
73+
self.parsed_http_response = None
74+
self._http_response = None
4975

5076
@property
5177
def http_headers(self) -> dict:
@@ -54,10 +80,22 @@ def http_headers(self) -> dict:
5480
params['headers'] = {'X-API-Key': self.client_params.api_key}
5581
return params
5682

83+
@sleep_and_retry
84+
@limits(calls=MAX_CALLS_PER_SECOND, period=RATE_LIMIT)
5785
def _get_request(self, **kwargs) -> Response:
86+
"""Get requests with a rate limiter."""
5887
updated_kwargs = kwargs | self.http_headers
5988
return request('GET', self.url, **updated_kwargs)
6089

90+
def parse_http_response(self, response_type: Type[BaseResponse], key: str)\
91+
-> list[Union[Type[BaseResponse], BaseResponse]]:
92+
if self._http_response:
93+
the_json = self._http_response.json()
94+
the_json = the_json[key] if isinstance(the_json, dict) else the_json # the collections endpoint needs this
95+
responses = [response_type(element) for element in the_json]
96+
return responses
97+
return list()
98+
6199
def get_pages(self) -> Generator[list[list[BaseResponse]], None, None]:
62100
self.processed_pages = 0
63101
self.client_params.offset = 0 if self.client_params.offset is None else self.client_params.offset

open_sea_v1/endpoints/collections.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,7 @@ def url(self):
4848

4949
@property
5050
def parsed_http_response(self) -> list[CollectionResponse]:
51-
resp_json = self._http_response.json()
52-
collections_json = resp_json if isinstance(resp_json, list) else resp_json['collections']
53-
collections = [CollectionResponse(collection_json) for collection_json in collections_json]
54-
return collections
51+
return self.parse_http_response(CollectionResponse, 'collections')
5552

5653
def _get_request(self, **kwargs):
5754
params = dict(

open_sea_v1/endpoints/events.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44

55
from requests import Response
66

7-
from open_sea_v1.endpoints.client import BaseClient, ClientParams
87
from open_sea_v1.endpoints.abc import BaseEndpoint
8+
from open_sea_v1.endpoints.client import BaseClient, ClientParams
99
from open_sea_v1.endpoints.urls import EndpointURLS
1010
from open_sea_v1.helpers.extended_classes import ExtendedStrEnum
1111
from open_sea_v1.responses.event import EventResponse
@@ -104,9 +104,7 @@ def _get_request(self, **kwargs) -> Response:
104104

105105
@property
106106
def parsed_http_response(self) -> list[EventResponse]:
107-
events_json = self._http_response.json()['asset_events']
108-
events = [EventResponse(event) for event in events_json]
109-
return events
107+
return self.parse_http_response(EventResponse, 'asset_events')
110108

111109
def _validate_request_params(self) -> None:
112110
self._validate_param_auction_type()

open_sea_v1/endpoints/orders.py

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
from dataclasses import dataclass
2+
from datetime import datetime
3+
4+
from open_sea_v1.endpoints.abc import BaseEndpoint
5+
from open_sea_v1.endpoints.client import BaseClient, ClientParams
6+
from open_sea_v1.endpoints.urls import EndpointURLS
7+
from open_sea_v1.responses.collection import CollectionResponse
8+
from open_sea_v1.responses.order import OrderResponse
9+
10+
11+
@dataclass
12+
class OrdersEndpoint(BaseClient, BaseEndpoint):
13+
"""
14+
How to fetch orders from the OpenSea system.
15+
16+
Parameters
17+
----------
18+
client_params:
19+
ClientParams instance.
20+
21+
asset_contract_address: str
22+
Filter by smart contract address for the asset category.
23+
Needs to be defined together with token_id or token_ids.
24+
25+
payment_token_address: str
26+
Filter by the address of the smart contract of the payment
27+
token that is accepted or offered by the order
28+
29+
maker: str
30+
Filter by the order maker's wallet address
31+
32+
taker: str
33+
Filter by the order taker's wallet address.
34+
Orders open for any taker have the null address as their taker.
35+
36+
owner: str
37+
Filter by the asset owner's wallet address
38+
39+
is_english: bool
40+
When "true", only show English Auction sell orders, which wait for the highest bidder.
41+
When "false", exclude those.
42+
43+
bundled: bool
44+
Only show orders for bundles of assets
45+
46+
include_bundled: bool
47+
Include orders on bundles where all assets in the bundle share the address
48+
provided in asset_contract_address or where the bundle's maker is the address provided in owner.
49+
50+
include_invalid: bool
51+
Include orders marked invalid by the orderbook, typically due to makers
52+
not owning enough of the token/asset anymore.
53+
54+
listed_after: datetime
55+
Only show orders listed after this timestamp.
56+
57+
listed_before: datetime
58+
Only show orders listed before this timestamp.
59+
60+
token_id: str
61+
Filter by the token ID of the order's asset.
62+
Needs to be defined together with asset_contract_address.
63+
64+
token_ids: list[str]
65+
Filter by a list of token IDs for the order's asset.
66+
Needs to be defined together with asset_contract_address.
67+
68+
side: int
69+
Filter by the side of the order.
70+
0 for buy orders and 1 for sell orders.
71+
72+
sale_kind: int
73+
Filter by the kind of sell order.
74+
0 for fixed-price sales or min-bid auctions, and 1 for declining-price Dutch Auctions.
75+
NOTE=use only_english=true for filtering for only English Auctions
76+
77+
limit: int
78+
Number of orders to return (capped at 50).
79+
80+
offset: int
81+
Number of orders to offset by (for pagination)
82+
83+
order_by: str
84+
How to sort the orders. Can be created_date for when they were made,
85+
or eth_price to see the lowest-priced orders first (converted to their ETH values).
86+
eth_price is only supported when asset_contract_address and token_id are also defined.
87+
88+
order_direction: str
89+
Can be asc or desc for ascending or descending sort.
90+
For example, to see the cheapest orders, do order_direction asc and order_by eth_price.
91+
92+
:return=Parsed JSON
93+
"""
94+
client_params: ClientParams = None
95+
asset_contract_address: str = None
96+
payment_token_address: str = None
97+
maker: str = None
98+
taker: str = None
99+
owner: str = None
100+
is_english: bool = None
101+
bundled: bool = None
102+
include_bundled: bool = None
103+
include_invalid: bool = None
104+
listed_after: datetime = None
105+
listed_before: datetime = None
106+
token_id: str = None
107+
token_ids: list[str] = None
108+
side: int = None
109+
sale_kind: int = None
110+
order_by: str = 'created_date'
111+
order_direction: str = 'desc'
112+
113+
def __post_init__(self):
114+
self._validate_request_params()
115+
116+
@property
117+
def url(self):
118+
return EndpointURLS.ORDERS.value
119+
120+
@property
121+
def parsed_http_response(self) -> list[OrderResponse]:
122+
if self._http_response:
123+
orders_jsons = self._http_response.json()['orders']
124+
orders = [OrderResponse(order_json) for order_json in orders_jsons]
125+
return orders
126+
return list()
127+
128+
def _get_request(self, **kwargs):
129+
params = dict(
130+
asset_contract_address=self.asset_contract_address,
131+
payment_token_address=self.payment_token_address,
132+
maker=self.maker,
133+
taker=self.taker,
134+
owner=self.owner,
135+
is_english=self.is_english,
136+
bundled=self.bundled,
137+
include_bundled=self.include_bundled,
138+
include_invalid=self.include_invalid,
139+
listed_after=self.listed_after,
140+
listed_before=self.listed_before,
141+
token_id=self.token_id,
142+
token_ids=self.token_ids,
143+
side=self.side,
144+
sale_kind=self.sale_kind,
145+
limit=self.client_params.limit,
146+
offset=self.client_params.offset,
147+
order_by=self.order_by,
148+
order_direction=self.order_direction,
149+
)
150+
get_request_kwargs = dict(params=params)
151+
self._http_response = super()._get_request(**get_request_kwargs)
152+
return self._http_response
153+
154+
def _validate_request_params(self) -> None:
155+
self._validate_values(self.side, attr_name='side', valid_values=[None, 1, 0])
156+
self._validate_values(self.sale_kind, attr_name='sale_kind', valid_values=[None, 1, 0])
157+
self._validate_values(self.order_by, attr_name='order_by', valid_values=[None, 'created_date', 'eth_price'])
158+
self._validate_values(self.order_direction, attr_name='order_direction', valid_values=[None, 'asc', 'desc'])
159+
160+
self._validate_contract_address_defined_with_token_id_or_tokens_ids()
161+
self._validate_token_id_defined_with_contract_address()
162+
self._validate_token_ids_defined_with_contract_address()
163+
self._validate_token_id_and_token_ids_cannot_be_defined_together()
164+
self._validate_listed_after_and_listed_before_are_datetimes()
165+
self._validate_order_by_eth_price_is_defined_with_asset_contract_address_and_token_id_or_token_ids()
166+
167+
if self.include_bundled:
168+
self._validate_include_bundled_is_defined_with_contract_address_or_owner_address()
169+
self._validate_include_bundled_is_defined_with_token_id_or_token_ids()
170+
171+
def _validate_contract_address_defined_with_token_id_or_tokens_ids(self) -> None:
172+
if self.asset_contract_address is None:
173+
return
174+
if not any([self.token_id, self.token_ids]):
175+
raise AttributeError('Attribute asset_contract_address must be defined together with either token_id or token_ids.')
176+
177+
def _validate_token_id_defined_with_contract_address(self) -> None:
178+
if self.token_id is None:
179+
return
180+
if not self.asset_contract_address:
181+
raise AttributeError('Attribute token_id must be defined together with asset_contract_address')
182+
183+
def _validate_token_ids_defined_with_contract_address(self) -> None:
184+
if self.token_ids is None:
185+
return
186+
if not self.asset_contract_address:
187+
raise AttributeError('Attribute token_ids must be defined together with asset_contract_address')
188+
189+
def _validate_token_id_and_token_ids_cannot_be_defined_together(self) -> None:
190+
if self.token_ids and self.token_id:
191+
raise AttributeError('Attribute token_id and token_ids cannot be defined together.')
192+
193+
def _validate_include_bundled_is_defined_with_contract_address_or_owner_address(self) -> None:
194+
if not any([self.asset_contract_address, self.owner]):
195+
raise AttributeError('Attribute include_bundled must be defined together with asset_contract_address or owner')
196+
197+
def _validate_include_bundled_is_defined_with_token_id_or_token_ids(self):
198+
if not any([self.token_id, self.token_ids]):
199+
raise AttributeError(
200+
'Attribute include_bundled must be defined together with token_id or token_ios')
201+
202+
def _validate_listed_after_and_listed_before_are_datetimes(self):
203+
if not isinstance(self.listed_after, (type(None), datetime)):
204+
raise TypeError("Attribute 'listed_after' must be a datetime instance")
205+
206+
if not isinstance(self.listed_before, (type(None), datetime)):
207+
raise TypeError("Attribute 'listed_before' must be a datetime instance")
208+
209+
def _validate_order_by_eth_price_is_defined_with_asset_contract_address_and_token_id_or_token_ids(self):
210+
if not self.order_by:
211+
return
212+
213+
if not self.asset_contract_address and self.order_by == 'eth_price' and not any([self.token_id, self.token_ids]):
214+
raise AttributeError("When attribute 'order_by' is set to 'eth_price', you must also set the asset_contract_address and token_id or token_ids attributes.")
215+
216+
@staticmethod
217+
def _validate_values(attr, *, attr_name: str, valid_values: list):
218+
if not any(attr is v for v in valid_values):
219+
raise ValueError(f"attr {attr_name} must be a value among: {valid_values}")

0 commit comments

Comments
 (0)