Skip to content

Commit 6ae1eff

Browse files
author
Jeff Schroeder
authored
Merge pull request #3 from SEJeff/start-of-unit-tests
Initial start of unit tests for pyth-client-py
2 parents 879054b + 19732fc commit 6ae1eff

11 files changed

+717
-28
lines changed

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,28 @@ Pyth Client in Python
22
=====================
33

44
A Python library to retrieve data from Pyth account structures off the Solana blockchain.
5+
6+
**NOTE**: This library requires Python 3.7 or greater due to `from __future__ import annotations`.
7+
8+
9+
Developer Setup
10+
---------------
11+
12+
Using python 3.7 or newer, create, and activate a virtual environment:
13+
14+
python3 -m venv ve
15+
. ve/bin/activate
16+
17+
To install this library in editable mode with test dependencies:
18+
19+
pip install -e '.[testing]'
20+
21+
To run the unit tests:
22+
23+
pytest
24+
25+
If html based test coverage is more your jam:
26+
27+
pytest --cov-report=html
28+
29+
The coverage webpages will be in the `htmlcov` directory.

pytest.ini

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[pytest]
2+
addopts =
3+
# Prevent SolanaClient from actually reaching out to devnet
4+
--disable-socket
5+
--allow-unix-socket
6+
--cov=pythclient
7+
--no-cov-on-fail

pythclient/pythaccounts.py

Lines changed: 44 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from __future__ import annotations
2-
from typing import List, Dict, Tuple, Optional, Any
2+
from typing import List, Dict, Tuple, Optional, Any, ClassVar
33
import base64
44
from enum import Enum
5+
from dataclasses import dataclass, field
56
import struct
67

78
from loguru import logger
@@ -205,7 +206,6 @@ class PythProductAccount(PythAccount):
205206
first_price_account_key (SolanaPublicKey): the public key of the first price account (the price accounts form a linked list)
206207
attrs (dict): a dictionary of metadata attributes
207208
"""
208-
209209
def __init__(self, key: SolanaPublicKey, solana: SolanaClient) -> None:
210210
super().__init__(key, solana)
211211
self._prices: Optional[Dict[PythPriceType, PythPriceAccount]] = None
@@ -229,7 +229,6 @@ def symbol(self) -> str:
229229
"""
230230
Gets this account's symbol, or 'Unknown' if there is no 'symbol' attribute.
231231
"""
232-
233232
return self.attrs.get("symbol", "Unknown")
234233

235234
async def get_prices(self) -> Dict[PythPriceType, PythPriceAccount]:
@@ -258,7 +257,10 @@ async def refresh_prices(self) -> Dict[PythPriceType, PythPriceAccount]:
258257
self._prices = prices
259258
return prices
260259

261-
async def check_price_changes(self, update_accounts: bool = True) -> Tuple[List[PythPriceAccount], List[PythPriceAccount]]:
260+
async def check_price_changes(
261+
self,
262+
update_accounts: bool = True
263+
) -> Tuple[List[PythPriceAccount], List[PythPriceAccount]]:
262264
"""
263265
Checks for changes to the list of price accounts of this product.
264266
@@ -345,7 +347,13 @@ def __str__(self) -> str:
345347
def __repr__(self) -> str:
346348
return str(self)
347349

350+
def __iter__(self):
351+
for key, val in self.__dict__.items():
352+
if not key.startswith('_'):
353+
yield key, val
354+
348355

356+
@dataclass
349357
class PythPriceInfo:
350358
"""
351359
Contains price information.
@@ -360,15 +368,18 @@ class PythPriceInfo:
360368
exponent (int): the power-of-10 order of the price
361369
"""
362370

363-
LENGTH = 32
371+
LENGTH: ClassVar[int] = 32
364372

365-
def __init__(self, raw_price: int, raw_confidence_interval: int, price_status: PythPriceStatus, slot: int, exponent: int) -> None:
366-
self.raw_price = raw_price
367-
self.raw_confidence_interval = raw_confidence_interval
368-
self.price_status = price_status
369-
self.slot = slot
370-
self.exponent = exponent
373+
raw_price: int
374+
raw_confidence_interval: int
375+
price_status: PythPriceStatus
376+
slot: int
377+
exponent: int
378+
379+
price: float = field(init=False)
380+
confidence_interval: float = field(init=False)
371381

382+
def __post_init__(self):
372383
self.price = self.raw_price * (10 ** self.exponent)
373384
self.confidence_interval = self.raw_confidence_interval * \
374385
(10 ** self.exponent)
@@ -397,9 +408,11 @@ def __repr__(self) -> str:
397408
return str(self)
398409

399410

