diff --git a/open_sea_v1/endpoints/abc.py b/open_sea_v1/endpoints/abc.py index 9fecbef..45eac40 100644 --- a/open_sea_v1/endpoints/abc.py +++ b/open_sea_v1/endpoints/abc.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Optional, Union, Generator +from typing import Union, Generator from requests import Response diff --git a/open_sea_v1/endpoints/assets.py b/open_sea_v1/endpoints/assets.py index 378aa69..7939c4e 100644 --- a/open_sea_v1/endpoints/assets.py +++ b/open_sea_v1/endpoints/assets.py @@ -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 @@ -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): @@ -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( @@ -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." diff --git a/open_sea_v1/endpoints/client.py b/open_sea_v1/endpoints/client.py index 0226328..45fa2cf 100644 --- a/open_sea_v1/endpoints/client.py +++ b/open_sea_v1/endpoints/client.py @@ -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.""" @@ -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: @@ -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: @@ -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 diff --git a/open_sea_v1/endpoints/collections.py b/open_sea_v1/endpoints/collections.py index 06a523b..ff819c4 100644 --- a/open_sea_v1/endpoints/collections.py +++ b/open_sea_v1/endpoints/collections.py @@ -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( diff --git a/open_sea_v1/endpoints/events.py b/open_sea_v1/endpoints/events.py index b665377..61d7fbc 100644 --- a/open_sea_v1/endpoints/events.py +++ b/open_sea_v1/endpoints/events.py @@ -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 @@ -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() diff --git a/open_sea_v1/endpoints/orders.py b/open_sea_v1/endpoints/orders.py new file mode 100644 index 0000000..2411fde --- /dev/null +++ b/open_sea_v1/endpoints/orders.py @@ -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}") diff --git a/open_sea_v1/endpoints/tests/test_assets.py b/open_sea_v1/endpoints/tests/test_assets.py index 358e6d3..7504da2 100644 --- a/open_sea_v1/endpoints/tests/test_assets.py +++ b/open_sea_v1/endpoints/tests/test_assets.py @@ -49,11 +49,6 @@ def test_param_order_direction_can_only_be_asc_or_desc(self): params = dict(token_ids=[1], asset_contract_address=self.sample_contract, order_direction=invalid_order) self.assertRaises((ValueError, TypeError), AssetsEndpoint, **params) - def test_param_order_by_token_id(self): - params = self.default_asset_params | dict(token_ids=[3, 2, 1], order_by=AssetsOrderBy.TOKEN_ID, order_direction='desc') - punks_ids = [punk.token_id for punk in self.create_and_get(**params)] - self.assertEqual(['3', '2', '1'], punks_ids) - def test_param_order_by_sale_date(self): params = self.default_asset_params | dict(token_ids=[1, 14, 33], order_by=AssetsOrderBy.SALE_DATE) punks_sales = [punk.last_sale.event_timestamp for punk in self.create_and_get(**params)] diff --git a/open_sea_v1/endpoints/tests/test_client.py b/open_sea_v1/endpoints/tests/test_client.py index 64d2872..eff549c 100644 --- a/open_sea_v1/endpoints/tests/test_client.py +++ b/open_sea_v1/endpoints/tests/test_client.py @@ -4,6 +4,7 @@ from open_sea_v1.endpoints.client import ClientParams from open_sea_v1.endpoints.events import EventsEndpoint, EventType + class TestClientParams(TestCase): def test_max_pages_attr_is_automatically_decremented_by_1(self): diff --git a/open_sea_v1/endpoints/tests/test_events.py b/open_sea_v1/endpoints/tests/test_events.py index cb989a7..9422e03 100644 --- a/open_sea_v1/endpoints/tests/test_events.py +++ b/open_sea_v1/endpoints/tests/test_events.py @@ -1,8 +1,8 @@ -from unittest import TestCase from datetime import datetime, timedelta +from unittest import TestCase -from open_sea_v1.endpoints.events import EventsEndpoint, EventType, AuctionType from open_sea_v1.endpoints.client import ClientParams +from open_sea_v1.endpoints.events import EventsEndpoint, EventType, AuctionType class TestEventsEndpoint(TestCase): diff --git a/open_sea_v1/endpoints/tests/test_orders.py b/open_sea_v1/endpoints/tests/test_orders.py new file mode 100644 index 0000000..3f3dc49 --- /dev/null +++ b/open_sea_v1/endpoints/tests/test_orders.py @@ -0,0 +1,238 @@ +import logging +from datetime import datetime +from unittest import TestCase + +from open_sea_v1.endpoints.client import ClientParams +from open_sea_v1.endpoints.orders import OrdersEndpoint +from open_sea_v1.responses.order import OrderResponse + +logger = logging.getLogger(__name__) + + +class TestOrdersEndpoint(TestCase): + + def setUp(self) -> None: + self.asset_contract_address = '0x3f4a885ed8d9cdf10f3349357e3b243f3695b24a' + self.owner_address = '0x85844112d2f9cfe2254188b4ee69edd942fad32d' + self.token_id = 3263 + self.token_id_2 = 8797 + self.endpoint_kwargs = dict( + client_params=ClientParams(limit=5, max_pages=1), + # listed_after=datetime(year=2021, month=8, day=15), + # listed_before=datetime(year=2021, month=8, day=20), + ) + + @staticmethod + def create_and_get(**kwargs) -> list[OrderResponse]: + orders_client = OrdersEndpoint(**kwargs) + orders_client._get_request() + return orders_client.parsed_http_response + + def test_attr_asset_contract_address_raises_if_not_defined_with_token_id(self): + self.endpoint_kwargs |= dict(asset_contract_address=self.asset_contract_address) + self.assertRaises(AttributeError, OrdersEndpoint, **self.endpoint_kwargs) + self.endpoint_kwargs |= dict(token_id=self.token_id) + OrdersEndpoint(**self.endpoint_kwargs) + + def test_attr_asset_contract_address_raises_if_not_defined_with_token_ids(self): + self.endpoint_kwargs |= dict(asset_contract_address=self.asset_contract_address, token_ids=[self.token_id, self.token_id_2]) + OrdersEndpoint(**self.endpoint_kwargs) + + def test_attr_token_id_raises_if_not_defined_together_with_asset_contract_address(self): + self.endpoint_kwargs |= dict(token_id=self.token_id) + self.assertRaises(AttributeError, OrdersEndpoint, **self.endpoint_kwargs) + + def test_attr_token_ids_raises_if_not_defined_together_with_asset_contract_address(self): + self.endpoint_kwargs |= dict(token_ids=[self.token_id, self.token_id_2]) + self.assertRaises(AttributeError, OrdersEndpoint, **self.endpoint_kwargs) + self.endpoint_kwargs |= dict(asset_contract_address='str') + OrdersEndpoint(**self.endpoint_kwargs) + + def test_attr_token_id_and_token_ids_cannot_be_defined_together(self): + self.endpoint_kwargs |= dict(asset_contract_address=self.asset_contract_address, token_id=1, token_ids=[self.token_id, self.token_id_2]) + self.assertRaises(AttributeError, OrdersEndpoint, **self.endpoint_kwargs) + + def test_response_returns_order_object(self): + resp = self.create_and_get(**self.endpoint_kwargs) + self.assertTrue(resp) + self.assertIsInstance(resp, list) + self.assertIsInstance(resp[0], OrderResponse) + + def test_attr_asset_contract_address_returns_correct_contract_address_orders(self): + self.endpoint_kwargs |= dict(asset_contract_address=self.asset_contract_address, token_id=self.token_id) + resp = self.create_and_get(**self.endpoint_kwargs) + self.assertTrue(resp) + self.assertEqual(resp[0].asset.asset_contract.address, self.endpoint_kwargs['asset_contract_address']) + + def test_attr_payment_token_address_returns_correct_payment_token_address_orders(self): + weth_payment_token_address = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2' + # https://etherscan.io/token/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 + self.endpoint_kwargs |= dict(payment_token_address=weth_payment_token_address) + resp = self.create_and_get(**self.endpoint_kwargs) + self.assertTrue(resp) + self.assertEqual(weth_payment_token_address, resp[0].payment_token_contract['address']) + + def test_attr_maker_address_correctly_returns_only_orders_made_by_maker_address(self): + """ + A maker is the first mover in a trade. + Makers either declare intent to sell an item, + or they declare intent to buy by bidding on one. + https://docs.opensea.io/reference/terminology + """ + random_buyer_address = '0xdcfd6d6e63f15a391d96d1b76575ae39ad6965d9' + self.endpoint_kwargs |= dict(maker=random_buyer_address) + resp = self.create_and_get(**self.endpoint_kwargs) + self.assertTrue(resp) + self.assertEqual(random_buyer_address, resp[0].maker['address']) + + def test_attr_taker_address_correctly_returns_only_orders_made_by_taker_address(self): + """ + A taker is the counterparty who responds to a + maker's order by, respectively, either buying the + item or accepting a bid on it. + https://docs.opensea.io/reference/terminology + + Observations from the maintainer: + # It would seem on OpenSea that all takers are actually OpenSea, as the platform acts + # as a middle-man. I am unsure of this and would greatly appreciate community feedback! + # https://github.com/dehidehidehi/opensea-python-wrapper/issues + """ + logger.warning("\nThe 'taker' parameter may cause confusion, please read test docstring and comments.") + open_sea_default_address = '0x0000000000000000000000000000000000000000' + self.endpoint_kwargs |= dict(taker=open_sea_default_address) + resp = self.create_and_get(**self.endpoint_kwargs) + self.assertTrue(resp) + self.assertEqual(open_sea_default_address, resp[0].taker['address']) + + def test_attr_is_english_returns_only_orders_from_english_auctions(self): + """ + Maintainer observations: + This is not fully testable, as per the documentation. + We can only assert that all responses have a zero value for the sale_kind attribute. + + Observation 2: + Setting the is_english attr to False does NOT return only dutch auctions. + In this case, use : sale_kind = 1 + + Documentation: + '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' + """ + self.endpoint_kwargs |= dict(is_english=True) + resp = self.create_and_get(**self.endpoint_kwargs) + self.assertTrue(all(r.sale_kind == 0 for r in resp)) + + def test_attr_bundled_returns_only_bundled_orders(self): + self.endpoint_kwargs |= dict(bundled=True) + resp = self.create_and_get(**self.endpoint_kwargs) + self.assertTrue(resp) + self.assertTrue(all(r.asset_bundle for r in resp)) + + def test_attr_include_bundled_raises_if_not_set_together_with_token_id_or_token_ids(self): + self.endpoint_kwargs |= dict(include_bundled=True) + self.assertRaises(AttributeError, self.create_and_get, **self.endpoint_kwargs) + + def test_attr_include_bundled_cannot_be_true_without_asset_contract_address_or_owner_address(self): + self.endpoint_kwargs |= dict(include_bundled=True, token_id=self.token_id) + self.assertRaises(AttributeError, self.create_and_get, **self.endpoint_kwargs) + + def test_attr_include_bundled_does_not_raise_when_false_without_asset_contract_address_or_owner_address(self): + self.endpoint_kwargs |= dict(asset_contract_address=self.asset_contract_address, include_bundled=False, token_id=self.token_id) + self.create_and_get(**self.endpoint_kwargs) + + def test_attr_include_bundled_returns_all_assets_which_share_the_same_asset_contract_address(self): + client_params = ClientParams(page_size=10, limit=1, max_pages=1) + self.endpoint_kwargs |= dict(client_params=client_params, include_bundled=True, asset_contract_address=self.asset_contract_address, token_id=self.token_id) + resp = self.create_and_get(**self.endpoint_kwargs) + self.assertTrue(resp) + all_have_same_contract_address: bool = len(set(r.asset.asset_contract.address for r in resp)) == 1 + self.assertTrue(all_have_same_contract_address) + + def test_attr_include_bundled_returns_all_assets_which_share_the_same_owner_address(self): + """ + This is difficult to test. You need to find a very specific set of assets and bundles. + Would appreciate some help from the community to find something here that does NOT return an empty response. + https://github.com/dehidehidehi/opensea-python-wrapper/issues + """ + logger.warning("\nThe 'include_bundled' set together with 'owner' address is untested, please read test docstring and comments.") + # client_params = ClientParams(page_size=10, limit=1, max_pages=1) + # self.endpoint_kwargs |= dict(client_params=client_params, owner=self.owner_address, asset_contract_address=self.asset_contract_address, token_id=9180, include_bundled=True) + # resp = self.create_and_get(**self.endpoint_kwargs) + # self.assertTrue(resp) + # all_have_same_owner_address: bool = len(set(r.asset.owner.address for r in resp)) == 1 + # self.assertTrue(all_have_same_owner_address) + + def test_attr_include_invalid_returns_also_invalid_orders(self): + """ + Include orders marked invalid by the orderbook, typically due to makers not owning enough of the token/asset anymore. + """ + logger.warning("\nAttribute 'include_invalid' is untested.") + + def test_attr_listed_after_raises_if_is_not_instance_of_datetime(self): + string_datetime = datetime.now().isoformat() + self.endpoint_kwargs |= dict(listed_after=string_datetime) + self.assertRaises(TypeError, self.create_and_get, **self.endpoint_kwargs) + + def test_attr_listed_after_returns_only_orders_after_specified_datetime(self): + date_ref = datetime(year=2021, month=8, day=15) + self.endpoint_kwargs |= dict(listed_after=date_ref) + resp = self.create_and_get(**self.endpoint_kwargs) + self.assertTrue(all(r.created_date > date_ref for r in resp)) + + def test_attr_listed_before_returns_only_orders_before_specified_datetime(self): + date_ref = datetime(year=2021, month=8, day=14) + self.endpoint_kwargs |= dict(listed_before=date_ref) + resp = self.create_and_get(**self.endpoint_kwargs) + self.assertTrue(all(r.created_date <= date_ref for r in resp)) + + def test_attr_listed_after_and_listed_before_work_together(self): + older_thresh = datetime(year=2021, month=8, day=10) + recent_thresh = datetime(year=2021, month=8, day=15) + self.endpoint_kwargs |= dict(listed_before=recent_thresh, listed_after=older_thresh) + resp = self.create_and_get(**self.endpoint_kwargs) + self.assertTrue(all(older_thresh <= r.created_date <= recent_thresh for r in resp)) + + def test_attr_side_not_raises_if_valid_value(self): + for valid_value in {None, 1, 0}: + self.endpoint_kwargs |= dict(side=valid_value) + self.create_and_get(**self.endpoint_kwargs) + + def test_attr_side_raises_if_invalid_value(self): + for invalid_value in {'string', -1, 0.0, 1.0, 2}: + self.endpoint_kwargs |= dict(side=invalid_value) + self.assertRaises(ValueError, self.create_and_get, **self.endpoint_kwargs) + + def test_sale_kind_not_raises_if_valid_value(self): + for valid_value in {None, 1, 0}: + self.endpoint_kwargs |= dict(sale_kind=valid_value) + self.create_and_get(**self.endpoint_kwargs) + + def test_attr_sale_kind_raises_if_invalid_value(self): + for invalid_value in {'string', -1, 0.0, 1.0, 2}: + self.endpoint_kwargs |= dict(sale_kind=invalid_value) + self.assertRaises(ValueError, self.create_and_get, **self.endpoint_kwargs) + + def test_attr_order_by_not_raises_if_valid_value(self): + self.endpoint_kwargs |= dict(order_by='created_date') + self.create_and_get(**self.endpoint_kwargs) + + self.endpoint_kwargs |= dict(asset_contract_address=self.asset_contract_address, token_id=self.token_id, order_by='eth_price') + self.create_and_get(**self.endpoint_kwargs) + + def test_attr_order_by_raises_if_invalid_value(self): + for invalid_value in {'asc', -1, 'desc'}: + self.endpoint_kwargs |= dict(order_by=invalid_value) + self.assertRaises(ValueError, self.create_and_get, **self.endpoint_kwargs) + + def test_attr_order_by_eth_price_raises_if_asset_contract_address_and_token_id_are_not_defined(self): + self.endpoint_kwargs |= dict(order_by='eth_price') + self.assertRaises(AttributeError, self.create_and_get, **self.endpoint_kwargs) + + def test_attr_order_direction_not_raises_if_valid_value(self): + for valid_value in {None, 'asc', 'desc'}: + self.endpoint_kwargs |= dict(order_direction=valid_value) + self.create_and_get(**self.endpoint_kwargs) + + def test_attr_order_direction_raises_if_invalid_value(self): + for invalid_value in {'up', -1, False}: + self.endpoint_kwargs |= dict(order_direction=invalid_value) + self.assertRaises(ValueError, self.create_and_get, **self.endpoint_kwargs) diff --git a/open_sea_v1/endpoints/urls.py b/open_sea_v1/endpoints/urls.py index e64c06a..951c6d2 100644 --- a/open_sea_v1/endpoints/urls.py +++ b/open_sea_v1/endpoints/urls.py @@ -1,7 +1,7 @@ from enum import Enum OPENSEA_API_V1 = "https://api.opensea.io/api/v1/" -OPENSEA_LISTINGS_V1 = "https://api.opensea.io/wyvern/v1/" +OPENSEA_ORDER_BOOK_V1 = "https://api.opensea.io/wyvern/v1/" class EndpointURLS(str, Enum): @@ -11,4 +11,4 @@ class EndpointURLS(str, Enum): BUNDLES = OPENSEA_API_V1 + "bundles" EVENTS = OPENSEA_API_V1 + "events" COLLECTIONS = OPENSEA_API_V1 + "collections" - LISTINGS = OPENSEA_LISTINGS_V1 + "orders" \ No newline at end of file + ORDERS = OPENSEA_ORDER_BOOK_V1 + "orders" \ No newline at end of file diff --git a/open_sea_v1/responses/event.py b/open_sea_v1/responses/event.py index 9abde5d..a72dcd6 100644 --- a/open_sea_v1/responses/event.py +++ b/open_sea_v1/responses/event.py @@ -1,10 +1,11 @@ +import locale from dataclasses import dataclass from open_sea_v1.helpers.ether_converter import EtherConverter, EtherUnit -from open_sea_v1.responses.asset import AssetResponse from open_sea_v1.responses.abc import BaseResponse +from open_sea_v1.responses.asset import AssetResponse + -import locale @dataclass class EventResponse(BaseResponse): _json: dict @@ -43,6 +44,7 @@ def __post_init__(self): self.to_account = self._json['to_account'] self.total_price = self._json['total_price'] self.bid_amount = self._json['bid_amount'] + self.is_private = self._json.get('is_private') @property def eth_price(self): diff --git a/open_sea_v1/responses/order.py b/open_sea_v1/responses/order.py new file mode 100644 index 0000000..ea1fd1d --- /dev/null +++ b/open_sea_v1/responses/order.py @@ -0,0 +1,75 @@ +""" +Assigns attributes to dictionnary values for easier object navigation. +""" +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + +from open_sea_v1.responses.abc import BaseResponse +from open_sea_v1.responses.asset import AssetResponse + + +@dataclass +class OrderResponse(BaseResponse): + _json: dict + + def __str__(self): + return f"order_id={self.id}" + + def __post_init__(self): + self._set_common_attrs() + self._set_optional_attrs() + + def _set_common_attrs(self): + self.id = self._json['id'] + self.asset_bundle = self._json['asset_bundle'] + self.closing_extendable = self._json['closing_extendable'] + self.expiration_time = self._json['expiration_time'] + self.listing_time = self._json['listing_time'] + self.order_hash = self._json['order_hash'] + self.exchange = self._json['exchange'] + self.current_price = self._json['current_price'] + self.current_bounty = self._json['current_bounty'] + self.bounty_multiple = self._json['bounty_multiple'] + self.maker_relayer_fee = self._json['maker_relayer_fee'] + self.taker_relayer_fee = self._json['taker_relayer_fee'] + self.maker_protocol_fee = self._json['maker_protocol_fee'] + self.taker_protocol_fee = self._json['taker_protocol_fee'] + self.maker_referrer_fee = self._json['maker_referrer_fee'] + self.fee_method = self._json['fee_method'] + self.side = self._json['side'] + self.sale_kind = self._json['sale_kind'] + self.target = self._json['target'] + self.how_to_call = self._json['how_to_call'] + self.calldata = self._json['calldata'] + self.replacement_pattern = self._json['replacement_pattern'] + self.static_target = self._json['static_target'] + self.static_extradata = self._json['static_extradata'] + self.payment_token = self._json['payment_token'] + self.base_price = self._json['base_price'] + self.extra = self._json['extra'] + self.quantity = self._json['quantity'] + self.salt = self._json['salt'] + self.v = self._json['v'] + self.r = self._json['r'] + self.s = self._json['s'] + self.approved_on_chain = self._json['approved_on_chain'] + self.cancelled = self._json['cancelled'] + self.finalized = self._json['finalized'] + self.marked_invalid = self._json['marked_invalid'] + self.prefixed_hash = self._json['prefixed_hash'] + self.metadata: dict = self._json['metadata'] + self.maker: dict = self._json['maker'] + self.taker: dict = self._json['taker'] + self.fee_recipient: dict = self._json['fee_recipient'] + self.payment_token_contract: dict = self._json['payment_token_contract'] + + @property + def asset(self) -> AssetResponse: + return AssetResponse(self._json['asset']) + + def _set_optional_attrs(self): + created_date = self._json['created_date'] + closing_date = self._json['closing_date'] + self.created_date: Optional[datetime] = datetime.fromisoformat(created_date) if created_date else None + self.closing_date: Optional[datetime] = datetime.fromisoformat(closing_date) if closing_date else None diff --git a/open_sea_v1/responses/tests/test_reponse_event.py b/open_sea_v1/responses/tests/test_reponse_event.py index 2ad46c4..1e9f484 100644 --- a/open_sea_v1/responses/tests/test_reponse_event.py +++ b/open_sea_v1/responses/tests/test_reponse_event.py @@ -1,7 +1,8 @@ -from open_sea_v1.endpoints.events import EventsEndpoint, EventType from open_sea_v1.endpoints.abc import ClientParams +from open_sea_v1.endpoints.events import EventsEndpoint, EventType from open_sea_v1.responses.tests._response_helpers import ResponseTestHelper + class TestEventsObj(ResponseTestHelper): events_default_kwargs = dict( client_params=ClientParams(limit=1), diff --git a/open_sea_v1/responses/tests/test_response_asset.py b/open_sea_v1/responses/tests/test_response_asset.py index 77a1784..6391763 100644 --- a/open_sea_v1/responses/tests/test_response_asset.py +++ b/open_sea_v1/responses/tests/test_response_asset.py @@ -1,7 +1,8 @@ -from open_sea_v1.endpoints.assets import AssetsEndpoint from open_sea_v1.endpoints.abc import ClientParams +from open_sea_v1.endpoints.assets import AssetsEndpoint from open_sea_v1.responses.tests._response_helpers import ResponseTestHelper + class TestAssetObj(ResponseTestHelper): sample_contract = "0xb47e3cd837ddf8e4c57f05d70ab865de6e193bbb" # punk default_asset_params = dict( diff --git a/open_sea_v1/responses/tests/test_response_order.py b/open_sea_v1/responses/tests/test_response_order.py new file mode 100644 index 0000000..6639c7d --- /dev/null +++ b/open_sea_v1/responses/tests/test_response_order.py @@ -0,0 +1,25 @@ +from open_sea_v1.endpoints.abc import ClientParams +from open_sea_v1.endpoints.orders import OrdersEndpoint +from open_sea_v1.responses.tests._response_helpers import ResponseTestHelper + + +class TestOrderObj(ResponseTestHelper): + + default_asset_params = dict( + asset_contract_address='0x3f4a885ed8d9cdf10f3349357e3b243f3695b24a', # incognito nft + token_id=7504, + side=0, + ) + + @classmethod + def setUpClass(cls) -> None: + client_params = ClientParams(page_size=2, limit=2) + params = cls.default_asset_params | dict(client_params=client_params) + cls.orders = cls.create_and_get(OrdersEndpoint, **params) + cls.order = cls.orders[0] + + def test_attributes_do_not_raise_unexpected_exceptions(self): + self.assert_attributes_do_not_raise_unexpected_exceptions(self.order) + + def test_no_missing_class_attributes_from_original_json_keys(self): + self.assert_no_missing_class_attributes_from_original_json_keys(response_obj=self.order, json=self.order._json) diff --git a/requirements.txt b/requirements.txt index 663bd1f..fc9beb9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -requests \ No newline at end of file +requests +ratelimit \ No newline at end of file