Skip to content

Commit

Permalink
Implemented OrdersEndpoint and OrderResponse.
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
dehidehidehi committed Aug 31, 2021
1 parent ee2e172 commit a279ea6
Show file tree
Hide file tree
Showing 17 changed files with 627 additions and 34 deletions.
2 changes: 1 addition & 1 deletion open_sea_v1/endpoints/abc.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from abc import ABC, abstractmethod
from typing import Optional, Union, Generator
from typing import Union, Generator

from requests import Response

Expand Down
14 changes: 8 additions & 6 deletions open_sea_v1/endpoints/assets.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from dataclasses import dataclass
from enum import Enum
from typing import Optional, Generator
from typing import Optional

from open_sea_v1.endpoints.abc import BaseEndpoint
from open_sea_v1.endpoints.client import BaseClient, ClientParams
Expand All @@ -12,12 +12,16 @@ class AssetsOrderBy(str, Enum):
"""
Helper Enum for remembering the possible values for the order_by param of the AssetsEndpoint class.
"""
TOKEN_ID = "token_id"
SALE_DATE = "sale_date"
SALE_COUNT = "sale_count"
VISITOR_COUNT = "visitor_count"
SALE_PRICE = "sale_price"

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


@dataclass
class AssetsEndpoint(BaseClient, BaseEndpoint):
Expand Down Expand Up @@ -70,9 +74,7 @@ def url(self):

@property
def parsed_http_response(self) -> list[AssetResponse]:
assets_json = self._http_response.json()['assets']
assets = [AssetResponse(asset_json) for asset_json in assets_json]
return assets
return self.parse_http_response(AssetResponse, 'assets')