400-
class PythPriceComponent: # This has the individual prices each publisher
411+
@dataclass
412+
class PythPriceComponent:
401413
"""
402-
Represents a price component.
414+
Represents a price component. This is the individual prices each
415+
publisher sends in addition to their aggregate.
403416
404417
Attributes:
405418
publisher_key (SolanaPublicKey): the public key of the publisher
@@ -411,13 +424,12 @@ class PythPriceComponent: # This has the individual prices each publisher
411424
in this price component
412425
"""
413426

414-
LENGTH = SolanaPublicKey.LENGTH + 2 * PythPriceInfo.LENGTH
427+
LENGTH: ClassVar[int] = SolanaPublicKey.LENGTH + 2 * PythPriceInfo.LENGTH
415428

416-
def __init__(self, publisher_key: SolanaPublicKey, last_aggregate_price_info: PythPriceInfo, latest_price_info: PythPriceInfo, exponent: int) -> None:
417-
self.publisher_key = publisher_key
418-
self.last_aggregate_price_info = last_aggregate_price_info
419-
self.latest_price_info = latest_price_info
420-
self.exponent = exponent
429+
publisher_key: SolanaPublicKey
430+
last_aggregate_price_info: PythPriceInfo
431+
latest_price_info: PythPriceInfo
432+
exponent: int
421433

