From 6e9118f0ff708cc4cda6084140400fcfd7082095 Mon Sep 17 00:00:00 2001 From: BobTheBuidler Date: Wed, 5 Feb 2025 22:14:11 +0000 Subject: [PATCH 01/45] WIP docs to edit/review --- eth_portfolio/_db/entities.py | 193 +++++++++++++++++++- eth_portfolio/_ydb/token_transfers.py | 107 ++++++++++- eth_portfolio/protocols/__init__.py | 42 ++++- eth_portfolio/protocols/lending/_base.py | 34 +++- eth_portfolio/protocols/lending/compound.py | 82 ++++++++- 5 files changed, 442 insertions(+), 16 deletions(-) diff --git a/eth_portfolio/_db/entities.py b/eth_portfolio/_db/entities.py index 3dcd4bde..2e4215c3 100644 --- a/eth_portfolio/_db/entities.py +++ b/eth_portfolio/_db/entities.py @@ -54,6 +54,35 @@ class TokenExtended(Token, AddressExtended): class Transaction(DbEntity): + """Represents a transaction entity in the database. + + This class provides properties to access decoded transaction data, + including input data, signature components, and access lists. + + Attributes: + _id: The primary key of the transaction. + block: The block containing this transaction. + transaction_index: The index of the transaction within the block. + hash: The hash of the transaction. + from_address: The address that sent the transaction. + to_address: The address that received the transaction. + value: The value transferred in the transaction. + price: The price of the transaction. + value_usd: The USD value of the transaction. + nonce: The nonce of the transaction. + type: The type of the transaction. + gas: The gas used by the transaction. + gas_price: The gas price of the transaction. + max_fee_per_gas: The maximum fee per gas for the transaction. + max_priority_fee_per_gas: The maximum priority fee per gas for the transaction. + raw: The raw bytes of the transaction. + + See Also: + - :class:`BlockExtended` + - :class:`AddressExtended` + - :class:`TokenTransfer` + """ + _id = PrimaryKey(int, auto=True) block = Required(BlockExtended, lazy=True, reverse="transactions") transaction_index = Required(int, lazy=True) @@ -77,40 +106,128 @@ class Transaction(DbEntity): @cached_property def decoded(self) -> structs.Transaction: + """Decodes the raw transaction data into a :class:`structs.Transaction` object. + + Example: + >>> transaction = Transaction(...) + >>> decoded_transaction = transaction.decoded + >>> isinstance(decoded_transaction, structs.Transaction) + True + """ return json.decode(self.raw, type=structs.Transaction) @property def input(self) -> HexBytes: + """Returns the input data of the transaction. + + Example: + >>> transaction = Transaction(...) + >>> input_data = transaction.input + >>> isinstance(input_data, HexBytes) + True + """ structs.Transaction.input.__doc__ return self.decoded.input @property def r(self) -> HexBytes: + """Returns the R component of the transaction's signature. + + Example: + >>> transaction = Transaction(...) + >>> r_value = transaction.r + >>> isinstance(r_value, HexBytes) + True + """ structs.Transaction.r.__doc__ return self.decoded.r @property def s(self) -> HexBytes: + """Returns the S component of the transaction's signature. + + Example: + >>> transaction = Transaction(...) + >>> s_value = transaction.s + >>> isinstance(s_value, HexBytes) + True + """ structs.Transaction.s.__doc__ return self.decoded.s @property def v(self) -> int: + """Returns the V component of the transaction's signature. + + Example: + >>> transaction = Transaction(...) + >>> v_value = transaction.v + >>> isinstance(v_value, int) + True + """ structs.Transaction.v.__doc__ return self.decoded.v @property def access_list(self) -> typing.List[AccessListEntry]: + """Returns the access list of the transaction. + + Example: + >>> transaction = Transaction(...) + >>> access_list = transaction.access_list + >>> isinstance(access_list, list) + True + >>> isinstance(access_list[0], AccessListEntry) + True + + See Also: + - :class:`AccessListEntry` + """ structs.Transaction.access_list.__doc__ return self.decoded.access_list @property def y_parity(self) -> typing.Optional[int]: - structs.TokenTransfer.y_parity.__doc__ + """Returns the y_parity of the transaction. + + Example: + >>> transaction = Transaction(...) + >>> y_parity_value = transaction.y_parity + >>> isinstance(y_parity_value, (int, type(None))) + True + """ + structs.Transaction.y_parity.__doc__ return self.decoded.y_parity class InternalTransfer(DbEntity): + """Represents an internal transfer entity in the database. + + This class provides properties to access decoded internal transfer data, + including input, output, and code. + + Attributes: + _id: The primary key of the internal transfer. + block: The block containing this internal transfer. + transaction_index: The index of the transaction within the block. + hash: The hash of the internal transfer. + from_address: The address that sent the internal transfer. + to_address: The address that received the internal transfer. + value: The value transferred in the internal transfer. + price: The price of the internal transfer. + value_usd: The USD value of the internal transfer. + type: The type of the internal transfer. + call_type: The call type of the internal transfer. + trace_address: The trace address of the internal transfer. + gas: The gas used by the internal transfer. + gas_used: The gas used by the internal transfer. + raw: The raw bytes of the internal transfer. + + See Also: + - :class:`BlockExtended` + - :class:`AddressExtended` + """ + _id = PrimaryKey(int, auto=True) # common @@ -152,31 +269,95 @@ class InternalTransfer(DbEntity): @cached_property def decoded(self) -> structs.InternalTransfer: + """Decodes the raw internal transfer data into a :class:`structs.InternalTransfer` object. + + Example: + >>> internal_transfer = InternalTransfer(...) + >>> decoded_transfer = internal_transfer.decoded + >>> isinstance(decoded_transfer, structs.InternalTransfer) + True + """ structs.InternalTransfer.__doc__ return json.decode(self.raw, type=structs.InternalTransfer) @property def code(self) -> HexBytes: + """Returns the code of the internal transfer. + + Example: + >>> internal_transfer = InternalTransfer(...) + >>> code_data = internal_transfer.code + >>> isinstance(code_data, HexBytes) + True + """ structs.InternalTransfer.code.__doc__ return self.decoded.code @property def input(self) -> HexBytes: + """Returns the input data of the internal transfer. + + Example: + >>> internal_transfer = InternalTransfer(...) + >>> input_data = internal_transfer.input + >>> isinstance(input_data, HexBytes) + True + """ structs.InternalTransfer.input.__doc__ return self.decoded.input @property def output(self) -> HexBytes: + """Returns the output data of the internal transfer. + + Example: + >>> internal_transfer = InternalTransfer(...) + >>> output_data = internal_transfer.output + >>> isinstance(output_data, HexBytes) + True + """ structs.InternalTransfer.output.__doc__ return self.decoded.output @property def subtraces(self) -> int: + """Returns the number of subtraces of the internal transfer. + + Example: + >>> internal_transfer = InternalTransfer(...) + >>> subtraces_count = internal_transfer.subtraces + >>> isinstance(subtraces_count, int) + True + """ structs.InternalTransfer.subtraces.__doc__ return self.decoded.subtraces class TokenTransfer(DbEntity): + """Represents a token transfer entity in the database. + + This class provides properties to access decoded token transfer data. + + Attributes: + _id: The primary key of the token transfer. + block: The block containing this token transfer. + transaction_index: The index of the transaction within the block. + hash: The hash of the token transfer. + from_address: The address that sent the token transfer. + to_address: The address that received the token transfer. + value: The value transferred in the token transfer. + price: The price of the token transfer. + value_usd: The USD value of the token transfer. + log_index: The log index of the token transfer. + token: The token involved in the transfer. + raw: The raw bytes of the token transfer. + + See Also: + - :class:`BlockExtended` + - :class:`AddressExtended` + - :class:`TokenExtended` + """ + _id = PrimaryKey(int, auto=True) # common @@ -201,4 +382,12 @@ class TokenTransfer(DbEntity): @cached_property def decoded(self) -> structs.TokenTransfer: - return json.decode(self.raw, type=structs.TokenTransfer) + """Decodes the raw token transfer data into a :class:`structs.TokenTransfer` object. + + Example: + >>> token_transfer = TokenTransfer(...) + >>> decoded_transfer = token_transfer.decoded + >>> isinstance(decoded_transfer, structs.TokenTransfer) + True + """ + return json.decode(self.raw, type=structs.TokenTransfer) \ No newline at end of file diff --git a/eth_portfolio/_ydb/token_transfers.py b/eth_portfolio/_ydb/token_transfers.py index 3d129c66..8d7c24a1 100644 --- a/eth_portfolio/_ydb/token_transfers.py +++ b/eth_portfolio/_ydb/token_transfers.py @@ -34,7 +34,23 @@ class _TokenTransfers(ProcessedEvents["Task[TokenTransfer]"]): - """A helper mixin that contains all logic for fetching token transfers for a particular wallet address""" + """A helper mixin that contains all logic for fetching token transfers for a particular wallet address. + + Attributes: + address (Address): The wallet address for which token transfers are fetched. + _load_prices (bool): Indicates whether to load prices for the token transfers. + + Examples: + Fetching token transfers for a specific address: + + >>> transfers = _TokenTransfers(address="0x123...", from_block=0, load_prices=True) + >>> async for transfer in transfers.yield_thru_block(1000000): + ... print(transfer) + + See Also: + - :class:`~eth_portfolio.structs.TokenTransfer`: For the structure of a token transfer. + - :func:`~eth_portfolio._loaders.load_token_transfer`: For loading token transfer data. + """ __slots__ = "address", "_load_prices" @@ -48,10 +64,23 @@ def __repr__(self) -> str: @property @abstractmethod - def _topics(self) -> List: ... + def _topics(self) -> List: + pass @ASyncIterator.wrap # type: ignore [call-overload] async def yield_thru_block(self, block) -> AsyncIterator["Task[TokenTransfer]"]: + """Yield token transfers up to a specified block. + + Args: + block: The block number up to which token transfers are yielded. + + Yields: + Token transfers as :class:`~asyncio.Task` objects. + + Examples: + >>> async for transfer in transfers.yield_thru_block(1000000): + ... print(transfer) + """ if not _logger_is_enabled_for(DEBUG): async for task in self._objects_thru(block=block): yield task @@ -68,6 +97,14 @@ async def yield_thru_block(self, block) -> AsyncIterator["Task[TokenTransfer]"]: _logger_log(DEBUG, "%s yield thru %s complete", (self, block)) async def _extend(self, objs: List[evmspec.Log]) -> None: + """Extend the list of token transfers with new logs. + + Args: + objs: A list of :class:`~evmspec.Log` objects representing token transfer logs. + + Examples: + >>> await transfers._extend(logs) + """ shitcoins = SHITCOINS.get(chain.id, set()) append_loader_task = self._objects.append done = 0 @@ -98,7 +135,18 @@ def _done_callback(self, task: Task) -> None: class InboundTokenTransfers(_TokenTransfers): - """A container that fetches and iterates over all inbound token transfers for a particular wallet address""" + """A container that fetches and iterates over all inbound token transfers for a particular wallet address. + + Examples: + Fetching inbound token transfers for a specific address: + + >>> inbound_transfers = InboundTokenTransfers(address="0x123...", from_block=0, load_prices=True) + >>> async for transfer in inbound_transfers.yield_thru_block(1000000): + ... print(transfer) + + See Also: + - :class:`~eth_portfolio.structs.TokenTransfer`: For the structure of a token transfer. + """ @property def _topics(self) -> List: @@ -106,7 +154,18 @@ def _topics(self) -> List: class OutboundTokenTransfers(_TokenTransfers): - """A container that fetches and iterates over all outbound token transfers for a particular wallet address""" + """A container that fetches and iterates over all outbound token transfers for a particular wallet address. + + Examples: + Fetching outbound token transfers for a specific address: + + >>> outbound_transfers = OutboundTokenTransfers(address="0x123...", from_block=0, load_prices=True) + >>> async for transfer in outbound_transfers.yield_thru_block(1000000): + ... print(transfer) + + See Also: + - :class:`~eth_portfolio.structs.TokenTransfer`: For the structure of a token transfer. + """ @property def _topics(self) -> List: @@ -116,7 +175,22 @@ def _topics(self) -> List: class TokenTransfers(ASyncIterable[TokenTransfer]): """ A container that fetches and iterates over all token transfers for a particular wallet address. - NOTE: These do not come back in chronologcal order. + + Attributes: + transfers_in (InboundTokenTransfers): Container for inbound token transfers. + transfers_out (OutboundTokenTransfers): Container for outbound token transfers. + + Examples: + Fetching all token transfers for a specific address: + + >>> token_transfers = TokenTransfers(address="0x123...", from_block=0, load_prices=True) + >>> async for transfer in token_transfers: + ... print(transfer) + + See Also: + - :class:`~eth_portfolio.structs.TokenTransfer`: For the structure of a token transfer. + - :class:`~InboundTokenTransfers`: For fetching inbound token transfers. + - :class:`~OutboundTokenTransfers`: For fetching outbound token transfers. """ def __init__(self, address: Address, from_block: int, load_prices: bool = False): @@ -124,13 +198,34 @@ def __init__(self, address: Address, from_block: int, load_prices: bool = False) self.transfers_out = OutboundTokenTransfers(address, from_block, load_prices=load_prices) async def __aiter__(self): + """Asynchronously iterate over all token transfers. + + Yields: + Token transfers as :class:`~eth_portfolio.structs.TokenTransfer` objects. + + Examples: + >>> async for transfer in token_transfers: + ... print(transfer) + """ async for transfer in self.yield_thru_block(await dank_mids.eth.block_number): yield transfer def yield_thru_block(self, block: int) -> ASyncIterator["Task[TokenTransfer]"]: + """Yield token transfers up to a specified block. + + Args: + block: The block number up to which token transfers are yielded. + + Yields: + Token transfers as :class:`~asyncio.Task` objects. + + Examples: + >>> async for transfer in token_transfers.yield_thru_block(1000000): + ... print(transfer) + """ return ASyncIterator( as_yielded( self.transfers_in.yield_thru_block(block), self.transfers_out.yield_thru_block(block), ) - ) + ) \ No newline at end of file diff --git a/eth_portfolio/protocols/__init__.py b/eth_portfolio/protocols/__init__.py index ef40aa8c..8e3a0ef1 100644 --- a/eth_portfolio/protocols/__init__.py +++ b/eth_portfolio/protocols/__init__.py @@ -15,6 +15,46 @@ @a_sync.future async def balances(address: Address, block: Optional[Block] = None) -> RemoteTokenBalances: + """Fetches token balances for a given address across various protocols. + + This function retrieves the token balances for a specified Ethereum address + at a given block across all available protocols. It is decorated with + :func:`a_sync.future`, allowing it to be used in both synchronous and + asynchronous contexts. + + If no protocols are available, the function returns an empty + :class:`~eth_portfolio.typing.RemoteTokenBalances` object. + + Args: + address (Address): The Ethereum address for which to fetch balances. + block (Optional[Block]): The block number at which to fetch balances. + If not provided, the latest block is used. + + Examples: + Fetching balances asynchronously: + + >>> from eth_portfolio.protocols import balances + >>> address = "0x1234567890abcdef1234567890abcdef12345678" + >>> block = 12345678 + >>> remote_balances = await balances(address, block) + >>> print(remote_balances) + + Fetching balances synchronously: + + >>> remote_balances = balances(address, block).result() + >>> print(remote_balances) + + The function constructs a dictionary `data` initialized with protocol names + and their corresponding balances. The `protocol_balances` variable is an + asynchronous mapping of protocol names to their respective balance data, + which is then used in an asynchronous comprehension to construct the + dictionary `data`. This dictionary is subsequently used to initialize the + :class:`~eth_portfolio.typing.RemoteTokenBalances` object. + + See Also: + - :class:`~eth_portfolio.typing.RemoteTokenBalances`: For more information on the return type. + - :func:`a_sync.future`: For more information on the decorator used. + """ if not protocols: return RemoteTokenBalances(block=block) protocol_balances = a_sync.map( @@ -26,4 +66,4 @@ async def balances(address: Address, block: Optional[Block] = None) -> RemoteTok async for protocol, protocol_balances in protocol_balances if protocol_balances is not None } - return RemoteTokenBalances(data, block=block) + return RemoteTokenBalances(data, block=block) \ No newline at end of file diff --git a/eth_portfolio/protocols/lending/_base.py b/eth_portfolio/protocols/lending/_base.py index 58a7e007..c62053f5 100644 --- a/eth_portfolio/protocols/lending/_base.py +++ b/eth_portfolio/protocols/lending/_base.py @@ -11,10 +11,19 @@ class LendingProtocol(metaclass=abc.ABCMeta): """ Subclass this class for any protocol that maintains a debt balance for a user but doesn't maintain collateral internally. - Example: Aave, because the user holds on to their collateral in the form of erc-20 aTokens. + Example: Aave, because the user holds on to their collateral in the form of ERC-20 aTokens. You must define the following async method: - `_debt_async(self, address: Address, block: Optional[Block] = None)` + `_debt(self, address: Address, block: Optional[Block] = None)` + + Example: + >>> class AaveProtocol(LendingProtocol): + ... async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenBalances: + ... # Implementation for fetching debt from Aave + ... pass + + See Also: + - :class:`LendingProtocolWithLockedCollateral` """ @a_sync.future @@ -22,7 +31,8 @@ async def debt(self, address: Address, block: Optional[Block] = None) -> TokenBa return await self._debt(address, block) # type: ignore @abc.abstractmethod - async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenBalances: ... + async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenBalances: + ... class LendingProtocolWithLockedCollateral(LendingProtocol, ProtocolABC): @@ -31,6 +41,18 @@ class LendingProtocolWithLockedCollateral(LendingProtocol, ProtocolABC): Example: Maker, because collateral is locked up inside of Maker's smart contracts. You must define the following async methods: - - `_debt_async(self, address: Address, block: Optional[Block] = None)` - - `_balances_async(self, address: Address, block: Optional[Block] = None)` - """ + - `_debt(self, address: Address, block: Optional[Block] = None)` + - `_balances(self, address: Address, block: Optional[Block] = None)` + + Example: + >>> class MakerProtocol(LendingProtocolWithLockedCollateral): + ... async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenBalances: + ... # Implementation for fetching debt from Maker + ... pass + ... async def _balances(self, address: Address, block: Optional[Block] = None) -> TokenBalances: + ... # Implementation for fetching balances from Maker + ... pass + + See Also: + - :class:`LendingProtocol` + """ \ No newline at end of file diff --git a/eth_portfolio/protocols/lending/compound.py b/eth_portfolio/protocols/lending/compound.py index 87d32863..47005fae 100644 --- a/eth_portfolio/protocols/lending/compound.py +++ b/eth_portfolio/protocols/lending/compound.py @@ -30,6 +30,27 @@ class Compound(LendingProtocol): @alru_cache(ttl=300) @stuck_coro_debugger async def underlyings(self) -> List[ERC20]: + """ + Fetches the underlying ERC20 tokens for all Compound markets. + + This method gathers all markets from the Compound protocol's trollers + and filters out those that do not have a `borrowBalanceStored` attribute + by using the :func:`_get_contract` function. It then separates markets + into those that use the native gas token and those that have an underlying + ERC20 token, fetching the underlying tokens accordingly. + + Returns: + A list of :class:`~y.classes.common.ERC20` instances representing the underlying tokens. + + Examples: + >>> compound = Compound() + >>> underlyings = await compound.underlyings() + >>> for token in underlyings: + ... print(token.symbol) + + See Also: + - :meth:`markets`: To get the list of market contracts. + """ all_markets: List[List[CToken]] = await gather( *[comp.markets for comp in compound.trollers.values()] ) @@ -58,10 +79,48 @@ async def underlyings(self) -> List[ERC20]: @a_sync.future @stuck_coro_debugger async def markets(self) -> List[Contract]: + """Fetches the list of market contracts for the Compound protocol. + + This method ensures that the underlying tokens are fetched first, + as they are used to determine the markets. + + Returns: + A list of :class:`~brownie.network.contract.Contract` instances representing the markets. + + Examples: + >>> compound = Compound() + >>> markets = await compound.markets() + >>> for market in markets: + ... print(market.address) + + See Also: + - :meth:`underlyings`: To get the list of underlying tokens. + """ await self.underlyings() return self._markets async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenBalances: + """Calculates the debt balance for a given address in the Compound protocol. + + This method fetches the borrow balance for each market and calculates + the debt in terms of the underlying token and its USD value. + + Args: + address: The Ethereum address to calculate the debt for. + block: The block number to query. Defaults to the latest block. + + Returns: + A :class:`~eth_portfolio.typing.TokenBalances` object representing the debt balances. + + Examples: + >>> compound = Compound() + >>> debt_balances = await compound._debt("0x1234567890abcdef1234567890abcdef12345678") + >>> for token, balance in debt_balances.items(): + ... print(f"Token: {token}, Balance: {balance.balance}, USD Value: {balance.usd_value}") + + See Also: + - :meth:`debt`: Public method to get the debt balances. + """ # if ypricemagic doesn't support any Compound forks on current chain if len(compound.trollers) == 0: return TokenBalances(block=block) @@ -93,9 +152,30 @@ async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenB async def _borrow_balance_stored( market: Contract, address: Address, block: Optional[Block] = None ) -> Optional[int]: + """Fetches the stored borrow balance for a given market and address. + + This function attempts to call the `borrowBalanceStored` method on the + market contract. If the call reverts, it returns None. + + Args: + market: The market contract to query. + address: The Ethereum address to fetch the borrow balance for. + block: The block number to query. Defaults to the latest block. + + Returns: + The stored borrow balance as an integer, or None if the call reverts. + + Examples: + >>> market = Contract.from_explorer("0x1234567890abcdef1234567890abcdef12345678") + >>> balance = await _borrow_balance_stored(market, "0xabcdefabcdefabcdefabcdefabcdefabcdef") + >>> print(balance) + + See Also: + - :meth:`_debt`: Uses this function to calculate debt balances. + """ try: return await market.borrowBalanceStored.coroutine(str(address), block_identifier=block) except ValueError as e: if str(e) != "No data was returned - the call likely reverted": raise - return None + return None \ No newline at end of file From ee9b5cce9eecc5f7c11516722ebb4f4e7e4d6e89 Mon Sep 17 00:00:00 2001 From: NV Date: Fri, 21 Feb 2025 18:21:53 -0500 Subject: [PATCH 02/45] fix --- eth_portfolio/_db/decorators.py | 57 +++++- eth_portfolio/_db/entities.py | 208 +++++++++++++++++++- eth_portfolio/_decimal.py | 55 +++++- eth_portfolio/_exceptions.py | 42 +++- eth_portfolio/_loaders/__init__.py | 31 +++ eth_portfolio/_loaders/utils.py | 42 +++- eth_portfolio/_ydb/token_transfers.py | 114 ++++++++++- eth_portfolio/buckets.py | 94 ++++++++- eth_portfolio/protocols/__init__.py | 43 +++- eth_portfolio/protocols/lending/_base.py | 34 +++- eth_portfolio/protocols/lending/compound.py | 85 +++++++- eth_portfolio/structs/__init__.py | 26 ++- setup.py | 34 ++++ tests/conftest.py | 28 +++ tests/protocols/test_external.py | 52 ++++- 15 files changed, 916 insertions(+), 29 deletions(-) diff --git a/eth_portfolio/_db/decorators.py b/eth_portfolio/_db/decorators.py index e80e4b3f..1afa35d0 100644 --- a/eth_portfolio/_db/decorators.py +++ b/eth_portfolio/_db/decorators.py @@ -22,6 +22,36 @@ def break_locks(fn: AnyFn[P, T]) -> AnyFn[P, T]: + """ + Decorator to handle database lock errors by retrying the function. + + This decorator is designed to wrap functions that interact with a database + and may encounter `OperationalError` due to database locks. It will retry + the function indefinitely if a "database is locked" error occurs. After + 5 attempts, a warning is logged, but the function will continue to retry + until it succeeds or a non-lock-related error occurs. + + Args: + fn: The function to be wrapped, which may be a coroutine or a regular function. + + Examples: + Basic usage with a regular function: + + >>> @break_locks + ... def my_function(): + ... # Function logic that may encounter a database lock + ... pass + + Basic usage with an asynchronous function: + + >>> @break_locks + ... async def my_async_function(): + ... # Async function logic that may encounter a database lock + ... pass + + See Also: + - :func:`pony.orm.db_session`: For managing database sessions. + """ if iscoroutinefunction(fn): @wraps(fn) @@ -74,6 +104,31 @@ def break_locks_wrap(*args: P.args, **kwargs: P.kwargs) -> T: def requery_objs_on_diff_tx_err(fn: Callable[P, T]) -> Callable[P, T]: + """ + Decorator to handle transaction errors by retrying the function. + + This decorator is designed to wrap functions that may encounter + `TransactionError` due to mixing objects from different transactions. + It will retry the function until it succeeds or a non-transaction-related + error occurs. + + Args: + fn: The function to be wrapped, which must not be a coroutine. + + Raises: + TypeError: If the function is a coroutine. + + Examples: + Basic usage with a function that may encounter transaction errors: + + >>> @requery_objs_on_diff_tx_err + ... def my_function(): + ... # Function logic that may encounter a transaction error + ... pass + + See Also: + - :func:`pony.orm.db_session`: For managing database sessions. + """ if iscoroutinefunction(fn): raise TypeError(f"{fn} must not be async") @@ -89,4 +144,4 @@ def requery_wrap(*args: P.args, **kwargs: P.kwargs) -> T: # and then tried to use the newly committed objects in the next transaction. Now that the objects are in the db this will # not reoccur. The next iteration will be successful. - return requery_wrap + return requery_wrap \ No newline at end of file diff --git a/eth_portfolio/_db/entities.py b/eth_portfolio/_db/entities.py index 3dcd4bde..33fa72c3 100644 --- a/eth_portfolio/_db/entities.py +++ b/eth_portfolio/_db/entities.py @@ -54,85 +54,207 @@ class TokenExtended(Token, AddressExtended): class Transaction(DbEntity): + """ + Represents a transaction entity in the database. + + This class provides properties to access decoded transaction data, + including input data, signature components, and access lists. + + + See Also: + - :class:`BlockExtended` + - :class:`AddressExtended` + - :class:`TokenTransfer` + """ + _id = PrimaryKey(int, auto=True) + "The primary key of the transaction." block = Required(BlockExtended, lazy=True, reverse="transactions") + "The block containing this transaction." transaction_index = Required(int, lazy=True) + "The index of the transaction within the block." hash = Required(str, index=True, lazy=True) + "The hash of the transaction." from_address = Required(AddressExtended, index=True, lazy=True, reverse="transactions_sent") + "The address that sent the transaction." to_address = Optional(AddressExtended, index=True, lazy=True, reverse="transactions_received") + "The address that received the transaction." value = Required(Decimal, 38, 18, lazy=True) + "The value transferred in the transaction." price = Optional(Decimal, 38, 18, lazy=True) + "The price of the transaction." value_usd = Optional(Decimal, 38, 18, lazy=True) + "The USD value of the transaction." nonce = Required(int, lazy=True) + "The nonce of the transaction." type = Optional(int, lazy=True) + "The type of the transaction." gas = Required(Decimal, 38, 1, lazy=True) + "The gas used by the transaction." gas_price = Required(Decimal, 38, 1, lazy=True) + "The gas price of the transaction." max_fee_per_gas = Optional(Decimal, 38, 1, lazy=True) + "The maximum fee per gas for the transaction." max_priority_fee_per_gas = Optional(Decimal, 38, 1, lazy=True) + "The maximum priority fee per gas for the transaction." composite_key(block, transaction_index) raw = Required(bytes, lazy=True) + "The raw bytes of the transaction." @cached_property def decoded(self) -> structs.Transaction: + """ + Decodes the raw transaction data into a :class:`structs.Transaction` object. + + Example: + >>> transaction = Transaction(...) + >>> decoded_transaction = transaction.decoded + >>> isinstance(decoded_transaction, structs.Transaction) + True + """ return json.decode(self.raw, type=structs.Transaction) @property def input(self) -> HexBytes: + """ + Returns the input data of the transaction. + + Example: + >>> transaction = Transaction(...) + >>> input_data = transaction.input + >>> isinstance(input_data, HexBytes) + True + """ structs.Transaction.input.__doc__ return self.decoded.input @property def r(self) -> HexBytes: + """ + Returns the R component of the transaction's signature. + + Example: + >>> transaction = Transaction(...) + >>> r_value = transaction.r + >>> isinstance(r_value, HexBytes) + True + """ structs.Transaction.r.__doc__ return self.decoded.r @property def s(self) -> HexBytes: + """ + Returns the S component of the transaction's signature. + + Example: + >>> transaction = Transaction(...) + >>> s_value = transaction.s + >>> isinstance(s_value, HexBytes) + True + """ structs.Transaction.s.__doc__ return self.decoded.s @property def v(self) -> int: + """ + Returns the V component of the transaction's signature. + + Example: + >>> transaction = Transaction(...) + >>> v_value = transaction.v + >>> isinstance(v_value, int) + True + """ structs.Transaction.v.__doc__ return self.decoded.v @property def access_list(self) -> typing.List[AccessListEntry]: + """ + Returns the access list of the transaction. + + Example: + >>> transaction = Transaction(...) + >>> access_list = transaction.access_list + >>> isinstance(access_list, list) + True + >>> isinstance(access_list[0], AccessListEntry) + True + + See Also: + - :class:`AccessListEntry` + """ structs.Transaction.access_list.__doc__ return self.decoded.access_list @property def y_parity(self) -> typing.Optional[int]: - structs.TokenTransfer.y_parity.__doc__ + """ + Returns the y_parity of the transaction. + + Example: + >>> transaction = Transaction(...) + >>> y_parity_value = transaction.y_parity + >>> isinstance(y_parity_value, (int, type(None))) + True + """ + structs.Transaction.y_parity.__doc__ return self.decoded.y_parity class InternalTransfer(DbEntity): + """ + Represents an internal transfer entity in the database. + + This class provides properties to access decoded internal transfer data, + including input, output, and code. + + See Also: + - :class:`BlockExtended` + - :class:`AddressExtended` + """ + _id = PrimaryKey(int, auto=True) + "The primary key of the internal transfer." # common block = Required(BlockExtended, lazy=True, reverse="internal_transfers") + "The block containing this internal transfer." transaction_index = Required(int, lazy=True) + "The index of the transaction within the block." hash = Required(str, lazy=True) + "The hash of the internal transfer." from_address = Required( AddressExtended, index=True, lazy=True, reverse="internal_transfers_sent" ) + "The address that sent the internal transfer." to_address = Optional( AddressExtended, index=True, lazy=True, reverse="internal_transfers_received" ) + "The address that received the internal transfer." value = Required(Decimal, 38, 18, lazy=True) + "The value transferred in the internal transfer." price = Optional(Decimal, 38, 18, lazy=True) + "The price of the internal transfer." value_usd = Optional(Decimal, 38, 18, lazy=True) + "The USD value of the internal transfer." # unique type = Required(str, lazy=True) + "The type of the internal transfer." call_type = Required(str, lazy=True) + "The call type of the internal transfer." trace_address = Required(str, lazy=True) + "The trace address of the internal transfer." gas = Required(Decimal, 38, 1, lazy=True) + "The gas used by the internal transfer." gas_used = Optional(Decimal, 38, 1, lazy=True) + "The gas used by the internal transfer." composite_key( block, @@ -149,56 +271,136 @@ class InternalTransfer(DbEntity): ) raw = Required(bytes, lazy=True) + "The raw bytes of the internal transfer." @cached_property def decoded(self) -> structs.InternalTransfer: + """ + Decodes the raw internal transfer data into a :class:`structs.InternalTransfer` object. + + Example: + >>> internal_transfer = InternalTransfer(...) + >>> decoded_transfer = internal_transfer.decoded + >>> isinstance(decoded_transfer, structs.InternalTransfer) + True + """ structs.InternalTransfer.__doc__ return json.decode(self.raw, type=structs.InternalTransfer) @property def code(self) -> HexBytes: + """ + Returns the code of the internal transfer. + + Example: + >>> internal_transfer = InternalTransfer(...) + >>> code_data = internal_transfer.code + >>> isinstance(code_data, HexBytes) + True + """ structs.InternalTransfer.code.__doc__ return self.decoded.code @property def input(self) -> HexBytes: + """ + Returns the input data of the internal transfer. + + Example: + >>> internal_transfer = InternalTransfer(...) + >>> input_data = internal_transfer.input + >>> isinstance(input_data, HexBytes) + True + """ structs.InternalTransfer.input.__doc__ return self.decoded.input @property def output(self) -> HexBytes: + """ + Returns the output data of the internal transfer. + + Example: + >>> internal_transfer = InternalTransfer(...) + >>> output_data = internal_transfer.output + >>> isinstance(output_data, HexBytes) + True + """ structs.InternalTransfer.output.__doc__ return self.decoded.output @property def subtraces(self) -> int: + """ + Returns the number of subtraces of the internal transfer. + + Example: + >>> internal_transfer = InternalTransfer(...) + >>> subtraces_count = internal_transfer.subtraces + >>> isinstance(subtraces_count, int) + True + """ structs.InternalTransfer.subtraces.__doc__ return self.decoded.subtraces class TokenTransfer(DbEntity): + """ + Represents a token transfer entity in the database. + + This class provides properties to access decoded token transfer data. + + See Also: + - :class:`BlockExtended` + - :class:`AddressExtended` + - :class:`TokenExtended` + """ + _id = PrimaryKey(int, auto=True) + "The primary key of the token transfer." # common block = Required(BlockExtended, lazy=True, reverse="token_transfers") + "The block containing this token transfer." transaction_index = Required(int, lazy=True) + "The index of the transaction within the block." hash = Required(str, lazy=True) - from_address = Required(AddressExtended, index=True, lazy=True, reverse="token_transfers_sent") + "The hash of the token transfer." + from_address = Required( + AddressExtended, index=True, lazy=True, reverse="token_transfers_sent" + ) + "The address that sent the token transfer." to_address = Required( AddressExtended, index=True, lazy=True, reverse="token_transfers_received" ) + "The address that received the token transfer." value = Required(Decimal, 38, 18, lazy=True) + "The value transferred in the token transfer." price = Optional(Decimal, 38, 18, lazy=True) + "The price of the token transfer." value_usd = Optional(Decimal, 38, 18, lazy=True) + "The USD value of the token transfer." # unique log_index = Required(int, lazy=True) + "The log index of the token transfer." token = Optional(TokenExtended, index=True, lazy=True, reverse="transfers") + "The token involved in the transfer." composite_key(block, transaction_index, log_index) raw = Required(bytes, lazy=True) + "The raw bytes of the token transfer." @cached_property def decoded(self) -> structs.TokenTransfer: - return json.decode(self.raw, type=structs.TokenTransfer) + """ + Decodes the raw token transfer data into a :class:`structs.TokenTransfer` object. + + Example: + >>> token_transfer = TokenTransfer(...) + >>> decoded_transfer = token_transfer.decoded + >>> isinstance(decoded_transfer, structs.TokenTransfer) + True + """ + return json.decode(self.raw, type=structs.TokenTransfer) \ No newline at end of file diff --git a/eth_portfolio/_decimal.py b/eth_portfolio/_decimal.py index ad782c3b..afe3ca52 100644 --- a/eth_portfolio/_decimal.py +++ b/eth_portfolio/_decimal.py @@ -9,8 +9,40 @@ class Decimal(decimal.Decimal): + """ + A subclass of :class:`decimal.Decimal` with additional functionality. + + This class extends the :class:`decimal.Decimal` class to provide additional + methods for JSON serialization and arithmetic operations. + + See Also: + - :class:`decimal.Decimal` + """ + def jsonify(self) -> Union[str, int]: - """Makes the Decimal as small as possible when converted to json.""" + """ + Converts the Decimal to a JSON-friendly format. + + This method attempts to represent the Decimal in the most compact form + possible for JSON serialization. It returns an integer if the Decimal + is equivalent to an integer, otherwise it returns a string in either + standard or scientific notation, depending on which is shorter. + + Trailing zeros in the string representation are removed. If the + scientific notation is equivalent to the original Decimal and shorter + than the standard string representation, it is returned. + + Raises: + Exception: If the resulting string representation is empty. + + Examples: + >>> Decimal('123.4500').jsonify() + '123.45' + >>> Decimal('123000').jsonify() + 123000 + >>> Decimal('0.000123').jsonify() + '1.23E-4' + """ string = str(self) integer = int(self) @@ -68,6 +100,25 @@ def __rfloordiv__(self, other): class Gwei(Decimal): + """ + A subclass of :class:`Decimal` representing Gwei values. + + This class provides a property to convert Gwei to Wei. + + See Also: + - :class:`Decimal` + - :class:`evmspec.data.Wei` + """ + @property def as_wei(self) -> Wei: - return Wei(self * 10**9) + """ + Converts the Gwei value to Wei. + + This property multiplies the Gwei value by 10^9 to convert it to Wei. + + Examples: + >>> Gwei('1').as_wei + Wei(1000000000) + """ + return Wei(self * 10**9) \ No newline at end of file diff --git a/eth_portfolio/_exceptions.py b/eth_portfolio/_exceptions.py index 0eae5504..b5fb6626 100644 --- a/eth_portfolio/_exceptions.py +++ b/eth_portfolio/_exceptions.py @@ -8,17 +8,57 @@ class BlockRangeIsCached(Exception): + """ + Exception raised when a block range is already cached. + + This exception is used to indicate that the requested block range + has already been loaded into memory and does not need to be fetched again. + + Examples: + >>> raise BlockRangeIsCached("Block range is already cached.") + """ pass class BlockRangeOutOfBounds(Exception): + """ + Exception raised when a block range is out of bounds. + + This exception is used to indicate that the requested block range + is outside the bounds of the cached data. It provides a method to + load the remaining ledger entries that are out of bounds. + + Args: + start_block: The starting block number of the out-of-bounds range. + end_block: The ending block number of the out-of-bounds range. + ledger: The ledger associated with the block range. + + Examples: + >>> raise BlockRangeOutOfBounds(100, 200, ledger) + + See Also: + - :class:`~eth_portfolio._ledgers.address.AddressLedgerBase`: The base class for address ledgers. + """ + def __init__(self, start_block: Block, end_block: Block, ledger: "AddressLedgerBase") -> None: self.ledger = ledger self.start_block = start_block self.end_block = end_block async def load_remaining(self) -> None: + """ + Asynchronously loads the remaining ledger entries that are out of bounds. + + This method fetches the ledger entries for the blocks that are outside + the cached range, ensuring that the entire requested block range is covered. + + Examples: + >>> await exception.load_remaining() + + See Also: + - :meth:`~eth_portfolio._ledgers.address.AddressLedgerBase._load_new_objects`: Method to load new ledger entries. + """ return await gather( self.ledger._load_new_objects(self.start_block, self.ledger.cached_thru - 1), self.ledger._load_new_objects(self.ledger.cached_from + 1, self.end_block), - ) + ) \ No newline at end of file diff --git a/eth_portfolio/_loaders/__init__.py b/eth_portfolio/_loaders/__init__.py index e3d3029b..9ba2143f 100644 --- a/eth_portfolio/_loaders/__init__.py +++ b/eth_portfolio/_loaders/__init__.py @@ -1,2 +1,33 @@ +""" +This module initializes the `_loaders` package within the `eth_portfolio` library. +It imports key functions responsible for loading blockchain data related to transactions +and token transfers for use within the package. + +The functions imported here are designed to facilitate the retrieval and processing of +Ethereum blockchain data, enabling efficient data handling and storage for portfolio analysis. + +Imported Functions: + - :func:`~eth_portfolio._loaders.transaction.load_transaction`: + Loads transaction data by address and nonce, with optional price data retrieval. + - :func:`~eth_portfolio._loaders.token_transfer.load_token_transfer`: + Processes and loads token transfer data from log entries, with optional price fetching. + +Examples: + These functions can be used to load and process blockchain data for portfolio analysis. + For example, you might use them as follows: + + >>> from eth_portfolio._loaders import load_transaction, load_token_transfer + >>> nonce, transaction = await load_transaction(address="0x1234567890abcdef1234567890abcdef12345678", nonce=5, load_prices=True) + >>> print(transaction) + + >>> transfer_log = {"address": "0xTokenAddress", "data": "0xData", "removed": False} + >>> token_transfer = await load_token_transfer(transfer_log, load_prices=True) + >>> print(token_transfer) + +See Also: + - :mod:`eth_portfolio._loaders.transaction`: Contains functions for loading transaction data. + - :mod:`eth_portfolio._loaders.token_transfer`: Contains functions for processing token transfer logs. +""" + from eth_portfolio._loaders.transaction import load_transaction from eth_portfolio._loaders.token_transfer import load_token_transfer diff --git a/eth_portfolio/_loaders/utils.py b/eth_portfolio/_loaders/utils.py index 1e17b9c4..8fcd2c3d 100644 --- a/eth_portfolio/_loaders/utils.py +++ b/eth_portfolio/_loaders/utils.py @@ -6,14 +6,52 @@ from eth_typing import HexStr from y._decorators import stuck_coro_debugger - @eth_retry.auto_retry @alru_cache(maxsize=None, ttl=60 * 60) @stuck_coro_debugger async def _get_transaction_receipt(txhash: HexStr) -> msgspec.Raw: + """ + Fetches the transaction receipt for a given transaction hash. + + This function retrieves the transaction receipt from the Ethereum network + using the provided transaction hash. It utilizes caching to store results + for a specified time-to-live (TTL) to improve performance and reduce + network calls. The function is also decorated to automatically retry + in case of failures and to debug if the coroutine gets stuck. + + Args: + txhash: The transaction hash for which to retrieve the receipt. + + Returns: + msgspec.Raw: The raw transaction receipt data. + + Examples: + >>> txhash = "0x1234567890abcdef..." + >>> receipt = await _get_transaction_receipt(txhash) + >>> print(receipt) + + See Also: + - :func:`eth_retry.auto_retry`: For automatic retry logic. + - :func:`async_lru.alru_cache`: For caching the results. + - :func:`y._decorators.stuck_coro_debugger`: For debugging stuck coroutines. + """ return await dank_mids.eth.get_transaction_receipt( txhash, decode_to=msgspec.Raw, decode_hook=None ) - get_transaction_receipt = SmartProcessingQueue(_get_transaction_receipt, 5000) +""" +A queue for processing transaction receipt requests. + +This queue manages the processing of transaction receipt requests, allowing +up to 5000 concurrent requests. It uses the `_get_transaction_receipt` function +to fetch the receipts. + +Examples: + >>> txhash = "0x1234567890abcdef..." + >>> receipt = await get_transaction_receipt(txhash) + >>> print(receipt) + +See Also: + - :class:`a_sync.SmartProcessingQueue`: For managing asynchronous processing queues. +""" \ No newline at end of file diff --git a/eth_portfolio/_ydb/token_transfers.py b/eth_portfolio/_ydb/token_transfers.py index 3d129c66..e0c2790b 100644 --- a/eth_portfolio/_ydb/token_transfers.py +++ b/eth_portfolio/_ydb/token_transfers.py @@ -34,7 +34,24 @@ class _TokenTransfers(ProcessedEvents["Task[TokenTransfer]"]): - """A helper mixin that contains all logic for fetching token transfers for a particular wallet address""" + """ + A helper mixin that contains all logic for fetching token transfers for a particular wallet address. + + Attributes: + address: The wallet address for which token transfers are fetched. + _load_prices: Indicates whether to load prices for the token transfers. + + Examples: + Fetching token transfers for a specific address: + + >>> transfers = _TokenTransfers(address="0x123...", from_block=0, load_prices=True) + >>> async for transfer in transfers.yield_thru_block(1000000): + ... print(transfer) + + See Also: + - :class:`~eth_portfolio.structs.TokenTransfer`: For the structure of a token transfer. + - :func:`~eth_portfolio._loaders.load_token_transfer`: For loading token transfer data. + """ __slots__ = "address", "_load_prices" @@ -48,10 +65,24 @@ def __repr__(self) -> str: @property @abstractmethod - def _topics(self) -> List: ... + def _topics(self) -> List: + pass @ASyncIterator.wrap # type: ignore [call-overload] async def yield_thru_block(self, block) -> AsyncIterator["Task[TokenTransfer]"]: + """ + Yield token transfers up to a specified block. + + Args: + block: The block number up to which token transfers are yielded. + + Yields: + Token transfers as :class:`~asyncio.Task` objects. + + Examples: + >>> async for transfer in transfers.yield_thru_block(1000000): + ... print(transfer) + """ if not _logger_is_enabled_for(DEBUG): async for task in self._objects_thru(block=block): yield task @@ -68,6 +99,15 @@ async def yield_thru_block(self, block) -> AsyncIterator["Task[TokenTransfer]"]: _logger_log(DEBUG, "%s yield thru %s complete", (self, block)) async def _extend(self, objs: List[evmspec.Log]) -> None: + """ + Extend the list of token transfers with new logs. + + Args: + objs: A list of :class:`~evmspec.Log` objects representing token transfer logs. + + Examples: + >>> await transfers._extend(logs) + """ shitcoins = SHITCOINS.get(chain.id, set()) append_loader_task = self._objects.append done = 0 @@ -98,7 +138,19 @@ def _done_callback(self, task: Task) -> None: class InboundTokenTransfers(_TokenTransfers): - """A container that fetches and iterates over all inbound token transfers for a particular wallet address""" + """ + A container that fetches and iterates over all inbound token transfers for a particular wallet address. + + Examples: + Fetching inbound token transfers for a specific address: + + >>> inbound_transfers = InboundTokenTransfers(address="0x123...", from_block=0, load_prices=True) + >>> async for transfer in inbound_transfers.yield_thru_block(1000000): + ... print(transfer) + + See Also: + - :class:`~eth_portfolio.structs.TokenTransfer`: For the structure of a token transfer. + """ @property def _topics(self) -> List: @@ -106,7 +158,19 @@ def _topics(self) -> List: class OutboundTokenTransfers(_TokenTransfers): - """A container that fetches and iterates over all outbound token transfers for a particular wallet address""" + """ + A container that fetches and iterates over all outbound token transfers for a particular wallet address. + + Examples: + Fetching outbound token transfers for a specific address: + + >>> outbound_transfers = OutboundTokenTransfers(address="0x123...", from_block=0, load_prices=True) + >>> async for transfer in outbound_transfers.yield_thru_block(1000000): + ... print(transfer) + + See Also: + - :class:`~eth_portfolio.structs.TokenTransfer`: For the structure of a token transfer. + """ @property def _topics(self) -> List: @@ -116,7 +180,22 @@ def _topics(self) -> List: class TokenTransfers(ASyncIterable[TokenTransfer]): """ A container that fetches and iterates over all token transfers for a particular wallet address. - NOTE: These do not come back in chronologcal order. + + Attributes: + transfers_in: Container for inbound token transfers. + transfers_out: Container for outbound token transfers. + + Examples: + Fetching all token transfers for a specific address: + + >>> token_transfers = TokenTransfers(address="0x123...", from_block=0, load_prices=True) + >>> async for transfer in token_transfers: + ... print(transfer) + + See Also: + - :class:`~eth_portfolio.structs.TokenTransfer`: For the structure of a token transfer. + - :class:`~InboundTokenTransfers`: For fetching inbound token transfers. + - :class:`~OutboundTokenTransfers`: For fetching outbound token transfers. """ def __init__(self, address: Address, from_block: int, load_prices: bool = False): @@ -124,13 +203,36 @@ def __init__(self, address: Address, from_block: int, load_prices: bool = False) self.transfers_out = OutboundTokenTransfers(address, from_block, load_prices=load_prices) async def __aiter__(self): + """ + Asynchronously iterate over all token transfers. + + Yields: + Token transfers as :class:`~eth_portfolio.structs.TokenTransfer` objects. + + Examples: + >>> async for transfer in token_transfers: + ... print(transfer) + """ async for transfer in self.yield_thru_block(await dank_mids.eth.block_number): yield transfer def yield_thru_block(self, block: int) -> ASyncIterator["Task[TokenTransfer]"]: + """ + Yield token transfers up to a specified block. + + Args: + block: The block number up to which token transfers are yielded. + + Yields: + Token transfers as :class:`~asyncio.Task` objects. + + Examples: + >>> async for transfer in token_transfers.yield_thru_block(1000000): + ... print(transfer) + """ return ASyncIterator( as_yielded( self.transfers_in.yield_thru_block(block), self.transfers_out.yield_thru_block(block), ) - ) + ) \ No newline at end of file diff --git a/eth_portfolio/buckets.py b/eth_portfolio/buckets.py index 74840d64..2f48b35a 100644 --- a/eth_portfolio/buckets.py +++ b/eth_portfolio/buckets.py @@ -19,6 +19,33 @@ async def get_token_bucket(token: AnyAddressType) -> str: + """ + Categorize a token into a specific bucket based on its type. + + This function attempts to categorize a given token into predefined buckets + such as "Cash & cash equivalents", "ETH", "BTC", "Other long term assets", + or "Other short term assets". The categorization is based on the token's + characteristics and its presence in specific sets like `ETH_LIKE`, `BTC_LIKE`, + and `OTHER_LONG_TERM_ASSETS`. + + Args: + token: The address of the token to categorize. + + Returns: + A string representing the bucket category of the token. + + Raises: + ValueError: If the token's source has not been verified and the error message + does not match the expected pattern. + + Example: + >>> await get_token_bucket("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + 'Cash & cash equivalents' + + See Also: + - :func:`_unwrap_token` + - :func:`_is_stable` + """ token = str(token) try: token = str(await _unwrap_token(token)) @@ -41,7 +68,29 @@ async def get_token_bucket(token: AnyAddressType) -> str: @alru_cache(maxsize=None) async def _unwrap_token(token) -> str: """ - Unwraps the base + Recursively unwrap a token to its underlying asset. + + This function attempts to unwrap a given token to its underlying asset by + checking if the token is a Yearn vault, a Curve pool, an Aave aToken, or a + Compound market. It recursively retrieves the underlying asset until it + reaches the base token. + + Args: + token: The address of the token to unwrap. + + Returns: + The address of the underlying asset. + + Example: + >>> await _unwrap_token("0x5f18C75AbDAe578b483E5F43f12a39cF75b973a9") + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' + + See Also: + - :func:`y.prices.yearn.is_yearn_vault` + - :class:`y.prices.yearn.YearnInspiredVault` + - :class:`y.prices.stable_swap.curve` + - :class:`y.prices.lending.aave` + - :class:`y.prices.lending.compound.CToken` """ if str(token) in {"ETH", "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"}: return token @@ -64,6 +113,30 @@ async def _unwrap_token(token) -> str: def _pool_bucket(pool_tokens: set) -> Optional[str]: + """ + Determine the bucket for a set of pool tokens. + + This function checks if a set of pool tokens belongs to specific categories + such as BTC-like, ETH-like, or stablecoins, and returns the corresponding + bucket. + + Args: + pool_tokens: A set of token addresses representing the pool tokens. + + Returns: + A string representing the bucket category of the pool tokens, or None if + no specific category is found. + + Example: + >>> _pool_bucket({"0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599"}) + '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599' + + See Also: + - :data:`BTC_LIKE` + - :data:`ETH_LIKE` + - :data:`STABLECOINS` + - :data:`INTL_STABLECOINS` + """ logger.debug("Pool tokens: %s", pool_tokens) if pool_tokens < BTC_LIKE: return list(BTC_LIKE)[0] @@ -75,4 +148,21 @@ def _pool_bucket(pool_tokens: set) -> Optional[str]: def _is_stable(token: Address) -> bool: - return token in STABLECOINS or token in INTL_STABLECOINS + """ + Check if a token is a stablecoin. + + This function checks if a given token is present in the `STABLECOINS` or + `INTL_STABLECOINS` sets, indicating that it is a stablecoin. + + Args: + token: The address of the token to check. + + Example: + >>> _is_stable("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + True + + See Also: + - :data:`STABLECOINS` + - :data:`INTL_STABLECOINS` + """ + return token in STABLECOINS or token in INTL_STABLECOINS \ No newline at end of file diff --git a/eth_portfolio/protocols/__init__.py b/eth_portfolio/protocols/__init__.py index ef40aa8c..7c89c85c 100644 --- a/eth_portfolio/protocols/__init__.py +++ b/eth_portfolio/protocols/__init__.py @@ -15,6 +15,47 @@ @a_sync.future async def balances(address: Address, block: Optional[Block] = None) -> RemoteTokenBalances: + """ + Fetches token balances for a given address across various protocols. + + This function retrieves the token balances for a specified Ethereum address + at a given block across all available protocols. It is decorated with + :func:`a_sync.future`, allowing it to be used in both synchronous and + asynchronous contexts. + + If no protocols are available, the function returns an empty + :class:`~eth_portfolio.typing.RemoteTokenBalances` object. + + Args: + address: The Ethereum address for which to fetch balances. + block: The block number at which to fetch balances. + If not provided, the latest block is used. + + Examples: + Fetching balances asynchronously: + + >>> from eth_portfolio.protocols import balances + >>> address = "0x1234567890abcdef1234567890abcdef12345678" + >>> block = 12345678 + >>> remote_balances = await balances(address, block) + >>> print(remote_balances) + + Fetching balances synchronously: + + >>> remote_balances = balances(address, block).result() + >>> print(remote_balances) + + The function constructs a dictionary `data` initialized with protocol names + and their corresponding balances. The `protocol_balances` variable is an + asynchronous mapping of protocol names to their respective balance data, + which is then used in an asynchronous comprehension to construct the + dictionary `data`. This dictionary is subsequently used to initialize the + :class:`~eth_portfolio.typing.RemoteTokenBalances` object. + + See Also: + - :class:`~eth_portfolio.typing.RemoteTokenBalances`: For more information on the return type. + - :func:`a_sync.future`: For more information on the decorator used. + """ if not protocols: return RemoteTokenBalances(block=block) protocol_balances = a_sync.map( @@ -26,4 +67,4 @@ async def balances(address: Address, block: Optional[Block] = None) -> RemoteTok async for protocol, protocol_balances in protocol_balances if protocol_balances is not None } - return RemoteTokenBalances(data, block=block) + return RemoteTokenBalances(data, block=block) \ No newline at end of file diff --git a/eth_portfolio/protocols/lending/_base.py b/eth_portfolio/protocols/lending/_base.py index 58a7e007..c62053f5 100644 --- a/eth_portfolio/protocols/lending/_base.py +++ b/eth_portfolio/protocols/lending/_base.py @@ -11,10 +11,19 @@ class LendingProtocol(metaclass=abc.ABCMeta): """ Subclass this class for any protocol that maintains a debt balance for a user but doesn't maintain collateral internally. - Example: Aave, because the user holds on to their collateral in the form of erc-20 aTokens. + Example: Aave, because the user holds on to their collateral in the form of ERC-20 aTokens. You must define the following async method: - `_debt_async(self, address: Address, block: Optional[Block] = None)` + `_debt(self, address: Address, block: Optional[Block] = None)` + + Example: + >>> class AaveProtocol(LendingProtocol): + ... async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenBalances: + ... # Implementation for fetching debt from Aave + ... pass + + See Also: + - :class:`LendingProtocolWithLockedCollateral` """ @a_sync.future @@ -22,7 +31,8 @@ async def debt(self, address: Address, block: Optional[Block] = None) -> TokenBa return await self._debt(address, block) # type: ignore @abc.abstractmethod - async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenBalances: ... + async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenBalances: + ... class LendingProtocolWithLockedCollateral(LendingProtocol, ProtocolABC): @@ -31,6 +41,18 @@ class LendingProtocolWithLockedCollateral(LendingProtocol, ProtocolABC): Example: Maker, because collateral is locked up inside of Maker's smart contracts. You must define the following async methods: - - `_debt_async(self, address: Address, block: Optional[Block] = None)` - - `_balances_async(self, address: Address, block: Optional[Block] = None)` - """ + - `_debt(self, address: Address, block: Optional[Block] = None)` + - `_balances(self, address: Address, block: Optional[Block] = None)` + + Example: + >>> class MakerProtocol(LendingProtocolWithLockedCollateral): + ... async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenBalances: + ... # Implementation for fetching debt from Maker + ... pass + ... async def _balances(self, address: Address, block: Optional[Block] = None) -> TokenBalances: + ... # Implementation for fetching balances from Maker + ... pass + + See Also: + - :class:`LendingProtocol` + """ \ No newline at end of file diff --git a/eth_portfolio/protocols/lending/compound.py b/eth_portfolio/protocols/lending/compound.py index 87d32863..9f064fe8 100644 --- a/eth_portfolio/protocols/lending/compound.py +++ b/eth_portfolio/protocols/lending/compound.py @@ -30,6 +30,27 @@ class Compound(LendingProtocol): @alru_cache(ttl=300) @stuck_coro_debugger async def underlyings(self) -> List[ERC20]: + """ + Fetches the underlying ERC20 tokens for all Compound markets. + + This method gathers all markets from the Compound protocol's trollers + and filters out those that do not have a `borrowBalanceStored` attribute + by using the :func:`_get_contract` function. It then separates markets + into those that use the native gas token and those that have an underlying + ERC20 token, fetching the underlying tokens accordingly. + + Returns: + A list of :class:`~y.classes.common.ERC20` instances representing the underlying tokens. + + Examples: + >>> compound = Compound() + >>> underlyings = await compound.underlyings() + >>> for token in underlyings: + ... print(token.symbol) + + See Also: + - :meth:`markets`: To get the list of market contracts. + """ all_markets: List[List[CToken]] = await gather( *[comp.markets for comp in compound.trollers.values()] ) @@ -58,10 +79,50 @@ async def underlyings(self) -> List[ERC20]: @a_sync.future @stuck_coro_debugger async def markets(self) -> List[Contract]: + """ + Fetches the list of market contracts for the Compound protocol. + + This method ensures that the underlying tokens are fetched first, + as they are used to determine the markets. + + Returns: + A list of :class:`~brownie.network.contract.Contract` instances representing the markets. + + Examples: + >>> compound = Compound() + >>> markets = await compound.markets() + >>> for market in markets: + ... print(market.address) + + See Also: + - :meth:`underlyings`: To get the list of underlying tokens. + """ await self.underlyings() return self._markets async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenBalances: + """ + Calculates the debt balance for a given address in the Compound protocol. + + This method fetches the borrow balance for each market and calculates + the debt in terms of the underlying token and its USD value. + + Args: + address: The Ethereum address to calculate the debt for. + block: The block number to query. Defaults to the latest block. + + Returns: + A :class:`~eth_portfolio.typing.TokenBalances` object representing the debt balances. + + Examples: + >>> compound = Compound() + >>> debt_balances = await compound._debt("0x1234567890abcdef1234567890abcdef12345678") + >>> for token, balance in debt_balances.items(): + ... print(f"Token: {token}, Balance: {balance.balance}, USD Value: {balance.usd_value}") + + See Also: + - :meth:`debt`: Public method to get the debt balances. + """ # if ypricemagic doesn't support any Compound forks on current chain if len(compound.trollers) == 0: return TokenBalances(block=block) @@ -93,9 +154,31 @@ async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenB async def _borrow_balance_stored( market: Contract, address: Address, block: Optional[Block] = None ) -> Optional[int]: + """ + Fetches the stored borrow balance for a given market and address. + + This function attempts to call the `borrowBalanceStored` method on the + market contract. If the call reverts, it returns None. + + Args: + market: The market contract to query. + address: The Ethereum address to fetch the borrow balance for. + block: The block number to query. Defaults to the latest block. + + Returns: + The stored borrow balance as an integer, or None if the call reverts. + + Examples: + >>> market = Contract.from_explorer("0x1234567890abcdef1234567890abcdef12345678") + >>> balance = await _borrow_balance_stored(market, "0xabcdefabcdefabcdefabcdefabcdefabcdef") + >>> print(balance) + + See Also: + - :meth:`_debt`: Uses this function to calculate debt balances. + """ try: return await market.borrowBalanceStored.coroutine(str(address), block_identifier=block) except ValueError as e: if str(e) != "No data was returned - the call likely reverted": raise - return None + return None \ No newline at end of file diff --git a/eth_portfolio/structs/__init__.py b/eth_portfolio/structs/__init__.py index 3dff3e6c..82689110 100644 --- a/eth_portfolio/structs/__init__.py +++ b/eth_portfolio/structs/__init__.py @@ -1,3 +1,28 @@ +""" +This module provides the primary union type and specific ledger entry types used in the `eth_portfolio` package. + +The `__all__` list defines the public API for this module, which includes the main union type `LedgerEntry` and its specific types: `Transaction`, `InternalTransfer`, `TokenTransfer`, and `TransactionRLP`. + +Examples: + Importing the main union type and specific ledger entry types: + + >>> from eth_portfolio.structs import LedgerEntry, Transaction, InternalTransfer, TokenTransfer, TransactionRLP + + Using the `LedgerEntry` union type to annotate a variable that can hold any ledger entry type: + + >>> entry: LedgerEntry = Transaction(...) + >>> entry = InternalTransfer(...) + >>> entry = TokenTransfer(...) + >>> entry = TransactionRLP(...) + +See Also: + - :class:`~eth_portfolio.structs.structs.LedgerEntry` + - :class:`~eth_portfolio.structs.structs.Transaction` + - :class:`~eth_portfolio.structs.structs.InternalTransfer` + - :class:`~eth_portfolio.structs.structs.TokenTransfer` + - :class:`~eth_portfolio.structs.structs.TransactionRLP` +""" + from eth_portfolio.structs.structs import ( InternalTransfer, LedgerEntry, @@ -6,7 +31,6 @@ TransactionRLP, ) - __all__ = [ # main union type "LedgerEntry", diff --git a/setup.py b/setup.py index 6ba0e8d1..61d27dd6 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,37 @@ +""" +Installation +------------ +To install the `eth-portfolio` package, you should start with a fresh virtual environment. +Due to the use of :mod:`setuptools_scm` for versioning, it is recommended to clone the repository first +to ensure the version can be determined correctly. + +The `setup.py` file automatically handles the installation of :mod:`setuptools_scm` and :mod:`cython`, +so you do not need to install them manually before running the setup process. Additionally, +the `requirements.txt` file is used to specify additional dependencies that are installed via +the `install_requires` parameter. Note that the last line of `requirements.txt` is intentionally excluded +from installation, so ensure that any necessary dependency is not placed on the last line. + +Example: + .. code-block:: bash + + git clone https://github.com/BobTheBuidler/eth-portfolio.git + cd eth-portfolio + pip install . + +If you encounter issues with :mod:`PyYaml` and :mod:`Cython`, you can resolve them by installing specific versions: + +Example: + .. code-block:: bash + + pip install wheel + pip install --no-build-isolation "Cython<3" "pyyaml==5.4.1" + pip install . + +See Also: + - :mod:`setuptools_scm`: For more information on versioning with setuptools_scm. + - :mod:`cython`: For more information on Cython. + - :mod:`requirements.txt`: For more information on managing dependencies. +""" from setuptools import find_packages, setup # type: ignore with open("requirements.txt", "r") as f: diff --git a/tests/conftest.py b/tests/conftest.py index 34d50d03..2d2bb7b9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,31 @@ +""" +This module configures the testing environment for the project by ensuring +that the Brownie network is connected using the specified network. It also +modifies the system path to include the current directory, allowing for +importing modules from the project root. + +Environment Variables: + PYTEST_NETWORK: The name of the Brownie network to use for testing. This + environment variable must be set before running the tests. + +Raises: + ValueError: If the PYTEST_NETWORK environment variable is not set. + +Examples: + To set the PYTEST_NETWORK environment variable and run tests: + + .. code-block:: bash + + export PYTEST_NETWORK=mainnet-fork + pytest + + This will connect to the specified Brownie network and run the tests. + +See Also: + - :mod:`brownie.network`: For more information on managing network connections with Brownie. + - :mod:`os`: For more information on interacting with the operating system. + - :mod:`sys`: For more information on the system-specific parameters and functions. +""" import os import sys diff --git a/tests/protocols/test_external.py b/tests/protocols/test_external.py index c39aa68c..4b78884d 100644 --- a/tests/protocols/test_external.py +++ b/tests/protocols/test_external.py @@ -10,15 +10,31 @@ SOME_OTHER_TOKEN = "0x0000000000000000000000000000000000000003" -class MockProtocolA(AsyncMock): ... +class MockProtocolA(AsyncMock): + """Mock class for simulating Protocol A behavior in tests.""" -class MockProtocolB(AsyncMock): ... +class MockProtocolB(AsyncMock): + """Mock class for simulating Protocol B behavior in tests.""" @patch("a_sync.map") @pytest.mark.asyncio async def test_balances_no_protocols(mock_map): + """ + Test the `balances` function with no protocols. + + This test verifies that when there are no protocols in the + `protocols.protocols` list, the `balances` function returns an + empty :class:`~eth_portfolio.typing.RemoteTokenBalances` object + and does not call the `a_sync.map` function. + + Args: + mock_map: The mock object for `a_sync.map`. + + See Also: + - :func:`~eth_portfolio.protocols.balances` + """ protocols.protocols = [] balances = await protocols.balances(SOME_ADDRESS) @@ -29,6 +45,20 @@ async def test_balances_no_protocols(mock_map): @pytest.mark.asyncio async def test_balances_with_protocols(): + """ + Test the `balances` function with multiple protocols. + + This test verifies that when there are multiple protocols in the + `protocols.protocols` list, the `balances` function correctly + aggregates balances from each protocol. + + The test uses :class:`~unittest.mock.AsyncMock` to simulate + protocol behavior and checks that each protocol's `balances` + method is called with the correct arguments. + + See Also: + - :func:`~eth_portfolio.protocols.balances` + """ mock_protocol_a = MockProtocolA() mock_protocol_a.balances.return_value = TokenBalances({SOME_TOKEN: Balance(100, 200)}) @@ -52,6 +82,22 @@ async def test_balances_with_protocols(): @pytest.mark.asyncio async def test_balances_with_protocols_and_block(): + """ + Test the `balances` function with protocols and a specific block. + + This test verifies that when there are multiple protocols in the + `protocols.protocols` list and a specific block is provided, the + `balances` function correctly aggregates balances from each + protocol for the specified block. + + The test uses :class:`~unittest.mock.AsyncMock` to simulate + protocol behavior and checks that each protocol's `balances` + method is called with the correct arguments, including the block + number. + + See Also: + - :func:`~eth_portfolio.protocols.balances` + """ block = 1234567 mock_protocol_a = MockProtocolA() @@ -72,4 +118,4 @@ async def test_balances_with_protocols_and_block(): ) for protocol in protocols.protocols: - protocol.balances.assert_called_once_with(SOME_ADDRESS, block) + protocol.balances.assert_called_once_with(SOME_ADDRESS, block) \ No newline at end of file From 1c06c714c770ac30647e18192ebc21c248e3a5ef Mon Sep 17 00:00:00 2001 From: NV Date: Fri, 21 Feb 2025 18:38:24 -0500 Subject: [PATCH 03/45] fix --- eth_portfolio/_db/entities.py | 208 +++++++++++++++++++- eth_portfolio/_decimal.py | 12 +- eth_portfolio/_exceptions.py | 15 +- eth_portfolio/_loaders/utils.py | 8 +- eth_portfolio/_ydb/token_transfers.py | 114 ++++++++++- eth_portfolio/protocols/__init__.py | 43 +++- eth_portfolio/protocols/lending/_base.py | 34 +++- eth_portfolio/protocols/lending/compound.py | 85 +++++++- setup.py | 34 ++++ tests/conftest.py | 28 +++ tests/protocols/test_external.py | 9 +- 11 files changed, 557 insertions(+), 33 deletions(-) diff --git a/eth_portfolio/_db/entities.py b/eth_portfolio/_db/entities.py index 3dcd4bde..33fa72c3 100644 --- a/eth_portfolio/_db/entities.py +++ b/eth_portfolio/_db/entities.py @@ -54,85 +54,207 @@ class TokenExtended(Token, AddressExtended): class Transaction(DbEntity): + """ + Represents a transaction entity in the database. + + This class provides properties to access decoded transaction data, + including input data, signature components, and access lists. + + + See Also: + - :class:`BlockExtended` + - :class:`AddressExtended` + - :class:`TokenTransfer` + """ + _id = PrimaryKey(int, auto=True) + "The primary key of the transaction." block = Required(BlockExtended, lazy=True, reverse="transactions") + "The block containing this transaction." transaction_index = Required(int, lazy=True) + "The index of the transaction within the block." hash = Required(str, index=True, lazy=True) + "The hash of the transaction." from_address = Required(AddressExtended, index=True, lazy=True, reverse="transactions_sent") + "The address that sent the transaction." to_address = Optional(AddressExtended, index=True, lazy=True, reverse="transactions_received") + "The address that received the transaction." value = Required(Decimal, 38, 18, lazy=True) + "The value transferred in the transaction." price = Optional(Decimal, 38, 18, lazy=True) + "The price of the transaction." value_usd = Optional(Decimal, 38, 18, lazy=True) + "The USD value of the transaction." nonce = Required(int, lazy=True) + "The nonce of the transaction." type = Optional(int, lazy=True) + "The type of the transaction." gas = Required(Decimal, 38, 1, lazy=True) + "The gas used by the transaction." gas_price = Required(Decimal, 38, 1, lazy=True) + "The gas price of the transaction." max_fee_per_gas = Optional(Decimal, 38, 1, lazy=True) + "The maximum fee per gas for the transaction." max_priority_fee_per_gas = Optional(Decimal, 38, 1, lazy=True) + "The maximum priority fee per gas for the transaction." composite_key(block, transaction_index) raw = Required(bytes, lazy=True) + "The raw bytes of the transaction." @cached_property def decoded(self) -> structs.Transaction: + """ + Decodes the raw transaction data into a :class:`structs.Transaction` object. + + Example: + >>> transaction = Transaction(...) + >>> decoded_transaction = transaction.decoded + >>> isinstance(decoded_transaction, structs.Transaction) + True + """ return json.decode(self.raw, type=structs.Transaction) @property def input(self) -> HexBytes: + """ + Returns the input data of the transaction. + + Example: + >>> transaction = Transaction(...) + >>> input_data = transaction.input + >>> isinstance(input_data, HexBytes) + True + """ structs.Transaction.input.__doc__ return self.decoded.input @property def r(self) -> HexBytes: + """ + Returns the R component of the transaction's signature. + + Example: + >>> transaction = Transaction(...) + >>> r_value = transaction.r + >>> isinstance(r_value, HexBytes) + True + """ structs.Transaction.r.__doc__ return self.decoded.r @property def s(self) -> HexBytes: + """ + Returns the S component of the transaction's signature. + + Example: + >>> transaction = Transaction(...) + >>> s_value = transaction.s + >>> isinstance(s_value, HexBytes) + True + """ structs.Transaction.s.__doc__ return self.decoded.s @property def v(self) -> int: + """ + Returns the V component of the transaction's signature. + + Example: + >>> transaction = Transaction(...) + >>> v_value = transaction.v + >>> isinstance(v_value, int) + True + """ structs.Transaction.v.__doc__ return self.decoded.v @property def access_list(self) -> typing.List[AccessListEntry]: + """ + Returns the access list of the transaction. + + Example: + >>> transaction = Transaction(...) + >>> access_list = transaction.access_list + >>> isinstance(access_list, list) + True + >>> isinstance(access_list[0], AccessListEntry) + True + + See Also: + - :class:`AccessListEntry` + """ structs.Transaction.access_list.__doc__ return self.decoded.access_list @property def y_parity(self) -> typing.Optional[int]: - structs.TokenTransfer.y_parity.__doc__ + """ + Returns the y_parity of the transaction. + + Example: + >>> transaction = Transaction(...) + >>> y_parity_value = transaction.y_parity + >>> isinstance(y_parity_value, (int, type(None))) + True + """ + structs.Transaction.y_parity.__doc__ return self.decoded.y_parity class InternalTransfer(DbEntity): + """ + Represents an internal transfer entity in the database. + + This class provides properties to access decoded internal transfer data, + including input, output, and code. + + See Also: + - :class:`BlockExtended` + - :class:`AddressExtended` + """ + _id = PrimaryKey(int, auto=True) + "The primary key of the internal transfer." # common block = Required(BlockExtended, lazy=True, reverse="internal_transfers") + "The block containing this internal transfer." transaction_index = Required(int, lazy=True) + "The index of the transaction within the block." hash = Required(str, lazy=True) + "The hash of the internal transfer." from_address = Required( AddressExtended, index=True, lazy=True, reverse="internal_transfers_sent" ) + "The address that sent the internal transfer." to_address = Optional( AddressExtended, index=True, lazy=True, reverse="internal_transfers_received" ) + "The address that received the internal transfer." value = Required(Decimal, 38, 18, lazy=True) + "The value transferred in the internal transfer." price = Optional(Decimal, 38, 18, lazy=True) + "The price of the internal transfer." value_usd = Optional(Decimal, 38, 18, lazy=True) + "The USD value of the internal transfer." # unique type = Required(str, lazy=True) + "The type of the internal transfer." call_type = Required(str, lazy=True) + "The call type of the internal transfer." trace_address = Required(str, lazy=True) + "The trace address of the internal transfer." gas = Required(Decimal, 38, 1, lazy=True) + "The gas used by the internal transfer." gas_used = Optional(Decimal, 38, 1, lazy=True) + "The gas used by the internal transfer." composite_key( block, @@ -149,56 +271,136 @@ class InternalTransfer(DbEntity): ) raw = Required(bytes, lazy=True) + "The raw bytes of the internal transfer." @cached_property def decoded(self) -> structs.InternalTransfer: + """ + Decodes the raw internal transfer data into a :class:`structs.InternalTransfer` object. + + Example: + >>> internal_transfer = InternalTransfer(...) + >>> decoded_transfer = internal_transfer.decoded + >>> isinstance(decoded_transfer, structs.InternalTransfer) + True + """ structs.InternalTransfer.__doc__ return json.decode(self.raw, type=structs.InternalTransfer) @property def code(self) -> HexBytes: + """ + Returns the code of the internal transfer. + + Example: + >>> internal_transfer = InternalTransfer(...) + >>> code_data = internal_transfer.code + >>> isinstance(code_data, HexBytes) + True + """ structs.InternalTransfer.code.__doc__ return self.decoded.code @property def input(self) -> HexBytes: + """ + Returns the input data of the internal transfer. + + Example: + >>> internal_transfer = InternalTransfer(...) + >>> input_data = internal_transfer.input + >>> isinstance(input_data, HexBytes) + True + """ structs.InternalTransfer.input.__doc__ return self.decoded.input @property def output(self) -> HexBytes: + """ + Returns the output data of the internal transfer. + + Example: + >>> internal_transfer = InternalTransfer(...) + >>> output_data = internal_transfer.output + >>> isinstance(output_data, HexBytes) + True + """ structs.InternalTransfer.output.__doc__ return self.decoded.output @property def subtraces(self) -> int: + """ + Returns the number of subtraces of the internal transfer. + + Example: + >>> internal_transfer = InternalTransfer(...) + >>> subtraces_count = internal_transfer.subtraces + >>> isinstance(subtraces_count, int) + True + """ structs.InternalTransfer.subtraces.__doc__ return self.decoded.subtraces class TokenTransfer(DbEntity): + """ + Represents a token transfer entity in the database. + + This class provides properties to access decoded token transfer data. + + See Also: + - :class:`BlockExtended` + - :class:`AddressExtended` + - :class:`TokenExtended` + """ + _id = PrimaryKey(int, auto=True) + "The primary key of the token transfer." # common block = Required(BlockExtended, lazy=True, reverse="token_transfers") + "The block containing this token transfer." transaction_index = Required(int, lazy=True) + "The index of the transaction within the block." hash = Required(str, lazy=True) - from_address = Required(AddressExtended, index=True, lazy=True, reverse="token_transfers_sent") + "The hash of the token transfer." + from_address = Required( + AddressExtended, index=True, lazy=True, reverse="token_transfers_sent" + ) + "The address that sent the token transfer." to_address = Required( AddressExtended, index=True, lazy=True, reverse="token_transfers_received" ) + "The address that received the token transfer." value = Required(Decimal, 38, 18, lazy=True) + "The value transferred in the token transfer." price = Optional(Decimal, 38, 18, lazy=True) + "The price of the token transfer." value_usd = Optional(Decimal, 38, 18, lazy=True) + "The USD value of the token transfer." # unique log_index = Required(int, lazy=True) + "The log index of the token transfer." token = Optional(TokenExtended, index=True, lazy=True, reverse="transfers") + "The token involved in the transfer." composite_key(block, transaction_index, log_index) raw = Required(bytes, lazy=True) + "The raw bytes of the token transfer." @cached_property def decoded(self) -> structs.TokenTransfer: - return json.decode(self.raw, type=structs.TokenTransfer) + """ + Decodes the raw token transfer data into a :class:`structs.TokenTransfer` object. + + Example: + >>> token_transfer = TokenTransfer(...) + >>> decoded_transfer = token_transfer.decoded + >>> isinstance(decoded_transfer, structs.TokenTransfer) + True + """ + return json.decode(self.raw, type=structs.TokenTransfer) \ No newline at end of file diff --git a/eth_portfolio/_decimal.py b/eth_portfolio/_decimal.py index 91e9a32c..afe3ca52 100644 --- a/eth_portfolio/_decimal.py +++ b/eth_portfolio/_decimal.py @@ -9,7 +9,8 @@ class Decimal(decimal.Decimal): - """A subclass of :class:`decimal.Decimal` with additional functionality. + """ + A subclass of :class:`decimal.Decimal` with additional functionality. This class extends the :class:`decimal.Decimal` class to provide additional methods for JSON serialization and arithmetic operations. @@ -19,7 +20,8 @@ class Decimal(decimal.Decimal): """ def jsonify(self) -> Union[str, int]: - """Converts the Decimal to a JSON-friendly format. + """ + Converts the Decimal to a JSON-friendly format. This method attempts to represent the Decimal in the most compact form possible for JSON serialization. It returns an integer if the Decimal @@ -98,7 +100,8 @@ def __rfloordiv__(self, other): class Gwei(Decimal): - """A subclass of :class:`Decimal` representing Gwei values. + """ + A subclass of :class:`Decimal` representing Gwei values. This class provides a property to convert Gwei to Wei. @@ -109,7 +112,8 @@ class Gwei(Decimal): @property def as_wei(self) -> Wei: - """Converts the Gwei value to Wei. + """ + Converts the Gwei value to Wei. This property multiplies the Gwei value by 10^9 to convert it to Wei. diff --git a/eth_portfolio/_exceptions.py b/eth_portfolio/_exceptions.py index df50b288..b5fb6626 100644 --- a/eth_portfolio/_exceptions.py +++ b/eth_portfolio/_exceptions.py @@ -8,7 +8,8 @@ class BlockRangeIsCached(Exception): - """Exception raised when a block range is already cached. + """ + Exception raised when a block range is already cached. This exception is used to indicate that the requested block range has already been loaded into memory and does not need to be fetched again. @@ -20,16 +21,17 @@ class BlockRangeIsCached(Exception): class BlockRangeOutOfBounds(Exception): - """Exception raised when a block range is out of bounds. + """ + Exception raised when a block range is out of bounds. This exception is used to indicate that the requested block range is outside the bounds of the cached data. It provides a method to load the remaining ledger entries that are out of bounds. Args: - start_block (Block): The starting block number of the out-of-bounds range. - end_block (Block): The ending block number of the out-of-bounds range. - ledger (AddressLedgerBase): The ledger associated with the block range. + start_block: The starting block number of the out-of-bounds range. + end_block: The ending block number of the out-of-bounds range. + ledger: The ledger associated with the block range. Examples: >>> raise BlockRangeOutOfBounds(100, 200, ledger) @@ -44,7 +46,8 @@ def __init__(self, start_block: Block, end_block: Block, ledger: "AddressLedgerB self.end_block = end_block async def load_remaining(self) -> None: - """Asynchronously loads the remaining ledger entries that are out of bounds. + """ + Asynchronously loads the remaining ledger entries that are out of bounds. This method fetches the ledger entries for the blocks that are outside the cached range, ensuring that the entire requested block range is covered. diff --git a/eth_portfolio/_loaders/utils.py b/eth_portfolio/_loaders/utils.py index 06059be4..8fcd2c3d 100644 --- a/eth_portfolio/_loaders/utils.py +++ b/eth_portfolio/_loaders/utils.py @@ -10,7 +10,8 @@ @alru_cache(maxsize=None, ttl=60 * 60) @stuck_coro_debugger async def _get_transaction_receipt(txhash: HexStr) -> msgspec.Raw: - """Fetches the transaction receipt for a given transaction hash. + """ + Fetches the transaction receipt for a given transaction hash. This function retrieves the transaction receipt from the Ethereum network using the provided transaction hash. It utilizes caching to store results @@ -19,7 +20,7 @@ async def _get_transaction_receipt(txhash: HexStr) -> msgspec.Raw: in case of failures and to debug if the coroutine gets stuck. Args: - txhash (HexStr): The transaction hash for which to retrieve the receipt. + txhash: The transaction hash for which to retrieve the receipt. Returns: msgspec.Raw: The raw transaction receipt data. @@ -39,7 +40,8 @@ async def _get_transaction_receipt(txhash: HexStr) -> msgspec.Raw: ) get_transaction_receipt = SmartProcessingQueue(_get_transaction_receipt, 5000) -"""A queue for processing transaction receipt requests. +""" +A queue for processing transaction receipt requests. This queue manages the processing of transaction receipt requests, allowing up to 5000 concurrent requests. It uses the `_get_transaction_receipt` function diff --git a/eth_portfolio/_ydb/token_transfers.py b/eth_portfolio/_ydb/token_transfers.py index 3d129c66..e0c2790b 100644 --- a/eth_portfolio/_ydb/token_transfers.py +++ b/eth_portfolio/_ydb/token_transfers.py @@ -34,7 +34,24 @@ class _TokenTransfers(ProcessedEvents["Task[TokenTransfer]"]): - """A helper mixin that contains all logic for fetching token transfers for a particular wallet address""" + """ + A helper mixin that contains all logic for fetching token transfers for a particular wallet address. + + Attributes: + address: The wallet address for which token transfers are fetched. + _load_prices: Indicates whether to load prices for the token transfers. + + Examples: + Fetching token transfers for a specific address: + + >>> transfers = _TokenTransfers(address="0x123...", from_block=0, load_prices=True) + >>> async for transfer in transfers.yield_thru_block(1000000): + ... print(transfer) + + See Also: + - :class:`~eth_portfolio.structs.TokenTransfer`: For the structure of a token transfer. + - :func:`~eth_portfolio._loaders.load_token_transfer`: For loading token transfer data. + """ __slots__ = "address", "_load_prices" @@ -48,10 +65,24 @@ def __repr__(self) -> str: @property @abstractmethod - def _topics(self) -> List: ... + def _topics(self) -> List: + pass @ASyncIterator.wrap # type: ignore [call-overload] async def yield_thru_block(self, block) -> AsyncIterator["Task[TokenTransfer]"]: + """ + Yield token transfers up to a specified block. + + Args: + block: The block number up to which token transfers are yielded. + + Yields: + Token transfers as :class:`~asyncio.Task` objects. + + Examples: + >>> async for transfer in transfers.yield_thru_block(1000000): + ... print(transfer) + """ if not _logger_is_enabled_for(DEBUG): async for task in self._objects_thru(block=block): yield task @@ -68,6 +99,15 @@ async def yield_thru_block(self, block) -> AsyncIterator["Task[TokenTransfer]"]: _logger_log(DEBUG, "%s yield thru %s complete", (self, block)) async def _extend(self, objs: List[evmspec.Log]) -> None: + """ + Extend the list of token transfers with new logs. + + Args: + objs: A list of :class:`~evmspec.Log` objects representing token transfer logs. + + Examples: + >>> await transfers._extend(logs) + """ shitcoins = SHITCOINS.get(chain.id, set()) append_loader_task = self._objects.append done = 0 @@ -98,7 +138,19 @@ def _done_callback(self, task: Task) -> None: class InboundTokenTransfers(_TokenTransfers): - """A container that fetches and iterates over all inbound token transfers for a particular wallet address""" + """ + A container that fetches and iterates over all inbound token transfers for a particular wallet address. + + Examples: + Fetching inbound token transfers for a specific address: + + >>> inbound_transfers = InboundTokenTransfers(address="0x123...", from_block=0, load_prices=True) + >>> async for transfer in inbound_transfers.yield_thru_block(1000000): + ... print(transfer) + + See Also: + - :class:`~eth_portfolio.structs.TokenTransfer`: For the structure of a token transfer. + """ @property def _topics(self) -> List: @@ -106,7 +158,19 @@ def _topics(self) -> List: class OutboundTokenTransfers(_TokenTransfers): - """A container that fetches and iterates over all outbound token transfers for a particular wallet address""" + """ + A container that fetches and iterates over all outbound token transfers for a particular wallet address. + + Examples: + Fetching outbound token transfers for a specific address: + + >>> outbound_transfers = OutboundTokenTransfers(address="0x123...", from_block=0, load_prices=True) + >>> async for transfer in outbound_transfers.yield_thru_block(1000000): + ... print(transfer) + + See Also: + - :class:`~eth_portfolio.structs.TokenTransfer`: For the structure of a token transfer. + """ @property def _topics(self) -> List: @@ -116,7 +180,22 @@ def _topics(self) -> List: class TokenTransfers(ASyncIterable[TokenTransfer]): """ A container that fetches and iterates over all token transfers for a particular wallet address. - NOTE: These do not come back in chronologcal order. + + Attributes: + transfers_in: Container for inbound token transfers. + transfers_out: Container for outbound token transfers. + + Examples: + Fetching all token transfers for a specific address: + + >>> token_transfers = TokenTransfers(address="0x123...", from_block=0, load_prices=True) + >>> async for transfer in token_transfers: + ... print(transfer) + + See Also: + - :class:`~eth_portfolio.structs.TokenTransfer`: For the structure of a token transfer. + - :class:`~InboundTokenTransfers`: For fetching inbound token transfers. + - :class:`~OutboundTokenTransfers`: For fetching outbound token transfers. """ def __init__(self, address: Address, from_block: int, load_prices: bool = False): @@ -124,13 +203,36 @@ def __init__(self, address: Address, from_block: int, load_prices: bool = False) self.transfers_out = OutboundTokenTransfers(address, from_block, load_prices=load_prices) async def __aiter__(self): + """ + Asynchronously iterate over all token transfers. + + Yields: + Token transfers as :class:`~eth_portfolio.structs.TokenTransfer` objects. + + Examples: + >>> async for transfer in token_transfers: + ... print(transfer) + """ async for transfer in self.yield_thru_block(await dank_mids.eth.block_number): yield transfer def yield_thru_block(self, block: int) -> ASyncIterator["Task[TokenTransfer]"]: + """ + Yield token transfers up to a specified block. + + Args: + block: The block number up to which token transfers are yielded. + + Yields: + Token transfers as :class:`~asyncio.Task` objects. + + Examples: + >>> async for transfer in token_transfers.yield_thru_block(1000000): + ... print(transfer) + """ return ASyncIterator( as_yielded( self.transfers_in.yield_thru_block(block), self.transfers_out.yield_thru_block(block), ) - ) + ) \ No newline at end of file diff --git a/eth_portfolio/protocols/__init__.py b/eth_portfolio/protocols/__init__.py index ef40aa8c..7c89c85c 100644 --- a/eth_portfolio/protocols/__init__.py +++ b/eth_portfolio/protocols/__init__.py @@ -15,6 +15,47 @@ @a_sync.future async def balances(address: Address, block: Optional[Block] = None) -> RemoteTokenBalances: + """ + Fetches token balances for a given address across various protocols. + + This function retrieves the token balances for a specified Ethereum address + at a given block across all available protocols. It is decorated with + :func:`a_sync.future`, allowing it to be used in both synchronous and + asynchronous contexts. + + If no protocols are available, the function returns an empty + :class:`~eth_portfolio.typing.RemoteTokenBalances` object. + + Args: + address: The Ethereum address for which to fetch balances. + block: The block number at which to fetch balances. + If not provided, the latest block is used. + + Examples: + Fetching balances asynchronously: + + >>> from eth_portfolio.protocols import balances + >>> address = "0x1234567890abcdef1234567890abcdef12345678" + >>> block = 12345678 + >>> remote_balances = await balances(address, block) + >>> print(remote_balances) + + Fetching balances synchronously: + + >>> remote_balances = balances(address, block).result() + >>> print(remote_balances) + + The function constructs a dictionary `data` initialized with protocol names + and their corresponding balances. The `protocol_balances` variable is an + asynchronous mapping of protocol names to their respective balance data, + which is then used in an asynchronous comprehension to construct the + dictionary `data`. This dictionary is subsequently used to initialize the + :class:`~eth_portfolio.typing.RemoteTokenBalances` object. + + See Also: + - :class:`~eth_portfolio.typing.RemoteTokenBalances`: For more information on the return type. + - :func:`a_sync.future`: For more information on the decorator used. + """ if not protocols: return RemoteTokenBalances(block=block) protocol_balances = a_sync.map( @@ -26,4 +67,4 @@ async def balances(address: Address, block: Optional[Block] = None) -> RemoteTok async for protocol, protocol_balances in protocol_balances if protocol_balances is not None } - return RemoteTokenBalances(data, block=block) + return RemoteTokenBalances(data, block=block) \ No newline at end of file diff --git a/eth_portfolio/protocols/lending/_base.py b/eth_portfolio/protocols/lending/_base.py index 58a7e007..c62053f5 100644 --- a/eth_portfolio/protocols/lending/_base.py +++ b/eth_portfolio/protocols/lending/_base.py @@ -11,10 +11,19 @@ class LendingProtocol(metaclass=abc.ABCMeta): """ Subclass this class for any protocol that maintains a debt balance for a user but doesn't maintain collateral internally. - Example: Aave, because the user holds on to their collateral in the form of erc-20 aTokens. + Example: Aave, because the user holds on to their collateral in the form of ERC-20 aTokens. You must define the following async method: - `_debt_async(self, address: Address, block: Optional[Block] = None)` + `_debt(self, address: Address, block: Optional[Block] = None)` + + Example: + >>> class AaveProtocol(LendingProtocol): + ... async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenBalances: + ... # Implementation for fetching debt from Aave + ... pass + + See Also: + - :class:`LendingProtocolWithLockedCollateral` """ @a_sync.future @@ -22,7 +31,8 @@ async def debt(self, address: Address, block: Optional[Block] = None) -> TokenBa return await self._debt(address, block) # type: ignore @abc.abstractmethod - async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenBalances: ... + async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenBalances: + ... class LendingProtocolWithLockedCollateral(LendingProtocol, ProtocolABC): @@ -31,6 +41,18 @@ class LendingProtocolWithLockedCollateral(LendingProtocol, ProtocolABC): Example: Maker, because collateral is locked up inside of Maker's smart contracts. You must define the following async methods: - - `_debt_async(self, address: Address, block: Optional[Block] = None)` - - `_balances_async(self, address: Address, block: Optional[Block] = None)` - """ + - `_debt(self, address: Address, block: Optional[Block] = None)` + - `_balances(self, address: Address, block: Optional[Block] = None)` + + Example: + >>> class MakerProtocol(LendingProtocolWithLockedCollateral): + ... async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenBalances: + ... # Implementation for fetching debt from Maker + ... pass + ... async def _balances(self, address: Address, block: Optional[Block] = None) -> TokenBalances: + ... # Implementation for fetching balances from Maker + ... pass + + See Also: + - :class:`LendingProtocol` + """ \ No newline at end of file diff --git a/eth_portfolio/protocols/lending/compound.py b/eth_portfolio/protocols/lending/compound.py index 87d32863..9f064fe8 100644 --- a/eth_portfolio/protocols/lending/compound.py +++ b/eth_portfolio/protocols/lending/compound.py @@ -30,6 +30,27 @@ class Compound(LendingProtocol): @alru_cache(ttl=300) @stuck_coro_debugger async def underlyings(self) -> List[ERC20]: + """ + Fetches the underlying ERC20 tokens for all Compound markets. + + This method gathers all markets from the Compound protocol's trollers + and filters out those that do not have a `borrowBalanceStored` attribute + by using the :func:`_get_contract` function. It then separates markets + into those that use the native gas token and those that have an underlying + ERC20 token, fetching the underlying tokens accordingly. + + Returns: + A list of :class:`~y.classes.common.ERC20` instances representing the underlying tokens. + + Examples: + >>> compound = Compound() + >>> underlyings = await compound.underlyings() + >>> for token in underlyings: + ... print(token.symbol) + + See Also: + - :meth:`markets`: To get the list of market contracts. + """ all_markets: List[List[CToken]] = await gather( *[comp.markets for comp in compound.trollers.values()] ) @@ -58,10 +79,50 @@ async def underlyings(self) -> List[ERC20]: @a_sync.future @stuck_coro_debugger async def markets(self) -> List[Contract]: + """ + Fetches the list of market contracts for the Compound protocol. + + This method ensures that the underlying tokens are fetched first, + as they are used to determine the markets. + + Returns: + A list of :class:`~brownie.network.contract.Contract` instances representing the markets. + + Examples: + >>> compound = Compound() + >>> markets = await compound.markets() + >>> for market in markets: + ... print(market.address) + + See Also: + - :meth:`underlyings`: To get the list of underlying tokens. + """ await self.underlyings() return self._markets async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenBalances: + """ + Calculates the debt balance for a given address in the Compound protocol. + + This method fetches the borrow balance for each market and calculates + the debt in terms of the underlying token and its USD value. + + Args: + address: The Ethereum address to calculate the debt for. + block: The block number to query. Defaults to the latest block. + + Returns: + A :class:`~eth_portfolio.typing.TokenBalances` object representing the debt balances. + + Examples: + >>> compound = Compound() + >>> debt_balances = await compound._debt("0x1234567890abcdef1234567890abcdef12345678") + >>> for token, balance in debt_balances.items(): + ... print(f"Token: {token}, Balance: {balance.balance}, USD Value: {balance.usd_value}") + + See Also: + - :meth:`debt`: Public method to get the debt balances. + """ # if ypricemagic doesn't support any Compound forks on current chain if len(compound.trollers) == 0: return TokenBalances(block=block) @@ -93,9 +154,31 @@ async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenB async def _borrow_balance_stored( market: Contract, address: Address, block: Optional[Block] = None ) -> Optional[int]: + """ + Fetches the stored borrow balance for a given market and address. + + This function attempts to call the `borrowBalanceStored` method on the + market contract. If the call reverts, it returns None. + + Args: + market: The market contract to query. + address: The Ethereum address to fetch the borrow balance for. + block: The block number to query. Defaults to the latest block. + + Returns: + The stored borrow balance as an integer, or None if the call reverts. + + Examples: + >>> market = Contract.from_explorer("0x1234567890abcdef1234567890abcdef12345678") + >>> balance = await _borrow_balance_stored(market, "0xabcdefabcdefabcdefabcdefabcdefabcdef") + >>> print(balance) + + See Also: + - :meth:`_debt`: Uses this function to calculate debt balances. + """ try: return await market.borrowBalanceStored.coroutine(str(address), block_identifier=block) except ValueError as e: if str(e) != "No data was returned - the call likely reverted": raise - return None + return None \ No newline at end of file diff --git a/setup.py b/setup.py index 7bf5eca7..a8b22600 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,37 @@ +""" +Installation +------------ +To install the `eth-portfolio` package, you should start with a fresh virtual environment. +Due to the use of :mod:`setuptools_scm` for versioning, it is recommended to clone the repository first +to ensure the version can be determined correctly. + +The `setup.py` file automatically handles the installation of :mod:`setuptools_scm` and :mod:`cython`, +so you do not need to install them manually before running the setup process. Additionally, +the `requirements.txt` file is used to specify additional dependencies that are installed via +the `install_requires` parameter. Note that the last line of `requirements.txt` is intentionally excluded +from installation, so ensure that any necessary dependency is not placed on the last line. + +Example: + .. code-block:: bash + + git clone https://github.com/BobTheBuidler/eth-portfolio.git + cd eth-portfolio + pip install . + +If you encounter issues with :mod:`PyYaml` and :mod:`Cython`, you can resolve them by installing specific versions: + +Example: + .. code-block:: bash + + pip install wheel + pip install --no-build-isolation "Cython<3" "pyyaml==5.4.1" + pip install . + +See Also: + - :mod:`setuptools_scm`: For more information on versioning with setuptools_scm. + - :mod:`cython`: For more information on Cython. + - :mod:`requirements.txt`: For more information on managing dependencies. +""" from setuptools import find_packages, setup # type: ignore with open("requirements.txt", "r") as f: diff --git a/tests/conftest.py b/tests/conftest.py index 3f9e6b25..417e3532 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,31 @@ +""" +This module configures the testing environment for the project by ensuring +that the Brownie network is connected using the specified network. It also +modifies the system path to include the current directory, allowing for +importing modules from the project root. + +Environment Variables: + PYTEST_NETWORK: The name of the Brownie network to use for testing. This + environment variable must be set before running the tests. + +Raises: + ValueError: If the PYTEST_NETWORK environment variable is not set. + +Examples: + To set the PYTEST_NETWORK environment variable and run tests: + + .. code-block:: bash + + export PYTEST_NETWORK=mainnet-fork + pytest + + This will connect to the specified Brownie network and run the tests. + +See Also: + - :mod:`brownie.network`: For more information on managing network connections with Brownie. + - :mod:`os`: For more information on interacting with the operating system. + - :mod:`sys`: For more information on the system-specific parameters and functions. +""" import os import sys diff --git a/tests/protocols/test_external.py b/tests/protocols/test_external.py index f5aa05d7..4b78884d 100644 --- a/tests/protocols/test_external.py +++ b/tests/protocols/test_external.py @@ -21,7 +21,8 @@ class MockProtocolB(AsyncMock): @patch("a_sync.map") @pytest.mark.asyncio async def test_balances_no_protocols(mock_map): - """Test the `balances` function with no protocols. + """ + Test the `balances` function with no protocols. This test verifies that when there are no protocols in the `protocols.protocols` list, the `balances` function returns an @@ -44,7 +45,8 @@ async def test_balances_no_protocols(mock_map): @pytest.mark.asyncio async def test_balances_with_protocols(): - """Test the `balances` function with multiple protocols. + """ + Test the `balances` function with multiple protocols. This test verifies that when there are multiple protocols in the `protocols.protocols` list, the `balances` function correctly @@ -80,7 +82,8 @@ async def test_balances_with_protocols(): @pytest.mark.asyncio async def test_balances_with_protocols_and_block(): - """Test the `balances` function with protocols and a specific block. + """ + Test the `balances` function with protocols and a specific block. This test verifies that when there are multiple protocols in the `protocols.protocols` list and a specific block is provided, the From c883186936aeba27066f2fa0464e5c4a8907cae8 Mon Sep 17 00:00:00 2001 From: NV Date: Fri, 21 Feb 2025 18:45:56 -0500 Subject: [PATCH 04/45] fix --- eth_portfolio/_db/entities.py | 208 +++++++++++++++++++- eth_portfolio/_decimal.py | 12 +- eth_portfolio/_exceptions.py | 15 +- eth_portfolio/_loaders/utils.py | 8 +- eth_portfolio/_ydb/token_transfers.py | 114 ++++++++++- eth_portfolio/protocols/__init__.py | 43 +++- eth_portfolio/protocols/lending/_base.py | 34 +++- eth_portfolio/protocols/lending/compound.py | 85 +++++++- setup.py | 34 ++++ tests/conftest.py | 28 +++ tests/protocols/test_external.py | 9 +- 11 files changed, 557 insertions(+), 33 deletions(-) diff --git a/eth_portfolio/_db/entities.py b/eth_portfolio/_db/entities.py index 3dcd4bde..33fa72c3 100644 --- a/eth_portfolio/_db/entities.py +++ b/eth_portfolio/_db/entities.py @@ -54,85 +54,207 @@ class TokenExtended(Token, AddressExtended): class Transaction(DbEntity): + """ + Represents a transaction entity in the database. + + This class provides properties to access decoded transaction data, + including input data, signature components, and access lists. + + + See Also: + - :class:`BlockExtended` + - :class:`AddressExtended` + - :class:`TokenTransfer` + """ + _id = PrimaryKey(int, auto=True) + "The primary key of the transaction." block = Required(BlockExtended, lazy=True, reverse="transactions") + "The block containing this transaction." transaction_index = Required(int, lazy=True) + "The index of the transaction within the block." hash = Required(str, index=True, lazy=True) + "The hash of the transaction." from_address = Required(AddressExtended, index=True, lazy=True, reverse="transactions_sent") + "The address that sent the transaction." to_address = Optional(AddressExtended, index=True, lazy=True, reverse="transactions_received") + "The address that received the transaction." value = Required(Decimal, 38, 18, lazy=True) + "The value transferred in the transaction." price = Optional(Decimal, 38, 18, lazy=True) + "The price of the transaction." value_usd = Optional(Decimal, 38, 18, lazy=True) + "The USD value of the transaction." nonce = Required(int, lazy=True) + "The nonce of the transaction." type = Optional(int, lazy=True) + "The type of the transaction." gas = Required(Decimal, 38, 1, lazy=True) + "The gas used by the transaction." gas_price = Required(Decimal, 38, 1, lazy=True) + "The gas price of the transaction." max_fee_per_gas = Optional(Decimal, 38, 1, lazy=True) + "The maximum fee per gas for the transaction." max_priority_fee_per_gas = Optional(Decimal, 38, 1, lazy=True) + "The maximum priority fee per gas for the transaction." composite_key(block, transaction_index) raw = Required(bytes, lazy=True) + "The raw bytes of the transaction." @cached_property def decoded(self) -> structs.Transaction: + """ + Decodes the raw transaction data into a :class:`structs.Transaction` object. + + Example: + >>> transaction = Transaction(...) + >>> decoded_transaction = transaction.decoded + >>> isinstance(decoded_transaction, structs.Transaction) + True + """ return json.decode(self.raw, type=structs.Transaction) @property def input(self) -> HexBytes: + """ + Returns the input data of the transaction. + + Example: + >>> transaction = Transaction(...) + >>> input_data = transaction.input + >>> isinstance(input_data, HexBytes) + True + """ structs.Transaction.input.__doc__ return self.decoded.input @property def r(self) -> HexBytes: + """ + Returns the R component of the transaction's signature. + + Example: + >>> transaction = Transaction(...) + >>> r_value = transaction.r + >>> isinstance(r_value, HexBytes) + True + """ structs.Transaction.r.__doc__ return self.decoded.r @property def s(self) -> HexBytes: + """ + Returns the S component of the transaction's signature. + + Example: + >>> transaction = Transaction(...) + >>> s_value = transaction.s + >>> isinstance(s_value, HexBytes) + True + """ structs.Transaction.s.__doc__ return self.decoded.s @property def v(self) -> int: + """ + Returns the V component of the transaction's signature. + + Example: + >>> transaction = Transaction(...) + >>> v_value = transaction.v + >>> isinstance(v_value, int) + True + """ structs.Transaction.v.__doc__ return self.decoded.v @property def access_list(self) -> typing.List[AccessListEntry]: + """ + Returns the access list of the transaction. + + Example: + >>> transaction = Transaction(...) + >>> access_list = transaction.access_list + >>> isinstance(access_list, list) + True + >>> isinstance(access_list[0], AccessListEntry) + True + + See Also: + - :class:`AccessListEntry` + """ structs.Transaction.access_list.__doc__ return self.decoded.access_list @property def y_parity(self) -> typing.Optional[int]: - structs.TokenTransfer.y_parity.__doc__ + """ + Returns the y_parity of the transaction. + + Example: + >>> transaction = Transaction(...) + >>> y_parity_value = transaction.y_parity + >>> isinstance(y_parity_value, (int, type(None))) + True + """ + structs.Transaction.y_parity.__doc__ return self.decoded.y_parity class InternalTransfer(DbEntity): + """ + Represents an internal transfer entity in the database. + + This class provides properties to access decoded internal transfer data, + including input, output, and code. + + See Also: + - :class:`BlockExtended` + - :class:`AddressExtended` + """ + _id = PrimaryKey(int, auto=True) + "The primary key of the internal transfer." # common block = Required(BlockExtended, lazy=True, reverse="internal_transfers") + "The block containing this internal transfer." transaction_index = Required(int, lazy=True) + "The index of the transaction within the block." hash = Required(str, lazy=True) + "The hash of the internal transfer." from_address = Required( AddressExtended, index=True, lazy=True, reverse="internal_transfers_sent" ) + "The address that sent the internal transfer." to_address = Optional( AddressExtended, index=True, lazy=True, reverse="internal_transfers_received" ) + "The address that received the internal transfer." value = Required(Decimal, 38, 18, lazy=True) + "The value transferred in the internal transfer." price = Optional(Decimal, 38, 18, lazy=True) + "The price of the internal transfer." value_usd = Optional(Decimal, 38, 18, lazy=True) + "The USD value of the internal transfer." # unique type = Required(str, lazy=True) + "The type of the internal transfer." call_type = Required(str, lazy=True) + "The call type of the internal transfer." trace_address = Required(str, lazy=True) + "The trace address of the internal transfer." gas = Required(Decimal, 38, 1, lazy=True) + "The gas used by the internal transfer." gas_used = Optional(Decimal, 38, 1, lazy=True) + "The gas used by the internal transfer." composite_key( block, @@ -149,56 +271,136 @@ class InternalTransfer(DbEntity): ) raw = Required(bytes, lazy=True) + "The raw bytes of the internal transfer." @cached_property def decoded(self) -> structs.InternalTransfer: + """ + Decodes the raw internal transfer data into a :class:`structs.InternalTransfer` object. + + Example: + >>> internal_transfer = InternalTransfer(...) + >>> decoded_transfer = internal_transfer.decoded + >>> isinstance(decoded_transfer, structs.InternalTransfer) + True + """ structs.InternalTransfer.__doc__ return json.decode(self.raw, type=structs.InternalTransfer) @property def code(self) -> HexBytes: + """ + Returns the code of the internal transfer. + + Example: + >>> internal_transfer = InternalTransfer(...) + >>> code_data = internal_transfer.code + >>> isinstance(code_data, HexBytes) + True + """ structs.InternalTransfer.code.__doc__ return self.decoded.code @property def input(self) -> HexBytes: + """ + Returns the input data of the internal transfer. + + Example: + >>> internal_transfer = InternalTransfer(...) + >>> input_data = internal_transfer.input + >>> isinstance(input_data, HexBytes) + True + """ structs.InternalTransfer.input.__doc__ return self.decoded.input @property def output(self) -> HexBytes: + """ + Returns the output data of the internal transfer. + + Example: + >>> internal_transfer = InternalTransfer(...) + >>> output_data = internal_transfer.output + >>> isinstance(output_data, HexBytes) + True + """ structs.InternalTransfer.output.__doc__ return self.decoded.output @property def subtraces(self) -> int: + """ + Returns the number of subtraces of the internal transfer. + + Example: + >>> internal_transfer = InternalTransfer(...) + >>> subtraces_count = internal_transfer.subtraces + >>> isinstance(subtraces_count, int) + True + """ structs.InternalTransfer.subtraces.__doc__ return self.decoded.subtraces class TokenTransfer(DbEntity): + """ + Represents a token transfer entity in the database. + + This class provides properties to access decoded token transfer data. + + See Also: + - :class:`BlockExtended` + - :class:`AddressExtended` + - :class:`TokenExtended` + """ + _id = PrimaryKey(int, auto=True) + "The primary key of the token transfer." # common block = Required(BlockExtended, lazy=True, reverse="token_transfers") + "The block containing this token transfer." transaction_index = Required(int, lazy=True) + "The index of the transaction within the block." hash = Required(str, lazy=True) - from_address = Required(AddressExtended, index=True, lazy=True, reverse="token_transfers_sent") + "The hash of the token transfer." + from_address = Required( + AddressExtended, index=True, lazy=True, reverse="token_transfers_sent" + ) + "The address that sent the token transfer." to_address = Required( AddressExtended, index=True, lazy=True, reverse="token_transfers_received" ) + "The address that received the token transfer." value = Required(Decimal, 38, 18, lazy=True) + "The value transferred in the token transfer." price = Optional(Decimal, 38, 18, lazy=True) + "The price of the token transfer." value_usd = Optional(Decimal, 38, 18, lazy=True) + "The USD value of the token transfer." # unique log_index = Required(int, lazy=True) + "The log index of the token transfer." token = Optional(TokenExtended, index=True, lazy=True, reverse="transfers") + "The token involved in the transfer." composite_key(block, transaction_index, log_index) raw = Required(bytes, lazy=True) + "The raw bytes of the token transfer." @cached_property def decoded(self) -> structs.TokenTransfer: - return json.decode(self.raw, type=structs.TokenTransfer) + """ + Decodes the raw token transfer data into a :class:`structs.TokenTransfer` object. + + Example: + >>> token_transfer = TokenTransfer(...) + >>> decoded_transfer = token_transfer.decoded + >>> isinstance(decoded_transfer, structs.TokenTransfer) + True + """ + return json.decode(self.raw, type=structs.TokenTransfer) \ No newline at end of file diff --git a/eth_portfolio/_decimal.py b/eth_portfolio/_decimal.py index 91e9a32c..afe3ca52 100644 --- a/eth_portfolio/_decimal.py +++ b/eth_portfolio/_decimal.py @@ -9,7 +9,8 @@ class Decimal(decimal.Decimal): - """A subclass of :class:`decimal.Decimal` with additional functionality. + """ + A subclass of :class:`decimal.Decimal` with additional functionality. This class extends the :class:`decimal.Decimal` class to provide additional methods for JSON serialization and arithmetic operations. @@ -19,7 +20,8 @@ class Decimal(decimal.Decimal): """ def jsonify(self) -> Union[str, int]: - """Converts the Decimal to a JSON-friendly format. + """ + Converts the Decimal to a JSON-friendly format. This method attempts to represent the Decimal in the most compact form possible for JSON serialization. It returns an integer if the Decimal @@ -98,7 +100,8 @@ def __rfloordiv__(self, other): class Gwei(Decimal): - """A subclass of :class:`Decimal` representing Gwei values. + """ + A subclass of :class:`Decimal` representing Gwei values. This class provides a property to convert Gwei to Wei. @@ -109,7 +112,8 @@ class Gwei(Decimal): @property def as_wei(self) -> Wei: - """Converts the Gwei value to Wei. + """ + Converts the Gwei value to Wei. This property multiplies the Gwei value by 10^9 to convert it to Wei. diff --git a/eth_portfolio/_exceptions.py b/eth_portfolio/_exceptions.py index df50b288..b5fb6626 100644 --- a/eth_portfolio/_exceptions.py +++ b/eth_portfolio/_exceptions.py @@ -8,7 +8,8 @@ class BlockRangeIsCached(Exception): - """Exception raised when a block range is already cached. + """ + Exception raised when a block range is already cached. This exception is used to indicate that the requested block range has already been loaded into memory and does not need to be fetched again. @@ -20,16 +21,17 @@ class BlockRangeIsCached(Exception): class BlockRangeOutOfBounds(Exception): - """Exception raised when a block range is out of bounds. + """ + Exception raised when a block range is out of bounds. This exception is used to indicate that the requested block range is outside the bounds of the cached data. It provides a method to load the remaining ledger entries that are out of bounds. Args: - start_block (Block): The starting block number of the out-of-bounds range. - end_block (Block): The ending block number of the out-of-bounds range. - ledger (AddressLedgerBase): The ledger associated with the block range. + start_block: The starting block number of the out-of-bounds range. + end_block: The ending block number of the out-of-bounds range. + ledger: The ledger associated with the block range. Examples: >>> raise BlockRangeOutOfBounds(100, 200, ledger) @@ -44,7 +46,8 @@ def __init__(self, start_block: Block, end_block: Block, ledger: "AddressLedgerB self.end_block = end_block async def load_remaining(self) -> None: - """Asynchronously loads the remaining ledger entries that are out of bounds. + """ + Asynchronously loads the remaining ledger entries that are out of bounds. This method fetches the ledger entries for the blocks that are outside the cached range, ensuring that the entire requested block range is covered. diff --git a/eth_portfolio/_loaders/utils.py b/eth_portfolio/_loaders/utils.py index 06059be4..8fcd2c3d 100644 --- a/eth_portfolio/_loaders/utils.py +++ b/eth_portfolio/_loaders/utils.py @@ -10,7 +10,8 @@ @alru_cache(maxsize=None, ttl=60 * 60) @stuck_coro_debugger async def _get_transaction_receipt(txhash: HexStr) -> msgspec.Raw: - """Fetches the transaction receipt for a given transaction hash. + """ + Fetches the transaction receipt for a given transaction hash. This function retrieves the transaction receipt from the Ethereum network using the provided transaction hash. It utilizes caching to store results @@ -19,7 +20,7 @@ async def _get_transaction_receipt(txhash: HexStr) -> msgspec.Raw: in case of failures and to debug if the coroutine gets stuck. Args: - txhash (HexStr): The transaction hash for which to retrieve the receipt. + txhash: The transaction hash for which to retrieve the receipt. Returns: msgspec.Raw: The raw transaction receipt data. @@ -39,7 +40,8 @@ async def _get_transaction_receipt(txhash: HexStr) -> msgspec.Raw: ) get_transaction_receipt = SmartProcessingQueue(_get_transaction_receipt, 5000) -"""A queue for processing transaction receipt requests. +""" +A queue for processing transaction receipt requests. This queue manages the processing of transaction receipt requests, allowing up to 5000 concurrent requests. It uses the `_get_transaction_receipt` function diff --git a/eth_portfolio/_ydb/token_transfers.py b/eth_portfolio/_ydb/token_transfers.py index 3d129c66..e0c2790b 100644 --- a/eth_portfolio/_ydb/token_transfers.py +++ b/eth_portfolio/_ydb/token_transfers.py @@ -34,7 +34,24 @@ class _TokenTransfers(ProcessedEvents["Task[TokenTransfer]"]): - """A helper mixin that contains all logic for fetching token transfers for a particular wallet address""" + """ + A helper mixin that contains all logic for fetching token transfers for a particular wallet address. + + Attributes: + address: The wallet address for which token transfers are fetched. + _load_prices: Indicates whether to load prices for the token transfers. + + Examples: + Fetching token transfers for a specific address: + + >>> transfers = _TokenTransfers(address="0x123...", from_block=0, load_prices=True) + >>> async for transfer in transfers.yield_thru_block(1000000): + ... print(transfer) + + See Also: + - :class:`~eth_portfolio.structs.TokenTransfer`: For the structure of a token transfer. + - :func:`~eth_portfolio._loaders.load_token_transfer`: For loading token transfer data. + """ __slots__ = "address", "_load_prices" @@ -48,10 +65,24 @@ def __repr__(self) -> str: @property @abstractmethod - def _topics(self) -> List: ... + def _topics(self) -> List: + pass @ASyncIterator.wrap # type: ignore [call-overload] async def yield_thru_block(self, block) -> AsyncIterator["Task[TokenTransfer]"]: + """ + Yield token transfers up to a specified block. + + Args: + block: The block number up to which token transfers are yielded. + + Yields: + Token transfers as :class:`~asyncio.Task` objects. + + Examples: + >>> async for transfer in transfers.yield_thru_block(1000000): + ... print(transfer) + """ if not _logger_is_enabled_for(DEBUG): async for task in self._objects_thru(block=block): yield task @@ -68,6 +99,15 @@ async def yield_thru_block(self, block) -> AsyncIterator["Task[TokenTransfer]"]: _logger_log(DEBUG, "%s yield thru %s complete", (self, block)) async def _extend(self, objs: List[evmspec.Log]) -> None: + """ + Extend the list of token transfers with new logs. + + Args: + objs: A list of :class:`~evmspec.Log` objects representing token transfer logs. + + Examples: + >>> await transfers._extend(logs) + """ shitcoins = SHITCOINS.get(chain.id, set()) append_loader_task = self._objects.append done = 0 @@ -98,7 +138,19 @@ def _done_callback(self, task: Task) -> None: class InboundTokenTransfers(_TokenTransfers): - """A container that fetches and iterates over all inbound token transfers for a particular wallet address""" + """ + A container that fetches and iterates over all inbound token transfers for a particular wallet address. + + Examples: + Fetching inbound token transfers for a specific address: + + >>> inbound_transfers = InboundTokenTransfers(address="0x123...", from_block=0, load_prices=True) + >>> async for transfer in inbound_transfers.yield_thru_block(1000000): + ... print(transfer) + + See Also: + - :class:`~eth_portfolio.structs.TokenTransfer`: For the structure of a token transfer. + """ @property def _topics(self) -> List: @@ -106,7 +158,19 @@ def _topics(self) -> List: class OutboundTokenTransfers(_TokenTransfers): - """A container that fetches and iterates over all outbound token transfers for a particular wallet address""" + """ + A container that fetches and iterates over all outbound token transfers for a particular wallet address. + + Examples: + Fetching outbound token transfers for a specific address: + + >>> outbound_transfers = OutboundTokenTransfers(address="0x123...", from_block=0, load_prices=True) + >>> async for transfer in outbound_transfers.yield_thru_block(1000000): + ... print(transfer) + + See Also: + - :class:`~eth_portfolio.structs.TokenTransfer`: For the structure of a token transfer. + """ @property def _topics(self) -> List: @@ -116,7 +180,22 @@ def _topics(self) -> List: class TokenTransfers(ASyncIterable[TokenTransfer]): """ A container that fetches and iterates over all token transfers for a particular wallet address. - NOTE: These do not come back in chronologcal order. + + Attributes: + transfers_in: Container for inbound token transfers. + transfers_out: Container for outbound token transfers. + + Examples: + Fetching all token transfers for a specific address: + + >>> token_transfers = TokenTransfers(address="0x123...", from_block=0, load_prices=True) + >>> async for transfer in token_transfers: + ... print(transfer) + + See Also: + - :class:`~eth_portfolio.structs.TokenTransfer`: For the structure of a token transfer. + - :class:`~InboundTokenTransfers`: For fetching inbound token transfers. + - :class:`~OutboundTokenTransfers`: For fetching outbound token transfers. """ def __init__(self, address: Address, from_block: int, load_prices: bool = False): @@ -124,13 +203,36 @@ def __init__(self, address: Address, from_block: int, load_prices: bool = False) self.transfers_out = OutboundTokenTransfers(address, from_block, load_prices=load_prices) async def __aiter__(self): + """ + Asynchronously iterate over all token transfers. + + Yields: + Token transfers as :class:`~eth_portfolio.structs.TokenTransfer` objects. + + Examples: + >>> async for transfer in token_transfers: + ... print(transfer) + """ async for transfer in self.yield_thru_block(await dank_mids.eth.block_number): yield transfer def yield_thru_block(self, block: int) -> ASyncIterator["Task[TokenTransfer]"]: + """ + Yield token transfers up to a specified block. + + Args: + block: The block number up to which token transfers are yielded. + + Yields: + Token transfers as :class:`~asyncio.Task` objects. + + Examples: + >>> async for transfer in token_transfers.yield_thru_block(1000000): + ... print(transfer) + """ return ASyncIterator( as_yielded( self.transfers_in.yield_thru_block(block), self.transfers_out.yield_thru_block(block), ) - ) + ) \ No newline at end of file diff --git a/eth_portfolio/protocols/__init__.py b/eth_portfolio/protocols/__init__.py index ef40aa8c..7c89c85c 100644 --- a/eth_portfolio/protocols/__init__.py +++ b/eth_portfolio/protocols/__init__.py @@ -15,6 +15,47 @@ @a_sync.future async def balances(address: Address, block: Optional[Block] = None) -> RemoteTokenBalances: + """ + Fetches token balances for a given address across various protocols. + + This function retrieves the token balances for a specified Ethereum address + at a given block across all available protocols. It is decorated with + :func:`a_sync.future`, allowing it to be used in both synchronous and + asynchronous contexts. + + If no protocols are available, the function returns an empty + :class:`~eth_portfolio.typing.RemoteTokenBalances` object. + + Args: + address: The Ethereum address for which to fetch balances. + block: The block number at which to fetch balances. + If not provided, the latest block is used. + + Examples: + Fetching balances asynchronously: + + >>> from eth_portfolio.protocols import balances + >>> address = "0x1234567890abcdef1234567890abcdef12345678" + >>> block = 12345678 + >>> remote_balances = await balances(address, block) + >>> print(remote_balances) + + Fetching balances synchronously: + + >>> remote_balances = balances(address, block).result() + >>> print(remote_balances) + + The function constructs a dictionary `data` initialized with protocol names + and their corresponding balances. The `protocol_balances` variable is an + asynchronous mapping of protocol names to their respective balance data, + which is then used in an asynchronous comprehension to construct the + dictionary `data`. This dictionary is subsequently used to initialize the + :class:`~eth_portfolio.typing.RemoteTokenBalances` object. + + See Also: + - :class:`~eth_portfolio.typing.RemoteTokenBalances`: For more information on the return type. + - :func:`a_sync.future`: For more information on the decorator used. + """ if not protocols: return RemoteTokenBalances(block=block) protocol_balances = a_sync.map( @@ -26,4 +67,4 @@ async def balances(address: Address, block: Optional[Block] = None) -> RemoteTok async for protocol, protocol_balances in protocol_balances if protocol_balances is not None } - return RemoteTokenBalances(data, block=block) + return RemoteTokenBalances(data, block=block) \ No newline at end of file diff --git a/eth_portfolio/protocols/lending/_base.py b/eth_portfolio/protocols/lending/_base.py index 58a7e007..c62053f5 100644 --- a/eth_portfolio/protocols/lending/_base.py +++ b/eth_portfolio/protocols/lending/_base.py @@ -11,10 +11,19 @@ class LendingProtocol(metaclass=abc.ABCMeta): """ Subclass this class for any protocol that maintains a debt balance for a user but doesn't maintain collateral internally. - Example: Aave, because the user holds on to their collateral in the form of erc-20 aTokens. + Example: Aave, because the user holds on to their collateral in the form of ERC-20 aTokens. You must define the following async method: - `_debt_async(self, address: Address, block: Optional[Block] = None)` + `_debt(self, address: Address, block: Optional[Block] = None)` + + Example: + >>> class AaveProtocol(LendingProtocol): + ... async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenBalances: + ... # Implementation for fetching debt from Aave + ... pass + + See Also: + - :class:`LendingProtocolWithLockedCollateral` """ @a_sync.future @@ -22,7 +31,8 @@ async def debt(self, address: Address, block: Optional[Block] = None) -> TokenBa return await self._debt(address, block) # type: ignore @abc.abstractmethod - async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenBalances: ... + async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenBalances: + ... class LendingProtocolWithLockedCollateral(LendingProtocol, ProtocolABC): @@ -31,6 +41,18 @@ class LendingProtocolWithLockedCollateral(LendingProtocol, ProtocolABC): Example: Maker, because collateral is locked up inside of Maker's smart contracts. You must define the following async methods: - - `_debt_async(self, address: Address, block: Optional[Block] = None)` - - `_balances_async(self, address: Address, block: Optional[Block] = None)` - """ + - `_debt(self, address: Address, block: Optional[Block] = None)` + - `_balances(self, address: Address, block: Optional[Block] = None)` + + Example: + >>> class MakerProtocol(LendingProtocolWithLockedCollateral): + ... async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenBalances: + ... # Implementation for fetching debt from Maker + ... pass + ... async def _balances(self, address: Address, block: Optional[Block] = None) -> TokenBalances: + ... # Implementation for fetching balances from Maker + ... pass + + See Also: + - :class:`LendingProtocol` + """ \ No newline at end of file diff --git a/eth_portfolio/protocols/lending/compound.py b/eth_portfolio/protocols/lending/compound.py index 87d32863..9f064fe8 100644 --- a/eth_portfolio/protocols/lending/compound.py +++ b/eth_portfolio/protocols/lending/compound.py @@ -30,6 +30,27 @@ class Compound(LendingProtocol): @alru_cache(ttl=300) @stuck_coro_debugger async def underlyings(self) -> List[ERC20]: + """ + Fetches the underlying ERC20 tokens for all Compound markets. + + This method gathers all markets from the Compound protocol's trollers + and filters out those that do not have a `borrowBalanceStored` attribute + by using the :func:`_get_contract` function. It then separates markets + into those that use the native gas token and those that have an underlying + ERC20 token, fetching the underlying tokens accordingly. + + Returns: + A list of :class:`~y.classes.common.ERC20` instances representing the underlying tokens. + + Examples: + >>> compound = Compound() + >>> underlyings = await compound.underlyings() + >>> for token in underlyings: + ... print(token.symbol) + + See Also: + - :meth:`markets`: To get the list of market contracts. + """ all_markets: List[List[CToken]] = await gather( *[comp.markets for comp in compound.trollers.values()] ) @@ -58,10 +79,50 @@ async def underlyings(self) -> List[ERC20]: @a_sync.future @stuck_coro_debugger async def markets(self) -> List[Contract]: + """ + Fetches the list of market contracts for the Compound protocol. + + This method ensures that the underlying tokens are fetched first, + as they are used to determine the markets. + + Returns: + A list of :class:`~brownie.network.contract.Contract` instances representing the markets. + + Examples: + >>> compound = Compound() + >>> markets = await compound.markets() + >>> for market in markets: + ... print(market.address) + + See Also: + - :meth:`underlyings`: To get the list of underlying tokens. + """ await self.underlyings() return self._markets async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenBalances: + """ + Calculates the debt balance for a given address in the Compound protocol. + + This method fetches the borrow balance for each market and calculates + the debt in terms of the underlying token and its USD value. + + Args: + address: The Ethereum address to calculate the debt for. + block: The block number to query. Defaults to the latest block. + + Returns: + A :class:`~eth_portfolio.typing.TokenBalances` object representing the debt balances. + + Examples: + >>> compound = Compound() + >>> debt_balances = await compound._debt("0x1234567890abcdef1234567890abcdef12345678") + >>> for token, balance in debt_balances.items(): + ... print(f"Token: {token}, Balance: {balance.balance}, USD Value: {balance.usd_value}") + + See Also: + - :meth:`debt`: Public method to get the debt balances. + """ # if ypricemagic doesn't support any Compound forks on current chain if len(compound.trollers) == 0: return TokenBalances(block=block) @@ -93,9 +154,31 @@ async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenB async def _borrow_balance_stored( market: Contract, address: Address, block: Optional[Block] = None ) -> Optional[int]: + """ + Fetches the stored borrow balance for a given market and address. + + This function attempts to call the `borrowBalanceStored` method on the + market contract. If the call reverts, it returns None. + + Args: + market: The market contract to query. + address: The Ethereum address to fetch the borrow balance for. + block: The block number to query. Defaults to the latest block. + + Returns: + The stored borrow balance as an integer, or None if the call reverts. + + Examples: + >>> market = Contract.from_explorer("0x1234567890abcdef1234567890abcdef12345678") + >>> balance = await _borrow_balance_stored(market, "0xabcdefabcdefabcdefabcdefabcdefabcdef") + >>> print(balance) + + See Also: + - :meth:`_debt`: Uses this function to calculate debt balances. + """ try: return await market.borrowBalanceStored.coroutine(str(address), block_identifier=block) except ValueError as e: if str(e) != "No data was returned - the call likely reverted": raise - return None + return None \ No newline at end of file diff --git a/setup.py b/setup.py index 7bf5eca7..a8b22600 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,37 @@ +""" +Installation +------------ +To install the `eth-portfolio` package, you should start with a fresh virtual environment. +Due to the use of :mod:`setuptools_scm` for versioning, it is recommended to clone the repository first +to ensure the version can be determined correctly. + +The `setup.py` file automatically handles the installation of :mod:`setuptools_scm` and :mod:`cython`, +so you do not need to install them manually before running the setup process. Additionally, +the `requirements.txt` file is used to specify additional dependencies that are installed via +the `install_requires` parameter. Note that the last line of `requirements.txt` is intentionally excluded +from installation, so ensure that any necessary dependency is not placed on the last line. + +Example: + .. code-block:: bash + + git clone https://github.com/BobTheBuidler/eth-portfolio.git + cd eth-portfolio + pip install . + +If you encounter issues with :mod:`PyYaml` and :mod:`Cython`, you can resolve them by installing specific versions: + +Example: + .. code-block:: bash + + pip install wheel + pip install --no-build-isolation "Cython<3" "pyyaml==5.4.1" + pip install . + +See Also: + - :mod:`setuptools_scm`: For more information on versioning with setuptools_scm. + - :mod:`cython`: For more information on Cython. + - :mod:`requirements.txt`: For more information on managing dependencies. +""" from setuptools import find_packages, setup # type: ignore with open("requirements.txt", "r") as f: diff --git a/tests/conftest.py b/tests/conftest.py index 3f9e6b25..417e3532 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,31 @@ +""" +This module configures the testing environment for the project by ensuring +that the Brownie network is connected using the specified network. It also +modifies the system path to include the current directory, allowing for +importing modules from the project root. + +Environment Variables: + PYTEST_NETWORK: The name of the Brownie network to use for testing. This + environment variable must be set before running the tests. + +Raises: + ValueError: If the PYTEST_NETWORK environment variable is not set. + +Examples: + To set the PYTEST_NETWORK environment variable and run tests: + + .. code-block:: bash + + export PYTEST_NETWORK=mainnet-fork + pytest + + This will connect to the specified Brownie network and run the tests. + +See Also: + - :mod:`brownie.network`: For more information on managing network connections with Brownie. + - :mod:`os`: For more information on interacting with the operating system. + - :mod:`sys`: For more information on the system-specific parameters and functions. +""" import os import sys diff --git a/tests/protocols/test_external.py b/tests/protocols/test_external.py index f5aa05d7..4b78884d 100644 --- a/tests/protocols/test_external.py +++ b/tests/protocols/test_external.py @@ -21,7 +21,8 @@ class MockProtocolB(AsyncMock): @patch("a_sync.map") @pytest.mark.asyncio async def test_balances_no_protocols(mock_map): - """Test the `balances` function with no protocols. + """ + Test the `balances` function with no protocols. This test verifies that when there are no protocols in the `protocols.protocols` list, the `balances` function returns an @@ -44,7 +45,8 @@ async def test_balances_no_protocols(mock_map): @pytest.mark.asyncio async def test_balances_with_protocols(): - """Test the `balances` function with multiple protocols. + """ + Test the `balances` function with multiple protocols. This test verifies that when there are multiple protocols in the `protocols.protocols` list, the `balances` function correctly @@ -80,7 +82,8 @@ async def test_balances_with_protocols(): @pytest.mark.asyncio async def test_balances_with_protocols_and_block(): - """Test the `balances` function with protocols and a specific block. + """ + Test the `balances` function with protocols and a specific block. This test verifies that when there are multiple protocols in the `protocols.protocols` list and a specific block is provided, the From 1c55f5d4a310aab3fdf9f98afcde7cf6dd5e8351 Mon Sep 17 00:00:00 2001 From: nvyncke Date: Wed, 26 Feb 2025 15:07:49 -0500 Subject: [PATCH 05/45] chore: auto-update documentation eth_portfolio/_decimal.py --- eth_portfolio/_decimal.py | 77 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/eth_portfolio/_decimal.py b/eth_portfolio/_decimal.py index afe3ca52..979b789f 100644 --- a/eth_portfolio/_decimal.py +++ b/eth_portfolio/_decimal.py @@ -10,10 +10,11 @@ class Decimal(decimal.Decimal): """ - A subclass of :class:`decimal.Decimal` with additional functionality. + A subclass of :class:`decimal.Decimal` with additional functionality for JSON serialization. - This class extends the :class:`decimal.Decimal` class to provide additional - methods for JSON serialization and arithmetic operations. + This class extends the :class:`decimal.Decimal` class to provide an additional + method for JSON serialization. It also overrides arithmetic operations to ensure + that the result is of type :class:`Decimal`. See Also: - :class:`decimal.Decimal` @@ -69,33 +70,103 @@ def jsonify(self) -> Union[str, int]: return string def __add__(self, other): + """ + Adds two Decimal values, ensuring the result is of type :class:`Decimal`. + + Examples: + >>> Decimal('1.1') + Decimal('2.2') + Decimal('3.3') + """ return type(self)(super().__add__(other)) def __radd__(self, other): + """ + Adds two Decimal values with reflected operands, ensuring the result is of type :class:`Decimal`. + + Examples: + >>> Decimal('1.1').__radd__(Decimal('2.2')) + Decimal('3.3') + """ return type(self)(super().__radd__(other)) def __sub__(self, other): + """ + Subtracts two Decimal values, ensuring the result is of type :class:`Decimal`. + + Examples: + >>> Decimal('3.3') - Decimal('1.1') + Decimal('2.2') + """ return type(self)(super().__sub__(other)) def __rsub__(self, other): + """ + Subtracts two Decimal values with reflected operands, ensuring the result is of type :class:`Decimal`. + + Examples: + >>> Decimal('3.3').__rsub__(Decimal('1.1')) + Decimal('-2.2') + """ return type(self)(super().__rsub__(other)) def __mul__(self, other): + """ + Multiplies two Decimal values, ensuring the result is of type :class:`Decimal`. + + Examples: + >>> Decimal('2') * Decimal('3') + Decimal('6') + """ return type(self)(super().__mul__(other)) def __rmul__(self, other): + """ + Multiplies two Decimal values with reflected operands, ensuring the result is of type :class:`Decimal`. + + Examples: + >>> Decimal('2').__rmul__(Decimal('3')) + Decimal('6') + """ return type(self)(super().__rmul__(other)) def __truediv__(self, other): + """ + Divides two Decimal values, ensuring the result is of type :class:`Decimal`. + + Examples: + >>> Decimal('6') / Decimal('3') + Decimal('2') + """ return type(self)(super().__truediv__(other)) def __rtruediv__(self, other): + """ + Divides two Decimal values with reflected operands, ensuring the result is of type :class:`Decimal`. + + Examples: + >>> Decimal('6').__rtruediv__(Decimal('3')) + Decimal('0.5') + """ return type(self)(super().__rtruediv__(other)) def __floordiv__(self, other): + """ + Performs floor division on two Decimal values, ensuring the result is of type :class:`Decimal`. + + Examples: + >>> Decimal('7') // Decimal('3') + Decimal('2') + """ return type(self)(super().__floordiv__(other)) def __rfloordiv__(self, other): + """ + Performs floor division on two Decimal values with reflected operands, ensuring the result is of type :class:`Decimal`. + + Examples: + >>> Decimal('7').__rfloordiv__(Decimal('3')) + Decimal('0') + """ return type(self)(super().__rfloordiv__(other)) From 9e5108bc0ee3e07c9418bceafc6c62d0955f4bfb Mon Sep 17 00:00:00 2001 From: nvyncke Date: Wed, 26 Feb 2025 15:17:33 -0500 Subject: [PATCH 06/45] chore: auto-update documentation eth_portfolio/protocols/__init__.py --- eth_portfolio/protocols/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eth_portfolio/protocols/__init__.py b/eth_portfolio/protocols/__init__.py index 7c89c85c..cc1fb0bc 100644 --- a/eth_portfolio/protocols/__init__.py +++ b/eth_portfolio/protocols/__init__.py @@ -45,9 +45,9 @@ async def balances(address: Address, block: Optional[Block] = None) -> RemoteTok >>> remote_balances = balances(address, block).result() >>> print(remote_balances) - The function constructs a dictionary `data` initialized with protocol names + The function constructs a dictionary `data` initialized with protocol class names and their corresponding balances. The `protocol_balances` variable is an - asynchronous mapping of protocol names to their respective balance data, + asynchronous mapping of protocols to their respective balance data, which is then used in an asynchronous comprehension to construct the dictionary `data`. This dictionary is subsequently used to initialize the :class:`~eth_portfolio.typing.RemoteTokenBalances` object. From 2fe32f518634130867ecc7123f4032ced74db437 Mon Sep 17 00:00:00 2001 From: nvyncke Date: Wed, 26 Feb 2025 15:17:59 -0500 Subject: [PATCH 07/45] chore: auto-update documentation eth_portfolio/_decimal.py --- eth_portfolio/_decimal.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/eth_portfolio/_decimal.py b/eth_portfolio/_decimal.py index 979b789f..8dc15533 100644 --- a/eth_portfolio/_decimal.py +++ b/eth_portfolio/_decimal.py @@ -31,7 +31,9 @@ def jsonify(self) -> Union[str, int]: Trailing zeros in the string representation are removed. If the scientific notation is equivalent to the original Decimal and shorter - than the standard string representation, it is returned. + than the standard string representation, it is returned. If the integer + representation is shorter than the scientific notation plus two characters, + the integer is returned. Raises: Exception: If the resulting string representation is empty. @@ -43,6 +45,8 @@ def jsonify(self) -> Union[str, int]: 123000 >>> Decimal('0.000123').jsonify() '1.23E-4' + >>> Decimal('1000000').jsonify() + 1000000 """ string = str(self) integer = int(self) From cb4b7c89030aab2df583e3d758725a3d14261038 Mon Sep 17 00:00:00 2001 From: nvyncke Date: Wed, 26 Feb 2025 15:19:14 -0500 Subject: [PATCH 08/45] chore: auto-update documentation tests/conftest.py --- tests/conftest.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 2d2bb7b9..4a6a77ff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,8 @@ """ -This module configures the testing environment for the project by ensuring -that the Brownie network is connected using the specified network. It also -modifies the system path to include the current directory, allowing for -importing modules from the project root. +This module connects to the Brownie network specified by the `PYTEST_NETWORK` +environment variable if it is not already connected. It also modifies the system +path to include the current directory, allowing for importing modules from the +project root. Environment Variables: PYTEST_NETWORK: The name of the Brownie network to use for testing. This @@ -41,4 +41,4 @@ ) if not network.is_connected(): - network.connect(brownie_network) + network.connect(brownie_network) \ No newline at end of file From ade0ab8772789d0f33eabdc934510865866ee28e Mon Sep 17 00:00:00 2001 From: nvyncke Date: Wed, 26 Feb 2025 15:19:25 -0500 Subject: [PATCH 09/45] chore: auto-update documentation eth_portfolio/protocols/lending/compound.py --- eth_portfolio/protocols/lending/compound.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/eth_portfolio/protocols/lending/compound.py b/eth_portfolio/protocols/lending/compound.py index 9f064fe8..91ed007e 100644 --- a/eth_portfolio/protocols/lending/compound.py +++ b/eth_portfolio/protocols/lending/compound.py @@ -35,9 +35,10 @@ async def underlyings(self) -> List[ERC20]: This method gathers all markets from the Compound protocol's trollers and filters out those that do not have a `borrowBalanceStored` attribute - by using the :func:`_get_contract` function. It then separates markets - into those that use the native gas token and those that have an underlying - ERC20 token, fetching the underlying tokens accordingly. + by using the :func:`hasattr` function directly on the result of + :func:`_get_contract`. It then separates markets into those that use + the native gas token and those that have an underlying ERC20 token, + fetching the underlying tokens accordingly. Returns: A list of :class:`~y.classes.common.ERC20` instances representing the underlying tokens. From a3b6e6f37d4ed62f7c58ec5f88da17ef99ad001d Mon Sep 17 00:00:00 2001 From: nvyncke Date: Wed, 26 Feb 2025 15:23:48 -0500 Subject: [PATCH 10/45] chore: auto-update documentation eth_portfolio/protocols/__init__.py --- eth_portfolio/protocols/__init__.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/eth_portfolio/protocols/__init__.py b/eth_portfolio/protocols/__init__.py index cc1fb0bc..5c6adfbe 100644 --- a/eth_portfolio/protocols/__init__.py +++ b/eth_portfolio/protocols/__init__.py @@ -16,7 +16,7 @@ @a_sync.future async def balances(address: Address, block: Optional[Block] = None) -> RemoteTokenBalances: """ - Fetches token balances for a given address across various protocols. + Fetch token balances for a given address across various protocols. This function retrieves the token balances for a specified Ethereum address at a given block across all available protocols. It is decorated with @@ -27,8 +27,8 @@ async def balances(address: Address, block: Optional[Block] = None) -> RemoteTok :class:`~eth_portfolio.typing.RemoteTokenBalances` object. Args: - address: The Ethereum address for which to fetch balances. - block: The block number at which to fetch balances. + address (Address): The Ethereum address for which to fetch balances. + block (Optional[Block]): The block number at which to fetch balances. If not provided, the latest block is used. Examples: @@ -46,10 +46,10 @@ async def balances(address: Address, block: Optional[Block] = None) -> RemoteTok >>> print(remote_balances) The function constructs a dictionary `data` initialized with protocol class names - and their corresponding balances. The `protocol_balances` variable is an - asynchronous mapping of protocols to their respective balance data, - which is then used in an asynchronous comprehension to construct the - dictionary `data`. This dictionary is subsequently used to initialize the + and their corresponding balances. The `protocol_balances` variable is a + mapping of protocols to their respective balance data, which is then used + in an asynchronous comprehension to construct the dictionary `data`. This + dictionary is subsequently used to initialize the :class:`~eth_portfolio.typing.RemoteTokenBalances` object. See Also: From 489776101f3297ac4dfc0a5afc08e80cd4a53186 Mon Sep 17 00:00:00 2001 From: nvyncke Date: Wed, 26 Feb 2025 15:24:00 -0500 Subject: [PATCH 11/45] chore: auto-update documentation eth_portfolio/_decimal.py --- eth_portfolio/_decimal.py | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/eth_portfolio/_decimal.py b/eth_portfolio/_decimal.py index 8dc15533..ebb185f6 100644 --- a/eth_portfolio/_decimal.py +++ b/eth_portfolio/_decimal.py @@ -22,17 +22,17 @@ class Decimal(decimal.Decimal): def jsonify(self) -> Union[str, int]: """ - Converts the Decimal to a JSON-friendly format. + Converts the :class:`Decimal` to a JSON-friendly format. - This method attempts to represent the Decimal in the most compact form - possible for JSON serialization. It returns an integer if the Decimal + This method attempts to represent the :class:`Decimal` in the most compact form + possible for JSON serialization. It returns an integer if the :class:`Decimal` is equivalent to an integer, otherwise it returns a string in either standard or scientific notation, depending on which is shorter. Trailing zeros in the string representation are removed. If the - scientific notation is equivalent to the original Decimal and shorter + scientific notation is equivalent to the original :class:`Decimal` and shorter than the standard string representation, it is returned. If the integer - representation is shorter than the scientific notation plus two characters, + representation is shorter than or equal to the scientific notation plus two characters, the integer is returned. Raises: @@ -47,6 +47,10 @@ def jsonify(self) -> Union[str, int]: '1.23E-4' >>> Decimal('1000000').jsonify() 1000000 + + See Also: + - :meth:`Decimal.__str__`: For converting :class:`Decimal` to a string. + - :meth:`Decimal.__int__`: For converting :class:`Decimal` to an integer. """ string = str(self) integer = int(self) @@ -75,7 +79,7 @@ def jsonify(self) -> Union[str, int]: def __add__(self, other): """ - Adds two Decimal values, ensuring the result is of type :class:`Decimal`. + Adds two :class:`Decimal` values, ensuring the result is of type :class:`Decimal`. Examples: >>> Decimal('1.1') + Decimal('2.2') @@ -85,7 +89,7 @@ def __add__(self, other): def __radd__(self, other): """ - Adds two Decimal values with reflected operands, ensuring the result is of type :class:`Decimal`. + Adds two :class:`Decimal` values with reflected operands, ensuring the result is of type :class:`Decimal`. Examples: >>> Decimal('1.1').__radd__(Decimal('2.2')) @@ -95,7 +99,7 @@ def __radd__(self, other): def __sub__(self, other): """ - Subtracts two Decimal values, ensuring the result is of type :class:`Decimal`. + Subtracts two :class:`Decimal` values, ensuring the result is of type :class:`Decimal`. Examples: >>> Decimal('3.3') - Decimal('1.1') @@ -105,7 +109,7 @@ def __sub__(self, other): def __rsub__(self, other): """ - Subtracts two Decimal values with reflected operands, ensuring the result is of type :class:`Decimal`. + Subtracts two :class:`Decimal` values with reflected operands, ensuring the result is of type :class:`Decimal`. Examples: >>> Decimal('3.3').__rsub__(Decimal('1.1')) @@ -115,7 +119,7 @@ def __rsub__(self, other): def __mul__(self, other): """ - Multiplies two Decimal values, ensuring the result is of type :class:`Decimal`. + Multiplies two :class:`Decimal` values, ensuring the result is of type :class:`Decimal`. Examples: >>> Decimal('2') * Decimal('3') @@ -125,7 +129,7 @@ def __mul__(self, other): def __rmul__(self, other): """ - Multiplies two Decimal values with reflected operands, ensuring the result is of type :class:`Decimal`. + Multiplies two :class:`Decimal` values with reflected operands, ensuring the result is of type :class:`Decimal`. Examples: >>> Decimal('2').__rmul__(Decimal('3')) @@ -135,7 +139,7 @@ def __rmul__(self, other): def __truediv__(self, other): """ - Divides two Decimal values, ensuring the result is of type :class:`Decimal`. + Divides two :class:`Decimal` values, ensuring the result is of type :class:`Decimal`. Examples: >>> Decimal('6') / Decimal('3') @@ -145,7 +149,7 @@ def __truediv__(self, other): def __rtruediv__(self, other): """ - Divides two Decimal values with reflected operands, ensuring the result is of type :class:`Decimal`. + Divides two :class:`Decimal` values with reflected operands, ensuring the result is of type :class:`Decimal`. Examples: >>> Decimal('6').__rtruediv__(Decimal('3')) @@ -155,7 +159,7 @@ def __rtruediv__(self, other): def __floordiv__(self, other): """ - Performs floor division on two Decimal values, ensuring the result is of type :class:`Decimal`. + Performs floor division on two :class:`Decimal` values, ensuring the result is of type :class:`Decimal`. Examples: >>> Decimal('7') // Decimal('3') @@ -165,7 +169,7 @@ def __floordiv__(self, other): def __rfloordiv__(self, other): """ - Performs floor division on two Decimal values with reflected operands, ensuring the result is of type :class:`Decimal`. + Performs floor division on two :class:`Decimal` values with reflected operands, ensuring the result is of type :class:`Decimal`. Examples: >>> Decimal('7').__rfloordiv__(Decimal('3')) From 09f5de533dd2027e387c4c38587cb5206edfe605 Mon Sep 17 00:00:00 2001 From: nvyncke Date: Wed, 26 Feb 2025 15:24:26 -0500 Subject: [PATCH 12/45] chore: auto-update documentation setup.py --- setup.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 61d27dd6..be281bbb 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,5 @@ +from setuptools import find_packages, setup # type: ignore + """ Installation ------------ @@ -12,6 +14,8 @@ from installation, so ensure that any necessary dependency is not placed on the last line. Example: + Clone the repository and install the package: + .. code-block:: bash git clone https://github.com/BobTheBuidler/eth-portfolio.git @@ -21,6 +25,8 @@ If you encounter issues with :mod:`PyYaml` and :mod:`Cython`, you can resolve them by installing specific versions: Example: + Install specific versions of dependencies to resolve issues: + .. code-block:: bash pip install wheel @@ -32,7 +38,6 @@ - :mod:`cython`: For more information on Cython. - :mod:`requirements.txt`: For more information on managing dependencies. """ -from setuptools import find_packages, setup # type: ignore with open("requirements.txt", "r") as f: requirements = list(map(str.strip, f.read().split("\n")))[:-1] @@ -55,4 +60,4 @@ package_data={ "eth_portfolio": ["py.typed"], }, -) +) \ No newline at end of file From 53a9237a3bc2e384051e454508a22ed7bfa614c2 Mon Sep 17 00:00:00 2001 From: nvyncke Date: Wed, 26 Feb 2025 15:27:11 -0500 Subject: [PATCH 13/45] chore: auto-update documentation setup.py --- setup.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index be281bbb..e9379b5c 100644 --- a/setup.py +++ b/setup.py @@ -10,8 +10,9 @@ The `setup.py` file automatically handles the installation of :mod:`setuptools_scm` and :mod:`cython`, so you do not need to install them manually before running the setup process. Additionally, the `requirements.txt` file is used to specify additional dependencies that are installed via -the `install_requires` parameter. Note that the last line of `requirements.txt` is intentionally excluded -from installation, so ensure that any necessary dependency is not placed on the last line. +the `install_requires` parameter. Note that the last line of `requirements.txt` is typically an empty string +due to the split operation and is therefore excluded from installation. Ensure that any necessary dependency +is not placed on the last line before the empty line. Example: Clone the repository and install the package: From 88d390c067108e68d327ddb44015ea9ad27b2483 Mon Sep 17 00:00:00 2001 From: nvyncke Date: Wed, 26 Feb 2025 15:28:00 -0500 Subject: [PATCH 14/45] chore: auto-update documentation tests/conftest.py --- tests/conftest.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 4a6a77ff..fe39ac25 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,7 @@ """ This module connects to the Brownie network specified by the `PYTEST_NETWORK` -environment variable if it is not already connected. It also modifies the system -path to include the current directory, allowing for importing modules from the -project root. +environment variable. It also modifies the system path to include the current +directory, allowing for importing modules from the project root. Environment Variables: PYTEST_NETWORK: The name of the Brownie network to use for testing. This From 38fc24bc98b59440a68892225d065f62f3448520 Mon Sep 17 00:00:00 2001 From: nvyncke Date: Wed, 26 Feb 2025 15:28:55 -0500 Subject: [PATCH 15/45] chore: auto-update documentation eth_portfolio/protocols/__init__.py --- eth_portfolio/protocols/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/eth_portfolio/protocols/__init__.py b/eth_portfolio/protocols/__init__.py index 5c6adfbe..67fdd4df 100644 --- a/eth_portfolio/protocols/__init__.py +++ b/eth_portfolio/protocols/__init__.py @@ -46,9 +46,9 @@ async def balances(address: Address, block: Optional[Block] = None) -> RemoteTok >>> print(remote_balances) The function constructs a dictionary `data` initialized with protocol class names - and their corresponding balances. The `protocol_balances` variable is a - mapping of protocols to their respective balance data, which is then used - in an asynchronous comprehension to construct the dictionary `data`. This + and their corresponding balances. The `protocol_balances` variable is an iterable + of tuples containing each protocol and its respective balance data. This iterable + is used in an asynchronous comprehension to construct the dictionary `data`. This dictionary is subsequently used to initialize the :class:`~eth_portfolio.typing.RemoteTokenBalances` object. From 8ff6f70cf7964295ce5e6fec22429ab05a2c9712 Mon Sep 17 00:00:00 2001 From: nvyncke Date: Wed, 26 Feb 2025 15:29:19 -0500 Subject: [PATCH 16/45] chore: auto-update documentation eth_portfolio/_decimal.py --- eth_portfolio/_decimal.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/eth_portfolio/_decimal.py b/eth_portfolio/_decimal.py index ebb185f6..60a67712 100644 --- a/eth_portfolio/_decimal.py +++ b/eth_portfolio/_decimal.py @@ -30,10 +30,9 @@ def jsonify(self) -> Union[str, int]: standard or scientific notation, depending on which is shorter. Trailing zeros in the string representation are removed. If the - scientific notation is equivalent to the original :class:`Decimal` and shorter - than the standard string representation, it is returned. If the integer - representation is shorter than or equal to the scientific notation plus two characters, - the integer is returned. + scientific notation is shorter than the standard string representation, + it is returned. If the integer representation is shorter than or equal + to the scientific notation plus two characters, the integer is returned. Raises: Exception: If the resulting string representation is empty. @@ -70,7 +69,7 @@ def jsonify(self) -> Union[str, int]: while string[-1] == "0": string = string[:-1] - if type(self)(scientific_notation) == self and len(scientific_notation) < len(string): + if len(scientific_notation) < len(string): return scientific_notation if not string: From c1223ce8c2d0c9d424e3550a223593b7f1766cae Mon Sep 17 00:00:00 2001 From: nvyncke Date: Wed, 26 Feb 2025 15:31:01 -0500 Subject: [PATCH 17/45] chore: auto-update documentation eth_portfolio/_ydb/token_transfers.py --- eth_portfolio/_ydb/token_transfers.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/eth_portfolio/_ydb/token_transfers.py b/eth_portfolio/_ydb/token_transfers.py index e0c2790b..cb62e825 100644 --- a/eth_portfolio/_ydb/token_transfers.py +++ b/eth_portfolio/_ydb/token_transfers.py @@ -35,11 +35,7 @@ class _TokenTransfers(ProcessedEvents["Task[TokenTransfer]"]): """ - A helper mixin that contains all logic for fetching token transfers for a particular wallet address. - - Attributes: - address: The wallet address for which token transfers are fetched. - _load_prices: Indicates whether to load prices for the token transfers. + A helper mixin that contains all logic for fetching token transfers for a particular address. Examples: Fetching token transfers for a specific address: @@ -56,6 +52,14 @@ class _TokenTransfers(ProcessedEvents["Task[TokenTransfer]"]): __slots__ = "address", "_load_prices" def __init__(self, address: Address, from_block: int, load_prices: bool = False): + """ + Initialize a _TokenTransfers instance. + + Args: + address: The address for which token transfers are fetched. + from_block: The block number from which to start fetching token transfers. + load_prices: Indicates whether to load prices for the token transfers. + """ self.address = address self._load_prices = load_prices super().__init__(topics=self._topics, from_block=from_block) @@ -139,7 +143,7 @@ def _done_callback(self, task: Task) -> None: class InboundTokenTransfers(_TokenTransfers): """ - A container that fetches and iterates over all inbound token transfers for a particular wallet address. + A container that fetches and iterates over all inbound token transfers for a particular address. Examples: Fetching inbound token transfers for a specific address: @@ -159,7 +163,7 @@ def _topics(self) -> List: class OutboundTokenTransfers(_TokenTransfers): """ - A container that fetches and iterates over all outbound token transfers for a particular wallet address. + A container that fetches and iterates over all outbound token transfers for a particular address. Examples: Fetching outbound token transfers for a specific address: @@ -179,11 +183,7 @@ def _topics(self) -> List: class TokenTransfers(ASyncIterable[TokenTransfer]): """ - A container that fetches and iterates over all token transfers for a particular wallet address. - - Attributes: - transfers_in: Container for inbound token transfers. - transfers_out: Container for outbound token transfers. + A container that fetches and iterates over all token transfers for a particular address. Examples: Fetching all token transfers for a specific address: From 14841d8bf2d1a88987fe3f80cdd60d4926378687 Mon Sep 17 00:00:00 2001 From: nvyncke Date: Wed, 26 Feb 2025 15:35:35 -0500 Subject: [PATCH 18/45] chore: auto-update documentation setup.py --- setup.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/setup.py b/setup.py index e9379b5c..52e8c2eb 100644 --- a/setup.py +++ b/setup.py @@ -7,12 +7,13 @@ Due to the use of :mod:`setuptools_scm` for versioning, it is recommended to clone the repository first to ensure the version can be determined correctly. -The `setup.py` file automatically handles the installation of :mod:`setuptools_scm` and :mod:`cython`, -so you do not need to install them manually before running the setup process. Additionally, -the `requirements.txt` file is used to specify additional dependencies that are installed via -the `install_requires` parameter. Note that the last line of `requirements.txt` is typically an empty string -due to the split operation and is therefore excluded from installation. Ensure that any necessary dependency -is not placed on the last line before the empty line. +The `setup.py` file specifies the installation of :mod:`setuptools_scm` and :mod:`cython` +via the `setup_requires` parameter. These dependencies must be available in your environment +before running the setup process. Additionally, the `requirements.txt` file is used to specify +additional dependencies that are installed via the `install_requires` parameter. Note that the +last line of `requirements.txt` is typically an empty string due to the split operation and is +therefore excluded from installation. Ensure that any necessary dependency is not placed on the +last line before the empty line. Example: Clone the repository and install the package: From 052831b87ebd658925eb8da1f6f3637162b48d84 Mon Sep 17 00:00:00 2001 From: nvyncke Date: Wed, 26 Feb 2025 15:37:17 -0500 Subject: [PATCH 19/45] chore: auto-update documentation eth_portfolio/protocols/__init__.py --- eth_portfolio/protocols/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/eth_portfolio/protocols/__init__.py b/eth_portfolio/protocols/__init__.py index 67fdd4df..b0469dd9 100644 --- a/eth_portfolio/protocols/__init__.py +++ b/eth_portfolio/protocols/__init__.py @@ -45,11 +45,11 @@ async def balances(address: Address, block: Optional[Block] = None) -> RemoteTok >>> remote_balances = balances(address, block).result() >>> print(remote_balances) - The function constructs a dictionary `data` initialized with protocol class names - and their corresponding balances. The `protocol_balances` variable is an iterable - of tuples containing each protocol and its respective balance data. This iterable - is used in an asynchronous comprehension to construct the dictionary `data`. This - dictionary is subsequently used to initialize the + The function constructs a dictionary `data` with protocol class names + as keys and their corresponding balances as values. The `protocol_balances` + variable is a mapping of protocols to their balance data, and it is used + in an asynchronous comprehension to filter and construct the `data` dictionary. + This dictionary is subsequently used to initialize the :class:`~eth_portfolio.typing.RemoteTokenBalances` object. See Also: From 9fda6657255e52a530aa18afe22f2630c55d2b10 Mon Sep 17 00:00:00 2001 From: nvyncke Date: Wed, 26 Feb 2025 15:38:07 -0500 Subject: [PATCH 20/45] chore: auto-update documentation eth_portfolio/_decimal.py --- eth_portfolio/_decimal.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/eth_portfolio/_decimal.py b/eth_portfolio/_decimal.py index 60a67712..6822c54b 100644 --- a/eth_portfolio/_decimal.py +++ b/eth_portfolio/_decimal.py @@ -29,8 +29,7 @@ def jsonify(self) -> Union[str, int]: is equivalent to an integer, otherwise it returns a string in either standard or scientific notation, depending on which is shorter. - Trailing zeros in the string representation are removed. If the - scientific notation is shorter than the standard string representation, + If the scientific notation is shorter than the standard string representation, it is returned. If the integer representation is shorter than or equal to the scientific notation plus two characters, the integer is returned. From e5a26d0a5e2299a068a50c38e7a995e1f0c462f0 Mon Sep 17 00:00:00 2001 From: nvyncke Date: Wed, 26 Feb 2025 15:45:41 -0500 Subject: [PATCH 21/45] chore: auto-update documentation eth_portfolio/_decimal.py --- eth_portfolio/_decimal.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/eth_portfolio/_decimal.py b/eth_portfolio/_decimal.py index 6822c54b..b0f2f5da 100644 --- a/eth_portfolio/_decimal.py +++ b/eth_portfolio/_decimal.py @@ -29,9 +29,9 @@ def jsonify(self) -> Union[str, int]: is equivalent to an integer, otherwise it returns a string in either standard or scientific notation, depending on which is shorter. - If the scientific notation is shorter than the standard string representation, - it is returned. If the integer representation is shorter than or equal - to the scientific notation plus two characters, the integer is returned. + If the integer representation is shorter than or equal to the scientific notation + plus two characters, the integer is returned. Otherwise, the method returns + the shorter of the standard string representation or the scientific notation. Raises: Exception: If the resulting string representation is empty. From 40a30306c19d4115dc27de427d20a57a0911ff7c Mon Sep 17 00:00:00 2001 From: nvyncke Date: Wed, 26 Feb 2025 15:46:28 -0500 Subject: [PATCH 22/45] chore: auto-update documentation eth_portfolio/protocols/__init__.py --- eth_portfolio/protocols/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eth_portfolio/protocols/__init__.py b/eth_portfolio/protocols/__init__.py index b0469dd9..34d7c3a2 100644 --- a/eth_portfolio/protocols/__init__.py +++ b/eth_portfolio/protocols/__init__.py @@ -46,7 +46,7 @@ async def balances(address: Address, block: Optional[Block] = None) -> RemoteTok >>> print(remote_balances) The function constructs a dictionary `data` with protocol class names - as keys and their corresponding balances as values. The `protocol_balances` + as keys and their corresponding protocol balances as values. The `protocol_balances` variable is a mapping of protocols to their balance data, and it is used in an asynchronous comprehension to filter and construct the `data` dictionary. This dictionary is subsequently used to initialize the From adf1fd26a70443c2cd773c023fe26b987b6a04cc Mon Sep 17 00:00:00 2001 From: nvyncke Date: Wed, 26 Feb 2025 15:47:44 -0500 Subject: [PATCH 23/45] chore: auto-update documentation eth_portfolio/protocols/lending/liquity.py --- eth_portfolio/protocols/lending/liquity.py | 61 +++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/eth_portfolio/protocols/lending/liquity.py b/eth_portfolio/protocols/lending/liquity.py index f62cf733..f680524c 100644 --- a/eth_portfolio/protocols/lending/liquity.py +++ b/eth_portfolio/protocols/lending/liquity.py @@ -13,6 +13,26 @@ class Liquity(LendingProtocolWithLockedCollateral): + """ + Represents the Liquity protocol, a decentralized borrowing protocol that allows users to draw loans against Ether collateral. + + This class is a subclass of :class:`~eth_portfolio.protocols.lending._base.LendingProtocolWithLockedCollateral`, which means it maintains a debt balance for a user and holds collateral internally. + + Attributes: + networks: The networks on which the protocol is available. + troveManager: The contract instance for the Trove Manager. + start_block: The block number from which the protocol starts. + + Examples: + >>> liquity = Liquity() + >>> balances = await liquity._balances("0xYourAddress", 12345678) + >>> print(balances) + + See Also: + - :class:`~eth_portfolio.protocols.lending._base.LendingProtocolWithLockedCollateral` + - :class:`~eth_portfolio.typing.TokenBalances` + """ + networks = [Network.Mainnet] def __init__(self) -> None: @@ -22,10 +42,35 @@ def __init__(self) -> None: @alru_cache(maxsize=128) @stuck_coro_debugger async def get_trove(self, address: Address, block: Block) -> dict: + """ + Retrieves the trove data for a given address at a specific block. + + Args: + address: The Ethereum address of the user. + block: The block number to query. + + Examples: + >>> trove_data = await liquity.get_trove("0xYourAddress", 12345678) + >>> print(trove_data) + """ return await self.troveManager.Troves.coroutine(address, block_identifier=block) @stuck_coro_debugger async def _balances(self, address: Address, block: Optional[Block] = None) -> TokenBalances: + """ + Retrieves the collateral balances for a given address at a specific block. + + Args: + address: The Ethereum address of the user. + block: The block number to query. + + Examples: + >>> balances = await liquity._balances("0xYourAddress", 12345678) + >>> print(balances) + + See Also: + - :class:`~eth_portfolio.typing.TokenBalances` + """ balances: TokenBalances = TokenBalances(block=block) if block and block < self.start_block: return balances @@ -41,6 +86,20 @@ async def _balances(self, address: Address, block: Optional[Block] = None) -> To @stuck_coro_debugger async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenBalances: + """ + Retrieves the debt balances for a given address at a specific block. + + Args: + address: The Ethereum address of the user. + block: The block number to query. + + Examples: + >>> debt_balances = await liquity._debt("0xYourAddress", 12345678) + >>> print(debt_balances) + + See Also: + - :class:`~eth_portfolio.typing.TokenBalances` + """ balances: TokenBalances = TokenBalances(block=block) if block and block < self.start_block: return balances @@ -50,4 +109,4 @@ async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenB lusd_debt /= 10**18 value = lusd_debt * await get_price(lusd, block, sync=False) balances[lusd] = Balance(lusd_debt, value, token=lusd, block=block) - return balances + return balances \ No newline at end of file From b78271bd8133bd442b49775c97f0b3381537b9dd Mon Sep 17 00:00:00 2001 From: nvyncke Date: Wed, 26 Feb 2025 15:47:56 -0500 Subject: [PATCH 24/45] chore: auto-update documentation eth_portfolio/_exceptions.py --- eth_portfolio/_exceptions.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/eth_portfolio/_exceptions.py b/eth_portfolio/_exceptions.py index b5fb6626..d748ecf6 100644 --- a/eth_portfolio/_exceptions.py +++ b/eth_portfolio/_exceptions.py @@ -26,7 +26,8 @@ class BlockRangeOutOfBounds(Exception): This exception is used to indicate that the requested block range is outside the bounds of the cached data. It provides a method to - load the remaining ledger entries that are out of bounds. + handle the loading of the remaining ledger entries that are out of bounds + by invoking the appropriate method in the associated ledger. Args: start_block: The starting block number of the out-of-bounds range. @@ -47,9 +48,10 @@ def __init__(self, start_block: Block, end_block: Block, ledger: "AddressLedgerB async def load_remaining(self) -> None: """ - Asynchronously loads the remaining ledger entries that are out of bounds. + Asynchronously handles the loading of the remaining ledger entries that are out of bounds. - This method fetches the ledger entries for the blocks that are outside + This method invokes the :meth:`~eth_portfolio._ledgers.address.AddressLedgerBase._load_new_objects` + method of the associated ledger to fetch the ledger entries for the blocks that are outside the cached range, ensuring that the entire requested block range is covered. Examples: From 6ae4a78d62db20a33799740eaf4c9ffd2ba9e4a2 Mon Sep 17 00:00:00 2001 From: nvyncke Date: Wed, 26 Feb 2025 15:48:07 -0500 Subject: [PATCH 25/45] chore: auto-update documentation eth_portfolio/_ydb/token_transfers.py --- eth_portfolio/_ydb/token_transfers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/eth_portfolio/_ydb/token_transfers.py b/eth_portfolio/_ydb/token_transfers.py index cb62e825..5e71ee2a 100644 --- a/eth_portfolio/_ydb/token_transfers.py +++ b/eth_portfolio/_ydb/token_transfers.py @@ -81,7 +81,7 @@ async def yield_thru_block(self, block) -> AsyncIterator["Task[TokenTransfer]"]: block: The block number up to which token transfers are yielded. Yields: - Token transfers as :class:`~asyncio.Task` objects. + Tasks that resolve to :class:`~eth_portfolio.structs.TokenTransfer` objects. Examples: >>> async for transfer in transfers.yield_thru_block(1000000): @@ -207,7 +207,7 @@ async def __aiter__(self): Asynchronously iterate over all token transfers. Yields: - Token transfers as :class:`~eth_portfolio.structs.TokenTransfer` objects. + :class:`~eth_portfolio.structs.TokenTransfer` objects. Examples: >>> async for transfer in token_transfers: @@ -224,7 +224,7 @@ def yield_thru_block(self, block: int) -> ASyncIterator["Task[TokenTransfer]"]: block: The block number up to which token transfers are yielded. Yields: - Token transfers as :class:`~asyncio.Task` objects. + Tasks that resolve to :class:`~eth_portfolio.structs.TokenTransfer` objects. Examples: >>> async for transfer in token_transfers.yield_thru_block(1000000): From 1ec94b34ad0bab45ec1c7e101583b63910277470 Mon Sep 17 00:00:00 2001 From: nvyncke Date: Wed, 26 Feb 2025 15:48:26 -0500 Subject: [PATCH 26/45] chore: auto-update documentation eth_portfolio/_db/entities.py --- eth_portfolio/_db/entities.py | 44 ++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/eth_portfolio/_db/entities.py b/eth_portfolio/_db/entities.py index 33fa72c3..fcfbca39 100644 --- a/eth_portfolio/_db/entities.py +++ b/eth_portfolio/_db/entities.py @@ -57,9 +57,10 @@ class Transaction(DbEntity): """ Represents a transaction entity in the database. - This class provides properties to access decoded transaction data, - including input data, signature components, and access lists. - + This class provides a property to access decoded transaction data, + which includes input data, signature components, and access lists. + The decoded data is accessed through the :attr:`decoded` property, + which returns a :class:`structs.Transaction` object. See Also: - :class:`BlockExtended` @@ -114,6 +115,9 @@ def decoded(self) -> structs.Transaction: >>> decoded_transaction = transaction.decoded >>> isinstance(decoded_transaction, structs.Transaction) True + + See Also: + - :class:`structs.Transaction` """ return json.decode(self.raw, type=structs.Transaction) @@ -127,6 +131,9 @@ def input(self) -> HexBytes: >>> input_data = transaction.input >>> isinstance(input_data, HexBytes) True + + See Also: + - :attr:`structs.Transaction.input` """ structs.Transaction.input.__doc__ return self.decoded.input @@ -141,6 +148,9 @@ def r(self) -> HexBytes: >>> r_value = transaction.r >>> isinstance(r_value, HexBytes) True + + See Also: + - :attr:`structs.Transaction.r` """ structs.Transaction.r.__doc__ return self.decoded.r @@ -155,6 +165,9 @@ def s(self) -> HexBytes: >>> s_value = transaction.s >>> isinstance(s_value, HexBytes) True + + See Also: + - :attr:`structs.Transaction.s` """ structs.Transaction.s.__doc__ return self.decoded.s @@ -169,6 +182,9 @@ def v(self) -> int: >>> v_value = transaction.v >>> isinstance(v_value, int) True + + See Also: + - :attr:`structs.Transaction.v` """ structs.Transaction.v.__doc__ return self.decoded.v @@ -188,6 +204,7 @@ def access_list(self) -> typing.List[AccessListEntry]: See Also: - :class:`AccessListEntry` + - :attr:`structs.Transaction.access_list` """ structs.Transaction.access_list.__doc__ return self.decoded.access_list @@ -202,6 +219,9 @@ def y_parity(self) -> typing.Optional[int]: >>> y_parity_value = transaction.y_parity >>> isinstance(y_parity_value, (int, type(None))) True + + See Also: + - :attr:`structs.Transaction.y_parity` """ structs.Transaction.y_parity.__doc__ return self.decoded.y_parity @@ -283,6 +303,9 @@ def decoded(self) -> structs.InternalTransfer: >>> decoded_transfer = internal_transfer.decoded >>> isinstance(decoded_transfer, structs.InternalTransfer) True + + See Also: + - :class:`structs.InternalTransfer` """ structs.InternalTransfer.__doc__ return json.decode(self.raw, type=structs.InternalTransfer) @@ -297,6 +320,9 @@ def code(self) -> HexBytes: >>> code_data = internal_transfer.code >>> isinstance(code_data, HexBytes) True + + See Also: + - :attr:`structs.InternalTransfer.code` """ structs.InternalTransfer.code.__doc__ return self.decoded.code @@ -311,6 +337,9 @@ def input(self) -> HexBytes: >>> input_data = internal_transfer.input >>> isinstance(input_data, HexBytes) True + + See Also: + - :attr:`structs.InternalTransfer.input` """ structs.InternalTransfer.input.__doc__ return self.decoded.input @@ -325,6 +354,9 @@ def output(self) -> HexBytes: >>> output_data = internal_transfer.output >>> isinstance(output_data, HexBytes) True + + See Also: + - :attr:`structs.InternalTransfer.output` """ structs.InternalTransfer.output.__doc__ return self.decoded.output @@ -339,6 +371,9 @@ def subtraces(self) -> int: >>> subtraces_count = internal_transfer.subtraces >>> isinstance(subtraces_count, int) True + + See Also: + - :attr:`structs.InternalTransfer.subtraces` """ structs.InternalTransfer.subtraces.__doc__ return self.decoded.subtraces @@ -402,5 +437,8 @@ def decoded(self) -> structs.TokenTransfer: >>> decoded_transfer = token_transfer.decoded >>> isinstance(decoded_transfer, structs.TokenTransfer) True + + See Also: + - :class:`structs.TokenTransfer` """ return json.decode(self.raw, type=structs.TokenTransfer) \ No newline at end of file From 1cdf8715f98291f5daed092ab4a74c032597d5b7 Mon Sep 17 00:00:00 2001 From: nvyncke Date: Wed, 26 Feb 2025 15:48:55 -0500 Subject: [PATCH 27/45] chore: auto-update documentation eth_portfolio/address.py --- eth_portfolio/address.py | 55 ++++++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/eth_portfolio/address.py b/eth_portfolio/address.py index 9987e68d..4d263674 100644 --- a/eth_portfolio/address.py +++ b/eth_portfolio/address.py @@ -1,20 +1,3 @@ -""" -This module defines the :class:`~PortfolioAddress` class, which represents an address managed by the `eth-portfolio` system. -The :class:`~PortfolioAddress` class is designed to manage different aspects of an Ethereum address within the portfolio, -such as transactions, transfers, balances, and interactions with both external and lending protocols. - -Key components and functionalities provided by the :class:`~eth_portfolio.address.PortfolioAddress` class include: -- Handling Ethereum and token balances -- Managing debt and collateral from lending protocols -- Tracking transactions and transfers (both internal and token transfers) -- Providing comprehensive balance descriptions at specific block heights - -The class leverages asynchronous operations using the `a_sync` library to efficiently gather and process data. -It also integrates with various submodules from `eth-portfolio` to load balances, manage ledgers, and interact -with external protocols. -""" - -import logging from asyncio import gather from typing import TYPE_CHECKING, Dict, Optional @@ -48,6 +31,19 @@ class PortfolioAddress(_LedgeredBase[AddressLedgerBase]): """ Represents a portfolio address within the eth-portfolio system. + + This class is designed to manage different aspects of an Ethereum address within the portfolio, + such as transactions, transfers, balances, and interactions with both external and lending protocols. + + Key components and functionalities provided by the :class:`~eth_portfolio.address.PortfolioAddress` class include: + - Handling Ethereum and token balances + - Managing debt and collateral from lending protocols + - Tracking transactions and transfers (both internal and token transfers) + - Providing comprehensive balance descriptions at specific block heights + + The class leverages asynchronous operations using the `a_sync` library to efficiently gather and process data. + It also integrates with various submodules from `eth-portfolio` to load balances, manage ledgers, and interact + with external protocols. """ def __init__( @@ -59,19 +55,30 @@ def __init__( asynchronous: bool = False, ) -> None: # type: ignore """ - Initializes the PortfolioAddress instance. + Initializes the :class:`~PortfolioAddress` instance. Args: - address: The address to manage. - portfolio: The portfolio instance managing this address. - asynchronous (optional): Flag for asynchronous operation. Defaults to False. + address (Address): The Ethereum address to manage. + start_block (Block): The block number from which to start tracking. + load_prices (bool): Flag indicating if price loading is enabled. + num_workers_transactions (int, optional): Number of workers for transaction processing. Defaults to 1000. + asynchronous (bool, optional): Flag for asynchronous operation. Defaults to False. Raises: TypeError: If `asynchronous` is not a boolean. Examples: - >>> portfolio = Portfolio() - >>> address = PortfolioAddress('0x1234...', portfolio) + >>> address = PortfolioAddress('0x1234...', 0, True) + >>> print(address) + + >>> address = PortfolioAddress('0x1234...', 0, False, num_workers_transactions=500, asynchronous=True) + >>> print(address) + + See Also: + - :class:`~eth_portfolio.portfolio.Portfolio` + - :class:`~eth_portfolio._ledgers.address.AddressTransactionsLedger` + - :class:`~eth_portfolio._ledgers.address.AddressInternalTransfersLedger` + - :class:`~eth_portfolio._ledgers.address.AddressTokenTransfersLedger` """ self.address = convert.to_address(address) """ @@ -357,4 +364,4 @@ async def all(self, start_block: Block, end_block: Block) -> Dict[str, PandableL ), "token_transfers": self.token_transfers.get(start_block, end_block, sync=False), } - return await a_sync.gather(coros) + return await a_sync.gather(coros) \ No newline at end of file From 55b0d4ae6f74a389bc33a77948c64183b9cc5264 Mon Sep 17 00:00:00 2001 From: nvyncke Date: Wed, 26 Feb 2025 15:57:45 -0500 Subject: [PATCH 28/45] chore: auto-update documentation eth_portfolio/protocols/__init__.py --- eth_portfolio/protocols/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/eth_portfolio/protocols/__init__.py b/eth_portfolio/protocols/__init__.py index 34d7c3a2..3ea39742 100644 --- a/eth_portfolio/protocols/__init__.py +++ b/eth_portfolio/protocols/__init__.py @@ -47,10 +47,10 @@ async def balances(address: Address, block: Optional[Block] = None) -> RemoteTok The function constructs a dictionary `data` with protocol class names as keys and their corresponding protocol balances as values. The `protocol_balances` - variable is a mapping of protocols to their balance data, and it is used - in an asynchronous comprehension to filter and construct the `data` dictionary. - This dictionary is subsequently used to initialize the - :class:`~eth_portfolio.typing.RemoteTokenBalances` object. + variable is a result of mapping the `balances` method over the `protocols` using + :func:`a_sync.map`. The asynchronous comprehension iterates over `protocol_balances` + to filter and construct the `data` dictionary. This dictionary is subsequently used + to initialize the :class:`~eth_portfolio.typing.RemoteTokenBalances` object. See Also: - :class:`~eth_portfolio.typing.RemoteTokenBalances`: For more information on the return type. From 7abe8ca9a288627dfb1cedb071640fd85541c30a Mon Sep 17 00:00:00 2001 From: nvyncke Date: Wed, 26 Feb 2025 15:58:02 -0500 Subject: [PATCH 29/45] chore: auto-update documentation eth_portfolio/_decimal.py --- eth_portfolio/_decimal.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/eth_portfolio/_decimal.py b/eth_portfolio/_decimal.py index b0f2f5da..54576056 100644 --- a/eth_portfolio/_decimal.py +++ b/eth_portfolio/_decimal.py @@ -26,12 +26,12 @@ def jsonify(self) -> Union[str, int]: This method attempts to represent the :class:`Decimal` in the most compact form possible for JSON serialization. It returns an integer if the :class:`Decimal` - is equivalent to an integer, otherwise it returns a string in either + is exactly equal to an integer, otherwise it returns a string in either standard or scientific notation, depending on which is shorter. - If the integer representation is shorter than or equal to the scientific notation - plus two characters, the integer is returned. Otherwise, the method returns - the shorter of the standard string representation or the scientific notation. + If the integer representation is exactly equal to the :class:`Decimal`, + the integer is returned. Otherwise, the method returns the shorter of the + standard string representation or the scientific notation. Raises: Exception: If the resulting string representation is empty. From 1ada2b8b69792815e3df0aebc3da95023d890d49 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 26 Feb 2025 21:04:09 +0000 Subject: [PATCH 30/45] chore: `black .` --- eth_portfolio/_db/decorators.py | 8 ++++---- eth_portfolio/_db/entities.py | 6 ++---- eth_portfolio/_decimal.py | 2 +- eth_portfolio/_decorators.py | 2 +- eth_portfolio/_exceptions.py | 3 ++- eth_portfolio/_ledgers/address.py | 10 ++++++---- eth_portfolio/_loaders/utils.py | 4 +++- eth_portfolio/_ydb/token_transfers.py | 2 +- eth_portfolio/address.py | 2 +- eth_portfolio/buckets.py | 2 +- eth_portfolio/protocols/__init__.py | 2 +- eth_portfolio/protocols/lending/_base.py | 5 ++--- eth_portfolio/protocols/lending/compound.py | 2 +- eth_portfolio/protocols/lending/liquity.py | 2 +- eth_portfolio/structs.py | 2 +- eth_portfolio/structs/__init__.py | 4 ++-- eth_portfolio/structs/structs.py | 2 +- eth_portfolio/typing.py | 20 ++++++++++---------- setup.py | 2 +- tests/conftest.py | 3 ++- tests/protocols/test_external.py | 2 +- 21 files changed, 45 insertions(+), 42 deletions(-) diff --git a/eth_portfolio/_db/decorators.py b/eth_portfolio/_db/decorators.py index 1afa35d0..21dc6f49 100644 --- a/eth_portfolio/_db/decorators.py +++ b/eth_portfolio/_db/decorators.py @@ -36,14 +36,14 @@ def break_locks(fn: AnyFn[P, T]) -> AnyFn[P, T]: Examples: Basic usage with a regular function: - + >>> @break_locks ... def my_function(): ... # Function logic that may encounter a database lock ... pass Basic usage with an asynchronous function: - + >>> @break_locks ... async def my_async_function(): ... # Async function logic that may encounter a database lock @@ -120,7 +120,7 @@ def requery_objs_on_diff_tx_err(fn: Callable[P, T]) -> Callable[P, T]: Examples: Basic usage with a function that may encounter transaction errors: - + >>> @requery_objs_on_diff_tx_err ... def my_function(): ... # Function logic that may encounter a transaction error @@ -144,4 +144,4 @@ def requery_wrap(*args: P.args, **kwargs: P.kwargs) -> T: # and then tried to use the newly committed objects in the next transaction. Now that the objects are in the db this will # not reoccur. The next iteration will be successful. - return requery_wrap \ No newline at end of file + return requery_wrap diff --git a/eth_portfolio/_db/entities.py b/eth_portfolio/_db/entities.py index fcfbca39..e04bdaff 100644 --- a/eth_portfolio/_db/entities.py +++ b/eth_portfolio/_db/entities.py @@ -401,9 +401,7 @@ class TokenTransfer(DbEntity): "The index of the transaction within the block." hash = Required(str, lazy=True) "The hash of the token transfer." - from_address = Required( - AddressExtended, index=True, lazy=True, reverse="token_transfers_sent" - ) + from_address = Required(AddressExtended, index=True, lazy=True, reverse="token_transfers_sent") "The address that sent the token transfer." to_address = Required( AddressExtended, index=True, lazy=True, reverse="token_transfers_received" @@ -441,4 +439,4 @@ def decoded(self) -> structs.TokenTransfer: See Also: - :class:`structs.TokenTransfer` """ - return json.decode(self.raw, type=structs.TokenTransfer) \ No newline at end of file + return json.decode(self.raw, type=structs.TokenTransfer) diff --git a/eth_portfolio/_decimal.py b/eth_portfolio/_decimal.py index 54576056..aae04cca 100644 --- a/eth_portfolio/_decimal.py +++ b/eth_portfolio/_decimal.py @@ -198,4 +198,4 @@ def as_wei(self) -> Wei: >>> Gwei('1').as_wei Wei(1000000000) """ - return Wei(self * 10**9) \ No newline at end of file + return Wei(self * 10**9) diff --git a/eth_portfolio/_decorators.py b/eth_portfolio/_decorators.py index 14b3e4d7..fb168c69 100644 --- a/eth_portfolio/_decorators.py +++ b/eth_portfolio/_decorators.py @@ -16,7 +16,7 @@ def set_end_block_if_none( - func: Callable[Concatenate[_I, Block, Block, _P], _T] + func: Callable[Concatenate[_I, Block, Block, _P], _T], ) -> Callable[Concatenate[_I, Block, Optional[Block], _P], _T]: """ Used to set `end_block` = `chain.height - _config.REORG_BUFFER` if `end_block` is None. diff --git a/eth_portfolio/_exceptions.py b/eth_portfolio/_exceptions.py index d748ecf6..07847ee8 100644 --- a/eth_portfolio/_exceptions.py +++ b/eth_portfolio/_exceptions.py @@ -17,6 +17,7 @@ class BlockRangeIsCached(Exception): Examples: >>> raise BlockRangeIsCached("Block range is already cached.") """ + pass @@ -63,4 +64,4 @@ async def load_remaining(self) -> None: return await gather( self.ledger._load_new_objects(self.start_block, self.ledger.cached_thru - 1), self.ledger._load_new_objects(self.ledger.cached_from + 1, self.end_block), - ) \ No newline at end of file + ) diff --git a/eth_portfolio/_ledgers/address.py b/eth_portfolio/_ledgers/address.py index 44fd4d7e..2f03a1c1 100644 --- a/eth_portfolio/_ledgers/address.py +++ b/eth_portfolio/_ledgers/address.py @@ -1,8 +1,8 @@ """ -This module defines the :class:`~eth_portfolio.AddressLedgerBase`, :class:`~eth_portfolio.TransactionsList`, -:class:`~eth_portfolio.AddressTransactionsLedger`, :class:`~eth_portfolio.InternalTransfersList`, -:class:`~eth_portfolio.AddressInternalTransfersLedger`, :class:`~eth_portfolio.TokenTransfersList`, -and :class:`~eth_portfolio.AddressTokenTransfersLedger` classes. These classes manage and interact with ledger entries +This module defines the :class:`~eth_portfolio.AddressLedgerBase`, :class:`~eth_portfolio.TransactionsList`, +:class:`~eth_portfolio.AddressTransactionsLedger`, :class:`~eth_portfolio.InternalTransfersList`, +:class:`~eth_portfolio.AddressInternalTransfersLedger`, :class:`~eth_portfolio.TokenTransfersList`, +and :class:`~eth_portfolio.AddressTokenTransfersLedger` classes. These classes manage and interact with ledger entries such as transactions, internal transfers, and token transfers associated with Ethereum addresses within the `eth-portfolio` system. These classes leverage the `a_sync` library to support both synchronous and asynchronous operations, allowing efficient data gathering @@ -658,9 +658,11 @@ async def _check_traces(traces: List[FilterTrace]) -> List[FilterTrace]: BlockRange = Tuple[Block, Block] + def _get_block_ranges(start_block: Block, end_block: Block) -> List[BlockRange]: return [(i, i + BATCH_SIZE - 1) for i in range(start_block, end_block, BATCH_SIZE)] + class AddressInternalTransfersLedger(AddressLedgerBase[InternalTransfersList, InternalTransfer]): """ A ledger for managing internal transfer entries. diff --git a/eth_portfolio/_loaders/utils.py b/eth_portfolio/_loaders/utils.py index 8fcd2c3d..83528402 100644 --- a/eth_portfolio/_loaders/utils.py +++ b/eth_portfolio/_loaders/utils.py @@ -6,6 +6,7 @@ from eth_typing import HexStr from y._decorators import stuck_coro_debugger + @eth_retry.auto_retry @alru_cache(maxsize=None, ttl=60 * 60) @stuck_coro_debugger @@ -39,6 +40,7 @@ async def _get_transaction_receipt(txhash: HexStr) -> msgspec.Raw: txhash, decode_to=msgspec.Raw, decode_hook=None ) + get_transaction_receipt = SmartProcessingQueue(_get_transaction_receipt, 5000) """ A queue for processing transaction receipt requests. @@ -54,4 +56,4 @@ async def _get_transaction_receipt(txhash: HexStr) -> msgspec.Raw: See Also: - :class:`a_sync.SmartProcessingQueue`: For managing asynchronous processing queues. -""" \ No newline at end of file +""" diff --git a/eth_portfolio/_ydb/token_transfers.py b/eth_portfolio/_ydb/token_transfers.py index 5e71ee2a..83ac0b5a 100644 --- a/eth_portfolio/_ydb/token_transfers.py +++ b/eth_portfolio/_ydb/token_transfers.py @@ -235,4 +235,4 @@ def yield_thru_block(self, block: int) -> ASyncIterator["Task[TokenTransfer]"]: self.transfers_in.yield_thru_block(block), self.transfers_out.yield_thru_block(block), ) - ) \ No newline at end of file + ) diff --git a/eth_portfolio/address.py b/eth_portfolio/address.py index 4d263674..e3d5fff8 100644 --- a/eth_portfolio/address.py +++ b/eth_portfolio/address.py @@ -364,4 +364,4 @@ async def all(self, start_block: Block, end_block: Block) -> Dict[str, PandableL ), "token_transfers": self.token_transfers.get(start_block, end_block, sync=False), } - return await a_sync.gather(coros) \ No newline at end of file + return await a_sync.gather(coros) diff --git a/eth_portfolio/buckets.py b/eth_portfolio/buckets.py index 2f48b35a..8208f0ad 100644 --- a/eth_portfolio/buckets.py +++ b/eth_portfolio/buckets.py @@ -165,4 +165,4 @@ def _is_stable(token: Address) -> bool: - :data:`STABLECOINS` - :data:`INTL_STABLECOINS` """ - return token in STABLECOINS or token in INTL_STABLECOINS \ No newline at end of file + return token in STABLECOINS or token in INTL_STABLECOINS diff --git a/eth_portfolio/protocols/__init__.py b/eth_portfolio/protocols/__init__.py index 3ea39742..56d632c6 100644 --- a/eth_portfolio/protocols/__init__.py +++ b/eth_portfolio/protocols/__init__.py @@ -67,4 +67,4 @@ async def balances(address: Address, block: Optional[Block] = None) -> RemoteTok async for protocol, protocol_balances in protocol_balances if protocol_balances is not None } - return RemoteTokenBalances(data, block=block) \ No newline at end of file + return RemoteTokenBalances(data, block=block) diff --git a/eth_portfolio/protocols/lending/_base.py b/eth_portfolio/protocols/lending/_base.py index c62053f5..354ad688 100644 --- a/eth_portfolio/protocols/lending/_base.py +++ b/eth_portfolio/protocols/lending/_base.py @@ -31,8 +31,7 @@ async def debt(self, address: Address, block: Optional[Block] = None) -> TokenBa return await self._debt(address, block) # type: ignore @abc.abstractmethod - async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenBalances: - ... + async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenBalances: ... class LendingProtocolWithLockedCollateral(LendingProtocol, ProtocolABC): @@ -55,4 +54,4 @@ class LendingProtocolWithLockedCollateral(LendingProtocol, ProtocolABC): See Also: - :class:`LendingProtocol` - """ \ No newline at end of file + """ diff --git a/eth_portfolio/protocols/lending/compound.py b/eth_portfolio/protocols/lending/compound.py index 91ed007e..a514ae40 100644 --- a/eth_portfolio/protocols/lending/compound.py +++ b/eth_portfolio/protocols/lending/compound.py @@ -182,4 +182,4 @@ async def _borrow_balance_stored( except ValueError as e: if str(e) != "No data was returned - the call likely reverted": raise - return None \ No newline at end of file + return None diff --git a/eth_portfolio/protocols/lending/liquity.py b/eth_portfolio/protocols/lending/liquity.py index f680524c..ecd4e520 100644 --- a/eth_portfolio/protocols/lending/liquity.py +++ b/eth_portfolio/protocols/lending/liquity.py @@ -109,4 +109,4 @@ async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenB lusd_debt /= 10**18 value = lusd_debt * await get_price(lusd, block, sync=False) balances[lusd] = Balance(lusd_debt, value, token=lusd, block=block) - return balances \ No newline at end of file + return balances diff --git a/eth_portfolio/structs.py b/eth_portfolio/structs.py index c66af4a2..65c88c32 100644 --- a/eth_portfolio/structs.py +++ b/eth_portfolio/structs.py @@ -1,5 +1,5 @@ """ -Defines the data classes used to represent the various types of value-transfer actions on the blockchain. These include transactions, internal transfers, and token transfers. +Defines the data classes used to represent the various types of value-transfer actions on the blockchain. These include transactions, internal transfers, and token transfers. The classes are designed to provide a consistent and flexible interface for working with blockchain data. Instance attributes can be fetched with either dot notation or key lookup. Classes are compatible with the standard dictionary interface. """ diff --git a/eth_portfolio/structs/__init__.py b/eth_portfolio/structs/__init__.py index 82689110..bc455575 100644 --- a/eth_portfolio/structs/__init__.py +++ b/eth_portfolio/structs/__init__.py @@ -5,11 +5,11 @@ Examples: Importing the main union type and specific ledger entry types: - + >>> from eth_portfolio.structs import LedgerEntry, Transaction, InternalTransfer, TokenTransfer, TransactionRLP Using the `LedgerEntry` union type to annotate a variable that can hold any ledger entry type: - + >>> entry: LedgerEntry = Transaction(...) >>> entry = InternalTransfer(...) >>> entry = TokenTransfer(...) diff --git a/eth_portfolio/structs/structs.py b/eth_portfolio/structs/structs.py index 923278ad..d4e75fd1 100644 --- a/eth_portfolio/structs/structs.py +++ b/eth_portfolio/structs/structs.py @@ -1,5 +1,5 @@ """ -Defines the data classes used to represent the various types of value-transfer actions on the blockchain. These include transactions, internal transfers, and token transfers. +Defines the data classes used to represent the various types of value-transfer actions on the blockchain. These include transactions, internal transfers, and token transfers. The classes are designed to provide a consistent and flexible interface for working with blockchain data. Instance attributes can be fetched with either dot notation or key lookup. Classes are compatible with the standard dictionary interface. """ diff --git a/eth_portfolio/typing.py b/eth_portfolio/typing.py index b43758b6..1fcb5751 100644 --- a/eth_portfolio/typing.py +++ b/eth_portfolio/typing.py @@ -1,26 +1,26 @@ """ -This module defines a set of classes to represent and manipulate various levels of balance structures -within an Ethereum portfolio. The focus of these classes is on reading, aggregating, and summarizing +This module defines a set of classes to represent and manipulate various levels of balance structures +within an Ethereum portfolio. The focus of these classes is on reading, aggregating, and summarizing balances, including the value in both tokens and their equivalent in USD. The main classes and their purposes are as follows: - :class:`~eth_portfolio.typing.Balance`: Represents the balance of a single token, including its token amount and equivalent USD value. -- :class:`~eth_portfolio.typing.TokenBalances`: Manages a collection of :class:`~eth_portfolio.typing.Balance` objects for multiple tokens, providing operations +- :class:`~eth_portfolio.typing.TokenBalances`: Manages a collection of :class:`~eth_portfolio.typing.Balance` objects for multiple tokens, providing operations such as summing balances across tokens. -- :class:`~eth_portfolio.typing.RemoteTokenBalances`: Extends :class:`~eth_portfolio.typing.TokenBalances` to manage balances across different protocols, enabling +- :class:`~eth_portfolio.typing.RemoteTokenBalances`: Extends :class:`~eth_portfolio.typing.TokenBalances` to manage balances across different protocols, enabling aggregation and analysis of balances by protocol. -- :class:`~eth_portfolio.typing.WalletBalances`: Organizes token balances into categories such as assets, debts, and external balances - for a single wallet. It combines :class:`~eth_portfolio.typing.TokenBalances` and :class:`~eth_portfolio.typing.RemoteTokenBalances` to provide a complete view +- :class:`~eth_portfolio.typing.WalletBalances`: Organizes token balances into categories such as assets, debts, and external balances + for a single wallet. It combines :class:`~eth_portfolio.typing.TokenBalances` and :class:`~eth_portfolio.typing.RemoteTokenBalances` to provide a complete view of a wallet's balances. -- :class:`~eth_portfolio.typing.PortfolioBalances`: Aggregates :class:`~eth_portfolio.typing.WalletBalances` for multiple wallets, providing operations to sum +- :class:`~eth_portfolio.typing.PortfolioBalances`: Aggregates :class:`~eth_portfolio.typing.WalletBalances` for multiple wallets, providing operations to sum balances across an entire portfolio. -- :class:`~eth_portfolio.typing.WalletBalancesRaw`: Similar to :class:`~eth_portfolio.typing.WalletBalances`, but with a key structure optimized for accessing +- :class:`~eth_portfolio.typing.WalletBalancesRaw`: Similar to :class:`~eth_portfolio.typing.WalletBalances`, but with a key structure optimized for accessing balances directly by wallet and token. -- :class:`~eth_portfolio.typing.PortfolioBalancesByCategory`: Provides an inverted view of :class:`~eth_portfolio.typing.PortfolioBalances`, allowing access +- :class:`~eth_portfolio.typing.PortfolioBalancesByCategory`: Provides an inverted view of :class:`~eth_portfolio.typing.PortfolioBalances`, allowing access by category first, then by wallet and token. -These classes are designed for efficient parsing, manipulation, and summarization of portfolio data, +These classes are designed for efficient parsing, manipulation, and summarization of portfolio data, without managing or altering any underlying assets. """ diff --git a/setup.py b/setup.py index 52e8c2eb..74f905ed 100644 --- a/setup.py +++ b/setup.py @@ -62,4 +62,4 @@ package_data={ "eth_portfolio": ["py.typed"], }, -) \ No newline at end of file +) diff --git a/tests/conftest.py b/tests/conftest.py index fe39ac25..31829905 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,6 +25,7 @@ - :mod:`os`: For more information on interacting with the operating system. - :mod:`sys`: For more information on the system-specific parameters and functions. """ + import os import sys @@ -40,4 +41,4 @@ ) if not network.is_connected(): - network.connect(brownie_network) \ No newline at end of file + network.connect(brownie_network) diff --git a/tests/protocols/test_external.py b/tests/protocols/test_external.py index 4b78884d..212e69cf 100644 --- a/tests/protocols/test_external.py +++ b/tests/protocols/test_external.py @@ -118,4 +118,4 @@ async def test_balances_with_protocols_and_block(): ) for protocol in protocols.protocols: - protocol.balances.assert_called_once_with(SOME_ADDRESS, block) \ No newline at end of file + protocol.balances.assert_called_once_with(SOME_ADDRESS, block) From daf9188b00da54e8cd666663486e749ceabca2e2 Mon Sep 17 00:00:00 2001 From: nvyncke Date: Fri, 28 Feb 2025 12:26:09 -0500 Subject: [PATCH 31/45] chore: auto-update documentation tests/conftest.py --- tests/conftest.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 31829905..8b8fc627 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ """ -This module connects to the Brownie network specified by the `PYTEST_NETWORK` -environment variable. It also modifies the system path to include the current -directory, allowing for importing modules from the project root. +This module manages the connection to the Brownie network specified by the `PYTEST_NETWORK` +environment variable. It also modifies the system path to include the current directory, +allowing for importing modules from the project root. Environment Variables: PYTEST_NETWORK: The name of the Brownie network to use for testing. This @@ -20,6 +20,8 @@ This will connect to the specified Brownie network and run the tests. + If the network is already connected, it will not attempt to reconnect. + See Also: - :mod:`brownie.network`: For more information on managing network connections with Brownie. - :mod:`os`: For more information on interacting with the operating system. @@ -41,4 +43,4 @@ ) if not network.is_connected(): - network.connect(brownie_network) + network.connect(brownie_network) \ No newline at end of file From f23b60ba891cf466ac9881c46041f3e9fc35bafa Mon Sep 17 00:00:00 2001 From: nvyncke Date: Fri, 28 Feb 2025 12:41:24 -0500 Subject: [PATCH 32/45] chore: auto-update documentation eth_portfolio/_db/entities.py --- eth_portfolio/_db/entities.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/eth_portfolio/_db/entities.py b/eth_portfolio/_db/entities.py index e04bdaff..5a01b74c 100644 --- a/eth_portfolio/_db/entities.py +++ b/eth_portfolio/_db/entities.py @@ -60,7 +60,8 @@ class Transaction(DbEntity): This class provides a property to access decoded transaction data, which includes input data, signature components, and access lists. The decoded data is accessed through the :attr:`decoded` property, - which returns a :class:`structs.Transaction` object. + which returns a :class:`structs.Transaction` object decoded from the + :attr:`raw` attribute. See Also: - :class:`BlockExtended` @@ -110,6 +111,9 @@ def decoded(self) -> structs.Transaction: """ Decodes the raw transaction data into a :class:`structs.Transaction` object. + The raw transaction data is stored in the :attr:`raw` attribute and is + decoded using :mod:`msgspec.json`. + Example: >>> transaction = Transaction(...) >>> decoded_transaction = transaction.decoded @@ -439,4 +443,4 @@ def decoded(self) -> structs.TokenTransfer: See Also: - :class:`structs.TokenTransfer` """ - return json.decode(self.raw, type=structs.TokenTransfer) + return json.decode(self.raw, type=structs.TokenTransfer) \ No newline at end of file From 656272b0df577e01987d8c5ad7ed0ccd1cbb44c2 Mon Sep 17 00:00:00 2001 From: nvyncke Date: Fri, 28 Feb 2025 12:44:18 -0500 Subject: [PATCH 33/45] chore: auto-update documentation tests/conftest.py --- tests/conftest.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 8b8fc627..f5cf5c56 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,8 +20,6 @@ This will connect to the specified Brownie network and run the tests. - If the network is already connected, it will not attempt to reconnect. - See Also: - :mod:`brownie.network`: For more information on managing network connections with Brownie. - :mod:`os`: For more information on interacting with the operating system. From 127290ef91ee8eee7e3b45c896eb7dc648685550 Mon Sep 17 00:00:00 2001 From: nvyncke Date: Fri, 28 Feb 2025 12:45:51 -0500 Subject: [PATCH 34/45] chore: auto-update documentation eth_portfolio/_exceptions.py --- eth_portfolio/_exceptions.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/eth_portfolio/_exceptions.py b/eth_portfolio/_exceptions.py index 07847ee8..d331aaa4 100644 --- a/eth_portfolio/_exceptions.py +++ b/eth_portfolio/_exceptions.py @@ -51,9 +51,11 @@ async def load_remaining(self) -> None: """ Asynchronously handles the loading of the remaining ledger entries that are out of bounds. - This method invokes the :meth:`~eth_portfolio._ledgers.address.AddressLedgerBase._load_new_objects` - method of the associated ledger to fetch the ledger entries for the blocks that are outside - the cached range, ensuring that the entire requested block range is covered. + This method uses the :func:`asyncio.gather` function to invoke the + :meth:`~eth_portfolio._ledgers.address.AddressLedgerBase._load_new_objects` + method of the associated ledger twice, with different block ranges. + This ensures that the entire requested block range is covered by fetching + the ledger entries for the blocks that are outside the cached range. Examples: >>> await exception.load_remaining() @@ -64,4 +66,4 @@ async def load_remaining(self) -> None: return await gather( self.ledger._load_new_objects(self.start_block, self.ledger.cached_thru - 1), self.ledger._load_new_objects(self.ledger.cached_from + 1, self.end_block), - ) + ) \ No newline at end of file From 9d8aa689f12fed83b127d0b0356631a000e8bd67 Mon Sep 17 00:00:00 2001 From: nvyncke Date: Fri, 28 Feb 2025 12:52:17 -0500 Subject: [PATCH 35/45] chore: auto-update documentation tests/conftest.py --- tests/conftest.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index f5cf5c56..84e54edb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ """ -This module manages the connection to the Brownie network specified by the `PYTEST_NETWORK` -environment variable. It also modifies the system path to include the current directory, -allowing for importing modules from the project root. +This module attempts to connect to the Brownie network specified by the `PYTEST_NETWORK` +environment variable if it is not already connected. It also modifies the system path to +include the current directory, allowing for importing modules from the project root. Environment Variables: PYTEST_NETWORK: The name of the Brownie network to use for testing. This @@ -18,7 +18,7 @@ export PYTEST_NETWORK=mainnet-fork pytest - This will connect to the specified Brownie network and run the tests. + This will attempt to connect to the specified Brownie network and run the tests. See Also: - :mod:`brownie.network`: For more information on managing network connections with Brownie. From e1acb2b285dcf4ac6a015ac55ad8c91c8625170e Mon Sep 17 00:00:00 2001 From: nvyncke Date: Fri, 28 Feb 2025 12:53:20 -0500 Subject: [PATCH 36/45] chore: auto-update documentation eth_portfolio/_exceptions.py --- eth_portfolio/_exceptions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/eth_portfolio/_exceptions.py b/eth_portfolio/_exceptions.py index d331aaa4..2ba82388 100644 --- a/eth_portfolio/_exceptions.py +++ b/eth_portfolio/_exceptions.py @@ -53,9 +53,9 @@ async def load_remaining(self) -> None: This method uses the :func:`asyncio.gather` function to invoke the :meth:`~eth_portfolio._ledgers.address.AddressLedgerBase._load_new_objects` - method of the associated ledger twice, with different block ranges. - This ensures that the entire requested block range is covered by fetching - the ledger entries for the blocks that are outside the cached range. + method of the associated ledger with block ranges that cover the entire requested range. + Specifically, it fetches the ledger entries for the blocks from `start_block` to + `ledger.cached_thru - 1` and from `ledger.cached_from + 1` to `end_block`. Examples: >>> await exception.load_remaining() From 332cf5800d1f5f3dd61b19bb11b756b6046814ac Mon Sep 17 00:00:00 2001 From: nvyncke Date: Fri, 28 Feb 2025 13:02:09 -0500 Subject: [PATCH 37/45] chore: auto-update documentation eth_portfolio/protocols/lending/liquity.py --- eth_portfolio/protocols/lending/liquity.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/eth_portfolio/protocols/lending/liquity.py b/eth_portfolio/protocols/lending/liquity.py index ecd4e520..d6c30fd1 100644 --- a/eth_portfolio/protocols/lending/liquity.py +++ b/eth_portfolio/protocols/lending/liquity.py @@ -18,11 +18,6 @@ class Liquity(LendingProtocolWithLockedCollateral): This class is a subclass of :class:`~eth_portfolio.protocols.lending._base.LendingProtocolWithLockedCollateral`, which means it maintains a debt balance for a user and holds collateral internally. - Attributes: - networks: The networks on which the protocol is available. - troveManager: The contract instance for the Trove Manager. - start_block: The block number from which the protocol starts. - Examples: >>> liquity = Liquity() >>> balances = await liquity._balances("0xYourAddress", 12345678) @@ -34,10 +29,13 @@ class Liquity(LendingProtocolWithLockedCollateral): """ networks = [Network.Mainnet] + """The networks on which the protocol is available.""" def __init__(self) -> None: self.troveManager = Contract("0xA39739EF8b0231DbFA0DcdA07d7e29faAbCf4bb2") + """The contract instance for the Trove Manager.""" self.start_block = 12178557 + """The block number from which the protocol starts.""" @alru_cache(maxsize=128) @stuck_coro_debugger @@ -46,8 +44,8 @@ async def get_trove(self, address: Address, block: Block) -> dict: Retrieves the trove data for a given address at a specific block. Args: - address: The Ethereum address of the user. - block: The block number to query. + address (Address): The Ethereum address of the user. + block (Block): The block number to query. Examples: >>> trove_data = await liquity.get_trove("0xYourAddress", 12345678) @@ -61,8 +59,8 @@ async def _balances(self, address: Address, block: Optional[Block] = None) -> To Retrieves the collateral balances for a given address at a specific block. Args: - address: The Ethereum address of the user. - block: The block number to query. + address (Address): The Ethereum address of the user. + block (Optional[Block]): The block number to query. Examples: >>> balances = await liquity._balances("0xYourAddress", 12345678) @@ -90,8 +88,8 @@ async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenB Retrieves the debt balances for a given address at a specific block. Args: - address: The Ethereum address of the user. - block: The block number to query. + address (Address): The Ethereum address of the user. + block (Optional[Block]): The block number to query. Examples: >>> debt_balances = await liquity._debt("0xYourAddress", 12345678) @@ -109,4 +107,4 @@ async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenB lusd_debt /= 10**18 value = lusd_debt * await get_price(lusd, block, sync=False) balances[lusd] = Balance(lusd_debt, value, token=lusd, block=block) - return balances + return balances \ No newline at end of file From 1524deea7527e729fb5979831df80a4e2569b4fd Mon Sep 17 00:00:00 2001 From: nvyncke Date: Fri, 28 Feb 2025 13:02:39 -0500 Subject: [PATCH 38/45] chore: auto-update documentation eth_portfolio/_db/entities.py --- eth_portfolio/_db/entities.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/eth_portfolio/_db/entities.py b/eth_portfolio/_db/entities.py index 5a01b74c..89c4d4bc 100644 --- a/eth_portfolio/_db/entities.py +++ b/eth_portfolio/_db/entities.py @@ -59,14 +59,15 @@ class Transaction(DbEntity): This class provides a property to access decoded transaction data, which includes input data, signature components, and access lists. - The decoded data is accessed through the :attr:`decoded` property, + The decoded data is accessed through the `decoded` property, which returns a :class:`structs.Transaction` object decoded from the - :attr:`raw` attribute. + `raw` attribute using :mod:`msgspec.json`. See Also: - :class:`BlockExtended` - :class:`AddressExtended` - :class:`TokenTransfer` + - :mod:`msgspec.json` """ _id = PrimaryKey(int, auto=True) @@ -111,7 +112,7 @@ def decoded(self) -> structs.Transaction: """ Decodes the raw transaction data into a :class:`structs.Transaction` object. - The raw transaction data is stored in the :attr:`raw` attribute and is + The raw transaction data is stored in the `raw` attribute and is decoded using :mod:`msgspec.json`. Example: @@ -122,6 +123,7 @@ def decoded(self) -> structs.Transaction: See Also: - :class:`structs.Transaction` + - :mod:`msgspec.json` """ return json.decode(self.raw, type=structs.Transaction) From 90d96359693bf7b0b525c691484f23f023857022 Mon Sep 17 00:00:00 2001 From: nvyncke Date: Fri, 28 Feb 2025 13:09:41 -0500 Subject: [PATCH 39/45] chore: auto-update documentation eth_portfolio/_db/entities.py --- eth_portfolio/_db/entities.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/eth_portfolio/_db/entities.py b/eth_portfolio/_db/entities.py index 89c4d4bc..d598c223 100644 --- a/eth_portfolio/_db/entities.py +++ b/eth_portfolio/_db/entities.py @@ -60,8 +60,8 @@ class Transaction(DbEntity): This class provides a property to access decoded transaction data, which includes input data, signature components, and access lists. The decoded data is accessed through the `decoded` property, - which returns a :class:`structs.Transaction` object decoded from the - `raw` attribute using :mod:`msgspec.json`. + which returns a :class:`evmspec.structs.transaction.Transaction` object + decoded from the `raw` attribute using :mod:`msgspec.json`. See Also: - :class:`BlockExtended` @@ -110,7 +110,7 @@ class Transaction(DbEntity): @cached_property def decoded(self) -> structs.Transaction: """ - Decodes the raw transaction data into a :class:`structs.Transaction` object. + Decodes the raw transaction data into a :class:`evmspec.structs.transaction.Transaction` object. The raw transaction data is stored in the `raw` attribute and is decoded using :mod:`msgspec.json`. @@ -118,11 +118,11 @@ def decoded(self) -> structs.Transaction: Example: >>> transaction = Transaction(...) >>> decoded_transaction = transaction.decoded - >>> isinstance(decoded_transaction, structs.Transaction) + >>> isinstance(decoded_transaction, evmspec.structs.transaction.Transaction) True See Also: - - :class:`structs.Transaction` + - :class:`evmspec.structs.transaction.Transaction` - :mod:`msgspec.json` """ return json.decode(self.raw, type=structs.Transaction) @@ -139,7 +139,7 @@ def input(self) -> HexBytes: True See Also: - - :attr:`structs.Transaction.input` + - :attr:`evmspec.structs.transaction.Transaction.input` """ structs.Transaction.input.__doc__ return self.decoded.input @@ -156,7 +156,7 @@ def r(self) -> HexBytes: True See Also: - - :attr:`structs.Transaction.r` + - :attr:`evmspec.structs.transaction.Transaction.r` """ structs.Transaction.r.__doc__ return self.decoded.r @@ -173,7 +173,7 @@ def s(self) -> HexBytes: True See Also: - - :attr:`structs.Transaction.s` + - :attr:`evmspec.structs.transaction.Transaction.s` """ structs.Transaction.s.__doc__ return self.decoded.s @@ -190,7 +190,7 @@ def v(self) -> int: True See Also: - - :attr:`structs.Transaction.v` + - :attr:`evmspec.structs.transaction.Transaction.v` """ structs.Transaction.v.__doc__ return self.decoded.v @@ -210,7 +210,7 @@ def access_list(self) -> typing.List[AccessListEntry]: See Also: - :class:`AccessListEntry` - - :attr:`structs.Transaction.access_list` + - :attr:`evmspec.structs.transaction.Transaction.access_list` """ structs.Transaction.access_list.__doc__ return self.decoded.access_list @@ -227,7 +227,7 @@ def y_parity(self) -> typing.Optional[int]: True See Also: - - :attr:`structs.Transaction.y_parity` + - :attr:`evmspec.structs.transaction.Transaction.y_parity` """ structs.Transaction.y_parity.__doc__ return self.decoded.y_parity From dbfecb4519e3a4dedfb32105a7f0417e1b289114 Mon Sep 17 00:00:00 2001 From: nvyncke Date: Fri, 28 Feb 2025 13:11:19 -0500 Subject: [PATCH 40/45] chore: auto-update documentation eth_portfolio/protocols/lending/compound.py --- eth_portfolio/protocols/lending/compound.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/eth_portfolio/protocols/lending/compound.py b/eth_portfolio/protocols/lending/compound.py index a514ae40..ff8ffe6f 100644 --- a/eth_portfolio/protocols/lending/compound.py +++ b/eth_portfolio/protocols/lending/compound.py @@ -35,10 +35,9 @@ async def underlyings(self) -> List[ERC20]: This method gathers all markets from the Compound protocol's trollers and filters out those that do not have a `borrowBalanceStored` attribute - by using the :func:`hasattr` function directly on the result of - :func:`_get_contract`. It then separates markets into those that use - the native gas token and those that have an underlying ERC20 token, - fetching the underlying tokens accordingly. + by checking the result of :func:`_get_contract`. It then separates markets + into those that use the native gas token and those that have an underlying + ERC20 token, fetching the underlying tokens accordingly. Returns: A list of :class:`~y.classes.common.ERC20` instances representing the underlying tokens. @@ -182,4 +181,4 @@ async def _borrow_balance_stored( except ValueError as e: if str(e) != "No data was returned - the call likely reverted": raise - return None + return None \ No newline at end of file From 93ead4fa6589e6b66ba124a597aacfede87548ac Mon Sep 17 00:00:00 2001 From: nvyncke Date: Fri, 28 Feb 2025 13:11:39 -0500 Subject: [PATCH 41/45] chore: auto-update documentation eth_portfolio/buckets.py --- eth_portfolio/buckets.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/eth_portfolio/buckets.py b/eth_portfolio/buckets.py index 8208f0ad..de09dfd7 100644 --- a/eth_portfolio/buckets.py +++ b/eth_portfolio/buckets.py @@ -25,8 +25,8 @@ async def get_token_bucket(token: AnyAddressType) -> str: This function attempts to categorize a given token into predefined buckets such as "Cash & cash equivalents", "ETH", "BTC", "Other long term assets", or "Other short term assets". The categorization is based on the token's - characteristics and its presence in specific sets like `ETH_LIKE`, `BTC_LIKE`, - and `OTHER_LONG_TERM_ASSETS`. + characteristics and its presence in specific sets like :data:`ETH_LIKE`, :data:`BTC_LIKE`, + and :data:`OTHER_LONG_TERM_ASSETS`. Args: token: The address of the token to categorize. @@ -39,9 +39,16 @@ async def get_token_bucket(token: AnyAddressType) -> str: does not match the expected pattern. Example: + Categorize a stablecoin: + >>> await get_token_bucket("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") 'Cash & cash equivalents' + Categorize an ETH-like token: + + >>> await get_token_bucket("0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE") + 'ETH' + See Also: - :func:`_unwrap_token` - :func:`_is_stable` @@ -82,6 +89,8 @@ async def _unwrap_token(token) -> str: The address of the underlying asset. Example: + Unwrap a Yearn vault token: + >>> await _unwrap_token("0x5f18C75AbDAe578b483E5F43f12a39cF75b973a9") '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' @@ -128,6 +137,8 @@ def _pool_bucket(pool_tokens: set) -> Optional[str]: no specific category is found. Example: + Determine the bucket for a BTC-like pool: + >>> _pool_bucket({"0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599"}) '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599' @@ -151,13 +162,15 @@ def _is_stable(token: Address) -> bool: """ Check if a token is a stablecoin. - This function checks if a given token is present in the `STABLECOINS` or - `INTL_STABLECOINS` sets, indicating that it is a stablecoin. + This function checks if a given token is present in the :data:`STABLECOINS` or + :data:`INTL_STABLECOINS` sets, indicating that it is a stablecoin. Args: token: The address of the token to check. Example: + Check if a token is a stablecoin: + >>> _is_stable("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") True @@ -165,4 +178,4 @@ def _is_stable(token: Address) -> bool: - :data:`STABLECOINS` - :data:`INTL_STABLECOINS` """ - return token in STABLECOINS or token in INTL_STABLECOINS + return token in STABLECOINS or token in INTL_STABLECOINS \ No newline at end of file From 2a6c4a1d319b39c3d1ec69bffa50a522abca28ee Mon Sep 17 00:00:00 2001 From: nvyncke Date: Fri, 28 Feb 2025 13:16:07 -0500 Subject: [PATCH 42/45] chore: auto-update documentation eth_portfolio/_exceptions.py --- eth_portfolio/_exceptions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/eth_portfolio/_exceptions.py b/eth_portfolio/_exceptions.py index 2ba82388..e57b8589 100644 --- a/eth_portfolio/_exceptions.py +++ b/eth_portfolio/_exceptions.py @@ -11,7 +11,7 @@ class BlockRangeIsCached(Exception): """ Exception raised when a block range is already cached. - This exception is used to indicate that the requested block range + This exception indicates that the requested block range has already been loaded into memory and does not need to be fetched again. Examples: @@ -25,10 +25,10 @@ class BlockRangeOutOfBounds(Exception): """ Exception raised when a block range is out of bounds. - This exception is used to indicate that the requested block range + This exception indicates that the requested block range is outside the bounds of the cached data. It provides a method to handle the loading of the remaining ledger entries that are out of bounds - by invoking the appropriate method in the associated ledger. + by directly invoking the :meth:`_load_new_objects` method in the associated ledger. Args: start_block: The starting block number of the out-of-bounds range. @@ -51,7 +51,7 @@ async def load_remaining(self) -> None: """ Asynchronously handles the loading of the remaining ledger entries that are out of bounds. - This method uses the :func:`asyncio.gather` function to invoke the + This method uses the :func:`asyncio.gather` function to directly invoke the :meth:`~eth_portfolio._ledgers.address.AddressLedgerBase._load_new_objects` method of the associated ledger with block ranges that cover the entire requested range. Specifically, it fetches the ledger entries for the blocks from `start_block` to From fefee05529ba9d357d83b52a82679421f3cdaf9d Mon Sep 17 00:00:00 2001 From: nvyncke Date: Fri, 28 Feb 2025 14:48:51 -0500 Subject: [PATCH 43/45] chore: auto-update documentation eth_portfolio/_exceptions.py --- eth_portfolio/_exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eth_portfolio/_exceptions.py b/eth_portfolio/_exceptions.py index e57b8589..9113312c 100644 --- a/eth_portfolio/_exceptions.py +++ b/eth_portfolio/_exceptions.py @@ -28,7 +28,7 @@ class BlockRangeOutOfBounds(Exception): This exception indicates that the requested block range is outside the bounds of the cached data. It provides a method to handle the loading of the remaining ledger entries that are out of bounds - by directly invoking the :meth:`_load_new_objects` method in the associated ledger. + by invoking the :meth:`load_remaining` method asynchronously. Args: start_block: The starting block number of the out-of-bounds range. From edfd83b66253174df1d6ccde1d199ce3c6557b8f Mon Sep 17 00:00:00 2001 From: BobTheBuidler Date: Sun, 2 Mar 2025 03:15:22 +0000 Subject: [PATCH 44/45] wip docs flattened --- eth_portfolio/_db/entities.py | 240 +++++++++++++++++++++++++- eth_portfolio/_decimal.py | 72 +++++++- eth_portfolio/_ydb/token_transfers.py | 111 +++++++++++- eth_portfolio/address.py | 34 +++- setup.py | 62 ++++--- tests/conftest.py | 41 +++-- tests/protocols/test_external.py | 9 +- 7 files changed, 505 insertions(+), 64 deletions(-) diff --git a/eth_portfolio/_db/entities.py b/eth_portfolio/_db/entities.py index 3dcd4bde..e04bdaff 100644 --- a/eth_portfolio/_db/entities.py +++ b/eth_portfolio/_db/entities.py @@ -54,85 +54,227 @@ class TokenExtended(Token, AddressExtended): class Transaction(DbEntity): + """ + Represents a transaction entity in the database. + + This class provides a property to access decoded transaction data, + which includes input data, signature components, and access lists. + The decoded data is accessed through the :attr:`decoded` property, + which returns a :class:`structs.Transaction` object. + + See Also: + - :class:`BlockExtended` + - :class:`AddressExtended` + - :class:`TokenTransfer` + """ + _id = PrimaryKey(int, auto=True) + "The primary key of the transaction." block = Required(BlockExtended, lazy=True, reverse="transactions") + "The block containing this transaction." transaction_index = Required(int, lazy=True) + "The index of the transaction within the block." hash = Required(str, index=True, lazy=True) + "The hash of the transaction." from_address = Required(AddressExtended, index=True, lazy=True, reverse="transactions_sent") + "The address that sent the transaction." to_address = Optional(AddressExtended, index=True, lazy=True, reverse="transactions_received") + "The address that received the transaction." value = Required(Decimal, 38, 18, lazy=True) + "The value transferred in the transaction." price = Optional(Decimal, 38, 18, lazy=True) + "The price of the transaction." value_usd = Optional(Decimal, 38, 18, lazy=True) + "The USD value of the transaction." nonce = Required(int, lazy=True) + "The nonce of the transaction." type = Optional(int, lazy=True) + "The type of the transaction." gas = Required(Decimal, 38, 1, lazy=True) + "The gas used by the transaction." gas_price = Required(Decimal, 38, 1, lazy=True) + "The gas price of the transaction." max_fee_per_gas = Optional(Decimal, 38, 1, lazy=True) + "The maximum fee per gas for the transaction." max_priority_fee_per_gas = Optional(Decimal, 38, 1, lazy=True) + "The maximum priority fee per gas for the transaction." composite_key(block, transaction_index) raw = Required(bytes, lazy=True) + "The raw bytes of the transaction." @cached_property def decoded(self) -> structs.Transaction: + """ + Decodes the raw transaction data into a :class:`structs.Transaction` object. + + Example: + >>> transaction = Transaction(...) + >>> decoded_transaction = transaction.decoded + >>> isinstance(decoded_transaction, structs.Transaction) + True + + See Also: + - :class:`structs.Transaction` + """ return json.decode(self.raw, type=structs.Transaction) @property def input(self) -> HexBytes: + """ + Returns the input data of the transaction. + + Example: + >>> transaction = Transaction(...) + >>> input_data = transaction.input + >>> isinstance(input_data, HexBytes) + True + + See Also: + - :attr:`structs.Transaction.input` + """ structs.Transaction.input.__doc__ return self.decoded.input @property def r(self) -> HexBytes: + """ + Returns the R component of the transaction's signature. + + Example: + >>> transaction = Transaction(...) + >>> r_value = transaction.r + >>> isinstance(r_value, HexBytes) + True + + See Also: + - :attr:`structs.Transaction.r` + """ structs.Transaction.r.__doc__ return self.decoded.r @property def s(self) -> HexBytes: + """ + Returns the S component of the transaction's signature. + + Example: + >>> transaction = Transaction(...) + >>> s_value = transaction.s + >>> isinstance(s_value, HexBytes) + True + + See Also: + - :attr:`structs.Transaction.s` + """ structs.Transaction.s.__doc__ return self.decoded.s @property def v(self) -> int: + """ + Returns the V component of the transaction's signature. + + Example: + >>> transaction = Transaction(...) + >>> v_value = transaction.v + >>> isinstance(v_value, int) + True + + See Also: + - :attr:`structs.Transaction.v` + """ structs.Transaction.v.__doc__ return self.decoded.v @property def access_list(self) -> typing.List[AccessListEntry]: + """ + Returns the access list of the transaction. + + Example: + >>> transaction = Transaction(...) + >>> access_list = transaction.access_list + >>> isinstance(access_list, list) + True + >>> isinstance(access_list[0], AccessListEntry) + True + + See Also: + - :class:`AccessListEntry` + - :attr:`structs.Transaction.access_list` + """ structs.Transaction.access_list.__doc__ return self.decoded.access_list @property def y_parity(self) -> typing.Optional[int]: - structs.TokenTransfer.y_parity.__doc__ + """ + Returns the y_parity of the transaction. + + Example: + >>> transaction = Transaction(...) + >>> y_parity_value = transaction.y_parity + >>> isinstance(y_parity_value, (int, type(None))) + True + + See Also: + - :attr:`structs.Transaction.y_parity` + """ + structs.Transaction.y_parity.__doc__ return self.decoded.y_parity class InternalTransfer(DbEntity): + """ + Represents an internal transfer entity in the database. + + This class provides properties to access decoded internal transfer data, + including input, output, and code. + + See Also: + - :class:`BlockExtended` + - :class:`AddressExtended` + """ + _id = PrimaryKey(int, auto=True) + "The primary key of the internal transfer." # common block = Required(BlockExtended, lazy=True, reverse="internal_transfers") + "The block containing this internal transfer." transaction_index = Required(int, lazy=True) + "The index of the transaction within the block." hash = Required(str, lazy=True) + "The hash of the internal transfer." from_address = Required( AddressExtended, index=True, lazy=True, reverse="internal_transfers_sent" ) + "The address that sent the internal transfer." to_address = Optional( AddressExtended, index=True, lazy=True, reverse="internal_transfers_received" ) + "The address that received the internal transfer." value = Required(Decimal, 38, 18, lazy=True) + "The value transferred in the internal transfer." price = Optional(Decimal, 38, 18, lazy=True) + "The price of the internal transfer." value_usd = Optional(Decimal, 38, 18, lazy=True) + "The USD value of the internal transfer." # unique type = Required(str, lazy=True) + "The type of the internal transfer." call_type = Required(str, lazy=True) + "The call type of the internal transfer." trace_address = Required(str, lazy=True) + "The trace address of the internal transfer." gas = Required(Decimal, 38, 1, lazy=True) + "The gas used by the internal transfer." gas_used = Optional(Decimal, 38, 1, lazy=True) + "The gas used by the internal transfer." composite_key( block, @@ -149,56 +291,152 @@ class InternalTransfer(DbEntity): ) raw = Required(bytes, lazy=True) + "The raw bytes of the internal transfer." @cached_property def decoded(self) -> structs.InternalTransfer: + """ + Decodes the raw internal transfer data into a :class:`structs.InternalTransfer` object. + + Example: + >>> internal_transfer = InternalTransfer(...) + >>> decoded_transfer = internal_transfer.decoded + >>> isinstance(decoded_transfer, structs.InternalTransfer) + True + + See Also: + - :class:`structs.InternalTransfer` + """ structs.InternalTransfer.__doc__ return json.decode(self.raw, type=structs.InternalTransfer) @property def code(self) -> HexBytes: + """ + Returns the code of the internal transfer. + + Example: + >>> internal_transfer = InternalTransfer(...) + >>> code_data = internal_transfer.code + >>> isinstance(code_data, HexBytes) + True + + See Also: + - :attr:`structs.InternalTransfer.code` + """ structs.InternalTransfer.code.__doc__ return self.decoded.code @property def input(self) -> HexBytes: + """ + Returns the input data of the internal transfer. + + Example: + >>> internal_transfer = InternalTransfer(...) + >>> input_data = internal_transfer.input + >>> isinstance(input_data, HexBytes) + True + + See Also: + - :attr:`structs.InternalTransfer.input` + """ structs.InternalTransfer.input.__doc__ return self.decoded.input @property def output(self) -> HexBytes: + """ + Returns the output data of the internal transfer. + + Example: + >>> internal_transfer = InternalTransfer(...) + >>> output_data = internal_transfer.output + >>> isinstance(output_data, HexBytes) + True + + See Also: + - :attr:`structs.InternalTransfer.output` + """ structs.InternalTransfer.output.__doc__ return self.decoded.output @property def subtraces(self) -> int: + """ + Returns the number of subtraces of the internal transfer. + + Example: + >>> internal_transfer = InternalTransfer(...) + >>> subtraces_count = internal_transfer.subtraces + >>> isinstance(subtraces_count, int) + True + + See Also: + - :attr:`structs.InternalTransfer.subtraces` + """ structs.InternalTransfer.subtraces.__doc__ return self.decoded.subtraces class TokenTransfer(DbEntity): + """ + Represents a token transfer entity in the database. + + This class provides properties to access decoded token transfer data. + + See Also: + - :class:`BlockExtended` + - :class:`AddressExtended` + - :class:`TokenExtended` + """ + _id = PrimaryKey(int, auto=True) + "The primary key of the token transfer." # common block = Required(BlockExtended, lazy=True, reverse="token_transfers") + "The block containing this token transfer." transaction_index = Required(int, lazy=True) + "The index of the transaction within the block." hash = Required(str, lazy=True) + "The hash of the token transfer." from_address = Required(AddressExtended, index=True, lazy=True, reverse="token_transfers_sent") + "The address that sent the token transfer." to_address = Required( AddressExtended, index=True, lazy=True, reverse="token_transfers_received" ) + "The address that received the token transfer." value = Required(Decimal, 38, 18, lazy=True) + "The value transferred in the token transfer." price = Optional(Decimal, 38, 18, lazy=True) + "The price of the token transfer." value_usd = Optional(Decimal, 38, 18, lazy=True) + "The USD value of the token transfer." # unique log_index = Required(int, lazy=True) + "The log index of the token transfer." token = Optional(TokenExtended, index=True, lazy=True, reverse="transfers") + "The token involved in the transfer." composite_key(block, transaction_index, log_index) raw = Required(bytes, lazy=True) + "The raw bytes of the token transfer." @cached_property def decoded(self) -> structs.TokenTransfer: + """ + Decodes the raw token transfer data into a :class:`structs.TokenTransfer` object. + + Example: + >>> token_transfer = TokenTransfer(...) + >>> decoded_transfer = token_transfer.decoded + >>> isinstance(decoded_transfer, structs.TokenTransfer) + True + + See Also: + - :class:`structs.TokenTransfer` + """ return json.decode(self.raw, type=structs.TokenTransfer) diff --git a/eth_portfolio/_decimal.py b/eth_portfolio/_decimal.py index e8b3f083..42019912 100644 --- a/eth_portfolio/_decimal.py +++ b/eth_portfolio/_decimal.py @@ -61,39 +61,109 @@ def jsonify(self) -> Union[str, int]: while string[-1] == "0": string = string[:-1] - if type(self)(scientific_notation) == self and len(scientific_notation) < len(string): + if len(scientific_notation) < len(string): return scientific_notation return string def __add__(self, other): + """ + Adds two :class:`Decimal` values, ensuring the result is of type :class:`Decimal`. + + Examples: + >>> Decimal('1.1') + Decimal('2.2') + Decimal('3.3') + """ return type(self)(super().__add__(other)) def __radd__(self, other): + """ + Adds two :class:`Decimal` values with reflected operands, ensuring the result is of type :class:`Decimal`. + + Examples: + >>> Decimal('1.1').__radd__(Decimal('2.2')) + Decimal('3.3') + """ return type(self)(super().__radd__(other)) def __sub__(self, other): + """ + Subtracts two :class:`Decimal` values, ensuring the result is of type :class:`Decimal`. + + Examples: + >>> Decimal('3.3') - Decimal('1.1') + Decimal('2.2') + """ return type(self)(super().__sub__(other)) def __rsub__(self, other): + """ + Subtracts two :class:`Decimal` values with reflected operands, ensuring the result is of type :class:`Decimal`. + + Examples: + >>> Decimal('3.3').__rsub__(Decimal('1.1')) + Decimal('-2.2') + """ return type(self)(super().__rsub__(other)) def __mul__(self, other): + """ + Multiplies two :class:`Decimal` values, ensuring the result is of type :class:`Decimal`. + + Examples: + >>> Decimal('2') * Decimal('3') + Decimal('6') + """ return type(self)(super().__mul__(other)) def __rmul__(self, other): + """ + Multiplies two :class:`Decimal` values with reflected operands, ensuring the result is of type :class:`Decimal`. + + Examples: + >>> Decimal('2').__rmul__(Decimal('3')) + Decimal('6') + """ return type(self)(super().__rmul__(other)) def __truediv__(self, other): + """ + Divides two :class:`Decimal` values, ensuring the result is of type :class:`Decimal`. + + Examples: + >>> Decimal('6') / Decimal('3') + Decimal('2') + """ return type(self)(super().__truediv__(other)) def __rtruediv__(self, other): + """ + Divides two :class:`Decimal` values with reflected operands, ensuring the result is of type :class:`Decimal`. + + Examples: + >>> Decimal('6').__rtruediv__(Decimal('3')) + Decimal('0.5') + """ return type(self)(super().__rtruediv__(other)) def __floordiv__(self, other): + """ + Performs floor division on two :class:`Decimal` values, ensuring the result is of type :class:`Decimal`. + + Examples: + >>> Decimal('7') // Decimal('3') + Decimal('2') + """ return type(self)(super().__floordiv__(other)) def __rfloordiv__(self, other): + """ + Performs floor division on two :class:`Decimal` values with reflected operands, ensuring the result is of type :class:`Decimal`. + + Examples: + >>> Decimal('7').__rfloordiv__(Decimal('3')) + Decimal('0') + """ return type(self)(super().__rfloordiv__(other)) diff --git a/eth_portfolio/_ydb/token_transfers.py b/eth_portfolio/_ydb/token_transfers.py index 3d129c66..890b4792 100644 --- a/eth_portfolio/_ydb/token_transfers.py +++ b/eth_portfolio/_ydb/token_transfers.py @@ -34,11 +34,32 @@ class _TokenTransfers(ProcessedEvents["Task[TokenTransfer]"]): - """A helper mixin that contains all logic for fetching token transfers for a particular wallet address""" + """ + A helper mixin that contains all logic for fetching token transfers for a particular address. + + Examples: + Fetching token transfers for a specific address: + + >>> transfers = _TokenTransfers(address="0x123...", from_block=0, load_prices=True) + >>> async for transfer in transfers.yield_thru_block(1000000): + ... print(transfer) + + See Also: + - :class:`~eth_portfolio.structs.TokenTransfer`: For the structure of a token transfer. + - :func:`~eth_portfolio._loaders.load_token_transfer`: For loading token transfer data. + """ __slots__ = "address", "_load_prices" def __init__(self, address: Address, from_block: int, load_prices: bool = False): + """ + Initialize a _TokenTransfers instance. + + Args: + address: The address for which token transfers are fetched. + from_block: The block number from which to start fetching token transfers. + load_prices: Indicates whether to load prices for the token transfers. + """ self.address = address self._load_prices = load_prices super().__init__(topics=self._topics, from_block=from_block) @@ -52,6 +73,19 @@ def _topics(self) -> List: ... @ASyncIterator.wrap # type: ignore [call-overload] async def yield_thru_block(self, block) -> AsyncIterator["Task[TokenTransfer]"]: + """ + Yield token transfers up to a specified block. + + Args: + block: The block number up to which token transfers are yielded. + + Yields: + Tasks that resolve to :class:`~eth_portfolio.structs.TokenTransfer` objects. + + Examples: + >>> async for transfer in transfers.yield_thru_block(1000000): + ... print(transfer) + """ if not _logger_is_enabled_for(DEBUG): async for task in self._objects_thru(block=block): yield task @@ -68,6 +102,15 @@ async def yield_thru_block(self, block) -> AsyncIterator["Task[TokenTransfer]"]: _logger_log(DEBUG, "%s yield thru %s complete", (self, block)) async def _extend(self, objs: List[evmspec.Log]) -> None: + """ + Extend the list of token transfers with new logs. + + Args: + objs: A list of :class:`~evmspec.Log` objects representing token transfer logs. + + Examples: + >>> await transfers._extend(logs) + """ shitcoins = SHITCOINS.get(chain.id, set()) append_loader_task = self._objects.append done = 0 @@ -98,7 +141,19 @@ def _done_callback(self, task: Task) -> None: class InboundTokenTransfers(_TokenTransfers): - """A container that fetches and iterates over all inbound token transfers for a particular wallet address""" + """ + A container that fetches and iterates over all inbound token transfers for a particular address. + + Examples: + Fetching inbound token transfers for a specific address: + + >>> inbound_transfers = InboundTokenTransfers(address="0x123...", from_block=0, load_prices=True) + >>> async for transfer in inbound_transfers.yield_thru_block(1000000): + ... print(transfer) + + See Also: + - :class:`~eth_portfolio.structs.TokenTransfer`: For the structure of a token transfer. + """ @property def _topics(self) -> List: @@ -106,7 +161,19 @@ def _topics(self) -> List: class OutboundTokenTransfers(_TokenTransfers): - """A container that fetches and iterates over all outbound token transfers for a particular wallet address""" + """ + A container that fetches and iterates over all outbound token transfers for a particular address. + + Examples: + Fetching outbound token transfers for a specific address: + + >>> outbound_transfers = OutboundTokenTransfers(address="0x123...", from_block=0, load_prices=True) + >>> async for transfer in outbound_transfers.yield_thru_block(1000000): + ... print(transfer) + + See Also: + - :class:`~eth_portfolio.structs.TokenTransfer`: For the structure of a token transfer. + """ @property def _topics(self) -> List: @@ -115,8 +182,19 @@ def _topics(self) -> List: class TokenTransfers(ASyncIterable[TokenTransfer]): """ - A container that fetches and iterates over all token transfers for a particular wallet address. - NOTE: These do not come back in chronologcal order. + A container that fetches and iterates over all token transfers for a particular address. + + Examples: + Fetching all token transfers for a specific address: + + >>> token_transfers = TokenTransfers(address="0x123...", from_block=0, load_prices=True) + >>> async for transfer in token_transfers: + ... print(transfer) + + See Also: + - :class:`~eth_portfolio.structs.TokenTransfer`: For the structure of a token transfer. + - :class:`~InboundTokenTransfers`: For fetching inbound token transfers. + - :class:`~OutboundTokenTransfers`: For fetching outbound token transfers. """ def __init__(self, address: Address, from_block: int, load_prices: bool = False): @@ -124,10 +202,33 @@ def __init__(self, address: Address, from_block: int, load_prices: bool = False) self.transfers_out = OutboundTokenTransfers(address, from_block, load_prices=load_prices) async def __aiter__(self): + """ + Asynchronously iterate over all token transfers. + + Yields: + :class:`~eth_portfolio.structs.TokenTransfer` objects. + + Examples: + >>> async for transfer in token_transfers: + ... print(transfer) + """ async for transfer in self.yield_thru_block(await dank_mids.eth.block_number): yield transfer def yield_thru_block(self, block: int) -> ASyncIterator["Task[TokenTransfer]"]: + """ + Yield token transfers up to a specified block. + + Args: + block: The block number up to which token transfers are yielded. + + Yields: + Tasks that resolve to :class:`~eth_portfolio.structs.TokenTransfer` objects. + + Examples: + >>> async for transfer in token_transfers.yield_thru_block(1000000): + ... print(transfer) + """ return ASyncIterator( as_yielded( self.transfers_in.yield_thru_block(block), diff --git a/eth_portfolio/address.py b/eth_portfolio/address.py index 9ae3eac4..8f7de88c 100644 --- a/eth_portfolio/address.py +++ b/eth_portfolio/address.py @@ -48,6 +48,19 @@ class PortfolioAddress(_LedgeredBase[AddressLedgerBase]): """ Represents a portfolio address within the eth-portfolio system. + + This class is designed to manage different aspects of an Ethereum address within the portfolio, + such as transactions, transfers, balances, and interactions with both external and lending protocols. + + Key components and functionalities provided by the :class:`~eth_portfolio.address.PortfolioAddress` class include: + - Handling Ethereum and token balances + - Managing debt and collateral from lending protocols + - Tracking transactions and transfers (both internal and token transfers) + - Providing comprehensive balance descriptions at specific block heights + + The class leverages asynchronous operations using the `a_sync` library to efficiently gather and process data. + It also integrates with various submodules from `eth-portfolio` to load balances, manage ledgers, and interact + with external protocols. """ def __init__( @@ -59,19 +72,30 @@ def __init__( asynchronous: bool = False, ) -> None: # type: ignore """ - Initializes the PortfolioAddress instance. + Initializes the :class:`~PortfolioAddress` instance. Args: - address: The address to manage. - portfolio: The portfolio instance managing this address. + address: The Ethereum address to manage. + start_block: The block number from which to start tracking. + load_prices: Flag indicating if price loading is enabled. + num_workers_transactions (optional): Number of workers for transaction processing. Defaults to 1000. asynchronous (optional): Flag for asynchronous operation. Defaults to False. Raises: TypeError: If `asynchronous` is not a boolean. Examples: - >>> portfolio = Portfolio() - >>> address = PortfolioAddress('0x1234...', portfolio) + >>> address = PortfolioAddress('0x1234...', 0, True) + >>> print(address) + + >>> address = PortfolioAddress('0x1234...', 0, False, num_workers_transactions=500, asynchronous=True) + >>> print(address) + + See Also: + - :class:`~eth_portfolio.portfolio.Portfolio` + - :class:`~eth_portfolio._ledgers.address.AddressTransactionsLedger` + - :class:`~eth_portfolio._ledgers.address.AddressInternalTransfersLedger` + - :class:`~eth_portfolio._ledgers.address.AddressTokenTransfersLedger` """ self.address = convert.to_address(address) """ diff --git a/setup.py b/setup.py index 9b30e465..74f905ed 100644 --- a/setup.py +++ b/setup.py @@ -1,28 +1,5 @@ from setuptools import find_packages, setup # type: ignore -with open("requirements.txt", "r") as f: - requirements = list(map(str.strip, f.read().split("\n")))[:-1] - -setup( - name="eth-portfolio", - packages=find_packages(), - use_scm_version={ - "root": ".", - "relative_to": __file__, - "local_scheme": "no-local-version", - "version_scheme": "python-simplified-semver", - }, - description="eth-portfolio makes it easy to analyze your portfolio.", - author="BobTheBuidler", - author_email="bobthebuidlerdefi@gmail.com", - url="https://github.com/BobTheBuidler/eth-portfolio", - install_requires=requirements, - setup_requires=["setuptools_scm", "cython"], - package_data={ - "eth_portfolio": ["py.typed"], - }, -) - """ Installation ------------ @@ -30,13 +7,17 @@ Due to the use of :mod:`setuptools_scm` for versioning, it is recommended to clone the repository first to ensure the version can be determined correctly. -The `setup.py` file automatically handles the installation of :mod:`setuptools_scm` and :mod:`cython`, -so you do not need to install them manually before running the setup process. Additionally, -the `requirements.txt` file is used to specify additional dependencies that are installed via -the `install_requires` parameter. Note that the last line of `requirements.txt` is intentionally excluded -from installation, so ensure that any necessary dependency is not placed on the last line. +The `setup.py` file specifies the installation of :mod:`setuptools_scm` and :mod:`cython` +via the `setup_requires` parameter. These dependencies must be available in your environment +before running the setup process. Additionally, the `requirements.txt` file is used to specify +additional dependencies that are installed via the `install_requires` parameter. Note that the +last line of `requirements.txt` is typically an empty string due to the split operation and is +therefore excluded from installation. Ensure that any necessary dependency is not placed on the +last line before the empty line. Example: + Clone the repository and install the package: + .. code-block:: bash git clone https://github.com/BobTheBuidler/eth-portfolio.git @@ -46,6 +27,8 @@ If you encounter issues with :mod:`PyYaml` and :mod:`Cython`, you can resolve them by installing specific versions: Example: + Install specific versions of dependencies to resolve issues: + .. code-block:: bash pip install wheel @@ -57,3 +40,26 @@ - :mod:`cython`: For more information on Cython. - :mod:`requirements.txt`: For more information on managing dependencies. """ + +with open("requirements.txt", "r") as f: + requirements = list(map(str.strip, f.read().split("\n")))[:-1] + +setup( + name="eth-portfolio", + packages=find_packages(), + use_scm_version={ + "root": ".", + "relative_to": __file__, + "local_scheme": "no-local-version", + "version_scheme": "python-simplified-semver", + }, + description="eth-portfolio makes it easy to analyze your portfolio.", + author="BobTheBuidler", + author_email="bobthebuidlerdefi@gmail.com", + url="https://github.com/BobTheBuidler/eth-portfolio", + install_requires=requirements, + setup_requires=["setuptools_scm", "cython"], + package_data={ + "eth_portfolio": ["py.typed"], + }, +) diff --git a/tests/conftest.py b/tests/conftest.py index 093aeec1..31829905 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,25 +1,7 @@ -import os -import sys - -from brownie import network - -sys.path.insert(0, os.path.abspath(".")) - -try: - brownie_network = os.environ["PYTEST_NETWORK"] -except KeyError: - raise ValueError( - "Please set the PYTEST_NETWORK environment variable to the name of the brownie network you want to use for testing." - ) - -if not network.is_connected(): - network.connect(brownie_network) - """ -This module configures the testing environment for the project by ensuring -that the Brownie network is connected using the specified network. It also -modifies the system path to include the current directory, allowing for -importing modules from the project root. +This module connects to the Brownie network specified by the `PYTEST_NETWORK` +environment variable. It also modifies the system path to include the current +directory, allowing for importing modules from the project root. Environment Variables: PYTEST_NETWORK: The name of the Brownie network to use for testing. This @@ -43,3 +25,20 @@ - :mod:`os`: For more information on interacting with the operating system. - :mod:`sys`: For more information on the system-specific parameters and functions. """ + +import os +import sys + +from brownie import network + +sys.path.insert(0, os.path.abspath(".")) + +try: + brownie_network = os.environ["PYTEST_NETWORK"] +except KeyError: + raise ValueError( + "Please set the PYTEST_NETWORK environment variable to the name of the brownie network you want to use for testing." + ) + +if not network.is_connected(): + network.connect(brownie_network) diff --git a/tests/protocols/test_external.py b/tests/protocols/test_external.py index 32c015fe..212e69cf 100644 --- a/tests/protocols/test_external.py +++ b/tests/protocols/test_external.py @@ -21,7 +21,8 @@ class MockProtocolB(AsyncMock): @patch("a_sync.map") @pytest.mark.asyncio async def test_balances_no_protocols(mock_map): - """Test the `balances` function with no protocols. + """ + Test the `balances` function with no protocols. This test verifies that when there are no protocols in the `protocols.protocols` list, the `balances` function returns an @@ -44,7 +45,8 @@ async def test_balances_no_protocols(mock_map): @pytest.mark.asyncio async def test_balances_with_protocols(): - """Test the `balances` function with multiple protocols. + """ + Test the `balances` function with multiple protocols. This test verifies that when there are multiple protocols in the `protocols.protocols` list, the `balances` function correctly @@ -80,7 +82,8 @@ async def test_balances_with_protocols(): @pytest.mark.asyncio async def test_balances_with_protocols_and_block(): - """Test the `balances` function with protocols and a specific block. + """ + Test the `balances` function with protocols and a specific block. This test verifies that when there are multiple protocols in the `protocols.protocols` list and a specific block is provided, the From d89c543d4ac07094c86dac81a5e0ecb106ad5c51 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 2 Mar 2025 21:26:53 +0000 Subject: [PATCH 45/45] chore: `black .` --- eth_portfolio/protocols/lending/compound.py | 2 +- eth_portfolio/protocols/lending/liquity.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/eth_portfolio/protocols/lending/compound.py b/eth_portfolio/protocols/lending/compound.py index 91ed007e..a514ae40 100644 --- a/eth_portfolio/protocols/lending/compound.py +++ b/eth_portfolio/protocols/lending/compound.py @@ -182,4 +182,4 @@ async def _borrow_balance_stored( except ValueError as e: if str(e) != "No data was returned - the call likely reverted": raise - return None \ No newline at end of file + return None diff --git a/eth_portfolio/protocols/lending/liquity.py b/eth_portfolio/protocols/lending/liquity.py index d791176c..ef754222 100644 --- a/eth_portfolio/protocols/lending/liquity.py +++ b/eth_portfolio/protocols/lending/liquity.py @@ -107,4 +107,4 @@ async def _debt(self, address: Address, block: Optional[Block] = None) -> TokenB lusd_debt /= 10**18 value = lusd_debt * await get_price(lusd, block, sync=False) balances[lusd] = Balance(lusd_debt, value, token=lusd, block=block) - return balances \ No newline at end of file + return balances