def _get_request(self, **kwargs):
params = dict(
Expand Down Expand Up @@ -129,7 +131,7 @@ def _validate_order_by(self) -> None:
if self.order_by is None:
return

if self.order_by not in (AssetsOrderBy.TOKEN_ID, AssetsOrderBy.SALE_COUNT, AssetsOrderBy.SALE_DATE, AssetsOrderBy.SALE_PRICE, AssetsOrderBy.VISITOR_COUNT):
if self.order_by not in AssetsOrderBy.list():
raise ValueError(
f"order_by param value ({self.order_by}) is invalid. "
f"Must be a value from {AssetsOrderBy.list()}, case sensitive."
Expand Down
48 changes: 43 additions & 5 deletions open_sea_v1/endpoints/client.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import logging
from abc import ABC
from dataclasses import dataclass
from typing import Optional, Generator
from typing import Optional, Generator, Union, Type

from ratelimit import limits, sleep_and_retry
from requests import Response, request

from open_sea_v1.responses.abc import BaseResponse

logger = logging.getLogger(__name__)

MAX_CALLS_PER_SECOND = 2 # gets overriden if API key is passed to ClientParams instance
RATE_LIMIT = 1 # second

@dataclass
class ClientParams:
"""Common OpenSea Endpoint parameters to pass in."""
Expand All @@ -21,6 +25,7 @@ class ClientParams:
def __post_init__(self):
self._validate_attrs()
self._decrement_max_pages_attr()
self._set_max_rate_limit()

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

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

@dataclass
class BaseClient(ABC):
"""
Parameters
----------
client_params:
ClientParams instance.
rate_limiting: bool
If True, will throttle the amount of requests per second to the OpenSea API.
If you pass an API key into the client_params instance, the rate limiting will change accordingly.
If False, will not throttle.
"""

client_params: ClientParams
processed_pages: int = 0
response = None
parsed_http_response = None
url = None
_http_response = None
rate_limiting: bool = True

def __post_init__(self):
self.processed_pages: int = 0
self.response = None
self.parsed_http_response = None
self._http_response = None

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

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

def parse_http_response(self, response_type: Type[BaseResponse], key: str)\
-> list[Union[Type[BaseResponse], BaseResponse]]:
if self._http_response:
the_json = self._http_response.json()
the_json = the_json[key] if isinstance(the_json, dict) else the_json # the collections endpoint needs this
responses = [response_type(element) for element in the_json]
return responses
return list()

def get_pages(self) -> Generator[list[list[BaseResponse]], None, None]:
self.processed_pages = 0
self.client_params.offset = 0 if self.client_params.offset is None else self.client_params.offset
Expand Down
5 changes: 1 addition & 4 deletions open_sea_v1/endpoints/collections.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,7 @@ def url(self):

@property
def parsed_http_response(self) -> list[CollectionResponse]:
resp_json = self._http_response.json()
collections_json = resp_json if isinstance(resp_json, list) else resp_json['collections']
collections = [CollectionResponse(collection_json) for collection_json in collections_json]
return collections
return self.parse_http_response(CollectionResponse, 'collections')

def _get_request(self, **kwargs):
params = dict(
Expand Down
6 changes: 2 additions & 4 deletions open_sea_v1/endpoints/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

from requests import Response

from open_sea_v1.endpoints.client import BaseClient, ClientParams
from open_sea_v1.endpoints.abc import BaseEndpoint
from open_sea_v1.endpoints.client import BaseClient, ClientParams
from open_sea_v1.endpoints.urls import EndpointURLS
from open_sea_v1.helpers.extended_classes import ExtendedStrEnum
from open_sea_v1.responses.event import EventResponse
Expand Down Expand Up @@ -104,9 +104,7 @@ def _get_request(self, **kwargs) -> Response:

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

def _validate_request_params(self) -> None:
self._validate_param_auction_type()
Expand Down
219 changes: 219 additions & 0 deletions open_sea_v1/endpoints/orders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
from dataclasses import dataclass
from datetime import datetime

from open_sea_v1.endpoints.abc import BaseEndpoint
from open_sea_v1.endpoints.client import BaseClient, ClientParams
from open_sea_v1.endpoints.urls import EndpointURLS
from open_sea_v1.responses.collection import CollectionResponse
from open_sea_v1.responses.order import OrderResponse


@dataclass
class OrdersEndpoint(BaseClient, BaseEndpoint):
"""
How to fetch orders from the OpenSea system.
Parameters
----------
client_params:
ClientParams instance.
asset_contract_address: str
Filter by smart contract address for the asset category.
Needs to be defined together with token_id or token_ids.
payment_token_address: str
Filter by the address of the smart contract of the payment
token that is accepted or offered by the order
maker: str
Filter by the order maker's wallet address
taker: str
Filter by the order taker's wallet address.
Orders open for any taker have the null address as their taker.
owner: str
Filter by the asset owner's wallet address
is_english: bool
When "true", only show English Auction sell orders, which wait for the highest bidder.
When "false", exclude those.
bundled: bool
Only show orders for bundles of assets
include_bundled: bool
Include orders on bundles where all assets in the bundle share the address
provided in asset_contract_address or where the bundle's maker is the address provided in owner.
include_invalid: bool
Include orders marked invalid by the orderbook, typically due to makers
not owning enough of the token/asset anymore.
listed_after: datetime
Only show orders listed after this timestamp.
listed_before: datetime
Only show orders listed before this timestamp.
token_id: str
Filter by the token ID of the order's asset.
Needs to be defined together with asset_contract_address.
token_ids: list[str]
Filter by a list of token IDs for the order's asset.
Needs to be defined together with asset_contract_address.
side: int
Filter by the side of the order.
0 for buy orders and 1 for sell orders.
sale_kind: int
Filter by the kind of sell order.
0 for fixed-price sales or min-bid auctions, and 1 for declining-price Dutch Auctions.
NOTE=use only_english=true for filtering for only English Auctions
limit: int
Number of orders to return (capped at 50).
offset: int
Number of orders to offset by (for pagination)
order_by: str
How to sort the orders. Can be created_date for when they were made,
or eth_price to see the lowest-priced orders first (converted to their ETH values).
eth_price is only supported when asset_contract_address and token_id are also defined.
order_direction: str
Can be asc or desc for ascending or descending sort.
For example, to see the cheapest orders, do order_direction asc and order_by eth_price.
:return=Parsed JSON
"""
client_params: ClientParams = None
asset_contract_address: str = None
payment_token_address: str = None
maker: str = None
taker: str = None
owner: str = None
is_english: bool = None
bundled: bool = None
include_bundled: bool = None
include_invalid: bool = None
listed_after: datetime = None
listed_before: datetime = None
token_id: str = None
token_ids: list[str] = None
side: int = None
sale_kind: int = None
order_by: str = 'created_date'
order_direction: str = 'desc'

def __post_init__(self):
self._validate_request_params()

@property
def url(self):
return EndpointURLS.ORDERS.value

@property
def parsed_http_response(self) -> list[OrderResponse]:
if self._http_response:
orders_jsons = self._http_response.json()['orders']
orders = [OrderResponse(order_json) for order_json in orders_jsons]
return orders
return list()

def _get_request(self, **kwargs):
params = dict(
asset_contract_address=self.asset_contract_address,
payment_token_address=self.payment_token_address,
maker=self.maker,
taker=self.taker,
owner=self.owner,
is_english=self.is_english,
bundled=self.bundled,
include_bundled=self.include_bundled,
include_invalid=self.include_invalid,
listed_after=self.listed_after,
listed_before=self.listed_before,
token_id=self.token_id,
token_ids=self.token_ids,
side=self.side,
sale_kind=self.sale_kind,
limit=self.client_params.limit,
offset=self.client_params.offset,
order_by=self.order_by,
order_direction=self.order_direction,
)
get_request_kwargs = dict(params=params)
self._http_response = super()._get_request(**get_request_kwargs)
return self._http_response

def _validate_request_params(self) -> None:
self._validate_values(self.side, attr_name='side', valid_values=[None, 1, 0])
self._validate_values(self.sale_kind, attr_name='sale_kind', valid_values=[None, 1, 0])
self._validate_values(self.order_by, attr_name='order_by', valid_values=[None, 'created_date', 'eth_price'])
self._validate_values(self.order_direction, attr_name='order_direction', valid_values=[None, 'asc', 'desc'])

self._validate_contract_address_defined_with_token_id_or_tokens_ids()
self._validate_token_id_defined_with_contract_address()
self._validate_token_ids_defined_with_contract_address()
self._validate_token_id_and_token_ids_cannot_be_defined_together()
self._validate_listed_after_and_listed_before_are_datetimes()
self._validate_order_by_eth_price_is_defined_with_asset_contract_address_and_token_id_or_token_ids()

if self.include_bundled:
self._validate_include_bundled_is_defined_with_contract_address_or_owner_address()
self._validate_include_bundled_is_defined_with_token_id_or_token_ids()

def _validate_contract_address_defined_with_token_id_or_tokens_ids(self) -> None:
if self.asset_contract_address is None:
return
if not any([self.token_id, self.token_ids]):
raise AttributeError('Attribute asset_contract_address must be defined together with either token_id or token_ids.')

def _validate_token_id_defined_with_contract_address(self) -> None:
if self.token_id is None:
return
if not self.asset_contract_address:
raise AttributeError('Attribute token_id must be defined together with asset_contract_address')

def _validate_token_ids_defined_with_contract_address(self) -> None:
if self.token_ids is None:
return
if not self.asset_contract_address:
raise AttributeError('Attribute token_ids must be defined together with asset_contract_address')

def _validate_token_id_and_token_ids_cannot_be_defined_together(self) -> None:
if self.token_ids and self.token_id:
raise AttributeError('Attribute token_id and token_ids cannot be defined together.')

def _validate_include_bundled_is_defined_with_contract_address_or_owner_address(self) -> None:
if not any([self.asset_contract_address, self.owner]):
raise AttributeError('Attribute include_bundled must be defined together with asset_contract_address or owner')

def _validate_include_bundled_is_defined_with_token_id_or_token_ids(self):
if not any([self.token_id, self.token_ids]):
raise AttributeError(
'Attribute include_bundled must be defined together with token_id or token_ios')

def _validate_listed_after_and_listed_before_are_datetimes(self):
if not isinstance(self.listed_after, (type(None), datetime)):
raise TypeError("Attribute 'listed_after' must be a datetime instance")

if not isinstance(self.listed_before, (type(None), datetime)):
raise TypeError("Attribute 'listed_before' must be a datetime instance")

def _validate_order_by_eth_price_is_defined_with_asset_contract_address_and_token_id_or_token_ids(self):
if not self.order_by:
return

if not self.asset_contract_address and self.order_by == 'eth_price' and not any([self.token_id, self.token_ids]):
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.")

@staticmethod
def _validate_values(attr, *, attr_name: str, valid_values: list):
if not any(attr is v for v in valid_values):
raise ValueError(f"attr {attr_name} must be a value among: {valid_values}")
Loading

0 comments on commit a279ea6

Please sign in to comment.