Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 105 additions & 1 deletion src/aleph/sdk/client/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,19 @@
RemovedMessageError,
ResourceNotFoundError,
)
from ..query.filters import BalanceFilter, MessageFilter, PostFilter
from ..query.filters import (
AccountFilesFilter,
AddressesFilter,
BalanceFilter,
ChainBalancesFilter,
MessageFilter,
PostFilter,
)
from ..query.responses import (
AccountFilesResponse,
AddressStatsResponse,
BalanceResponse,
ChainBalancesResponse,
CreditsHistoryResponse,
MessagesResponse,
Post,
Expand Down Expand Up @@ -727,3 +737,97 @@ async def get_balances(
resp.raise_for_status()
result = await resp.json()
return BalanceResponse.model_validate(result)

async def get_chain_balances(
self,
page_size: int = 100,
page: int = 1,
filter: Optional[ChainBalancesFilter] = None,
) -> ChainBalancesResponse:
"""
Get balances across multiple addresses and chains.
:param page_size: Number of results per page (default 100)
:param page: Page number starting at 1
:param filter: Query parameters for filtering by chains and minimum balance
:return: Chain balances response with pagination
"""
if not filter:
params = {
"page": str(page),
"pagination": str(page_size),
}
else:
params = filter.as_http_params()
params["page"] = str(page)
params["pagination"] = str(page_size)

async with self.http_session.get("/api/v0/balances", params=params) as resp:
resp.raise_for_status()
result = await resp.json()
return ChainBalancesResponse.model_validate(result)

async def get_address_stats(
self,
page_size: int = 200,
page: int = 1,
filter: Optional[AddressesFilter] = None,
) -> AddressStatsResponse:
"""
Get address statistics with optional filtering and sorting.
:param page_size: Number of results per page
:param page: Page number starting at 1
:param filter: Query parameters for filtering and sorting
:return: Address statistics response with pagination
"""
if not filter:
params = {
"page": str(page),
"pagination": str(page_size),
}
else:
params = filter.as_http_params()
params["page"] = str(page)
params["pagination"] = str(page_size)

async with self.http_session.get(
"/api/v1/addresses/stats.json", params=params
) as resp:
resp.raise_for_status()
result = await resp.json()
return AddressStatsResponse.model_validate(result)

async def get_account_files(
self,
address: str,
page_size: int = 100,
page: int = 1,
filter: Optional[AccountFilesFilter] = None,
) -> AccountFilesResponse:
"""
Get files stored by a specific address.
:param address: The account address to query files for
:param page_size: Number of results per page (default 100)
:param page: Page number starting at 1
:param filter: Query parameters for filtering and sorting
:return: Account files response with pagination
:raises aiohttp.ClientResponseError: If the address has no files (HTTP 404)
"""
if not filter:
params = {
"page": str(page),
"pagination": str(page_size),
}
else:
params = filter.as_http_params()
params["page"] = str(page)
params["pagination"] = str(page_size)

async with self.http_session.get(
f"/api/v0/addresses/{address}/files", params=params
) as resp:
resp.raise_for_status()
result = await resp.json()
return AccountFilesResponse.model_validate(result)
146 changes: 146 additions & 0 deletions src/aleph/sdk/query/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,25 @@ class SortOrder(str, Enum):
DESCENDING = "-1"


class SortByMessageType(str, Enum):
"""Supported SortByMessageType types for address stats"""

AGGREGATE = "aggregate"
FORGET = "forget"
INSTANCE = "instance"
POST = "post"
PROGRAM = "program"
STORE = "store"
TOTAL = "total"


class FileType(str, Enum):
"""Supported file types"""

FILE = "file"
DIRECTORY = "directory"


class MessageFilter:
"""
A collection of filters that can be applied on message queries.
Expand Down Expand Up @@ -228,3 +247,130 @@ def as_http_params(self) -> Dict[str, str]:
result[key] = value

return result


class AddressesFilter:
"""
A collection of query parameters for address stats queries.
:param address_contains: Case-insensitive substring to filter addresses
:param sort_by: Message type to sort by (aggregate, forget, instance, post, program, store, total)
:param sort_order: Sort order (ascending or descending)
"""

address_contains: Optional[str]
sort_by: Optional[SortByMessageType]
sort_order: Optional[SortOrder]

def __init__(
self,
address_contains: Optional[str] = None,
sort_by: Optional[SortByMessageType] = None,
sort_order: Optional[SortOrder] = None,
):
self.address_contains = address_contains
self.sort_by = sort_by
self.sort_order = sort_order

def as_http_params(self) -> Dict[str, str]:
"""Convert the filters into a dict that can be used by an `aiohttp` client
as `params` to build the HTTP query string.
"""

partial_result = {
"addressContains": self.address_contains,
"sortBy": enum_as_str(self.sort_by),
"sortOrder": enum_as_str(self.sort_order),
}

# Ensure all values are strings.
result: Dict[str, str] = {}

# Drop empty values
for key, value in partial_result.items():
if value:
assert isinstance(value, str), f"Value must be a string: `{value}`"
result[key] = value

return result


class AccountFilesFilter:
"""
A collection of query parameters for account files queries.
:param sort_order: Sort order (ascending or descending by creation date)
"""

sort_order: Optional[SortOrder]

def __init__(
self,
sort_order: Optional[SortOrder] = None,
):
self.sort_order = sort_order

def as_http_params(self) -> Dict[str, str]:
"""Convert the filters into a dict that can be used by an `aiohttp` client
as `params` to build the HTTP query string.
"""

partial_result = {
"sort_order": enum_as_str(self.sort_order),
}

# Ensure all values are strings.
result: Dict[str, str] = {}

# Drop empty values
for key, value in partial_result.items():
if value:
assert isinstance(value, str), f"Value must be a string: `{value}`"
result[key] = value

return result


class ChainBalancesFilter:
"""
A collection of query parameters for chain balances queries.
:param chains: Filter by specific blockchain chains
:param min_balance: Minimum balance required (must be >= 1)
"""

chains: Optional[Iterable[Chain]]
min_balance: Optional[int]

def __init__(
self,
chains: Optional[Iterable[Chain]] = None,
min_balance: Optional[int] = None,
):
self.chains = chains
self.min_balance = min_balance

def as_http_params(self) -> Dict[str, str]:
"""Convert the filters into a dict that can be used by an `aiohttp` client
as `params` to build the HTTP query string.
"""

partial_result = {
"chains": serialize_list(
[chain.value for chain in self.chains] if self.chains else None
),
"min_balance": (
str(self.min_balance) if self.min_balance is not None else None
),
}

# Ensure all values are strings.
result: Dict[str, str] = {}

# Drop empty values
for key, value in partial_result.items():
if value:
assert isinstance(value, str), f"Value must be a string: `{value}`"
result[key] = value

return result
77 changes: 77 additions & 0 deletions src/aleph/sdk/query/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
)
from pydantic import BaseModel, ConfigDict, Field

from aleph.sdk.query.filters import FileType


class Post(BaseModel):
"""
Expand Down Expand Up @@ -114,3 +116,78 @@ class BalanceResponse(BaseModel):
details: Optional[Dict[str, Decimal]] = None
locked_amount: Decimal
credit_balance: int = 0


class AddressStats(BaseModel):
"""
Statistics for a single address showing message counts by type.
"""

messages: int = Field(description="Total number of messages")
post: int = Field(description="Number of POST messages")
aggregate: int = Field(description="Number of AGGREGATE messages")
store: int = Field(description="Number of STORE messages")
forget: int = Field(description="Number of FORGET messages")
program: int = Field(description="Number of PROGRAM messages")
instance: int = Field(description="Number of INSTANCE messages")

model_config = ConfigDict(extra="forbid")


class AddressStatsResponse(PaginationResponse):
"""Response from an aleph.im node API on the path /api/v1/addresses/stats.json"""

data: Dict[str, AddressStats] = Field(
description="Dictionary mapping addresses to their statistics"
)
pagination_item: str = "addresses"


class AccountFilesResponseItem(BaseModel):
"""
A single file entry in an account's file list.
"""

file_hash: str = Field(description="Hash of the file content")
size: int = Field(description="Size of the file in bytes")
type: FileType = Field(description="Type of the file (FILE or DIRECTORY)")
created: dt.datetime = Field(description="Timestamp when the file was created")
item_hash: str = Field(description="Hash of the message that created this file")

model_config = ConfigDict(extra="forbid")


class AccountFilesResponse(PaginationResponse):
"""Response from an aleph.im node API on the path /api/v0/addresses/{address}/files"""

address: str = Field(description="The account address")
total_size: int = Field(description="Total size of all files in bytes")
files: List[AccountFilesResponseItem] = Field(
description="List of files owned by the address"
)
pagination_item: str = "files"

model_config = ConfigDict(extra="forbid")


class AddressBalanceResponseItem(BaseModel):
"""
A single balance entry for an address on a specific chain.
"""

address: str = Field(description="The account address")
balance: Decimal = Field(description="Balance amount")
chain: Chain = Field(description="Blockchain the balance is on")

model_config = ConfigDict(extra="forbid")


class ChainBalancesResponse(PaginationResponse):
"""Response from an aleph.im node API on the path /api/v0/balances"""

balances: List[AddressBalanceResponseItem] = Field(
description="List of address balances across different chains"
)
pagination_item: str = "balances"

model_config = ConfigDict(extra="forbid")
Loading
Loading