diff --git a/eth_portfolio/_ledgers/address.py b/eth_portfolio/_ledgers/address.py index 6e3aee99..46b18e8c 100644 --- a/eth_portfolio/_ledgers/address.py +++ b/eth_portfolio/_ledgers/address.py @@ -1,3 +1,14 @@ +""" +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 +and processing without blocking, thus improving the overall responsiveness and performance of portfolio operations. +""" + import abc import asyncio import logging @@ -40,36 +51,92 @@ class BadResponse(Exception): PandableLedgerEntryList = Union["TransactionsList", "InternalTransfersList", "TokenTransfersList"] class AddressLedgerBase(a_sync.ASyncGenericBase, _AiterMixin[T], Generic[_LedgerEntryList, T], metaclass=abc.ABCMeta): + """ + Abstract base class for address ledgers in the eth-portfolio system. + """ __slots__ = "address", "asynchronous", "cached_from", "cached_thru", "load_prices", "objects", "portfolio_address", "_lock" def __init__(self, portfolio_address: "PortfolioAddress") -> None: # TODO replace the following line with an abc implementation. # assert isinstance(portfolio_address, PortfolioAddress), f"address must be a PortfolioAddress. try passing in PortfolioAddress({portfolio_address}) instead." + """ + Initializes the AddressLedgerBase instance. + Args: + portfolio_address: The :class:`~eth_portfolio.address.PortfolioAddress` this ledger belongs to. + """ self.portfolio_address = portfolio_address + """ + The portfolio address this ledger belongs to. + """ self.address = self.portfolio_address.address + """ + The Ethereum address being managed. + """ self.asynchronous = self.portfolio_address.asynchronous + """ + Flag indicating if the operations are asynchronous. + """ self.load_prices = self.portfolio_address.load_prices + """ + Indicates if price loading is enabled. + """ self.objects: _LedgerEntryList = self._list_type() - # The following two properties will both be ints once the cache has contents - self.cached_from: int = None # type: ignore - """The block from which all entries for this ledger have been loaded into memory""" - self.cached_thru: int = None # type: ignore - """The block thru which all entries for this ledger have been loaded into memory""" + + # The following two properties will both be ints once the cache has contents + """ + _LedgerEntryList: List of ledger entries. + """ + self.cached_from: int = None # type: ignore + """ + The block from which all entries for this ledger have been loaded into memory. + """ + self.cached_thru: int = None # type: ignore + """ + The block through which all entries for this ledger have been loaded into memory. + """ self._lock = asyncio.Lock() + """ + asyncio.Lock: Lock for synchronizing access to ledger entries. + """ def __hash__(self) -> int: + """ + Returns the hash of the address. + + Returns: + The hash value. + """ return hash(self.address) @abc.abstractproperty def _list_type(self) -> Type[_LedgerEntryList]: + """ + Type of list used to store ledger entries. + """ ... @property def _start_block(self) -> int: + """ + Returns the starting block for the portfolio address. + + Returns: + The starting block number. + """ return self.portfolio_address._start_block async def _get_and_yield(self, start_block: Block, end_block: Block) -> AsyncGenerator[T, None]: + """ + Yields ledger entries between the specified blocks. + + Args: + start_block: The starting block number. + end_block: The ending block number. + + Yields: + AsyncGenerator[T, None]: An async generator of ledger entries. + """ yielded = set() for obj in self.objects: block = obj.block_number @@ -100,6 +167,19 @@ async def _get_and_yield(self, start_block: Block, end_block: Block) -> AsyncGen @set_end_block_if_none @stuck_coro_debugger async def get(self, start_block: Block, end_block: Block) -> _LedgerEntryList: + """ + Retrieves ledger entries between the specified blocks. + + Args: + start_block: The starting block number. + end_block: The ending block number. + + Returns: + _LedgerEntryList: The list of ledger entries. + + Examples: + >>> entries = await ledger.get(12000000, 12345678) + """ objects = self._list_type() async for obj in self[start_block: end_block]: objects.append(obj) @@ -107,6 +187,15 @@ async def get(self, start_block: Block, end_block: Block) -> _LedgerEntryList: @stuck_coro_debugger async def new(self) -> _LedgerEntryList: + """ + Retrieves new ledger entries since the last cached block. + + Returns: + _LedgerEntryList: The list of new ledger entries. + + Examples: + >>> new_entries = await ledger.new() + """ start_block = 0 if self.cached_thru is None else self.cached_thru + 1 end_block = await get_buffered_chain_height() return self[start_block, end_block] @@ -114,15 +203,50 @@ async def new(self) -> _LedgerEntryList: @set_end_block_if_none @stuck_coro_debugger async def _get_new_objects(self, start_block: Block, end_block: Block) -> AsyncIterator[T]: + """ + Retrieves new ledger entries between the specified blocks. + + Args: + start_block: The starting block number. + end_block: The ending block number. + + Yields: + AsyncIterator[T]: An async iterator of new ledger entries. + """ async with self._lock: async for obj in self._load_new_objects(start_block, end_block): yield obj @abc.abstractmethod async def _load_new_objects(self, start_block: Block, end_block: Block) -> AsyncIterator[T]: + """ + Abstract method to load new ledger entries between the specified blocks. + + Args: + start_block: The starting block number. + end_block: The ending block number. + + Yields: + AsyncIterator[T]: An async iterator of new ledger entries. + """ yield def _check_blocks_against_cache(self, start_block: Block, end_block: Block) -> Tuple[Block, Block]: + """ + Checks the specified block range against the cached block range. + + Args: + start_block: The starting block number. + end_block: The ending block number. + + Returns: + Tuple: The adjusted block range. + + Raises: + ValueError: If the start block is after the end block. + _exceptions.BlockRangeIsCached: If the block range is already cached. + _exceptions.BlockRangeOutOfBounds: If the block range is out of bounds. + """ if start_block > end_block: raise ValueError(f"Start block {start_block} is after end block {end_block}") @@ -160,10 +284,19 @@ def _check_blocks_against_cache(self, start_block: Block, end_block: Block) -> T class TransactionsList(PandableList[Transaction]): + """ + A list class for handling transaction entries and converting them to DataFrames. + """ def __init__(self): super().__init__() def _df(self) -> DataFrame: + """ + Converts the list of transactions to a DataFrame. + + Returns: + DataFrame: The transactions as a DataFrame. + """ df = DataFrame(self) if len(df) > 0: df.chainId = df.chainId.apply(int) @@ -175,15 +308,38 @@ def _df(self) -> DataFrame: return df class AddressTransactionsLedger(AddressLedgerBase[TransactionsList, Transaction]): + """ + A ledger for managing transaction entries. + """ _list_type = TransactionsList __slots__ = "cached_thru_nonce", + def __init__(self, portfolio_address: "PortfolioAddress"): + """ + Initializes the AddressTransactionsLedger instance. + + Args: + portfolio_address: The :class:`~eth_portfolio.address.PortfolioAddress` this ledger belongs to. + """ super().__init__(portfolio_address) self.cached_thru_nonce = -1 + """ + The nonce through which all transactions have been loaded into memory. + """ @set_end_block_if_none @stuck_coro_debugger async def _load_new_objects(self, _: Block, end_block: Block) -> AsyncIterator[Transaction]: + """ + Loads new transaction entries between the specified blocks. + + Args: + _: The starting block number (unused). + end_block: The ending block number. + + Yields: + AsyncIterator[Transaction]: An async iterator of transaction entries. + """ if self.cached_thru and end_block < self.cached_thru: return end_block_nonce = await get_nonce_at_block(self.address, end_block) @@ -218,6 +374,9 @@ async def _load_new_objects(self, _: Block, end_block: Block) -> AsyncIterator[T class InternalTransfersList(PandableList[InternalTransfer]): + """ + A list class for handling internal transfer entries. + """ pass @@ -228,17 +387,42 @@ class InternalTransfersList(PandableList[InternalTransfer]): @a_sync.Semaphore(32, __name__ + ".trace_semaphore") @eth_retry.auto_retry async def get_traces(params: list) -> List[dict]: + """ + Retrieves traces from the web3 provider using the given parameters. + + Args: + params: The parameters for the trace filter. + + Returns: + List[dict]: The list of traces. + + Raises: + :class:`~eth_portfolio.BadResponse`: If the response from the web3 provider is invalid. + """ traces = await dank_mids.web3.provider.make_request("trace_filter", params) # type: ignore [arg-type, misc] if 'result' not in traces: raise BadResponse(traces) return [trace for trace in traces['result'] if "error" not in trace] class AddressInternalTransfersLedger(AddressLedgerBase[InternalTransfersList, InternalTransfer]): + """ + A ledger for managing internal transfer entries. + """ _list_type = InternalTransfersList @set_end_block_if_none @stuck_coro_debugger async def _load_new_objects(self, start_block: Block, end_block: Block) -> AsyncIterator[InternalTransfer]: + """ + Loads new internal transfer entries between the specified blocks. + + Args: + start_block: The starting block number. + end_block: The ending block number. + + Yields: + AsyncIterator[InternalTransfer]: An async iterator of internal transfer entries. + """ if start_block == 0: start_block = 1 @@ -283,20 +467,57 @@ async def _load_new_objects(self, start_block: Block, end_block: Block) -> Async class TokenTransfersList(PandableList[TokenTransfer]): + """ + A list class for handling token transfer entries. + """ pass class AddressTokenTransfersLedger(AddressLedgerBase[TokenTransfersList, TokenTransfer]): + """ + A ledger for managing token transfer entries. + """ _list_type = TokenTransfersList __slots__ = "_transfers", + def __init__(self, portfolio_address: "PortfolioAddress"): + """ + Initializes the AddressTokenTransfersLedger instance. + + Args: + portfolio_address: The :class:`~eth_portfolio.address.PortfolioAddress` this ledger belongs to. + """ super().__init__(portfolio_address) self._transfers = TokenTransfers(self.address, self.portfolio_address._start_block, load_prices=self.load_prices) + """ + TokenTransfers: Instance for handling token transfer operations. + """ @stuck_coro_debugger async def list_tokens_at_block(self, block: Optional[int] = None) -> List[ERC20]: + """ + Lists the tokens held at a specific block. + + Args: + block (Optional[int], optional): The block number. Defaults to None. + + Returns: + List[ERC20]: The list of ERC20 tokens. + + Examples: + >>> tokens = await ledger.list_tokens_at_block(12345678) + """ return [token async for token in self._yield_tokens_at_block(block)] async def _yield_tokens_at_block(self, block: Optional[int] = None) -> AsyncIterator[ERC20]: + """ + Yields the tokens held at a specific block. + + Args: + block (Optional[int], optional): The block number. Defaults to None. + + Yields: + AsyncIterator[ERC20]: An async iterator of ERC20 tokens. + """ yielded = set() async for transfer in self[0: block]: if transfer.token_address not in yielded: @@ -306,6 +527,16 @@ async def _yield_tokens_at_block(self, block: Optional[int] = None) -> AsyncIter @set_end_block_if_none @stuck_coro_debugger async def _load_new_objects(self, start_block: Block, end_block: Block) -> AsyncIterator[TokenTransfer]: + """ + Loads new token transfer entries between the specified blocks. + + Args: + start_block: The starting block number. + end_block: The ending block number. + + Yields: + AsyncIterator[TokenTransfer]: An async iterator of token transfer entries. + """ try: start_block, end_block = self._check_blocks_against_cache(start_block, end_block) except _exceptions.BlockRangeIsCached: diff --git a/eth_portfolio/address.py b/eth_portfolio/address.py index 24559f1a..94a09a1f 100644 --- a/eth_portfolio/address.py +++ b/eth_portfolio/address.py @@ -1,3 +1,19 @@ +""" +This module defines the :class:`~eth_portfolio.address.PortfolioAddress` class, which represents an address managed by the `eth-portfolio` system. +The `eth-portfolio` acts as a manager of addresses, handling various lending protocols and external interactions. +The :class:`~eth_portfolio.address.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 asyncio import logging @@ -27,24 +43,88 @@ logger = logging.getLogger(__name__) class PortfolioAddress(_LedgeredBase[AddressLedgerBase]): + """ + Represents a portfolio address within the eth-portfolio system. + """ + def __init__(self, address: Address, portfolio: "Portfolio", asynchronous: bool = False) -> None: # type: ignore + """ + Initializes the PortfolioAddress instance. + + Args: + address: The address to manage. + portfolio: The portfolio instance managing this address. + asynchronous (optional): Flag for asynchronous operation. Defaults to False. + + Raises: + TypeError: If `asynchronous` is not a boolean. + + Examples: + >>> portfolio = Portfolio() + >>> address = PortfolioAddress('0x1234...', portfolio) + """ self.address = convert.to_address(address) + """ + The address being managed. + """ + if not isinstance(asynchronous, bool): raise TypeError(f"`asynchronous` must be a boolean, you passed {type(asynchronous)}") self.asynchronous = asynchronous + """ + Flag indicating if the operations are asynchronous. + """ + self.load_prices = portfolio.load_prices + """ + Indicates if price loading is enabled. + """ + super().__init__(portfolio._start_block) + self.transactions = AddressTransactionsLedger(self) + """ + Ledger for tracking transactions. + """ + self.internal_transfers = AddressInternalTransfersLedger(self) + """ + Ledger for tracking internal transfers. + """ + self.token_transfers = AddressTokenTransfersLedger(self) + """ + Ledger for tracking token transfers. + """ def __str__(self) -> str: + """ + Returns the string representation of the address. + + Returns: + The address as a string. + """ return self.address def __repr__(self) -> str: + """ + Returns the string representation of the PortfolioAddress instance. + + Returns: + The string representation. + """ return f"<{self.__class__.__name__} address={self.address} at {hex(id(self))}>" def __eq__(self, other: object) -> bool: + """ + Checks equality with another object. + + Args: + other: The object to compare with. + + Returns: + True if equal, False otherwise. + """ if isinstance(other, PortfolioAddress): return self.address == other.address elif isinstance(other, str): @@ -52,12 +132,33 @@ def __eq__(self, other: object) -> bool: return False def __hash__(self) -> int: + """ + Returns the hash of the address. + + Returns: + The hash value. + """ return hash(self.address) # Primary functions @stuck_coro_debugger async def describe(self, block: int) -> WalletBalances: + """ + Describes the wallet balances at a given block. + + Args: + block: The block number. + + Returns: + :class:`~eth_portfolio.typing.WalletBalances`: The wallet balances. + + Raises: + TypeError: If block is not an integer. + + Examples: + >>> wallet_balances = await address.describe(12345678) + """ if not isinstance(block, int): raise TypeError(f"Block must be an integer. You passed {type(block)} {block}") return WalletBalances(await a_sync.gather({ @@ -100,12 +201,36 @@ async def debt(self, block: Optional[Block] = None) -> RemoteTokenBalances: @stuck_coro_debugger async def external_balances(self, block: Optional[Block] = None) -> RemoteTokenBalances: + """ + Retrieves the external balances at a given block. + + Args: + block (optional): The block number. Defaults to None. + + Returns: + :class:`~eth_portfolio.typing.RemoteTokenBalances`: The external balances. + + Examples: + >>> external_balances = await address.external_balances(12345678) + """ return sum(await asyncio.gather(self.staking(block, sync=False), self.collateral(block, sync=False))) # Assets @stuck_coro_debugger async def balances(self, block: Optional[Block]) -> TokenBalances: + """ + Retrieves the balances at a given block. + + Args: + block: The block number. + + Returns: + :class:`~eth_portfolio.typing.TokenBalances`: The balances. + + Examples: + >>> balances = await address.balances(12345678) + """ eth_balance, token_balances = await asyncio.gather( self.eth_balance(block, sync=False), self.token_balances(block, sync=False), @@ -166,7 +291,20 @@ async def staking(self, block: Optional[Block] = None) -> RemoteTokenBalances: @stuck_coro_debugger async def all(self, start_block: Block, end_block: Block) -> Dict[str, PandableLedgerEntryList]: - return await a_sync.gather({ + """ + Retrieves all ledger entries between two blocks. + + Args: + start_block: The starting block number. + end_block: The ending block number. + + Returns: + Dict[str, :class:`~eth_portfolio._ledgers.address.PandableLedgerEntryList`]: The ledger entries. + + Examples: + >>> all_entries = await address.all(12000000, 12345678) + """ + return a_sync.gather({ "transactions": self.transactions.get(start_block, end_block, sync=False), "internal_transactions": self.internal_transfers.get(start_block, end_block, sync=False), "token_transfers": self.token_transfers.get(start_block, end_block, sync=False), diff --git a/eth_portfolio/typing.py b/eth_portfolio/typing.py index eb4b2a7c..90d6ab0e 100644 --- a/eth_portfolio/typing.py +++ b/eth_portfolio/typing.py @@ -1,3 +1,28 @@ +""" +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 + such as summing balances across tokens. +- :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 + of a wallet's balances. +- :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 + balances directly by wallet and token. +- :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, +without managing or altering any underlying assets. +""" from decimal import Decimal from functools import cached_property @@ -203,7 +228,13 @@ def __radd__(self, other: Union[Self, Literal[0]]) -> Self: class TokenBalances(DefaultChecksumDict[Balance], _SummableNonNumericMixin): """ - A specialized defaultdict subclass made for holding a mapping of ``token -> balance`` + A specialized defaultdict subclass made for holding a mapping of ``token -> balance``. + + Manages a collection of :class:`~eth_portfolio.typing.Balance` objects for multiple tokens, allowing for operations + such as summing balances across tokens. + + The class uses token addresses as keys and :class:`~eth_portfolio.typing.Balance` objects as values. It supports + arithmetic operations like addition and subtraction of token balances. Token addresses are checksummed automatically when adding items to the dict, and the default value for a token not present is an empty :class:`~eth_portfolio.typing.Balance` object. @@ -356,7 +387,7 @@ def __sub__(self, other: 'TokenBalances') -> 'TokenBalances': """ if not isinstance(other, TokenBalances): raise TypeError(f"{other} is not a TokenBalances object") - # We need a new object to avoid mutating the inputs + # NOTE We need a new object to avoid mutating the inputs subtracted: TokenBalances = TokenBalances(self) for token, balance in other.items(): subtracted[token] -= balance @@ -527,7 +558,7 @@ def __sub__(self, other: 'RemoteTokenBalances') -> 'RemoteTokenBalances': """ if not isinstance(other, RemoteTokenBalances): raise TypeError(f"{other} is not a RemoteTokenBalances object") - # We need a new object to avoid mutating the inputs + # NOTE We need a new object to avoid mutating the inputs subtracted: RemoteTokenBalances = RemoteTokenBalances(self) for protocol, token_balances in other.items(): subtracted[protocol] -= token_balances @@ -981,10 +1012,18 @@ def __sub__(self, other: 'PortfolioBalances') -> 'PortfolioBalances': _WTBInput = Union[Dict[Address, TokenBalances], Iterable[Tuple[Address, TokenBalances]]] class WalletBalancesRaw(DefaultChecksumDict[TokenBalances], _SummableNonNumericMixin): + #Since PortfolioBalances key lookup is: ``wallet -> category -> token -> balance`` + #We need a new structure for key pattern: ``wallet -> token -> balance`` + + #WalletBalancesRaw fills this role. """ - Since PortfolioBalances key lookup is: ``wallet -> category -> token -> balance`` - and WalletBalances key lookup is: ``category -> token -> balance`` - We need a new structure for key pattern: ``wallet -> token -> balance`` + A structure for key pattern `wallet -> token -> balance`. + + This class is similar to :class:`~eth_portfolio.typing.WalletBalances` but optimized for key lookups by wallet and token directly. + It manages :class:`~eth_portfolio.typing.TokenBalances` objects for multiple wallets. + + Args: + seed: An initial seed of wallet balances, either as a dictionary or an iterable of tuples. Example: >>> raw_balances = WalletBalancesRaw({'0x123': TokenBalances({'0x456': Balance(Decimal('100'), Decimal('2000'))})}) @@ -1080,7 +1119,7 @@ def __sub__(self, other: 'WalletBalancesRaw') -> 'WalletBalancesRaw': """ if not isinstance(other, WalletBalancesRaw): raise TypeError(f"{other} is not a WalletBalancesRaw object") - # We need a new object to avoid mutating the inputs + # NOTE We need a new object to avoid mutating the inputs subtracted: WalletBalancesRaw = WalletBalancesRaw(self) for wallet, balances in other.items(): if balances: @@ -1237,7 +1276,7 @@ def __sub__(self, other: 'PortfolioBalancesByCategory') -> 'PortfolioBalancesByC """ if not isinstance(other, PortfolioBalancesByCategory): raise TypeError(f"{other} is not a PortfolioBalancesByCategory object") - # We need a new object to avoid mutating the inputs + # NOTE We need a new object to avoid mutating the inputs subtracted: PortfolioBalancesByCategory = PortfolioBalancesByCategory(self) for protocol, balances in other.items(): subtracted[protocol] -= balances