422434
@staticmethod
423435
def deserialise(buffer: bytes, offset: int = 0, *, exponent: int) -> Optional[PythPriceComponent]:
@@ -477,6 +489,7 @@ def __init__(self, key: SolanaPublicKey, solana: SolanaClient, *, product: Optio
477489
self.aggregate_price_info: Optional[PythPriceInfo] = None
478490
self.price_components: List[PythPriceComponent] = []
479491
self.derivations: Dict[TwEmaType, int] = {}
492+
self.min_publishers: Optional[int] = None
480493

481494
@property
482495
def aggregate_price(self) -> Optional[float]:
@@ -501,7 +514,8 @@ def update_from(self, buffer: bytes, *, version: int, offset: int = 0) -> None:
501514
unused (u32)
502515
currently accumulating price slot (u64)
503516
slot of current aggregate price (u64)
504-
derivations (u64[8] - array index corresponds to (DeriveType - 1) - v2 only)
517+
derivations (u64[6] - array index corresponds to (DeriveType - 1) - v2 only)
518+
unused derivation values and minimum publishers (u64[2], i32[2], )
505519
product account key (char[32])
506520
next price account key (char[32])
507521
account key of quoter who computed last aggregate price (char[32])
@@ -510,19 +524,22 @@ def update_from(self, buffer: bytes, *, version: int, offset: int = 0) -> None:
510524
"""
511525
if version == _VERSION_2:
512526
price_type, exponent, num_components = struct.unpack_from("<IiI", buffer, offset)
513-
offset += 16 # struct.calcsize("IiII") (last I unused)
527+
offset += 16 # struct.calcsize("IiII") (last I is the number of quoters that make up the aggregate)
514528
last_slot, valid_slot = struct.unpack_from("<QQ", buffer, offset)
515-
offset += 16 # QQ
516-
derivations = list(struct.unpack_from("<8q", buffer, offset))
529+
offset += 16 # QQ
530+
derivations = list(struct.unpack_from("<6q", buffer, offset))
517531
self.derivations = dict((type_, derivations[type_.value - 1]) for type_ in [TwEmaType.TWACVALUE, TwEmaType.TWAPVALUE])
518-
offset += 64 # 8q
532+
offset += 48 # 6q
533+
# All drv*_ fields sans min_publishers are currently unused
534+
_, min_publishers = struct.unpack_from("<qQ", buffer, offset)
535+
offset += 16 # <qQ
519536
product_account_key_bytes, next_price_account_key_bytes = struct.unpack_from("32s32s", buffer, offset)
520-
offset += 96 # 32s32s32s
537+
offset += 96 # 32s32s32s
521538
elif version == _VERSION_1:
522539
price_type, exponent, num_components, _, last_slot, valid_slot, product_account_key_bytes, next_price_account_key_bytes, aggregator_key_bytes = struct.unpack_from(
523540
"<IiIIQQ32s32s32s", buffer, offset)
524541
self.derivations = {}
525-
offset += 128 # struct.calcsize("<IiIIQQ32s32s32s")
542+
offset += 128 # struct.calcsize("<IiIIQQ32s32s32s")
526543
else:
527544
assert False
528545

@@ -552,6 +569,7 @@ def update_from(self, buffer: bytes, *, version: int, offset: int = 0) -> None:
552569
next_price_account_key_bytes)
553570
self.aggregate_price_info = aggregate_price_info
554571
self.price_components = price_components
572+
self.min_publishers = min_publishers
555573

556574
def __str__(self) -> str:
557575
if self.product:

setup.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
from setuptools import setup
22

3+
requirements = ['aiodns', 'aiohttp', 'backoff', 'base58', 'loguru']
4+
35
setup(
46
name='pythclient',
5-
version='0.0.1',
7+
version='0.0.2',
68
packages=['pythclient'],
79
author='Pyth Developers',
810
author_email='[email protected]',
9-
install_requires=['aiodns', 'aiohttp', 'backoff', 'base58', 'loguru']
11+
install_requires=requirements,
12+
extras_require={
13+
'testing': requirements + ['mock', 'pytest', 'pytest-cov', 'pytest-socket'],
14+
},
15+
python_requires='>=3.7.0',
1016
)

tests/conftest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import pytest
2+
from pythclient.solana import SolanaClient
3+
4+
5+
@pytest.fixture
6+
def solana_client():
7+
return SolanaClient(
8+
endpoint="https://example.com",
9+
ws_endpoint="wss://example.com",
10+
)

tests/test_mapping_account.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import base64
2+
import pytest
3+
4+
from pythclient.pythaccounts import PythMappingAccount, _VERSION_2
5+
from pythclient.solana import SolanaPublicKey
6+
7+
8+
@pytest.fixture
9+
def mapping_account_bytes():
10+
"""
11+
Put a debugger breakpoint in PythMappingAccount.update_from() at the top.
12+
Get the mapping account number of entries and 3 keys:
13+
14+
fmt_size = struct.calcsize(fmt)
15+
intermediate_buffer = buffer[offset:offset + fmt_size + (SolanaPublicKey.LENGTH * 3)]
16+
17+
Replace the num_keys bytes with 3:
18+
19+
num_entries_bytes = int(3).to_bytes(4, 'little')
20+
product_account_bytes = num_entries_bytes + intermediate_buffer[struct.calcsize("<I"):]
21+
22+
Render those into a pasteable form with:
23+
24+
print(base64.b6encode(product_account_bytes))
25+
26+
"""
27+
return base64.b64decode((
28+
b'AwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEjWAz1zPieVDC4DUeJQVJHNkVSCT3FtlRNRT'
29+
b'HS5+Y9YyAwLFIq6mUslZupUp4/wjvo9Xg7D0M7qDPqgP+wl7qI1FbOGHo/pPl9UC6QHfCFkBHgrhtXngHezy/0nMT'
30+
b'qzvA=='
31+
))
32+
33+
34+
@pytest.fixture
35+
def entries():
36+
return [
37+
SolanaPublicKey("5uKdRzB3FzdmwyCHrqSGq4u2URja617jqtKkM71BVrkw"),
38+
SolanaPublicKey("ETuC4VK6kuHfxc9MCU14dASfnGBfzgFUVCs1oVowawHb"),
39+
SolanaPublicKey("4aDoSXJ5o3AuvL7QFeR6h44jALQfTmUUCTVGDD6aoJTM"),
40+
]
41+
42+
43+
@pytest.fixture
44+
def mapping_account(solana_client):
45+
return PythMappingAccount(
46+
key=SolanaPublicKey("AHtgzX45WTKfkPG53L6WYhGEXwQkN1BVknET3sVsLL8J"),
47+
solana=solana_client,
48+
)
49+
50+
51+
def test_mapping_account_update_from(
52+
solana_client, mapping_account, mapping_account_bytes, entries
53+
):
54+
mapping_account.update_from(
55+
buffer=mapping_account_bytes,
56+
version=_VERSION_2,
57+
offset=0,
58+
)
59+
60+
assert mapping_account.entries == entries
61+
assert mapping_account.next_account_key is None
62+
63+
64+
def test_mapping_account_upate_from_null_key(
65+
solana_client, mapping_account, mapping_account_bytes, entries
66+
):
67+
# Replace the last key with a null key
68+
null_key_bytes = b"\0" * SolanaPublicKey.LENGTH
69+
70+
# Length of bytes with the last pubkey trimmed
71+
offset = len(mapping_account_bytes) - SolanaPublicKey.LENGTH
72+
73+
# Take the original bytes and add a null key to the end
74+
bad_bytes = mapping_account_bytes[:offset] + null_key_bytes
75+
76+
mapping_account.update_from(
77+
buffer=bad_bytes,
78+
version=_VERSION_2,
79+
offset=0,
80+
)
81+
82+
# The last key in the list is null, so remove it
83+
expected = entries[:-1]
84+
85+
assert mapping_account.entries == expected
86+
assert mapping_account.next_account_key is None
87+
88+
89+
def test_mapping_account_str(mapping_account, solana_client):
90+
actual = str(mapping_account)
91+
expected = f"PythMappingAccount ({mapping_account.key})"
92+
assert actual == expected

0 commit comments

Comments
 (0)