Skip to content

Commit

Permalink
README.md: Added some basic information about the package.
Browse files Browse the repository at this point in the history
Added test runner, all tests pass.
Minor fix: poorly coded paginator was tested and fixed.
Minor fix: changed id attributes from responses from int to str.
  • Loading branch information
dehidehidehi committed Aug 17, 2021
1 parent b7fa57e commit 67cff5b
Show file tree
Hide file tree
Showing 10 changed files with 100 additions and 52 deletions.
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,13 @@
# opensea-python-wrapper
A convenient Python wrapper for the OpenSea API.
# OpenSea Python API Wrapper
A convenient package for interacting with the OpenSea API; which allows for retrieval of asset data from the OpenSea marketplace (https://opensea.io/).

# Warning about the dev branch
* Do not expect the dev branch to be stable or complete.
* There is no documentation yet. To get a sense on how the package works, have a look at the endpoint classes in the open_sea_v1 folder. You'll probably want to instanciate an endpoint class, and call it's methods.
* There will be non backwards-compatible pushes and commit squashes on the dev branch.

# About the documentation
* OpenSea API V1 Documentation: https://docs.opensea.io/reference/
* Request an API key here: https://docs.opensea.io/reference/request-an-api-key
* Anonymous API usage is limited to 2 queries per second.

6 changes: 3 additions & 3 deletions open_sea_v1/endpoints/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ class AssetsEndpoint(BaseClient, BaseEndpoint):
:return: Parsed JSON
"""
client_params: ClientParams = None
asset_contract_address: Optional[list[str]] = None
asset_contract_addresses: Optional[str] = None
token_ids: Optional[list[str]] = None
asset_contract_address: Optional[str] = None
asset_contract_addresses: Optional[list[str]] = None
token_ids: Optional[list[int]] = None
collection: Optional[str] = None
owner: Optional[str] = None
order_by: Optional[AssetsOrderBy] = None
Expand Down
27 changes: 23 additions & 4 deletions open_sea_v1/endpoints/client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
from abc import ABC
from dataclasses import dataclass
from typing import Optional, Generator
Expand All @@ -6,6 +7,8 @@

from open_sea_v1.responses.abc import BaseResponse

logger = logging.getLogger(__name__)

@dataclass
class ClientParams:
"""Common OpenSea Endpoint parameters to pass in."""
Expand All @@ -16,8 +19,24 @@ class ClientParams:
api_key: Optional[str] = None

def __post_init__(self):
if self.limit is not None and not 0 < int(self.limit) <= 300:
raise ValueError(f'{self.limit=} max value is 300.')
self._validate_attrs()
self._decrement_max_pages_attr()

def _validate_attrs(self) -> None:
if self.limit is not None and not 0 < self.limit <= 300:
raise ValueError(f'{self.limit=} must be over 0 and lesser or equal to 300.')
if self.page_size is not None and not 0 <= self.page_size <= 50:
raise ValueError(f'{self.page_size=} must be between 0 and 50.')
if self.max_pages is not None and self.max_pages < 0:
raise ValueError(f'{self.max_pages=} must be greater than or equal to 0.')

def _decrement_max_pages_attr(self) -> None:
"""
For OpenSea, the max pages attribute starts at zero.
However, and for clarity our package, will have this value start at 1 and decrement it for OpenSea.
"""
if self.max_pages is not None:
self.max_pages -= 1


class BaseClient(ABC):
Expand All @@ -41,12 +60,12 @@ def _get_request(self, **kwargs) -> Response:

def get_pages(self) -> Generator[list[list[BaseResponse]], None, None]:
self.processed_pages = 0
self.client_params.offset = 0
self.client_params.offset = 0 if self.client_params.offset is None else self.client_params.offset
self._http_response = None

while self.remaining_pages():
self._http_response = self._get_request()
if self.parsed_http_response: # edge case
if self.parsed_http_response is not None: # edge case
self.processed_pages += 1
self.client_params.offset += self.client_params.page_size
yield self.parsed_http_response
Expand Down
4 changes: 0 additions & 4 deletions open_sea_v1/endpoints/tests/test_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,3 @@ def test_param_order_by_sale_price(self):
def test_param_order_by_visitor_count(self):
pass # as far as I know this is not returned in the API http_response and done directly by OpenSea

def test_param_limit_cannot_be_below_0_or_above_50(self):
self.assertRaises(ValueError, AssetsEndpoint, **{**self.default_asset_params, **{'client_params': ClientParams(limit="25")}})
self.assertRaises(ValueError, AssetsEndpoint, **{**self.default_asset_params, **{'client_params': ClientParams(limit=-1)}})
self.assertRaises(ValueError, AssetsEndpoint, **{**self.default_asset_params, **{'client_params': ClientParams(limit=51)}})
70 changes: 41 additions & 29 deletions open_sea_v1/endpoints/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@
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):
params = ClientParams(max_pages=1)
self.assertEqual(params.max_pages, 0)

def test_max_pages_attr_raises_value_error_if_below_or_equal_to_zero(self):
self.assertRaises(ValueError, ClientParams, max_pages=-1)

def test_limit_attr_raises_value_error_if_not_between_0_and_300(self):
self.assertRaises(ValueError, ClientParams, limit=-1)
self.assertRaises(ValueError, ClientParams, limit=301)

def test_page_size_attr_raises_value_error_if_not_between_0_and_50(self):
self.assertRaises(ValueError, ClientParams, page_size=-1)
self.assertRaises(ValueError, ClientParams, page_size=51)


class TestBaseEndpointClient(TestCase):

Expand All @@ -17,8 +34,14 @@ def setUpClass(cls) -> None:
token_id=str(10152),
event_type=EventType.SUCCESSFUL,
)
cls.sample_client = EventsEndpoint(**cls.sample_client_kwargs)
cls.sample_pages = list(cls.sample_client.get_pages())
cls.sample_pages = list(cls.mk_events_endpoint().get_pages())

def setUp(self) -> None:
self.sample_client = self.mk_events_endpoint()

@classmethod
def mk_events_endpoint(cls) -> EventsEndpoint:
return EventsEndpoint(**cls.sample_client_kwargs)

def test_remaining_pages_true_if_http_response_is_none(self):
self.sample_client._http_response = None
Expand All @@ -30,13 +53,6 @@ def test_remaining_pages_does_not_raise_if_client_params_all_none(self):
next(client.get_pages())
client.remaining_pages() # assert not raises

def test_get_pages_resets_processed_pages_and_offset_attr_on_new_calls(self):
for _ in range(2):
next(self.sample_client.get_pages())
self.assertEqual(self.sample_client.processed_pages, 1)
expected_offset_value = self.sample_client.client_params.limit
self.assertEqual(self.sample_client.client_params.offset, expected_offset_value)

def test_get_pages_does_not_append_empty_pages(self):
no_empty_pages = all(not page == list() for page in self.sample_pages)
self.assertTrue(no_empty_pages)
Expand All @@ -46,26 +62,22 @@ def test_get_pages_max_pages_and_limit_params_works(self):
for page in self.sample_pages[:-1]:
self.assertEqual(self.limit, len(page))

def test_pagination_works(self):
id_list_1 = [[e.id for e in page] for page in self.sample_client.get_pages()]
id_list_1 = list(chain.from_iterable(id_list_1))
id_list_1.sort(reverse=True)
def test_pagination_does_not_return_duplicates_between_different_pages(self):

self.sample_client.client_params = ClientParams(limit=4, offset=0, max_pages=2)
id_list_2 = [[e.id for e in page] for page in self.sample_client.get_pages()]
id_list_2 = list(chain.from_iterable(id_list_2))
id_list_2.sort(reverse=True)
def get_event_ids(offset) -> list[str]:
self.sample_client.client_params = ClientParams(limit=5, offset=offset, max_pages=1)
return [event.id for event in chain.from_iterable(self.sample_client.get_pages())]

self.assertEqual(len(id_list_2), 12) # updated limit * max_pages+1
self.assertGreater(len(id_list_1), len(id_list_2))
self.assertTrue(id_list_1[i] == id_list_2[i] for i in range(len(id_list_2)))
pages = [get_event_ids(offset) for offset in range(0, 14, 5)]
pages = list(chain.from_iterable(pages))
total_events = len(pages)
total_unique_events = len(set(pages))
self.assertEqual(total_events, total_unique_events)

def test_pagination_does_not_return_duplicates_between_different_pages(self):
raise NotImplementedError
self.sample_client.client_params = ClientParams(limit=5, offset=0, max_pages=1)
page_1_event_ids = [e.event_id for e in self.sample_client.get_pages()]
self.sample_client.client_params = ClientParams(limit=5, offset=5, max_pages=1)
page_2_event_ids = self.sample_client.get_pages()
self.sample_client.client_params = ClientParams(limit=5, offset=10, max_pages=1)
page_3_event_ids = self.sample_client.get_pages()
...
def test_pagination_pages_are_in_perfect_sequence(self):
"""Making sure we are not skipping things between pages by mistake."""
self.sample_client.client_params = ClientParams(limit=2, offset=0, max_pages=1)
short_page_event_ids = [e.id for e in list(self.sample_client.get_pages())[0]]
self.sample_client.client_params = ClientParams(limit=3, offset=1, max_pages=1)
longer_page_event_ids = [e.id for e in list(self.sample_client.get_pages())[0]]
self.assertEqual(short_page_event_ids[-1], longer_page_event_ids[0])
12 changes: 5 additions & 7 deletions open_sea_v1/helpers/response_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from pathlib import Path
from typing import Type, Optional, Any, Union

from open_sea_v1.responses import OpenSeaResponse
from open_sea_v1.responses.abc import BaseResponse


@dataclass
Expand All @@ -12,13 +12,13 @@ class ResponseParser:
Interface for saving and loading OpenseaAPI responses from and to JSON files.
"""
destination: Path
response_type: Type[OpenSeaResponse]
response_type: Type[BaseResponse]

def __post_init__(self):
if not self.destination.exists():
self.destination.parent.mkdir(parents=True, exist_ok=True)

def dump(self, to_parse: Optional[Union[OpenSeaResponse, list[OpenSeaResponse]]]) -> None:
def dump(self, to_parse: Optional[Union[BaseResponse, list[BaseResponse]]]) -> None:
if isinstance(to_parse, list):
the_jsons = [e._json for e in to_parse]
else:
Expand All @@ -28,12 +28,10 @@ def dump(self, to_parse: Optional[Union[OpenSeaResponse, list[OpenSeaResponse]]]

def load(self, json_path: Optional[Path] = None) -> Any:
json_path = self.destination if not json_path else json_path
with open(str(self.destination), 'r') as f:
with open(str(json_path), 'r') as f:
parsed_json = json.load(f)
return [self.response_type(collection) for collection in parsed_json]

def load_from_dir(self) -> Any:
detected_json_files = (p for p in self.destination.iterdir() if '.json' in p.name and not p.is_dir())
resp = list()
for json_path in detected_json_files:
resp.append(self.load(destination=json_path))
return [self.load(json_path) for json_path in detected_json_files]
4 changes: 2 additions & 2 deletions open_sea_v1/responses/asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ def __post_init__(self):
self._set_optional_attrs()

def _set_common_attrs(self):
self.token_id = self._json["token_id"]
self.token_id = str(self._json["token_id"])
self.num_sales = self._json["num_sales"]
self.background_color = self._json["background_color"]
self.image_url = self._json["image_url"]
Expand All @@ -103,7 +103,7 @@ def _set_common_attrs(self):
self.permalink = self._json["permalink"]
self.decimals = self._json["decimals"]
self.token_metadata = self._json["token_metadata"]
self.id = self._json["id"]
self.id = str(self._json["id"])

def _set_optional_attrs(self):
"""
Expand Down
2 changes: 1 addition & 1 deletion open_sea_v1/responses/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def __post_init__(self):
self.ending_price = self._json['ending_price']
self.event_type = self._json['event_type']
self.from_account = self._json['from_account']
self.id = self._json['id']
self.id = str(self._json['id'])
self.owner_account = self._json['owner_account']
self.quantity = self._json['quantity']
self.starting_price = self._json['starting_price']
Expand Down
Empty file added open_sea_v1/tests/__init__.py
Empty file.
12 changes: 12 additions & 0 deletions open_sea_v1/tests/run_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from unittest import TestLoader, TextTestRunner


def discover_and_run_tests() -> None:
loader = TestLoader()
tests = loader.discover('..', pattern='test_*')
test_runner = TextTestRunner()
test_runner.run(tests)


if __name__ == '__main__':
discover_and_run_tests()

0 comments on commit 67cff5b

Please sign in to comment.