From 32bd9d7c02de3d96d62b75fbda2fddf116ba538c Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Thu, 13 Nov 2025 14:20:24 +0100 Subject: [PATCH 1/3] fix(api): handle unhashable keys in msgpack responses --- .../renderer/templates/client.py.j2 | 98 +++++++++++++++-- .../renderer/templates/models/block.py.j2 | 22 +++- .../templates/models/ledger_state_delta.py.j2 | 100 ++++++++++++------ src/algokit_algod_client/client.py | 91 +++++++++++++++- src/algokit_algod_client/models/_block.py | 22 +++- .../models/_ledger_state_delta.py | 100 ++++++++++++------ src/algokit_common/serde/_core.py | 6 ++ src/algokit_indexer_client/client.py | 91 +++++++++++++++- src/algokit_kmd_client/client.py | 91 +++++++++++++++- .../algod_client/test_ledger_state_delta.py | 39 ++++--- 10 files changed, 548 insertions(+), 112 deletions(-) diff --git a/api/oas-generator/src/oas_generator/renderer/templates/client.py.j2 b/api/oas-generator/src/oas_generator/renderer/templates/client.py.j2 index ffb15ef4..8f0676f2 100644 --- a/api/oas-generator/src/oas_generator/renderer/templates/client.py.j2 +++ b/api/oas-generator/src/oas_generator/renderer/templates/client.py.j2 @@ -20,6 +20,14 @@ ModelT = TypeVar("ModelT") ListModelT = TypeVar("ListModelT") PrimitiveT = TypeVar("PrimitiveT") +# Prefixed markers used when converting unhashable msgpack map keys into hashable tuples +_UNHASHABLE_PREFIXES: dict[str, str] = { + "dict": "__dict_key__", + "list": "__list_key__", + "set": "__set_key__", + "generic": "__unhashable__", +} + class {{ client.class_name }}: def __init__(self, config: ClientConfig | None = None, *, http_client: httpx.Client | None = None) -> None: @@ -248,7 +256,22 @@ class {{ client.class_name }}: return response.content content_type = response.headers.get("content-type", "application/json") if "msgpack" in content_type: - data = msgpack.unpackb(response.content, raw=True, strict_map_key=False) + # Handle msgpack unpacking with support for unhashable keys + # Use Unpacker for more control over the unpacking process + unpacker = msgpack.Unpacker( + raw=True, + strict_map_key=False, + object_pairs_hook=self._msgpack_pairs_hook, + ) + unpacker.feed(response.content) + try: + data = unpacker.unpack() + except TypeError: + # If unpacking fails due to unhashable keys, try without the hook + # and handle in normalization + unpacker = msgpack.Unpacker(raw=True, strict_map_key=False) + unpacker.feed(response.content) + data = unpacker.unpack() data = self._normalize_msgpack(data) elif content_type.startswith("application/json"): data = response.json() @@ -262,12 +285,42 @@ class {{ client.class_name }}: return data return data - def _normalize_msgpack(self, value: object) -> object: + def _normalize_msgpack(self, value: object) -> object: # noqa: C901, PLR0912 + # Handle pairs returned from msgpack_pairs_hook when keys are unhashable + _pair_length = 2 + if ( + isinstance(value, list) + and value + and isinstance(value[0], tuple | list) + and len(value[0]) == _pair_length + ): + # Convert to dict with normalized keys + pairs_dict: dict[object, object] = {} + for pair in value: + if isinstance(pair, tuple | list) and len(pair) == _pair_length: + k, v = pair + # For unhashable keys (like dict keys), use a tuple representation + try: + normalized_key = self._coerce_msgpack_key(k) + pairs_dict[normalized_key] = self._normalize_msgpack(v) + except TypeError: + # Key is unhashable - use tuple representation + normalized_key = ("__unhashable__", id(k), str(k)) + pairs_dict[normalized_key] = self._normalize_msgpack(v) + return pairs_dict if isinstance(value, dict): - normalized: dict[object, object] = {} - for key, item in value.items(): - normalized[self._coerce_msgpack_key(key)] = self._normalize_msgpack(item) - return normalized + # Safely normalize maps: coerce string/bytes keys, but tolerate complex/unhashable keys + try: + normalized_dict: dict[object, object] = {} + for key, item in value.items(): + normalized_dict[self._coerce_msgpack_key(key)] = self._normalize_msgpack(item) + return normalized_dict + except TypeError: + # Some maps can decode to object/dict keys; keep original keys and + # only normalize values to avoid "unhashable type: 'dict'" errors. + for k, item in list(value.items()): + value[k] = self._normalize_msgpack(item) + return value if isinstance(value, list): return [self._normalize_msgpack(item) for item in value] return value @@ -279,3 +332,36 @@ class {{ client.class_name }}: except UnicodeDecodeError: return key return key + + def _msgpack_pairs_hook(self, pairs: list[tuple[object, object]] | list[list[object]]) -> dict[object, object]: + # Convert pairs to dict, handling unhashable keys by converting them to hashable tuples + out: dict[object, object] = {} + _hashable_type_tuple = (str, int, float, bool, type(None), bytes) + + for k, v in pairs: + if isinstance(k, dict | list | set): + # Convert unhashable key to hashable tuple + hashable_key: tuple[str, object] + if isinstance(k, dict): + try: + hashable_key = (_UNHASHABLE_PREFIXES["dict"], tuple(sorted(k.items()))) + except TypeError: + hashable_key = (_UNHASHABLE_PREFIXES["dict"], str(k)) + elif isinstance(k, list): + prefix = _UNHASHABLE_PREFIXES["list"] + hashable_key = (prefix, tuple(k) if all(isinstance(x, _hashable_type_tuple) for x in k) else str(k)) + else: # set + prefix = _UNHASHABLE_PREFIXES["set"] + if all(isinstance(x, _hashable_type_tuple) for x in k): + hashable_key = (prefix, tuple(sorted(k))) + else: + hashable_key = (prefix, str(k)) + out[hashable_key] = v + else: + # Key should be hashable, use as-is + try: + out[k] = v + except TypeError: + # Unexpected unhashable type, convert to tuple + out[(_UNHASHABLE_PREFIXES["generic"], str(type(k).__name__), str(k))] = v + return out diff --git a/api/oas-generator/src/oas_generator/renderer/templates/models/block.py.j2 b/api/oas-generator/src/oas_generator/renderer/templates/models/block.py.j2 index 5962502e..2e967787 100644 --- a/api/oas-generator/src/oas_generator/renderer/templates/models/block.py.j2 +++ b/api/oas-generator/src/oas_generator/renderer/templates/models/block.py.j2 @@ -26,6 +26,8 @@ __all__ = [ "BlockAppEvalDelta", "BlockStateProofTrackingData", "BlockStateProofTracking", + "ApplyData", + "SignedTxnWithAD", "SignedTxnInBlock", "Block", "GetBlock", @@ -151,10 +153,9 @@ class BlockStateProofTrackingData: @dataclass(slots=True) -class SignedTxnInBlock: - """Signed transaction details with block-specific apply data.""" +class ApplyData: + """Transaction execution apply data containing state changes and rewards.""" - signed_transaction: SignedTransaction = field(metadata=flatten(lambda: SignedTransaction)) closing_amount: int | None = field(default=None, metadata=wire("ca")) asset_closing_amount: int | None = field(default=None, metadata=wire("aca")) sender_rewards: int | None = field(default=None, metadata=wire("rs")) @@ -166,6 +167,21 @@ class SignedTxnInBlock: ) config_asset: int | None = field(default=None, metadata=wire("caid")) application_id: int | None = field(default=None, metadata=wire("apid")) + + +@dataclass(slots=True) +class SignedTxnWithAD: + """Signed transaction with associated apply data.""" + + signed_transaction: SignedTransaction = field(metadata=flatten(lambda: SignedTransaction)) + apply_data: ApplyData = field(metadata=flatten(lambda: ApplyData)) + + +@dataclass(slots=True) +class SignedTxnInBlock: + """Signed transaction details with block-specific apply data.""" + + signed_transaction: SignedTxnWithAD = field(metadata=flatten(lambda: SignedTxnWithAD)) has_genesis_id: bool | None = field(default=None, metadata=wire("hgi", decode=decode_optional_bool)) has_genesis_hash: bool | None = field(default=None, metadata=wire("hgh", decode=decode_optional_bool)) diff --git a/api/oas-generator/src/oas_generator/renderer/templates/models/ledger_state_delta.py.j2 b/api/oas-generator/src/oas_generator/renderer/templates/models/ledger_state_delta.py.j2 index 7a10c160..0d3f77eb 100644 --- a/api/oas-generator/src/oas_generator/renderer/templates/models/ledger_state_delta.py.j2 +++ b/api/oas-generator/src/oas_generator/renderer/templates/models/ledger_state_delta.py.j2 @@ -1,6 +1,7 @@ # AUTO-GENERATED: oas_generator from collections.abc import Mapping +from contextlib import suppress from dataclasses import dataclass, field from typing import Callable, TypeVar, cast @@ -76,8 +77,7 @@ def _encode_teal_value_map(mapping: Mapping[bytes, "LedgerTealValue"] | None) -> def _decode_teal_value_map(raw: object) -> dict[bytes, "LedgerTealValue"] | None: - decoded = decode_model_mapping(lambda: LedgerTealValue, raw, key_decoder=_decode_bytes_key) - return decoded or None + return decode_model_mapping(lambda: LedgerTealValue, raw, key_decoder=_decode_bytes_key) def _encode_kv_delta_map(mapping: Mapping[bytes, "LedgerKvValueDelta"] | None) -> dict[str, object] | None: @@ -91,8 +91,7 @@ def _encode_kv_delta_map(mapping: Mapping[bytes, "LedgerKvValueDelta"] | None) - def _decode_kv_delta_map(raw: object) -> dict[bytes, "LedgerKvValueDelta"] | None: - decoded = decode_model_mapping(lambda: LedgerKvValueDelta, raw, key_decoder=_decode_bytes_key) - return decoded or None + return decode_model_mapping(lambda: LedgerKvValueDelta, raw, key_decoder=_decode_bytes_key) def _encode_txid_map(mapping: Mapping[bytes, "LedgerIncludedTransactions"] | None) -> dict[str, object] | None: @@ -106,19 +105,14 @@ def _encode_txid_map(mapping: Mapping[bytes, "LedgerIncludedTransactions"] | Non def _decode_txid_map(raw: object) -> dict[bytes, "LedgerIncludedTransactions"] | None: - decoded = decode_model_mapping(lambda: LedgerIncludedTransactions, raw, key_decoder=_decode_bytes_key) - return decoded or None + return decode_model_mapping(lambda: LedgerIncludedTransactions, raw, key_decoder=_decode_bytes_key) def _encode_creatables(mapping: Mapping[int, "LedgerModifiedCreatable | None"] | None) -> dict[int, object] | None: if not mapping: return None - encoded: dict[int, object] = {} - for key, value in mapping.items(): - if value is None: - continue - encoded[int(key)] = to_wire(value) - return encoded or None + encoded: dict[int, object] = {int(k): to_wire(v) for k, v in mapping.items() if v is not None} + return encoded if encoded else None def _decode_creatables(raw: object) -> dict[int, "LedgerModifiedCreatable"] | None: @@ -126,25 +120,43 @@ def _decode_creatables(raw: object) -> dict[int, "LedgerModifiedCreatable"] | No return None decoded: dict[int, LedgerModifiedCreatable] = {} for key, value in raw.items(): - if not isinstance(value, Mapping): - continue - try: - decoded[int(key)] = from_wire(LedgerModifiedCreatable, value) - except (DecodeError, TypeError, ValueError): - continue - return decoded or None + if isinstance(value, Mapping): + with suppress(DecodeError, TypeError, ValueError): + decoded[int(key)] = from_wire(LedgerModifiedCreatable, value) + return decoded if decoded else None -def _encode_optional_sequence(values: list[object] | None) -> list[dict[str, object]] | None: - if values is None: +def _decode_tx_leases(raw: object) -> list[tuple[dict[bytes, bytes], int]] | None: + """Decode txleases from dict with tuple keys to list of pairs (Option 4). + + Converts dict structure {('__dict_key__', ((b'Lease', ...), (b'Sender', ...))): expiration} + to list of pairs [({b'Lease': ..., b'Sender': ...}, expiration), ...] + """ + if not isinstance(raw, Mapping): return None - encoded = encode_model_sequence(values) - return encoded or None + _tuple_len = 2 + result: list[tuple[dict[bytes, bytes], int]] = [] + for key, expiration in raw.items(): + if ( + isinstance(key, tuple) + and len(key) == _tuple_len + and key[0] == "__dict_key__" + and isinstance(key[1], tuple) + and len(key[1]) == _tuple_len + ): + # Reconstruct dict from tuple: key[1] is ((b'Lease', ...), (b'Sender', ...)) + key_dict = dict(key[1]) + if isinstance(expiration, int): + result.append((key_dict, expiration)) + return result if result else None + + +def _encode_optional_sequence(values: list[object] | None) -> list[dict[str, object]] | None: + return encode_model_sequence(values) if values is not None else None def _decode_sequence(factory: Callable[[], type[DecodedT]], raw: object) -> list[DecodedT]: - decoded = decode_model_sequence(factory, raw) - return decoded or [] + return decode_model_sequence(factory, raw) or [] @dataclass(slots=True) @@ -185,7 +197,7 @@ class LedgerAppParams: ) local_state_schema: LedgerStateSchema = field(metadata=nested("lsch", lambda: LedgerStateSchema)) global_state_schema: LedgerStateSchema = field(metadata=nested("gsch", lambda: LedgerStateSchema)) - extra_program_pages: int = field(metadata=wire("epp")) + extra_program_pages: int = field(default=0, metadata=wire("epp")) version: int | None = field(default=None, metadata=wire("v")) size_sponsor: str | None = field( default=None, @@ -244,8 +256,8 @@ class LedgerAppResourceRecord: @dataclass(slots=True) class LedgerAssetHolding: - amount: int = field(metadata=wire("a")) - frozen: bool = field(metadata=wire("f")) + amount: int = field(default=0, metadata=wire("a")) + frozen: bool = field(default=False, metadata=wire("f")) @dataclass(slots=True) @@ -259,9 +271,9 @@ class LedgerAssetHoldingDelta: @dataclass(slots=True) class LedgerAssetParams: - total: int = field(metadata=wire("t")) - decimals: int = field(metadata=wire("dc")) - default_frozen: bool = field(metadata=wire("df")) + total: int = field(default=0, metadata=wire("t")) + decimals: int = field(default=0, metadata=wire("dc")) + default_frozen: bool = field(default=False, metadata=wire("df")) unit_name: str | None = field(default=None, metadata=wire("un")) asset_name: str | None = field(default=None, metadata=wire("an")) url: str | None = field(default=None, metadata=wire("au")) @@ -415,7 +427,7 @@ class LedgerModifiedCreatable: creatable_type: int = field(metadata=wire("Ctype")) created: bool = field(metadata=wire("Created")) creator: str = field(metadata=addr("Creator")) - ndeltas: int = field(metadata=wire("Ndeltas")) + n_deltas: int = field(metadata=wire("Ndeltas")) @dataclass(slots=True) @@ -447,7 +459,7 @@ class LedgerStateDelta: decode=_decode_kv_delta_map, ), ) - txids: dict[bytes, LedgerIncludedTransactions] | None = field( + tx_ids: dict[bytes, LedgerIncludedTransactions] | None = field( default=None, metadata=wire( "Txids", @@ -455,7 +467,27 @@ class LedgerStateDelta: decode=_decode_txid_map, ), ) - txleases: object | None = field(default=None, metadata=wire("Txleases")) + tx_leases: list[tuple[dict[bytes, bytes], int]] | None = field( + default=None, + metadata=wire("Txleases", decode=_decode_tx_leases), + ) + """Transaction leases as list of pairs (key dict, expiration round). + + This field contains a list of tuples where each tuple is: + (key_dict, expiration_round) + + The key_dict is a dict with keys: + - b'Lease': bytes (32-byte lease identifier) + - b'Sender': bytes (32-byte sender address) + + Example:: + delta = client.get_ledger_state_delta(round_=24098947) + if delta.tx_leases: + for key_dict, expiration in delta.tx_leases: + lease_bytes = key_dict[b'Lease'] + sender_bytes = key_dict[b'Sender'] + print(f"Lease: {lease_bytes.hex()}, expires at round {expiration}") + """ creatables: dict[int, LedgerModifiedCreatable] | None = field( default=None, metadata=wire( diff --git a/src/algokit_algod_client/client.py b/src/algokit_algod_client/client.py index ce029649..766ca3bc 100644 --- a/src/algokit_algod_client/client.py +++ b/src/algokit_algod_client/client.py @@ -18,6 +18,14 @@ ListModelT = TypeVar("ListModelT") PrimitiveT = TypeVar("PrimitiveT") +# Prefixed markers used when converting unhashable msgpack map keys into hashable tuples +_UNHASHABLE_PREFIXES: dict[str, str] = { + "dict": "__dict_key__", + "list": "__list_key__", + "set": "__set_key__", + "generic": "__unhashable__", +} + class AlgodClient: def __init__(self, config: ClientConfig | None = None, *, http_client: httpx.Client | None = None) -> None: @@ -1937,7 +1945,22 @@ def _decode_response( return response.content content_type = response.headers.get("content-type", "application/json") if "msgpack" in content_type: - data = msgpack.unpackb(response.content, raw=True, strict_map_key=False) + # Handle msgpack unpacking with support for unhashable keys + # Use Unpacker for more control over the unpacking process + unpacker = msgpack.Unpacker( + raw=True, + strict_map_key=False, + object_pairs_hook=self._msgpack_pairs_hook, + ) + unpacker.feed(response.content) + try: + data = unpacker.unpack() + except TypeError: + # If unpacking fails due to unhashable keys, try without the hook + # and handle in normalization + unpacker = msgpack.Unpacker(raw=True, strict_map_key=False) + unpacker.feed(response.content) + data = unpacker.unpack() data = self._normalize_msgpack(data) elif content_type.startswith("application/json"): data = response.json() @@ -1952,11 +1975,36 @@ def _decode_response( return data def _normalize_msgpack(self, value: object) -> object: + # Handle pairs returned from msgpack_pairs_hook when keys are unhashable + _pair_length = 2 + if isinstance(value, list) and value and isinstance(value[0], tuple | list) and len(value[0]) == _pair_length: + # Convert to dict with normalized keys + pairs_dict: dict[object, object] = {} + for pair in value: + if isinstance(pair, tuple | list) and len(pair) == _pair_length: + k, v = pair + # For unhashable keys (like dict keys), use a tuple representation + try: + normalized_key = self._coerce_msgpack_key(k) + pairs_dict[normalized_key] = self._normalize_msgpack(v) + except TypeError: + # Key is unhashable - use tuple representation + normalized_key = ("__unhashable__", id(k), str(k)) + pairs_dict[normalized_key] = self._normalize_msgpack(v) + return pairs_dict if isinstance(value, dict): - normalized: dict[object, object] = {} - for key, item in value.items(): - normalized[self._coerce_msgpack_key(key)] = self._normalize_msgpack(item) - return normalized + # Safely normalize maps: coerce string/bytes keys, but tolerate complex/unhashable keys + try: + normalized_dict: dict[object, object] = {} + for key, item in value.items(): + normalized_dict[self._coerce_msgpack_key(key)] = self._normalize_msgpack(item) + return normalized_dict + except TypeError: + # Some maps can decode to object/dict keys; keep original keys and + # only normalize values to avoid "unhashable type: 'dict'" errors. + for k, item in list(value.items()): + value[k] = self._normalize_msgpack(item) + return value if isinstance(value, list): return [self._normalize_msgpack(item) for item in value] return value @@ -1968,3 +2016,36 @@ def _coerce_msgpack_key(self, key: object) -> object: except UnicodeDecodeError: return key return key + + def _msgpack_pairs_hook(self, pairs: list[tuple[object, object]] | list[list[object]]) -> dict[object, object]: + # Convert pairs to dict, handling unhashable keys by converting them to hashable tuples + out: dict[object, object] = {} + _hashable_type_tuple = (str, int, float, bool, type(None), bytes) + + for k, v in pairs: + if isinstance(k, dict | list | set): + # Convert unhashable key to hashable tuple + hashable_key: tuple[str, object] + if isinstance(k, dict): + try: + hashable_key = (_UNHASHABLE_PREFIXES["dict"], tuple(sorted(k.items()))) + except TypeError: + hashable_key = (_UNHASHABLE_PREFIXES["dict"], str(k)) + elif isinstance(k, list): + prefix = _UNHASHABLE_PREFIXES["list"] + hashable_key = (prefix, tuple(k) if all(isinstance(x, _hashable_type_tuple) for x in k) else str(k)) + else: # set + prefix = _UNHASHABLE_PREFIXES["set"] + if all(isinstance(x, _hashable_type_tuple) for x in k): + hashable_key = (prefix, tuple(sorted(k))) + else: + hashable_key = (prefix, str(k)) + out[hashable_key] = v + else: + # Key should be hashable, use as-is + try: + out[k] = v + except TypeError: + # Unexpected unhashable type, convert to tuple + out[(_UNHASHABLE_PREFIXES["generic"], str(type(k).__name__), str(k))] = v + return out diff --git a/src/algokit_algod_client/models/_block.py b/src/algokit_algod_client/models/_block.py index 3279d990..5fb5a23d 100644 --- a/src/algokit_algod_client/models/_block.py +++ b/src/algokit_algod_client/models/_block.py @@ -21,6 +21,7 @@ ) __all__ = [ + "ApplyData", "Block", "BlockAccountStateDelta", "BlockAppEvalDelta", @@ -30,6 +31,7 @@ "BlockStateProofTrackingData", "GetBlock", "SignedTxnInBlock", + "SignedTxnWithAD", ] @@ -152,10 +154,9 @@ class BlockStateProofTrackingData: @dataclass(slots=True) -class SignedTxnInBlock: - """Signed transaction details with block-specific apply data.""" +class ApplyData: + """Transaction execution apply data containing state changes and rewards.""" - signed_transaction: SignedTransaction = field(metadata=flatten(lambda: SignedTransaction)) closing_amount: int | None = field(default=None, metadata=wire("ca")) asset_closing_amount: int | None = field(default=None, metadata=wire("aca")) sender_rewards: int | None = field(default=None, metadata=wire("rs")) @@ -167,6 +168,21 @@ class SignedTxnInBlock: ) config_asset: int | None = field(default=None, metadata=wire("caid")) application_id: int | None = field(default=None, metadata=wire("apid")) + + +@dataclass(slots=True) +class SignedTxnWithAD: + """Signed transaction with associated apply data.""" + + signed_transaction: SignedTransaction = field(metadata=flatten(lambda: SignedTransaction)) + apply_data: ApplyData = field(metadata=flatten(lambda: ApplyData)) + + +@dataclass(slots=True) +class SignedTxnInBlock: + """Signed transaction details with block-specific apply data.""" + + signed_transaction: SignedTxnWithAD = field(metadata=flatten(lambda: SignedTxnWithAD)) has_genesis_id: bool | None = field(default=None, metadata=wire("hgi", decode=decode_optional_bool)) has_genesis_hash: bool | None = field(default=None, metadata=wire("hgh", decode=decode_optional_bool)) diff --git a/src/algokit_algod_client/models/_ledger_state_delta.py b/src/algokit_algod_client/models/_ledger_state_delta.py index 55d5ac94..d1e473bd 100644 --- a/src/algokit_algod_client/models/_ledger_state_delta.py +++ b/src/algokit_algod_client/models/_ledger_state_delta.py @@ -1,6 +1,7 @@ # AUTO-GENERATED: oas_generator from collections.abc import Callable, Mapping +from contextlib import suppress from dataclasses import dataclass, field from typing import TypeVar, cast @@ -76,8 +77,7 @@ def _encode_teal_value_map(mapping: Mapping[bytes, "LedgerTealValue"] | None) -> def _decode_teal_value_map(raw: object) -> dict[bytes, "LedgerTealValue"] | None: - decoded = decode_model_mapping(lambda: LedgerTealValue, raw, key_decoder=_decode_bytes_key) - return decoded or None + return decode_model_mapping(lambda: LedgerTealValue, raw, key_decoder=_decode_bytes_key) def _encode_kv_delta_map(mapping: Mapping[bytes, "LedgerKvValueDelta"] | None) -> dict[str, object] | None: @@ -91,8 +91,7 @@ def _encode_kv_delta_map(mapping: Mapping[bytes, "LedgerKvValueDelta"] | None) - def _decode_kv_delta_map(raw: object) -> dict[bytes, "LedgerKvValueDelta"] | None: - decoded = decode_model_mapping(lambda: LedgerKvValueDelta, raw, key_decoder=_decode_bytes_key) - return decoded or None + return decode_model_mapping(lambda: LedgerKvValueDelta, raw, key_decoder=_decode_bytes_key) def _encode_txid_map(mapping: Mapping[bytes, "LedgerIncludedTransactions"] | None) -> dict[str, object] | None: @@ -106,19 +105,14 @@ def _encode_txid_map(mapping: Mapping[bytes, "LedgerIncludedTransactions"] | Non def _decode_txid_map(raw: object) -> dict[bytes, "LedgerIncludedTransactions"] | None: - decoded = decode_model_mapping(lambda: LedgerIncludedTransactions, raw, key_decoder=_decode_bytes_key) - return decoded or None + return decode_model_mapping(lambda: LedgerIncludedTransactions, raw, key_decoder=_decode_bytes_key) def _encode_creatables(mapping: Mapping[int, "LedgerModifiedCreatable | None"] | None) -> dict[int, object] | None: if not mapping: return None - encoded: dict[int, object] = {} - for key, value in mapping.items(): - if value is None: - continue - encoded[int(key)] = to_wire(value) - return encoded or None + encoded: dict[int, object] = {int(k): to_wire(v) for k, v in mapping.items() if v is not None} + return encoded if encoded else None def _decode_creatables(raw: object) -> dict[int, "LedgerModifiedCreatable"] | None: @@ -126,25 +120,43 @@ def _decode_creatables(raw: object) -> dict[int, "LedgerModifiedCreatable"] | No return None decoded: dict[int, LedgerModifiedCreatable] = {} for key, value in raw.items(): - if not isinstance(value, Mapping): - continue - try: - decoded[int(key)] = from_wire(LedgerModifiedCreatable, value) - except (DecodeError, TypeError, ValueError): - continue - return decoded or None + if isinstance(value, Mapping): + with suppress(DecodeError, TypeError, ValueError): + decoded[int(key)] = from_wire(LedgerModifiedCreatable, value) + return decoded if decoded else None -def _encode_optional_sequence(values: list[object] | None) -> list[dict[str, object]] | None: - if values is None: +def _decode_tx_leases(raw: object) -> list[tuple[dict[bytes, bytes], int]] | None: + """Decode txleases from dict with tuple keys to list of pairs (Option 4). + + Converts dict structure {('__dict_key__', ((b'Lease', ...), (b'Sender', ...))): expiration} + to list of pairs [({b'Lease': ..., b'Sender': ...}, expiration), ...] + """ + if not isinstance(raw, Mapping): return None - encoded = encode_model_sequence(values) - return encoded or None + _tuple_len = 2 + result: list[tuple[dict[bytes, bytes], int]] = [] + for key, expiration in raw.items(): + if ( + isinstance(key, tuple) + and len(key) == _tuple_len + and key[0] == "__dict_key__" + and isinstance(key[1], tuple) + and len(key[1]) == _tuple_len + ): + # Reconstruct dict from tuple: key[1] is ((b'Lease', ...), (b'Sender', ...)) + key_dict = dict(key[1]) + if isinstance(expiration, int): + result.append((key_dict, expiration)) + return result if result else None + + +def _encode_optional_sequence(values: list[object] | None) -> list[dict[str, object]] | None: + return encode_model_sequence(values) if values is not None else None def _decode_sequence(factory: Callable[[], type[DecodedT]], raw: object) -> list[DecodedT]: - decoded = decode_model_sequence(factory, raw) - return decoded or [] + return decode_model_sequence(factory, raw) or [] @dataclass(slots=True) @@ -185,7 +197,7 @@ class LedgerAppParams: ) local_state_schema: LedgerStateSchema = field(metadata=nested("lsch", lambda: LedgerStateSchema)) global_state_schema: LedgerStateSchema = field(metadata=nested("gsch", lambda: LedgerStateSchema)) - extra_program_pages: int = field(metadata=wire("epp")) + extra_program_pages: int = field(default=0, metadata=wire("epp")) version: int | None = field(default=None, metadata=wire("v")) size_sponsor: str | None = field( default=None, @@ -244,8 +256,8 @@ class LedgerAppResourceRecord: @dataclass(slots=True) class LedgerAssetHolding: - amount: int = field(metadata=wire("a")) - frozen: bool = field(metadata=wire("f")) + amount: int = field(default=0, metadata=wire("a")) + frozen: bool = field(default=False, metadata=wire("f")) @dataclass(slots=True) @@ -259,9 +271,9 @@ class LedgerAssetHoldingDelta: @dataclass(slots=True) class LedgerAssetParams: - total: int = field(metadata=wire("t")) - decimals: int = field(metadata=wire("dc")) - default_frozen: bool = field(metadata=wire("df")) + total: int = field(default=0, metadata=wire("t")) + decimals: int = field(default=0, metadata=wire("dc")) + default_frozen: bool = field(default=False, metadata=wire("df")) unit_name: str | None = field(default=None, metadata=wire("un")) asset_name: str | None = field(default=None, metadata=wire("an")) url: str | None = field(default=None, metadata=wire("au")) @@ -415,7 +427,7 @@ class LedgerModifiedCreatable: creatable_type: int = field(metadata=wire("Ctype")) created: bool = field(metadata=wire("Created")) creator: str = field(metadata=addr("Creator")) - ndeltas: int = field(metadata=wire("Ndeltas")) + n_deltas: int = field(metadata=wire("Ndeltas")) @dataclass(slots=True) @@ -447,7 +459,7 @@ class LedgerStateDelta: decode=_decode_kv_delta_map, ), ) - txids: dict[bytes, LedgerIncludedTransactions] | None = field( + tx_ids: dict[bytes, LedgerIncludedTransactions] | None = field( default=None, metadata=wire( "Txids", @@ -455,7 +467,27 @@ class LedgerStateDelta: decode=_decode_txid_map, ), ) - txleases: object | None = field(default=None, metadata=wire("Txleases")) + tx_leases: list[tuple[dict[bytes, bytes], int]] | None = field( + default=None, + metadata=wire("Txleases", decode=_decode_tx_leases), + ) + """Transaction leases as list of pairs (key dict, expiration round). + + This field contains a list of tuples where each tuple is: + (key_dict, expiration_round) + + The key_dict is a dict with keys: + - b'Lease': bytes (32-byte lease identifier) + - b'Sender': bytes (32-byte sender address) + + Example:: + delta = client.get_ledger_state_delta(round_=24098947) + if delta.tx_leases: + for key_dict, expiration in delta.tx_leases: + lease_bytes = key_dict[b'Lease'] + sender_bytes = key_dict[b'Sender'] + print(f"Lease: {lease_bytes.hex()}, expires at round {expiration}") + """ creatables: dict[int, LedgerModifiedCreatable] | None = field( default=None, metadata=wire( diff --git a/src/algokit_common/serde/_core.py b/src/algokit_common/serde/_core.py index 73e90363..55b1a9cc 100644 --- a/src/algokit_common/serde/_core.py +++ b/src/algokit_common/serde/_core.py @@ -311,6 +311,12 @@ def _wire_aliases_for(cls: type[object]) -> frozenset[str]: plan = _plan_for(cls) aliases = {h.alias for h in plan.fields if h.kind == "wire" and h.alias} aliases.update(h.nested_alias for h in plan.fields if h.kind == "nested" and h.nested_alias) + # For flattened fields, recursively collect wire aliases from child classes + for h in plan.fields: + if h.kind == "flatten": + child_cls = _resolve_child_cls(h) + if child_cls is not None: + aliases.update(_wire_aliases_for(child_cls)) return frozenset(aliases) diff --git a/src/algokit_indexer_client/client.py b/src/algokit_indexer_client/client.py index df9613b1..6596b710 100644 --- a/src/algokit_indexer_client/client.py +++ b/src/algokit_indexer_client/client.py @@ -19,6 +19,14 @@ ListModelT = TypeVar("ListModelT") PrimitiveT = TypeVar("PrimitiveT") +# Prefixed markers used when converting unhashable msgpack map keys into hashable tuples +_UNHASHABLE_PREFIXES: dict[str, str] = { + "dict": "__dict_key__", + "list": "__list_key__", + "set": "__set_key__", + "generic": "__unhashable__", +} + class IndexerClient: def __init__(self, config: ClientConfig | None = None, *, http_client: httpx.Client | None = None) -> None: @@ -1223,7 +1231,22 @@ def _decode_response( return response.content content_type = response.headers.get("content-type", "application/json") if "msgpack" in content_type: - data = msgpack.unpackb(response.content, raw=True, strict_map_key=False) + # Handle msgpack unpacking with support for unhashable keys + # Use Unpacker for more control over the unpacking process + unpacker = msgpack.Unpacker( + raw=True, + strict_map_key=False, + object_pairs_hook=self._msgpack_pairs_hook, + ) + unpacker.feed(response.content) + try: + data = unpacker.unpack() + except TypeError: + # If unpacking fails due to unhashable keys, try without the hook + # and handle in normalization + unpacker = msgpack.Unpacker(raw=True, strict_map_key=False) + unpacker.feed(response.content) + data = unpacker.unpack() data = self._normalize_msgpack(data) elif content_type.startswith("application/json"): data = response.json() @@ -1238,11 +1261,36 @@ def _decode_response( return data def _normalize_msgpack(self, value: object) -> object: + # Handle pairs returned from msgpack_pairs_hook when keys are unhashable + _pair_length = 2 + if isinstance(value, list) and value and isinstance(value[0], tuple | list) and len(value[0]) == _pair_length: + # Convert to dict with normalized keys + pairs_dict: dict[object, object] = {} + for pair in value: + if isinstance(pair, tuple | list) and len(pair) == _pair_length: + k, v = pair + # For unhashable keys (like dict keys), use a tuple representation + try: + normalized_key = self._coerce_msgpack_key(k) + pairs_dict[normalized_key] = self._normalize_msgpack(v) + except TypeError: + # Key is unhashable - use tuple representation + normalized_key = ("__unhashable__", id(k), str(k)) + pairs_dict[normalized_key] = self._normalize_msgpack(v) + return pairs_dict if isinstance(value, dict): - normalized: dict[object, object] = {} - for key, item in value.items(): - normalized[self._coerce_msgpack_key(key)] = self._normalize_msgpack(item) - return normalized + # Safely normalize maps: coerce string/bytes keys, but tolerate complex/unhashable keys + try: + normalized_dict: dict[object, object] = {} + for key, item in value.items(): + normalized_dict[self._coerce_msgpack_key(key)] = self._normalize_msgpack(item) + return normalized_dict + except TypeError: + # Some maps can decode to object/dict keys; keep original keys and + # only normalize values to avoid "unhashable type: 'dict'" errors. + for k, item in list(value.items()): + value[k] = self._normalize_msgpack(item) + return value if isinstance(value, list): return [self._normalize_msgpack(item) for item in value] return value @@ -1254,3 +1302,36 @@ def _coerce_msgpack_key(self, key: object) -> object: except UnicodeDecodeError: return key return key + + def _msgpack_pairs_hook(self, pairs: list[tuple[object, object]] | list[list[object]]) -> dict[object, object]: + # Convert pairs to dict, handling unhashable keys by converting them to hashable tuples + out: dict[object, object] = {} + _hashable_type_tuple = (str, int, float, bool, type(None), bytes) + + for k, v in pairs: + if isinstance(k, dict | list | set): + # Convert unhashable key to hashable tuple + hashable_key: tuple[str, object] + if isinstance(k, dict): + try: + hashable_key = (_UNHASHABLE_PREFIXES["dict"], tuple(sorted(k.items()))) + except TypeError: + hashable_key = (_UNHASHABLE_PREFIXES["dict"], str(k)) + elif isinstance(k, list): + prefix = _UNHASHABLE_PREFIXES["list"] + hashable_key = (prefix, tuple(k) if all(isinstance(x, _hashable_type_tuple) for x in k) else str(k)) + else: # set + prefix = _UNHASHABLE_PREFIXES["set"] + if all(isinstance(x, _hashable_type_tuple) for x in k): + hashable_key = (prefix, tuple(sorted(k))) + else: + hashable_key = (prefix, str(k)) + out[hashable_key] = v + else: + # Key should be hashable, use as-is + try: + out[k] = v + except TypeError: + # Unexpected unhashable type, convert to tuple + out[(_UNHASHABLE_PREFIXES["generic"], str(type(k).__name__), str(k))] = v + return out diff --git a/src/algokit_kmd_client/client.py b/src/algokit_kmd_client/client.py index 25edcf5e..36329e05 100644 --- a/src/algokit_kmd_client/client.py +++ b/src/algokit_kmd_client/client.py @@ -18,6 +18,14 @@ ListModelT = TypeVar("ListModelT") PrimitiveT = TypeVar("PrimitiveT") +# Prefixed markers used when converting unhashable msgpack map keys into hashable tuples +_UNHASHABLE_PREFIXES: dict[str, str] = { + "dict": "__dict_key__", + "list": "__list_key__", + "set": "__set_key__", + "generic": "__unhashable__", +} + class KmdClient: def __init__(self, config: ClientConfig | None = None, *, http_client: httpx.Client | None = None) -> None: @@ -1056,7 +1064,22 @@ def _decode_response( return response.content content_type = response.headers.get("content-type", "application/json") if "msgpack" in content_type: - data = msgpack.unpackb(response.content, raw=True, strict_map_key=False) + # Handle msgpack unpacking with support for unhashable keys + # Use Unpacker for more control over the unpacking process + unpacker = msgpack.Unpacker( + raw=True, + strict_map_key=False, + object_pairs_hook=self._msgpack_pairs_hook, + ) + unpacker.feed(response.content) + try: + data = unpacker.unpack() + except TypeError: + # If unpacking fails due to unhashable keys, try without the hook + # and handle in normalization + unpacker = msgpack.Unpacker(raw=True, strict_map_key=False) + unpacker.feed(response.content) + data = unpacker.unpack() data = self._normalize_msgpack(data) elif content_type.startswith("application/json"): data = response.json() @@ -1071,11 +1094,36 @@ def _decode_response( return data def _normalize_msgpack(self, value: object) -> object: + # Handle pairs returned from msgpack_pairs_hook when keys are unhashable + _pair_length = 2 + if isinstance(value, list) and value and isinstance(value[0], tuple | list) and len(value[0]) == _pair_length: + # Convert to dict with normalized keys + pairs_dict: dict[object, object] = {} + for pair in value: + if isinstance(pair, tuple | list) and len(pair) == _pair_length: + k, v = pair + # For unhashable keys (like dict keys), use a tuple representation + try: + normalized_key = self._coerce_msgpack_key(k) + pairs_dict[normalized_key] = self._normalize_msgpack(v) + except TypeError: + # Key is unhashable - use tuple representation + normalized_key = ("__unhashable__", id(k), str(k)) + pairs_dict[normalized_key] = self._normalize_msgpack(v) + return pairs_dict if isinstance(value, dict): - normalized: dict[object, object] = {} - for key, item in value.items(): - normalized[self._coerce_msgpack_key(key)] = self._normalize_msgpack(item) - return normalized + # Safely normalize maps: coerce string/bytes keys, but tolerate complex/unhashable keys + try: + normalized_dict: dict[object, object] = {} + for key, item in value.items(): + normalized_dict[self._coerce_msgpack_key(key)] = self._normalize_msgpack(item) + return normalized_dict + except TypeError: + # Some maps can decode to object/dict keys; keep original keys and + # only normalize values to avoid "unhashable type: 'dict'" errors. + for k, item in list(value.items()): + value[k] = self._normalize_msgpack(item) + return value if isinstance(value, list): return [self._normalize_msgpack(item) for item in value] return value @@ -1087,3 +1135,36 @@ def _coerce_msgpack_key(self, key: object) -> object: except UnicodeDecodeError: return key return key + + def _msgpack_pairs_hook(self, pairs: list[tuple[object, object]] | list[list[object]]) -> dict[object, object]: + # Convert pairs to dict, handling unhashable keys by converting them to hashable tuples + out: dict[object, object] = {} + _hashable_type_tuple = (str, int, float, bool, type(None), bytes) + + for k, v in pairs: + if isinstance(k, dict | list | set): + # Convert unhashable key to hashable tuple + hashable_key: tuple[str, object] + if isinstance(k, dict): + try: + hashable_key = (_UNHASHABLE_PREFIXES["dict"], tuple(sorted(k.items()))) + except TypeError: + hashable_key = (_UNHASHABLE_PREFIXES["dict"], str(k)) + elif isinstance(k, list): + prefix = _UNHASHABLE_PREFIXES["list"] + hashable_key = (prefix, tuple(k) if all(isinstance(x, _hashable_type_tuple) for x in k) else str(k)) + else: # set + prefix = _UNHASHABLE_PREFIXES["set"] + if all(isinstance(x, _hashable_type_tuple) for x in k): + hashable_key = (prefix, tuple(sorted(k))) + else: + hashable_key = (prefix, str(k)) + out[hashable_key] = v + else: + # Key should be hashable, use as-is + try: + out[k] = v + except TypeError: + # Unexpected unhashable type, convert to tuple + out[(_UNHASHABLE_PREFIXES["generic"], str(type(k).__name__), str(k))] = v + return out diff --git a/tests/modules/algod_client/test_ledger_state_delta.py b/tests/modules/algod_client/test_ledger_state_delta.py index 6d010fe0..b139bd02 100644 --- a/tests/modules/algod_client/test_ledger_state_delta.py +++ b/tests/modules/algod_client/test_ledger_state_delta.py @@ -1,45 +1,50 @@ +import pytest + from algokit_algod_client import AlgodClient, ClientConfig -def test_ledger_state_delta_endpoint() -> None: +@pytest.mark.parametrize( + ("base_url", "block_rounds", "genesis_id"), + [ + ("https://mainnet-api.4160.nodely.dev", [24098947, 55240407], "mainnet-v1.0"), + ("https://testnet-api.4160.nodely.dev", [24099447, 24099347], "testnet-v1.0"), + ], +) +def test_ledger_state_delta_endpoint(base_url: str, block_rounds: list[int], genesis_id: str) -> None: config = ClientConfig( - base_url="https://testnet-api.4160.nodely.dev", + base_url=base_url, token=None, ) algod_client = AlgodClient(config) - # 1. Large block with state proof type txns on testnet (skip if not accessible) - # 2. Block with global and local state deltas where keys can not be decoded as - block_rounds = [24099447, 24099347] - for block_round in block_rounds: resp = algod_client.get_ledger_state_delta(round_=block_round) assert resp.block.round == block_round - assert resp.block.genesis_id == "testnet-v1.0" - assert resp.accounts.accounts + assert resp.block.genesis_id == genesis_id + + # Blocks may have no account changes for account in resp.accounts.accounts: - assert len(account.address) == 58 - assert resp.totals.online.money > 0 + assert len(account.address) == 58 # base32 + checksum + + # Totals can be zero if no changes + assert resp.totals.online.money >= 0 assert resp.totals.offline.money >= 0 assert resp.totals.not_participating.money >= 0 - # App resources (if any) should keep both IDs and decoded addresses. for resource in resp.accounts.app_resources or []: assert resource.app_id > 0 assert len(resource.address) == 58 if resource.state.local_state: - # Local state deltas should track schema counts. - assert resource.state.local_state.schema.num_byte_slices is not None + schema = resource.state.local_state.schema + assert schema.num_uints is not None or schema.num_byte_slices is not None - # Creatables (if any) should expose creators and types. for creatable in (resp.creatables or {}).values(): assert len(creatable.creator) == 58 assert creatable.creatable_type in (0, 1) - # TxIDs are keyed by raw digest bytes (32-byte values) with last-valid metadata. - if resp.txids: - for txid_bytes, tx_info in resp.txids.items(): + if resp.tx_ids: + for txid_bytes, tx_info in resp.tx_ids.items(): assert isinstance(txid_bytes, bytes) assert len(txid_bytes) == 32 assert tx_info.last_valid >= block_round From 21fa3aaf4295e14ae590d197f23016117e5d0da3 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Thu, 13 Nov 2025 17:42:54 +0100 Subject: [PATCH 2/3] chore: drop txn leases field --- .../templates/models/ledger_state_delta.py.j2 | 46 ------------------- .../models/_ledger_state_delta.py | 46 ------------------- 2 files changed, 92 deletions(-) diff --git a/api/oas-generator/src/oas_generator/renderer/templates/models/ledger_state_delta.py.j2 b/api/oas-generator/src/oas_generator/renderer/templates/models/ledger_state_delta.py.j2 index 0d3f77eb..aa48aae0 100644 --- a/api/oas-generator/src/oas_generator/renderer/templates/models/ledger_state_delta.py.j2 +++ b/api/oas-generator/src/oas_generator/renderer/templates/models/ledger_state_delta.py.j2 @@ -126,31 +126,6 @@ def _decode_creatables(raw: object) -> dict[int, "LedgerModifiedCreatable"] | No return decoded if decoded else None -def _decode_tx_leases(raw: object) -> list[tuple[dict[bytes, bytes], int]] | None: - """Decode txleases from dict with tuple keys to list of pairs (Option 4). - - Converts dict structure {('__dict_key__', ((b'Lease', ...), (b'Sender', ...))): expiration} - to list of pairs [({b'Lease': ..., b'Sender': ...}, expiration), ...] - """ - if not isinstance(raw, Mapping): - return None - _tuple_len = 2 - result: list[tuple[dict[bytes, bytes], int]] = [] - for key, expiration in raw.items(): - if ( - isinstance(key, tuple) - and len(key) == _tuple_len - and key[0] == "__dict_key__" - and isinstance(key[1], tuple) - and len(key[1]) == _tuple_len - ): - # Reconstruct dict from tuple: key[1] is ((b'Lease', ...), (b'Sender', ...)) - key_dict = dict(key[1]) - if isinstance(expiration, int): - result.append((key_dict, expiration)) - return result if result else None - - def _encode_optional_sequence(values: list[object] | None) -> list[dict[str, object]] | None: return encode_model_sequence(values) if values is not None else None @@ -467,27 +442,6 @@ class LedgerStateDelta: decode=_decode_txid_map, ), ) - tx_leases: list[tuple[dict[bytes, bytes], int]] | None = field( - default=None, - metadata=wire("Txleases", decode=_decode_tx_leases), - ) - """Transaction leases as list of pairs (key dict, expiration round). - - This field contains a list of tuples where each tuple is: - (key_dict, expiration_round) - - The key_dict is a dict with keys: - - b'Lease': bytes (32-byte lease identifier) - - b'Sender': bytes (32-byte sender address) - - Example:: - delta = client.get_ledger_state_delta(round_=24098947) - if delta.tx_leases: - for key_dict, expiration in delta.tx_leases: - lease_bytes = key_dict[b'Lease'] - sender_bytes = key_dict[b'Sender'] - print(f"Lease: {lease_bytes.hex()}, expires at round {expiration}") - """ creatables: dict[int, LedgerModifiedCreatable] | None = field( default=None, metadata=wire( diff --git a/src/algokit_algod_client/models/_ledger_state_delta.py b/src/algokit_algod_client/models/_ledger_state_delta.py index d1e473bd..7ad1995d 100644 --- a/src/algokit_algod_client/models/_ledger_state_delta.py +++ b/src/algokit_algod_client/models/_ledger_state_delta.py @@ -126,31 +126,6 @@ def _decode_creatables(raw: object) -> dict[int, "LedgerModifiedCreatable"] | No return decoded if decoded else None -def _decode_tx_leases(raw: object) -> list[tuple[dict[bytes, bytes], int]] | None: - """Decode txleases from dict with tuple keys to list of pairs (Option 4). - - Converts dict structure {('__dict_key__', ((b'Lease', ...), (b'Sender', ...))): expiration} - to list of pairs [({b'Lease': ..., b'Sender': ...}, expiration), ...] - """ - if not isinstance(raw, Mapping): - return None - _tuple_len = 2 - result: list[tuple[dict[bytes, bytes], int]] = [] - for key, expiration in raw.items(): - if ( - isinstance(key, tuple) - and len(key) == _tuple_len - and key[0] == "__dict_key__" - and isinstance(key[1], tuple) - and len(key[1]) == _tuple_len - ): - # Reconstruct dict from tuple: key[1] is ((b'Lease', ...), (b'Sender', ...)) - key_dict = dict(key[1]) - if isinstance(expiration, int): - result.append((key_dict, expiration)) - return result if result else None - - def _encode_optional_sequence(values: list[object] | None) -> list[dict[str, object]] | None: return encode_model_sequence(values) if values is not None else None @@ -467,27 +442,6 @@ class LedgerStateDelta: decode=_decode_txid_map, ), ) - tx_leases: list[tuple[dict[bytes, bytes], int]] | None = field( - default=None, - metadata=wire("Txleases", decode=_decode_tx_leases), - ) - """Transaction leases as list of pairs (key dict, expiration round). - - This field contains a list of tuples where each tuple is: - (key_dict, expiration_round) - - The key_dict is a dict with keys: - - b'Lease': bytes (32-byte lease identifier) - - b'Sender': bytes (32-byte sender address) - - Example:: - delta = client.get_ledger_state_delta(round_=24098947) - if delta.tx_leases: - for key_dict, expiration in delta.tx_leases: - lease_bytes = key_dict[b'Lease'] - sender_bytes = key_dict[b'Sender'] - print(f"Lease: {lease_bytes.hex()}, expires at round {expiration}") - """ creatables: dict[int, LedgerModifiedCreatable] | None = field( default=None, metadata=wire( From 3b6bc34703815e8e69aa3810859657f170048822 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Fri, 14 Nov 2025 16:15:16 +0100 Subject: [PATCH 3/3] chore: remove typed ledger state delta endpoint support to align with ts constraints; Add Missing ParticipationUpdates model --- .../src/oas_generator/builder.py | 28 +- api/oas-generator/src/oas_generator/models.py | 2 +- .../src/oas_generator/renderer/engine.py | 37 +- .../src/oas_generator/renderer/filters.py | 18 +- .../renderer/templates/client.py.j2 | 17 +- .../renderer/templates/models/__init__.py.j2 | 7 +- .../renderer/templates/models/block.py.j2 | 23 +- .../templates/models/ledger_state_delta.py.j2 | 452 ------------------ src/algokit_algod_client/client.py | 32 +- src/algokit_algod_client/models/__init__.py | 56 +-- src/algokit_algod_client/models/_block.py | 23 +- ...r_state_deltas_for_round_response_model.py | 20 - .../models/_ledger_state_delta.py | 452 ------------------ ...edger_state_delta_for_transaction_group.py | 22 - src/algokit_indexer_client/client.py | 16 +- src/algokit_kmd_client/client.py | 38 +- tests/modules/algod_client/test_block.py | 26 +- .../algod_client/test_ledger_state_delta.py | 40 +- 18 files changed, 176 insertions(+), 1133 deletions(-) delete mode 100644 api/oas-generator/src/oas_generator/renderer/templates/models/ledger_state_delta.py.j2 delete mode 100644 src/algokit_algod_client/models/_get_transaction_group_ledger_state_deltas_for_round_response_model.py delete mode 100644 src/algokit_algod_client/models/_ledger_state_delta.py delete mode 100644 src/algokit_algod_client/models/_ledger_state_delta_for_transaction_group.py diff --git a/api/oas-generator/src/oas_generator/builder.py b/api/oas-generator/src/oas_generator/builder.py index 7cf4b4e8..587c48ec 100644 --- a/api/oas-generator/src/oas_generator/builder.py +++ b/api/oas-generator/src/oas_generator/builder.py @@ -1,7 +1,7 @@ import re from collections import defaultdict from dataclasses import dataclass, field -from typing import Any +from typing import Any, ClassVar from oas_generator import models as ctx from oas_generator.naming import IdentifierSanitizer @@ -33,6 +33,13 @@ class TypeInfo: imports: set[str] = field(default_factory=set) +LEDGER_STATE_DELTA_MODEL_NAMES: set[str] = { + "LedgerStateDelta", + "LedgerStateDeltaForTransactionGroup", + "GetTransactionGroupLedgerStateDeltasForRoundResponseModel", +} + + class SchemaRegistry: def __init__(self, spec: ctx.ParsedSpec, sanitizer: IdentifierSanitizer) -> None: self.spec = spec @@ -454,6 +461,12 @@ def _collect_alias_imports(self, annotation: str, entry: SchemaEntry) -> list[st class OperationBuilder: + RAW_LEDGER_STATE_DELTA_OPERATIONS: ClassVar[set[str]] = { + "GetLedgerStateDelta", + "GetLedgerStateDeltaForTransactionGroup", + "GetTransactionGroupLedgerStateDeltasForRound", + } + def __init__( self, spec: ctx.ParsedSpec, resolver: TypeResolver, sanitizer: IdentifierSanitizer, registry: SchemaRegistry ) -> None: @@ -622,6 +635,17 @@ def _build_response(self, responses: dict[str, Any], operation_id: str) -> ctx.R if media_type in content: schema = content[media_type].get("schema") media_types.append(media_type) + if operation_id in self.RAW_LEDGER_STATE_DELTA_OPERATIONS: + if not media_types: + media_types = ["application/msgpack"] + if "application/msgpack" in media_types: + self.uses_msgpack = True + return ctx.ResponseDescriptor( + type_hint="bytes", + media_types=media_types, + description=payload.get("description"), + is_raw_msgpack=True, + ) if operation_id == "GetBlock" and schema is not None: self.uses_block_models = True media_types = media_types or ["application/json"] @@ -673,6 +697,7 @@ def build_client_descriptor( groups = operation_builder.build() model_builder = ModelBuilder(registry, resolver, sanitizer) models, enums, aliases = model_builder.build() + models = [model for model in models if model.name not in LEDGER_STATE_DELTA_MODEL_NAMES] uses_signed_txn = model_builder.uses_signed_transaction or operation_builder.uses_signed_transaction defaults = { "algod_client": ("http://localhost:4001", "X-Algo-API-Token"), @@ -694,5 +719,4 @@ def build_client_descriptor( uses_signed_transaction=uses_signed_txn, uses_msgpack=operation_builder.uses_msgpack, include_block_models=operation_builder.uses_block_models, - include_ledger_state_delta_models="LedgerStateDelta" in registry.entries, ) diff --git a/api/oas-generator/src/oas_generator/models.py b/api/oas-generator/src/oas_generator/models.py index a4e05ff4..e04b81cb 100644 --- a/api/oas-generator/src/oas_generator/models.py +++ b/api/oas-generator/src/oas_generator/models.py @@ -98,6 +98,7 @@ class ResponseDescriptor: media_types: list[str] description: str | None is_binary: bool = False + is_raw_msgpack: bool = False model: str | None = None list_model: str | None = None enum: str | None = None @@ -146,4 +147,3 @@ class ClientDescriptor: uses_signed_transaction: bool = False uses_msgpack: bool = False include_block_models: bool = False - include_ledger_state_delta_models: bool = False diff --git a/api/oas-generator/src/oas_generator/renderer/engine.py b/api/oas-generator/src/oas_generator/renderer/engine.py index 64ef1978..aad02d24 100644 --- a/api/oas-generator/src/oas_generator/renderer/engine.py +++ b/api/oas-generator/src/oas_generator/renderer/engine.py @@ -22,35 +22,11 @@ class TemplateRenderer: "BlockAppEvalDelta", "BlockStateProofTrackingData", "BlockStateProofTracking", + "ParticipationUpdates", "SignedTxnInBlock", "Block", "GetBlock", ] - LEDGER_STATE_DELTA_EXPORTS: ClassVar[list[str]] = [ - "LedgerTealValue", - "LedgerStateSchema", - "LedgerAppParams", - "LedgerAppLocalState", - "LedgerAppLocalStateDelta", - "LedgerAppParamsDelta", - "LedgerAppResourceRecord", - "LedgerAssetHolding", - "LedgerAssetHoldingDelta", - "LedgerAssetParams", - "LedgerAssetParamsDelta", - "LedgerAssetResourceRecord", - "LedgerVotingData", - "LedgerAccountBaseData", - "LedgerAccountData", - "LedgerBalanceRecord", - "LedgerAccountDeltas", - "LedgerKvValueDelta", - "LedgerIncludedTransactions", - "LedgerModifiedCreatable", - "LedgerAlgoCount", - "LedgerAccountTotals", - "LedgerStateDelta", - ] def __init__(self, template_dir: Path | None = None) -> None: if template_dir: @@ -81,8 +57,6 @@ def render(self, client: ctx.ClientDescriptor, config: GeneratorConfig) -> dict[ files[models_dir / "__init__.py"] = self._render_template("models/__init__.py.j2", context) files[models_dir / "_serde_helpers.py"] = self._render_template("models/_serde_helpers.py.j2", context) for model in context["client"].models: - if context["client"].include_ledger_state_delta_models and model.name == "LedgerStateDelta": - continue model_context = {**context, "model": model} files[models_dir / f"{model.module_name}.py"] = self._render_template("models/model.py.j2", model_context) for enum in context["client"].enums: @@ -95,10 +69,6 @@ def render(self, client: ctx.ClientDescriptor, config: GeneratorConfig) -> dict[ ) if client.include_block_models: files[models_dir / "_block.py"] = self._render_template("models/block.py.j2", context) - if client.include_ledger_state_delta_models: - files[models_dir / "_ledger_state_delta.py"] = self._render_template( - "models/ledger_state_delta.py.j2", context - ) files[target / "py.typed"] = "" return files @@ -116,10 +86,6 @@ def _build_context(self, client: ctx.ClientDescriptor, config: GeneratorConfig) for name in self.BLOCK_MODEL_EXPORTS: if name not in model_exports: model_exports.append(name) - if client.include_ledger_state_delta_models: - for name in self.LEDGER_STATE_DELTA_EXPORTS: - if name not in model_exports: - model_exports.append(name) metadata_usage = self._collect_metadata_usage(client) model_modules = [{"module": model.module_name, "name": model.name} for model in client.models] enum_modules = [{"module": enum.module_name, "name": enum.name} for enum in client.enums] @@ -140,7 +106,6 @@ def _build_context(self, client: ctx.ClientDescriptor, config: GeneratorConfig) "needs_datetime": any(model.requires_datetime for model in client.models), "client_needs_datetime": self._client_requires_datetime(client), "block_exports": self.BLOCK_MODEL_EXPORTS, - "ledger_state_delta_exports": self.LEDGER_STATE_DELTA_EXPORTS, "needs_literal": needs_literal, } diff --git a/api/oas-generator/src/oas_generator/renderer/filters.py b/api/oas-generator/src/oas_generator/renderer/filters.py index 9e26fc9a..d08ecfd7 100644 --- a/api/oas-generator/src/oas_generator/renderer/filters.py +++ b/api/oas-generator/src/oas_generator/renderer/filters.py @@ -22,7 +22,11 @@ def descriptor_literal(descriptor: object, indent: int = 0) -> str: if descriptor is None: return "{}" fields: dict[str, Any] = {} - for key in ("is_binary", "model", "list_model", "enum", "list_enum"): + bool_fields = ("is_binary", "is_raw_msgpack") + for key in bool_fields: + if getattr(descriptor, key, False): + fields[key] = True + for key in ("model", "list_model", "enum", "list_enum"): value = getattr(descriptor, key, None) if value is not None: fields[key] = value @@ -41,15 +45,25 @@ def response_decode_arguments(descriptor: object, indent: int = 0) -> str: model = getattr(descriptor, "model", None) or getattr(descriptor, "enum", None) list_model = getattr(descriptor, "list_model", None) or getattr(descriptor, "list_enum", None) is_binary = bool(getattr(descriptor, "is_binary", False)) + raw_msgpack = bool(getattr(descriptor, "is_raw_msgpack", False)) type_hint = getattr(descriptor, "type_hint", None) parts: list[str] = [] if is_binary: parts.append("is_binary=True") + if raw_msgpack: + parts.append("raw_msgpack=True") if model: parts.append(f"model=models.{model}") if list_model: parts.append(f"list_model=models.{list_model}") - if not model and not list_model and not is_binary and type_hint and type_hint != "object": + if ( + not model + and not list_model + and not is_binary + and not raw_msgpack + and type_hint + and type_hint != "object" + ): parts.append(f"type_={type_hint}") if not parts: return "" diff --git a/api/oas-generator/src/oas_generator/renderer/templates/client.py.j2 b/api/oas-generator/src/oas_generator/renderer/templates/client.py.j2 index 8f0676f2..b4f0f447 100644 --- a/api/oas-generator/src/oas_generator/renderer/templates/client.py.j2 +++ b/api/oas-generator/src/oas_generator/renderer/templates/client.py.j2 @@ -201,6 +201,7 @@ class {{ client.class_name }}: *, model: type[ModelT], is_binary: bool = False, + raw_msgpack: bool = False, ) -> ModelT: ... @@ -211,6 +212,7 @@ class {{ client.class_name }}: *, list_model: type[ListModelT], is_binary: bool = False, + raw_msgpack: bool = False, ) -> list[ListModelT]: ... @@ -221,6 +223,7 @@ class {{ client.class_name }}: *, type_: type[PrimitiveT], is_binary: bool = False, + raw_msgpack: bool = False, ) -> PrimitiveT: ... @@ -230,6 +233,16 @@ class {{ client.class_name }}: response: httpx.Response, *, is_binary: Literal[True], + raw_msgpack: bool = False, + ) -> bytes: + ... + + @overload + def _decode_response( + self, + response: httpx.Response, + *, + raw_msgpack: Literal[True], ) -> bytes: ... @@ -240,6 +253,7 @@ class {{ client.class_name }}: *, type_: None = None, is_binary: bool = False, + raw_msgpack: bool = False, ) -> object: ... @@ -251,8 +265,9 @@ class {{ client.class_name }}: list_model: type[Any] | None = None, type_: type[Any] | None = None, is_binary: bool = False, + raw_msgpack: bool = False, ) -> object: - if is_binary: + if is_binary or raw_msgpack: return response.content content_type = response.headers.get("content-type", "application/json") if "msgpack" in content_type: diff --git a/api/oas-generator/src/oas_generator/renderer/templates/models/__init__.py.j2 b/api/oas-generator/src/oas_generator/renderer/templates/models/__init__.py.j2 index a79e75b7..01c69d48 100644 --- a/api/oas-generator/src/oas_generator/renderer/templates/models/__init__.py.j2 +++ b/api/oas-generator/src/oas_generator/renderer/templates/models/__init__.py.j2 @@ -1,15 +1,12 @@ {% if client.uses_signed_transaction %}from algokit_transact.models.signed_transaction import SignedTransaction -{% endif %}{% for item in model_modules %}{% if not (client.include_ledger_state_delta_models and item.name == "LedgerStateDelta") %}from .{{ item.module }} import {{ item.name }} -{% endif %}{% endfor %}{% for item in enum_modules %}from .{{ item.module }} import {{ item.name }} +{% endif %}{% for item in model_modules %}from .{{ item.module }} import {{ item.name }} +{% endfor %}{% for item in enum_modules %}from .{{ item.module }} import {{ item.name }} {% endfor %}{% for item in alias_modules %}from .{{ item.module }} import {{ item.name }} {% endfor %}{% if client.include_block_models %}from ._block import ( {{ block_exports | join(',\n ') }} ) -{% endif %}{% if client.include_ledger_state_delta_models %}from ._ledger_state_delta import ( - {{ ledger_state_delta_exports | join(',\n ') }} -) {% endif %} __all__ = [ diff --git a/api/oas-generator/src/oas_generator/renderer/templates/models/block.py.j2 b/api/oas-generator/src/oas_generator/renderer/templates/models/block.py.j2 index 2e967787..91020b7b 100644 --- a/api/oas-generator/src/oas_generator/renderer/templates/models/block.py.j2 +++ b/api/oas-generator/src/oas_generator/renderer/templates/models/block.py.j2 @@ -26,6 +26,7 @@ __all__ = [ "BlockAppEvalDelta", "BlockStateProofTrackingData", "BlockStateProofTracking", + "ParticipationUpdates", "ApplyData", "SignedTxnWithAD", "SignedTxnInBlock", @@ -177,6 +178,20 @@ class SignedTxnWithAD: apply_data: ApplyData = field(metadata=flatten(lambda: ApplyData)) +@dataclass(slots=True) +class ParticipationUpdates: + """Participation account updates embedded in a block.""" + + expired_participation_accounts: list[bytes] | None = field( + default=None, + metadata=wire("partupdrmv"), + ) + absent_participation_accounts: list[bytes] | None = field( + default=None, + metadata=wire("partupdabs"), + ) + + @dataclass(slots=True) class SignedTxnInBlock: """Signed transaction details with block-specific apply data.""" @@ -262,13 +277,9 @@ class Block: ), ), ) - expired_participation_accounts: list[bytes] | None = field( + participation_updates: ParticipationUpdates | None = field( default=None, - metadata=wire("partupdrmv"), - ) - absent_participation_accounts: list[bytes] | None = field( - default=None, - metadata=wire("partupdabs"), + metadata=flatten(lambda: ParticipationUpdates), ) transactions: list[SignedTxnInBlock] | None = field( default=None, diff --git a/api/oas-generator/src/oas_generator/renderer/templates/models/ledger_state_delta.py.j2 b/api/oas-generator/src/oas_generator/renderer/templates/models/ledger_state_delta.py.j2 deleted file mode 100644 index aa48aae0..00000000 --- a/api/oas-generator/src/oas_generator/renderer/templates/models/ledger_state_delta.py.j2 +++ /dev/null @@ -1,452 +0,0 @@ -# AUTO-GENERATED: oas_generator - -from collections.abc import Mapping -from contextlib import suppress -from dataclasses import dataclass, field -from typing import Callable, TypeVar, cast - -from algokit_common.serde import DecodeError, addr, flatten, from_wire, nested, to_wire, wire - -from ._serde_helpers import ( - decode_bytes_base64, - decode_model_mapping, - decode_model_sequence, - encode_bytes_base64, - encode_model_mapping, - encode_model_sequence, -) -from ._block import Block - -DecodedT = TypeVar("DecodedT") - -__all__ = [ - "LedgerTealValue", - "LedgerStateSchema", - "LedgerAppParams", - "LedgerAppLocalState", - "LedgerAppLocalStateDelta", - "LedgerAppParamsDelta", - "LedgerAppResourceRecord", - "LedgerAssetHolding", - "LedgerAssetHoldingDelta", - "LedgerAssetParams", - "LedgerAssetParamsDelta", - "LedgerAssetResourceRecord", - "LedgerVotingData", - "LedgerAccountBaseData", - "LedgerAccountData", - "LedgerBalanceRecord", - "LedgerAccountDeltas", - "LedgerKvValueDelta", - "LedgerIncludedTransactions", - "LedgerModifiedCreatable", - "LedgerAlgoCount", - "LedgerAccountTotals", - "LedgerStateDelta", -] - - -def _encode_bytes_key(key: object) -> str: - if isinstance(key, bytes | bytearray | memoryview): - return encode_bytes_base64(key) - if isinstance(key, str): - return key - raise TypeError("Ledger delta map keys must be bytes-like or str") - - -def _decode_bytes_key(key: object) -> bytes: - if isinstance(key, bytes): - return key - if isinstance(key, str): - try: - return decode_bytes_base64(key) - except (TypeError, ValueError): - pass - return key.encode("utf-8") - raise TypeError("Ledger delta map keys must be bytes-like or str") - - -def _encode_teal_value_map(mapping: Mapping[bytes, "LedgerTealValue"] | None) -> dict[str, object] | None: - if not mapping: - return None - return encode_model_mapping( - lambda: LedgerTealValue, - cast(Mapping[object, object], mapping), - key_encoder=_encode_bytes_key, - ) - - -def _decode_teal_value_map(raw: object) -> dict[bytes, "LedgerTealValue"] | None: - return decode_model_mapping(lambda: LedgerTealValue, raw, key_decoder=_decode_bytes_key) - - -def _encode_kv_delta_map(mapping: Mapping[bytes, "LedgerKvValueDelta"] | None) -> dict[str, object] | None: - if not mapping: - return None - return encode_model_mapping( - lambda: LedgerKvValueDelta, - cast(Mapping[object, object], mapping), - key_encoder=_encode_bytes_key, - ) - - -def _decode_kv_delta_map(raw: object) -> dict[bytes, "LedgerKvValueDelta"] | None: - return decode_model_mapping(lambda: LedgerKvValueDelta, raw, key_decoder=_decode_bytes_key) - - -def _encode_txid_map(mapping: Mapping[bytes, "LedgerIncludedTransactions"] | None) -> dict[str, object] | None: - if not mapping: - return None - return encode_model_mapping( - lambda: LedgerIncludedTransactions, - cast(Mapping[object, object], mapping), - key_encoder=_encode_bytes_key, - ) - - -def _decode_txid_map(raw: object) -> dict[bytes, "LedgerIncludedTransactions"] | None: - return decode_model_mapping(lambda: LedgerIncludedTransactions, raw, key_decoder=_decode_bytes_key) - - -def _encode_creatables(mapping: Mapping[int, "LedgerModifiedCreatable | None"] | None) -> dict[int, object] | None: - if not mapping: - return None - encoded: dict[int, object] = {int(k): to_wire(v) for k, v in mapping.items() if v is not None} - return encoded if encoded else None - - -def _decode_creatables(raw: object) -> dict[int, "LedgerModifiedCreatable"] | None: - if not isinstance(raw, Mapping): - return None - decoded: dict[int, LedgerModifiedCreatable] = {} - for key, value in raw.items(): - if isinstance(value, Mapping): - with suppress(DecodeError, TypeError, ValueError): - decoded[int(key)] = from_wire(LedgerModifiedCreatable, value) - return decoded if decoded else None - - -def _encode_optional_sequence(values: list[object] | None) -> list[dict[str, object]] | None: - return encode_model_sequence(values) if values is not None else None - - -def _decode_sequence(factory: Callable[[], type[DecodedT]], raw: object) -> list[DecodedT]: - return decode_model_sequence(factory, raw) or [] - - -@dataclass(slots=True) -class LedgerTealValue: - type: int = field(metadata=wire("tt")) - bytes_: bytes | None = field( - default=None, - metadata=wire( - "tb", - encode=encode_bytes_base64, - decode=decode_bytes_base64, - ), - ) - uint: int | None = field(default=None, metadata=wire("ui")) - - -@dataclass(slots=True) -class LedgerStateSchema: - num_uints: int | None = field(default=None, metadata=wire("nui")) - num_byte_slices: int | None = field(default=None, metadata=wire("nbs")) - - -@dataclass(slots=True) -class LedgerAppParams: - approval_program: bytes = field( - metadata=wire( - "approv", - encode=encode_bytes_base64, - decode=decode_bytes_base64, - ), - ) - clear_state_program: bytes = field( - metadata=wire( - "clearp", - encode=encode_bytes_base64, - decode=decode_bytes_base64, - ), - ) - local_state_schema: LedgerStateSchema = field(metadata=nested("lsch", lambda: LedgerStateSchema)) - global_state_schema: LedgerStateSchema = field(metadata=nested("gsch", lambda: LedgerStateSchema)) - extra_program_pages: int = field(default=0, metadata=wire("epp")) - version: int | None = field(default=None, metadata=wire("v")) - size_sponsor: str | None = field( - default=None, - metadata=addr("ss"), - ) - global_state: dict[bytes, LedgerTealValue] | None = field( - default=None, - metadata=wire( - "gs", - encode=_encode_teal_value_map, - decode=_decode_teal_value_map, - ), - ) - - -@dataclass(slots=True) -class LedgerAppLocalState: - schema: LedgerStateSchema = field(metadata=nested("hsch", lambda: LedgerStateSchema)) - key_value: dict[bytes, LedgerTealValue] | None = field( - default=None, - metadata=wire( - "tkv", - encode=_encode_teal_value_map, - decode=_decode_teal_value_map, - ), - ) - - -@dataclass(slots=True) -class LedgerAppLocalStateDelta: - deleted: bool = field(metadata=wire("Deleted")) - local_state: LedgerAppLocalState | None = field( - default=None, - metadata=nested("LocalState", lambda: LedgerAppLocalState), - ) - - -@dataclass(slots=True) -class LedgerAppParamsDelta: - deleted: bool = field(metadata=wire("Deleted")) - params: LedgerAppParams | None = field( - default=None, - metadata=nested("Params", lambda: LedgerAppParams), - ) - - -@dataclass(slots=True) -class LedgerAppResourceRecord: - app_id: int = field(metadata=wire("Aidx")) - address: str = field(metadata=addr("Addr")) - params: LedgerAppParamsDelta = field(metadata=nested("Params", lambda: LedgerAppParamsDelta)) - state: LedgerAppLocalStateDelta = field( - metadata=nested("State", lambda: LedgerAppLocalStateDelta), - ) - - -@dataclass(slots=True) -class LedgerAssetHolding: - amount: int = field(default=0, metadata=wire("a")) - frozen: bool = field(default=False, metadata=wire("f")) - - -@dataclass(slots=True) -class LedgerAssetHoldingDelta: - deleted: bool = field(metadata=wire("Deleted")) - holding: LedgerAssetHolding | None = field( - default=None, - metadata=nested("Holding", lambda: LedgerAssetHolding), - ) - - -@dataclass(slots=True) -class LedgerAssetParams: - total: int = field(default=0, metadata=wire("t")) - decimals: int = field(default=0, metadata=wire("dc")) - default_frozen: bool = field(default=False, metadata=wire("df")) - unit_name: str | None = field(default=None, metadata=wire("un")) - asset_name: str | None = field(default=None, metadata=wire("an")) - url: str | None = field(default=None, metadata=wire("au")) - metadata_hash: bytes | None = field( - default=None, - metadata=wire( - "am", - encode=encode_bytes_base64, - decode=decode_bytes_base64, - ), - ) - manager: str | None = field(default=None, metadata=addr("m")) - reserve: str | None = field(default=None, metadata=addr("r")) - freeze: str | None = field(default=None, metadata=addr("f")) - clawback: str | None = field(default=None, metadata=addr("c")) - - -@dataclass(slots=True) -class LedgerAssetParamsDelta: - deleted: bool = field(metadata=wire("Deleted")) - params: LedgerAssetParams | None = field( - default=None, - metadata=nested("Params", lambda: LedgerAssetParams), - ) - - -@dataclass(slots=True) -class LedgerAssetResourceRecord: - asset_id: int = field(metadata=wire("Aidx")) - address: str = field(metadata=addr("Addr")) - params: LedgerAssetParamsDelta = field(metadata=nested("Params", lambda: LedgerAssetParamsDelta)) - holding: LedgerAssetHoldingDelta = field(metadata=nested("Holding", lambda: LedgerAssetHoldingDelta)) - - -@dataclass(slots=True) -class LedgerVotingData: - vote_id: bytes = field( - metadata=wire( - "VoteID", - encode=encode_bytes_base64, - decode=decode_bytes_base64, - ), - ) - selection_id: bytes = field( - metadata=wire( - "SelectionID", - encode=encode_bytes_base64, - decode=decode_bytes_base64, - ), - ) - state_proof_id: bytes = field( - metadata=wire( - "StateProofID", - encode=encode_bytes_base64, - decode=decode_bytes_base64, - ), - ) - vote_first_valid: int = field(metadata=wire("VoteFirstValid")) - vote_last_valid: int = field(metadata=wire("VoteLastValid")) - vote_key_dilution: int = field(metadata=wire("VoteKeyDilution")) - - -@dataclass(slots=True) -class LedgerAccountBaseData: - status: int = field(metadata=wire("Status")) - micro_algos: int = field(metadata=wire("MicroAlgos")) - rewards_base: int = field(metadata=wire("RewardsBase")) - rewarded_micro_algos: int = field(metadata=wire("RewardedMicroAlgos")) - auth_address: str = field(metadata=addr("AuthAddr")) - incentive_eligible: bool = field(metadata=wire("IncentiveEligible")) - total_app_schema: LedgerStateSchema = field(metadata=nested("TotalAppSchema", lambda: LedgerStateSchema)) - total_extra_app_pages: int = field(metadata=wire("TotalExtraAppPages")) - total_app_params: int = field(metadata=wire("TotalAppParams")) - total_app_local_states: int = field(metadata=wire("TotalAppLocalStates")) - total_asset_params: int = field(metadata=wire("TotalAssetParams")) - total_assets: int = field(metadata=wire("TotalAssets")) - total_boxes: int = field(metadata=wire("TotalBoxes")) - total_box_bytes: int = field(metadata=wire("TotalBoxBytes")) - last_proposed: int = field(metadata=wire("LastProposed")) - last_heartbeat: int = field(metadata=wire("LastHeartbeat")) - - -@dataclass(slots=True) -class LedgerAccountData: - account_base_data: LedgerAccountBaseData = field(metadata=flatten(lambda: LedgerAccountBaseData)) - voting_data: LedgerVotingData = field(metadata=flatten(lambda: LedgerVotingData)) - - -@dataclass(slots=True) -class LedgerBalanceRecord: - address: str = field(metadata=addr("Addr")) - account_data: LedgerAccountData = field(metadata=flatten(lambda: LedgerAccountData)) - - -@dataclass(slots=True) -class LedgerAccountDeltas: - accounts: list[LedgerBalanceRecord] = field( - default_factory=list, - metadata=wire( - "Accts", - encode=encode_model_sequence, - decode=lambda raw: _decode_sequence(lambda: LedgerBalanceRecord, raw), - ), - ) - app_resources: list[LedgerAppResourceRecord] = field( - default_factory=list, - metadata=wire( - "AppResources", - encode=_encode_optional_sequence, - decode=lambda raw: _decode_sequence(lambda: LedgerAppResourceRecord, raw), - ), - ) - asset_resources: list[LedgerAssetResourceRecord] = field( - default_factory=list, - metadata=wire( - "AssetResources", - encode=_encode_optional_sequence, - decode=lambda raw: _decode_sequence(lambda: LedgerAssetResourceRecord, raw), - ), - ) - - -@dataclass(slots=True) -class LedgerKvValueDelta: - data: bytes | None = field( - default=None, - metadata=wire( - "Data", - encode=encode_bytes_base64, - decode=decode_bytes_base64, - ), - ) - old_data: bytes | None = field( - default=None, - metadata=wire( - "OldData", - encode=encode_bytes_base64, - decode=decode_bytes_base64, - ), - ) - - -@dataclass(slots=True) -class LedgerIncludedTransactions: - last_valid: int = field(metadata=wire("LastValid")) - intra: int = field(metadata=wire("Intra")) - - -@dataclass(slots=True) -class LedgerModifiedCreatable: - creatable_type: int = field(metadata=wire("Ctype")) - created: bool = field(metadata=wire("Created")) - creator: str = field(metadata=addr("Creator")) - n_deltas: int = field(metadata=wire("Ndeltas")) - - -@dataclass(slots=True) -class LedgerAlgoCount: - money: int = field(metadata=wire("mon")) - reward_units: int = field(metadata=wire("rwd")) - - -@dataclass(slots=True) -class LedgerAccountTotals: - online: LedgerAlgoCount = field(metadata=nested("online", lambda: LedgerAlgoCount)) - offline: LedgerAlgoCount = field(metadata=nested("offline", lambda: LedgerAlgoCount)) - not_participating: LedgerAlgoCount = field(metadata=nested("notpart", lambda: LedgerAlgoCount)) - rewards_level: int = field(metadata=wire("rwdlvl")) - - -@dataclass(slots=True) -class LedgerStateDelta: - accounts: LedgerAccountDeltas = field(metadata=nested("Accts", lambda: LedgerAccountDeltas)) - block: Block = field(metadata=nested("Hdr", lambda: Block)) - state_proof_next: int = field(metadata=wire("StateProofNext")) - prev_timestamp: int = field(metadata=wire("PrevTimestamp")) - totals: LedgerAccountTotals = field(metadata=nested("Totals", lambda: LedgerAccountTotals)) - kv_mods: dict[bytes, LedgerKvValueDelta] | None = field( - default=None, - metadata=wire( - "KvMods", - encode=_encode_kv_delta_map, - decode=_decode_kv_delta_map, - ), - ) - tx_ids: dict[bytes, LedgerIncludedTransactions] | None = field( - default=None, - metadata=wire( - "Txids", - encode=_encode_txid_map, - decode=_decode_txid_map, - ), - ) - creatables: dict[int, LedgerModifiedCreatable] | None = field( - default=None, - metadata=wire( - "Creatables", - encode=_encode_creatables, - decode=_decode_creatables, - ), - ) diff --git a/src/algokit_algod_client/client.py b/src/algokit_algod_client/client.py index 766ca3bc..8113e905 100644 --- a/src/algokit_algod_client/client.py +++ b/src/algokit_algod_client/client.py @@ -922,7 +922,7 @@ def get_genesis( def get_ledger_state_delta( self, round_: int, - ) -> models.LedgerStateDelta: + ) -> bytes: """ Get a LedgerStateDelta object for a given round """ @@ -949,14 +949,14 @@ def get_ledger_state_delta( response = self._client.request(**request_kwargs) if response.is_success: - return self._decode_response(response, model=models.LedgerStateDelta) + return self._decode_response(response, raw_msgpack=True) raise UnexpectedStatusError(response.status_code, response.text) def get_ledger_state_delta_for_transaction_group( self, id_: str, - ) -> models.LedgerStateDelta: + ) -> bytes: """ Get a LedgerStateDelta object for a given transaction group """ @@ -983,7 +983,7 @@ def get_ledger_state_delta_for_transaction_group( response = self._client.request(**request_kwargs) if response.is_success: - return self._decode_response(response, model=models.LedgerStateDelta) + return self._decode_response(response, raw_msgpack=True) raise UnexpectedStatusError(response.status_code, response.text) @@ -1231,7 +1231,7 @@ def get_sync_round( def get_transaction_group_ledger_state_deltas_for_round( self, round_: int, - ) -> models.GetTransactionGroupLedgerStateDeltasForRoundResponseModel: + ) -> bytes: """ Get LedgerStateDelta objects for all transaction groups in a given round """ @@ -1258,9 +1258,7 @@ def get_transaction_group_ledger_state_deltas_for_round( response = self._client.request(**request_kwargs) if response.is_success: - return self._decode_response( - response, model=models.GetTransactionGroupLedgerStateDeltasForRoundResponseModel - ) + return self._decode_response(response, raw_msgpack=True) raise UnexpectedStatusError(response.status_code, response.text) @@ -1604,7 +1602,6 @@ def simulate_transaction( request_kwargs, body, { - "is_binary": False, "model": "SimulateRequest", }, body_media_types, @@ -1757,7 +1754,6 @@ def teal_dryrun( request_kwargs, body, { - "is_binary": False, "model": "DryrunRequest", }, body_media_types, @@ -1895,6 +1891,7 @@ def _decode_response( *, model: type[ModelT], is_binary: bool = False, + raw_msgpack: bool = False, ) -> ModelT: ... @overload @@ -1904,6 +1901,7 @@ def _decode_response( *, list_model: type[ListModelT], is_binary: bool = False, + raw_msgpack: bool = False, ) -> list[ListModelT]: ... @overload @@ -1913,6 +1911,7 @@ def _decode_response( *, type_: type[PrimitiveT], is_binary: bool = False, + raw_msgpack: bool = False, ) -> PrimitiveT: ... @overload @@ -1921,6 +1920,15 @@ def _decode_response( response: httpx.Response, *, is_binary: Literal[True], + raw_msgpack: bool = False, + ) -> bytes: ... + + @overload + def _decode_response( + self, + response: httpx.Response, + *, + raw_msgpack: Literal[True], ) -> bytes: ... @overload @@ -1930,6 +1938,7 @@ def _decode_response( *, type_: None = None, is_binary: bool = False, + raw_msgpack: bool = False, ) -> object: ... def _decode_response( @@ -1940,8 +1949,9 @@ def _decode_response( list_model: type[Any] | None = None, type_: type[Any] | None = None, is_binary: bool = False, + raw_msgpack: bool = False, ) -> object: - if is_binary: + if is_binary or raw_msgpack: return response.content content_type = response.headers.get("content-type", "application/json") if "msgpack" in content_type: diff --git a/src/algokit_algod_client/models/__init__.py b/src/algokit_algod_client/models/__init__.py index 9be8dd09..14f941c2 100644 --- a/src/algokit_algod_client/models/__init__.py +++ b/src/algokit_algod_client/models/__init__.py @@ -39,6 +39,7 @@ BlockStateProofTracking, BlockStateProofTrackingData, GetBlock, + ParticipationUpdates, SignedTxnInBlock, ) from ._box import Box @@ -65,35 +66,6 @@ from ._get_status_response_model import GetStatusResponseModel from ._get_supply_response_model import GetSupplyResponseModel from ._get_sync_round_response_model import GetSyncRoundResponseModel -from ._get_transaction_group_ledger_state_deltas_for_round_response_model import ( - GetTransactionGroupLedgerStateDeltasForRoundResponseModel, -) -from ._ledger_state_delta import ( - LedgerAccountBaseData, - LedgerAccountData, - LedgerAccountDeltas, - LedgerAccountTotals, - LedgerAlgoCount, - LedgerAppLocalState, - LedgerAppLocalStateDelta, - LedgerAppParams, - LedgerAppParamsDelta, - LedgerAppResourceRecord, - LedgerAssetHolding, - LedgerAssetHoldingDelta, - LedgerAssetParams, - LedgerAssetParamsDelta, - LedgerAssetResourceRecord, - LedgerBalanceRecord, - LedgerIncludedTransactions, - LedgerKvValueDelta, - LedgerModifiedCreatable, - LedgerStateDelta, - LedgerStateSchema, - LedgerTealValue, - LedgerVotingData, -) -from ._ledger_state_delta_for_transaction_group import LedgerStateDeltaForTransactionGroup from ._light_block_header_proof import LightBlockHeaderProof from ._participation_key import ParticipationKey from ._pending_transaction_response import PendingTransactionResponse @@ -184,33 +156,9 @@ "GetStatusResponseModel", "GetSupplyResponseModel", "GetSyncRoundResponseModel", - "GetTransactionGroupLedgerStateDeltasForRoundResponseModel", - "LedgerAccountBaseData", - "LedgerAccountData", - "LedgerAccountDeltas", - "LedgerAccountTotals", - "LedgerAlgoCount", - "LedgerAppLocalState", - "LedgerAppLocalStateDelta", - "LedgerAppParams", - "LedgerAppParamsDelta", - "LedgerAppResourceRecord", - "LedgerAssetHolding", - "LedgerAssetHoldingDelta", - "LedgerAssetParams", - "LedgerAssetParamsDelta", - "LedgerAssetResourceRecord", - "LedgerBalanceRecord", - "LedgerIncludedTransactions", - "LedgerKvValueDelta", - "LedgerModifiedCreatable", - "LedgerStateDelta", - "LedgerStateDeltaForTransactionGroup", - "LedgerStateSchema", - "LedgerTealValue", - "LedgerVotingData", "LightBlockHeaderProof", "ParticipationKey", + "ParticipationUpdates", "PendingTransactionResponse", "RawTransactionResponseModel", "ScratchChange", diff --git a/src/algokit_algod_client/models/_block.py b/src/algokit_algod_client/models/_block.py index 5fb5a23d..61695dd3 100644 --- a/src/algokit_algod_client/models/_block.py +++ b/src/algokit_algod_client/models/_block.py @@ -30,6 +30,7 @@ "BlockStateProofTracking", "BlockStateProofTrackingData", "GetBlock", + "ParticipationUpdates", "SignedTxnInBlock", "SignedTxnWithAD", ] @@ -178,6 +179,20 @@ class SignedTxnWithAD: apply_data: ApplyData = field(metadata=flatten(lambda: ApplyData)) +@dataclass(slots=True) +class ParticipationUpdates: + """Participation account updates embedded in a block.""" + + expired_participation_accounts: list[bytes] | None = field( + default=None, + metadata=wire("partupdrmv"), + ) + absent_participation_accounts: list[bytes] | None = field( + default=None, + metadata=wire("partupdabs"), + ) + + @dataclass(slots=True) class SignedTxnInBlock: """Signed transaction details with block-specific apply data.""" @@ -263,13 +278,9 @@ class Block: ), ), ) - expired_participation_accounts: list[bytes] | None = field( + participation_updates: ParticipationUpdates | None = field( default=None, - metadata=wire("partupdrmv"), - ) - absent_participation_accounts: list[bytes] | None = field( - default=None, - metadata=wire("partupdabs"), + metadata=flatten(lambda: ParticipationUpdates), ) transactions: list[SignedTxnInBlock] | None = field( default=None, diff --git a/src/algokit_algod_client/models/_get_transaction_group_ledger_state_deltas_for_round_response_model.py b/src/algokit_algod_client/models/_get_transaction_group_ledger_state_deltas_for_round_response_model.py deleted file mode 100644 index df08069e..00000000 --- a/src/algokit_algod_client/models/_get_transaction_group_ledger_state_deltas_for_round_response_model.py +++ /dev/null @@ -1,20 +0,0 @@ -# AUTO-GENERATED: oas_generator - - -from dataclasses import dataclass, field - -from algokit_common.serde import wire - -from ._ledger_state_delta_for_transaction_group import LedgerStateDeltaForTransactionGroup -from ._serde_helpers import decode_model_sequence, encode_model_sequence - - -@dataclass(slots=True) -class GetTransactionGroupLedgerStateDeltasForRoundResponseModel: - deltas: list[LedgerStateDeltaForTransactionGroup] = field( - metadata=wire( - "Deltas", - encode=encode_model_sequence, - decode=lambda raw: decode_model_sequence(lambda: LedgerStateDeltaForTransactionGroup, raw), - ), - ) diff --git a/src/algokit_algod_client/models/_ledger_state_delta.py b/src/algokit_algod_client/models/_ledger_state_delta.py deleted file mode 100644 index 7ad1995d..00000000 --- a/src/algokit_algod_client/models/_ledger_state_delta.py +++ /dev/null @@ -1,452 +0,0 @@ -# AUTO-GENERATED: oas_generator - -from collections.abc import Callable, Mapping -from contextlib import suppress -from dataclasses import dataclass, field -from typing import TypeVar, cast - -from algokit_common.serde import DecodeError, addr, flatten, from_wire, nested, to_wire, wire - -from ._block import Block -from ._serde_helpers import ( - decode_bytes_base64, - decode_model_mapping, - decode_model_sequence, - encode_bytes_base64, - encode_model_mapping, - encode_model_sequence, -) - -DecodedT = TypeVar("DecodedT") - -__all__ = [ - "LedgerAccountBaseData", - "LedgerAccountData", - "LedgerAccountDeltas", - "LedgerAccountTotals", - "LedgerAlgoCount", - "LedgerAppLocalState", - "LedgerAppLocalStateDelta", - "LedgerAppParams", - "LedgerAppParamsDelta", - "LedgerAppResourceRecord", - "LedgerAssetHolding", - "LedgerAssetHoldingDelta", - "LedgerAssetParams", - "LedgerAssetParamsDelta", - "LedgerAssetResourceRecord", - "LedgerBalanceRecord", - "LedgerIncludedTransactions", - "LedgerKvValueDelta", - "LedgerModifiedCreatable", - "LedgerStateDelta", - "LedgerStateSchema", - "LedgerTealValue", - "LedgerVotingData", -] - - -def _encode_bytes_key(key: object) -> str: - if isinstance(key, bytes | bytearray | memoryview): - return encode_bytes_base64(key) - if isinstance(key, str): - return key - raise TypeError("Ledger delta map keys must be bytes-like or str") - - -def _decode_bytes_key(key: object) -> bytes: - if isinstance(key, bytes): - return key - if isinstance(key, str): - try: - return decode_bytes_base64(key) - except (TypeError, ValueError): - pass - return key.encode("utf-8") - raise TypeError("Ledger delta map keys must be bytes-like or str") - - -def _encode_teal_value_map(mapping: Mapping[bytes, "LedgerTealValue"] | None) -> dict[str, object] | None: - if not mapping: - return None - return encode_model_mapping( - lambda: LedgerTealValue, - cast(Mapping[object, object], mapping), - key_encoder=_encode_bytes_key, - ) - - -def _decode_teal_value_map(raw: object) -> dict[bytes, "LedgerTealValue"] | None: - return decode_model_mapping(lambda: LedgerTealValue, raw, key_decoder=_decode_bytes_key) - - -def _encode_kv_delta_map(mapping: Mapping[bytes, "LedgerKvValueDelta"] | None) -> dict[str, object] | None: - if not mapping: - return None - return encode_model_mapping( - lambda: LedgerKvValueDelta, - cast(Mapping[object, object], mapping), - key_encoder=_encode_bytes_key, - ) - - -def _decode_kv_delta_map(raw: object) -> dict[bytes, "LedgerKvValueDelta"] | None: - return decode_model_mapping(lambda: LedgerKvValueDelta, raw, key_decoder=_decode_bytes_key) - - -def _encode_txid_map(mapping: Mapping[bytes, "LedgerIncludedTransactions"] | None) -> dict[str, object] | None: - if not mapping: - return None - return encode_model_mapping( - lambda: LedgerIncludedTransactions, - cast(Mapping[object, object], mapping), - key_encoder=_encode_bytes_key, - ) - - -def _decode_txid_map(raw: object) -> dict[bytes, "LedgerIncludedTransactions"] | None: - return decode_model_mapping(lambda: LedgerIncludedTransactions, raw, key_decoder=_decode_bytes_key) - - -def _encode_creatables(mapping: Mapping[int, "LedgerModifiedCreatable | None"] | None) -> dict[int, object] | None: - if not mapping: - return None - encoded: dict[int, object] = {int(k): to_wire(v) for k, v in mapping.items() if v is not None} - return encoded if encoded else None - - -def _decode_creatables(raw: object) -> dict[int, "LedgerModifiedCreatable"] | None: - if not isinstance(raw, Mapping): - return None - decoded: dict[int, LedgerModifiedCreatable] = {} - for key, value in raw.items(): - if isinstance(value, Mapping): - with suppress(DecodeError, TypeError, ValueError): - decoded[int(key)] = from_wire(LedgerModifiedCreatable, value) - return decoded if decoded else None - - -def _encode_optional_sequence(values: list[object] | None) -> list[dict[str, object]] | None: - return encode_model_sequence(values) if values is not None else None - - -def _decode_sequence(factory: Callable[[], type[DecodedT]], raw: object) -> list[DecodedT]: - return decode_model_sequence(factory, raw) or [] - - -@dataclass(slots=True) -class LedgerTealValue: - type: int = field(metadata=wire("tt")) - bytes_: bytes | None = field( - default=None, - metadata=wire( - "tb", - encode=encode_bytes_base64, - decode=decode_bytes_base64, - ), - ) - uint: int | None = field(default=None, metadata=wire("ui")) - - -@dataclass(slots=True) -class LedgerStateSchema: - num_uints: int | None = field(default=None, metadata=wire("nui")) - num_byte_slices: int | None = field(default=None, metadata=wire("nbs")) - - -@dataclass(slots=True) -class LedgerAppParams: - approval_program: bytes = field( - metadata=wire( - "approv", - encode=encode_bytes_base64, - decode=decode_bytes_base64, - ), - ) - clear_state_program: bytes = field( - metadata=wire( - "clearp", - encode=encode_bytes_base64, - decode=decode_bytes_base64, - ), - ) - local_state_schema: LedgerStateSchema = field(metadata=nested("lsch", lambda: LedgerStateSchema)) - global_state_schema: LedgerStateSchema = field(metadata=nested("gsch", lambda: LedgerStateSchema)) - extra_program_pages: int = field(default=0, metadata=wire("epp")) - version: int | None = field(default=None, metadata=wire("v")) - size_sponsor: str | None = field( - default=None, - metadata=addr("ss"), - ) - global_state: dict[bytes, LedgerTealValue] | None = field( - default=None, - metadata=wire( - "gs", - encode=_encode_teal_value_map, - decode=_decode_teal_value_map, - ), - ) - - -@dataclass(slots=True) -class LedgerAppLocalState: - schema: LedgerStateSchema = field(metadata=nested("hsch", lambda: LedgerStateSchema)) - key_value: dict[bytes, LedgerTealValue] | None = field( - default=None, - metadata=wire( - "tkv", - encode=_encode_teal_value_map, - decode=_decode_teal_value_map, - ), - ) - - -@dataclass(slots=True) -class LedgerAppLocalStateDelta: - deleted: bool = field(metadata=wire("Deleted")) - local_state: LedgerAppLocalState | None = field( - default=None, - metadata=nested("LocalState", lambda: LedgerAppLocalState), - ) - - -@dataclass(slots=True) -class LedgerAppParamsDelta: - deleted: bool = field(metadata=wire("Deleted")) - params: LedgerAppParams | None = field( - default=None, - metadata=nested("Params", lambda: LedgerAppParams), - ) - - -@dataclass(slots=True) -class LedgerAppResourceRecord: - app_id: int = field(metadata=wire("Aidx")) - address: str = field(metadata=addr("Addr")) - params: LedgerAppParamsDelta = field(metadata=nested("Params", lambda: LedgerAppParamsDelta)) - state: LedgerAppLocalStateDelta = field( - metadata=nested("State", lambda: LedgerAppLocalStateDelta), - ) - - -@dataclass(slots=True) -class LedgerAssetHolding: - amount: int = field(default=0, metadata=wire("a")) - frozen: bool = field(default=False, metadata=wire("f")) - - -@dataclass(slots=True) -class LedgerAssetHoldingDelta: - deleted: bool = field(metadata=wire("Deleted")) - holding: LedgerAssetHolding | None = field( - default=None, - metadata=nested("Holding", lambda: LedgerAssetHolding), - ) - - -@dataclass(slots=True) -class LedgerAssetParams: - total: int = field(default=0, metadata=wire("t")) - decimals: int = field(default=0, metadata=wire("dc")) - default_frozen: bool = field(default=False, metadata=wire("df")) - unit_name: str | None = field(default=None, metadata=wire("un")) - asset_name: str | None = field(default=None, metadata=wire("an")) - url: str | None = field(default=None, metadata=wire("au")) - metadata_hash: bytes | None = field( - default=None, - metadata=wire( - "am", - encode=encode_bytes_base64, - decode=decode_bytes_base64, - ), - ) - manager: str | None = field(default=None, metadata=addr("m")) - reserve: str | None = field(default=None, metadata=addr("r")) - freeze: str | None = field(default=None, metadata=addr("f")) - clawback: str | None = field(default=None, metadata=addr("c")) - - -@dataclass(slots=True) -class LedgerAssetParamsDelta: - deleted: bool = field(metadata=wire("Deleted")) - params: LedgerAssetParams | None = field( - default=None, - metadata=nested("Params", lambda: LedgerAssetParams), - ) - - -@dataclass(slots=True) -class LedgerAssetResourceRecord: - asset_id: int = field(metadata=wire("Aidx")) - address: str = field(metadata=addr("Addr")) - params: LedgerAssetParamsDelta = field(metadata=nested("Params", lambda: LedgerAssetParamsDelta)) - holding: LedgerAssetHoldingDelta = field(metadata=nested("Holding", lambda: LedgerAssetHoldingDelta)) - - -@dataclass(slots=True) -class LedgerVotingData: - vote_id: bytes = field( - metadata=wire( - "VoteID", - encode=encode_bytes_base64, - decode=decode_bytes_base64, - ), - ) - selection_id: bytes = field( - metadata=wire( - "SelectionID", - encode=encode_bytes_base64, - decode=decode_bytes_base64, - ), - ) - state_proof_id: bytes = field( - metadata=wire( - "StateProofID", - encode=encode_bytes_base64, - decode=decode_bytes_base64, - ), - ) - vote_first_valid: int = field(metadata=wire("VoteFirstValid")) - vote_last_valid: int = field(metadata=wire("VoteLastValid")) - vote_key_dilution: int = field(metadata=wire("VoteKeyDilution")) - - -@dataclass(slots=True) -class LedgerAccountBaseData: - status: int = field(metadata=wire("Status")) - micro_algos: int = field(metadata=wire("MicroAlgos")) - rewards_base: int = field(metadata=wire("RewardsBase")) - rewarded_micro_algos: int = field(metadata=wire("RewardedMicroAlgos")) - auth_address: str = field(metadata=addr("AuthAddr")) - incentive_eligible: bool = field(metadata=wire("IncentiveEligible")) - total_app_schema: LedgerStateSchema = field(metadata=nested("TotalAppSchema", lambda: LedgerStateSchema)) - total_extra_app_pages: int = field(metadata=wire("TotalExtraAppPages")) - total_app_params: int = field(metadata=wire("TotalAppParams")) - total_app_local_states: int = field(metadata=wire("TotalAppLocalStates")) - total_asset_params: int = field(metadata=wire("TotalAssetParams")) - total_assets: int = field(metadata=wire("TotalAssets")) - total_boxes: int = field(metadata=wire("TotalBoxes")) - total_box_bytes: int = field(metadata=wire("TotalBoxBytes")) - last_proposed: int = field(metadata=wire("LastProposed")) - last_heartbeat: int = field(metadata=wire("LastHeartbeat")) - - -@dataclass(slots=True) -class LedgerAccountData: - account_base_data: LedgerAccountBaseData = field(metadata=flatten(lambda: LedgerAccountBaseData)) - voting_data: LedgerVotingData = field(metadata=flatten(lambda: LedgerVotingData)) - - -@dataclass(slots=True) -class LedgerBalanceRecord: - address: str = field(metadata=addr("Addr")) - account_data: LedgerAccountData = field(metadata=flatten(lambda: LedgerAccountData)) - - -@dataclass(slots=True) -class LedgerAccountDeltas: - accounts: list[LedgerBalanceRecord] = field( - default_factory=list, - metadata=wire( - "Accts", - encode=encode_model_sequence, - decode=lambda raw: _decode_sequence(lambda: LedgerBalanceRecord, raw), - ), - ) - app_resources: list[LedgerAppResourceRecord] = field( - default_factory=list, - metadata=wire( - "AppResources", - encode=_encode_optional_sequence, - decode=lambda raw: _decode_sequence(lambda: LedgerAppResourceRecord, raw), - ), - ) - asset_resources: list[LedgerAssetResourceRecord] = field( - default_factory=list, - metadata=wire( - "AssetResources", - encode=_encode_optional_sequence, - decode=lambda raw: _decode_sequence(lambda: LedgerAssetResourceRecord, raw), - ), - ) - - -@dataclass(slots=True) -class LedgerKvValueDelta: - data: bytes | None = field( - default=None, - metadata=wire( - "Data", - encode=encode_bytes_base64, - decode=decode_bytes_base64, - ), - ) - old_data: bytes | None = field( - default=None, - metadata=wire( - "OldData", - encode=encode_bytes_base64, - decode=decode_bytes_base64, - ), - ) - - -@dataclass(slots=True) -class LedgerIncludedTransactions: - last_valid: int = field(metadata=wire("LastValid")) - intra: int = field(metadata=wire("Intra")) - - -@dataclass(slots=True) -class LedgerModifiedCreatable: - creatable_type: int = field(metadata=wire("Ctype")) - created: bool = field(metadata=wire("Created")) - creator: str = field(metadata=addr("Creator")) - n_deltas: int = field(metadata=wire("Ndeltas")) - - -@dataclass(slots=True) -class LedgerAlgoCount: - money: int = field(metadata=wire("mon")) - reward_units: int = field(metadata=wire("rwd")) - - -@dataclass(slots=True) -class LedgerAccountTotals: - online: LedgerAlgoCount = field(metadata=nested("online", lambda: LedgerAlgoCount)) - offline: LedgerAlgoCount = field(metadata=nested("offline", lambda: LedgerAlgoCount)) - not_participating: LedgerAlgoCount = field(metadata=nested("notpart", lambda: LedgerAlgoCount)) - rewards_level: int = field(metadata=wire("rwdlvl")) - - -@dataclass(slots=True) -class LedgerStateDelta: - accounts: LedgerAccountDeltas = field(metadata=nested("Accts", lambda: LedgerAccountDeltas)) - block: Block = field(metadata=nested("Hdr", lambda: Block)) - state_proof_next: int = field(metadata=wire("StateProofNext")) - prev_timestamp: int = field(metadata=wire("PrevTimestamp")) - totals: LedgerAccountTotals = field(metadata=nested("Totals", lambda: LedgerAccountTotals)) - kv_mods: dict[bytes, LedgerKvValueDelta] | None = field( - default=None, - metadata=wire( - "KvMods", - encode=_encode_kv_delta_map, - decode=_decode_kv_delta_map, - ), - ) - tx_ids: dict[bytes, LedgerIncludedTransactions] | None = field( - default=None, - metadata=wire( - "Txids", - encode=_encode_txid_map, - decode=_decode_txid_map, - ), - ) - creatables: dict[int, LedgerModifiedCreatable] | None = field( - default=None, - metadata=wire( - "Creatables", - encode=_encode_creatables, - decode=_decode_creatables, - ), - ) diff --git a/src/algokit_algod_client/models/_ledger_state_delta_for_transaction_group.py b/src/algokit_algod_client/models/_ledger_state_delta_for_transaction_group.py deleted file mode 100644 index 05a41a76..00000000 --- a/src/algokit_algod_client/models/_ledger_state_delta_for_transaction_group.py +++ /dev/null @@ -1,22 +0,0 @@ -# AUTO-GENERATED: oas_generator - - -from dataclasses import dataclass, field - -from algokit_common.serde import nested, wire - -from ._ledger_state_delta import LedgerStateDelta - - -@dataclass(slots=True) -class LedgerStateDeltaForTransactionGroup: - """ - Contains a ledger delta for a single transaction group - """ - - delta: LedgerStateDelta = field( - metadata=nested("Delta", lambda: LedgerStateDelta), - ) - ids: list[str] = field( - metadata=wire("Ids"), - ) diff --git a/src/algokit_indexer_client/client.py b/src/algokit_indexer_client/client.py index 6596b710..6760d411 100644 --- a/src/algokit_indexer_client/client.py +++ b/src/algokit_indexer_client/client.py @@ -1181,6 +1181,7 @@ def _decode_response( *, model: type[ModelT], is_binary: bool = False, + raw_msgpack: bool = False, ) -> ModelT: ... @overload @@ -1190,6 +1191,7 @@ def _decode_response( *, list_model: type[ListModelT], is_binary: bool = False, + raw_msgpack: bool = False, ) -> list[ListModelT]: ... @overload @@ -1199,6 +1201,7 @@ def _decode_response( *, type_: type[PrimitiveT], is_binary: bool = False, + raw_msgpack: bool = False, ) -> PrimitiveT: ... @overload @@ -1207,6 +1210,15 @@ def _decode_response( response: httpx.Response, *, is_binary: Literal[True], + raw_msgpack: bool = False, + ) -> bytes: ... + + @overload + def _decode_response( + self, + response: httpx.Response, + *, + raw_msgpack: Literal[True], ) -> bytes: ... @overload @@ -1216,6 +1228,7 @@ def _decode_response( *, type_: None = None, is_binary: bool = False, + raw_msgpack: bool = False, ) -> object: ... def _decode_response( @@ -1226,8 +1239,9 @@ def _decode_response( list_model: type[Any] | None = None, type_: type[Any] | None = None, is_binary: bool = False, + raw_msgpack: bool = False, ) -> object: - if is_binary: + if is_binary or raw_msgpack: return response.content content_type = response.headers.get("content-type", "application/json") if "msgpack" in content_type: diff --git a/src/algokit_kmd_client/client.py b/src/algokit_kmd_client/client.py index 36329e05..9693431e 100644 --- a/src/algokit_kmd_client/client.py +++ b/src/algokit_kmd_client/client.py @@ -70,7 +70,6 @@ def create_wallet( request_kwargs, body, { - "is_binary": False, "model": "CreateWalletRequest", }, body_media_types, @@ -111,7 +110,6 @@ def delete_key( request_kwargs, body, { - "is_binary": False, "model": "DeleteKeyRequest", }, body_media_types, @@ -152,7 +150,6 @@ def delete_multisig( request_kwargs, body, { - "is_binary": False, "model": "DeleteMultisigRequest", }, body_media_types, @@ -193,7 +190,6 @@ def export_key( request_kwargs, body, { - "is_binary": False, "model": "ExportKeyRequest", }, body_media_types, @@ -234,7 +230,6 @@ def export_master_key( request_kwargs, body, { - "is_binary": False, "model": "ExportMasterKeyRequest", }, body_media_types, @@ -275,7 +270,6 @@ def export_multisig( request_kwargs, body, { - "is_binary": False, "model": "ExportMultisigRequest", }, body_media_types, @@ -316,7 +310,6 @@ def generate_key( request_kwargs, body, { - "is_binary": False, "model": "GenerateKeyRequest", }, body_media_types, @@ -358,7 +351,6 @@ def get_version( request_kwargs, body, { - "is_binary": False, "model": "VersionsRequest", }, body_media_types, @@ -399,7 +391,6 @@ def get_wallet_info( request_kwargs, body, { - "is_binary": False, "model": "WalletInfoRequest", }, body_media_types, @@ -440,7 +431,6 @@ def import_key( request_kwargs, body, { - "is_binary": False, "model": "ImportKeyRequest", }, body_media_types, @@ -481,7 +471,6 @@ def import_multisig( request_kwargs, body, { - "is_binary": False, "model": "ImportMultisigRequest", }, body_media_types, @@ -522,7 +511,6 @@ def init_wallet_handle_token( request_kwargs, body, { - "is_binary": False, "model": "InitWalletHandleTokenRequest", }, body_media_types, @@ -563,7 +551,6 @@ def list_keys_in_wallet( request_kwargs, body, { - "is_binary": False, "model": "ListKeysRequest", }, body_media_types, @@ -604,7 +591,6 @@ def list_multisg( request_kwargs, body, { - "is_binary": False, "model": "ListMultisigRequest", }, body_media_types, @@ -646,7 +632,6 @@ def list_wallets( request_kwargs, body, { - "is_binary": False, "model": "ListWalletsRequest", }, body_media_types, @@ -687,7 +672,6 @@ def release_wallet_handle_token( request_kwargs, body, { - "is_binary": False, "model": "ReleaseWalletHandleTokenRequest", }, body_media_types, @@ -728,7 +712,6 @@ def rename_wallet( request_kwargs, body, { - "is_binary": False, "model": "RenameWalletRequest", }, body_media_types, @@ -769,7 +752,6 @@ def renew_wallet_handle_token( request_kwargs, body, { - "is_binary": False, "model": "RenewWalletHandleTokenRequest", }, body_media_types, @@ -810,7 +792,6 @@ def sign_multisig_program( request_kwargs, body, { - "is_binary": False, "model": "SignProgramMultisigRequest", }, body_media_types, @@ -851,7 +832,6 @@ def sign_multisig_transaction( request_kwargs, body, { - "is_binary": False, "model": "SignMultisigRequest", }, body_media_types, @@ -892,7 +872,6 @@ def sign_program( request_kwargs, body, { - "is_binary": False, "model": "SignProgramRequest", }, body_media_types, @@ -933,7 +912,6 @@ def sign_transaction( request_kwargs, body, { - "is_binary": False, "model": "SignTransactionRequest", }, body_media_types, @@ -1014,6 +992,7 @@ def _decode_response( *, model: type[ModelT], is_binary: bool = False, + raw_msgpack: bool = False, ) -> ModelT: ... @overload @@ -1023,6 +1002,7 @@ def _decode_response( *, list_model: type[ListModelT], is_binary: bool = False, + raw_msgpack: bool = False, ) -> list[ListModelT]: ... @overload @@ -1032,6 +1012,7 @@ def _decode_response( *, type_: type[PrimitiveT], is_binary: bool = False, + raw_msgpack: bool = False, ) -> PrimitiveT: ... @overload @@ -1040,6 +1021,15 @@ def _decode_response( response: httpx.Response, *, is_binary: Literal[True], + raw_msgpack: bool = False, + ) -> bytes: ... + + @overload + def _decode_response( + self, + response: httpx.Response, + *, + raw_msgpack: Literal[True], ) -> bytes: ... @overload @@ -1049,6 +1039,7 @@ def _decode_response( *, type_: None = None, is_binary: bool = False, + raw_msgpack: bool = False, ) -> object: ... def _decode_response( @@ -1059,8 +1050,9 @@ def _decode_response( list_model: type[Any] | None = None, type_: type[Any] | None = None, is_binary: bool = False, + raw_msgpack: bool = False, ) -> object: - if is_binary: + if is_binary or raw_msgpack: return response.content content_type = response.headers.get("content-type", "application/json") if "msgpack" in content_type: diff --git a/tests/modules/algod_client/test_block.py b/tests/modules/algod_client/test_block.py index a6b94454..f2e291f1 100644 --- a/tests/modules/algod_client/test_block.py +++ b/tests/modules/algod_client/test_block.py @@ -1,20 +1,34 @@ +import pytest + from algokit_algod_client import AlgodClient, ClientConfig +from algokit_algod_client.models import ParticipationUpdates -def test_block_endpoint() -> None: +@pytest.mark.parametrize( + ("base_url", "block_rounds"), + [ + ("https://mainnet-api.4160.nodely.dev", [24098947, 55240407]), + ("https://testnet-api.4160.nodely.dev", [24099447, 24099347]), + ], +) +def test_block_endpoint(base_url: str, block_rounds: list[int]) -> None: config = ClientConfig( - base_url="https://testnet-api.4160.nodely.dev", + base_url=base_url, token=None, ) algod_client = AlgodClient(config) - # 1. Large block with state proof type txns on testnet (skip if not accessible) - # 2. Block with global and local state deltas where keys can not be decoded as - block_rounds = [24099447, 24099347] - for block_round in block_rounds: resp = algod_client.get_block(round_=block_round, header_only=False) assert resp.block.state_proof_tracking is not None assert resp.block.transactions is not None assert len(resp.block.transactions) > 0 + + participation_updates = resp.block.participation_updates + if participation_updates is not None: + assert isinstance(participation_updates, ParticipationUpdates) + if participation_updates.expired_participation_accounts is not None: + assert isinstance(participation_updates.expired_participation_accounts, list) + if participation_updates.absent_participation_accounts is not None: + assert isinstance(participation_updates.absent_participation_accounts, list) diff --git a/tests/modules/algod_client/test_ledger_state_delta.py b/tests/modules/algod_client/test_ledger_state_delta.py index b139bd02..b25105f7 100644 --- a/tests/modules/algod_client/test_ledger_state_delta.py +++ b/tests/modules/algod_client/test_ledger_state_delta.py @@ -4,13 +4,13 @@ @pytest.mark.parametrize( - ("base_url", "block_rounds", "genesis_id"), + ("base_url", "block_rounds"), [ - ("https://mainnet-api.4160.nodely.dev", [24098947, 55240407], "mainnet-v1.0"), - ("https://testnet-api.4160.nodely.dev", [24099447, 24099347], "testnet-v1.0"), + ("https://mainnet-api.4160.nodely.dev", [24098947, 55240407]), + ("https://testnet-api.4160.nodely.dev", [24099447, 24099347]), ], ) -def test_ledger_state_delta_endpoint(base_url: str, block_rounds: list[int], genesis_id: str) -> None: +def test_ledger_state_delta_endpoint(base_url: str, block_rounds: list[int]) -> None: config = ClientConfig( base_url=base_url, token=None, @@ -18,33 +18,7 @@ def test_ledger_state_delta_endpoint(base_url: str, block_rounds: list[int], gen algod_client = AlgodClient(config) for block_round in block_rounds: - resp = algod_client.get_ledger_state_delta(round_=block_round) + raw_delta = algod_client.get_ledger_state_delta(round_=block_round) - assert resp.block.round == block_round - assert resp.block.genesis_id == genesis_id - - # Blocks may have no account changes - for account in resp.accounts.accounts: - assert len(account.address) == 58 # base32 + checksum - - # Totals can be zero if no changes - assert resp.totals.online.money >= 0 - assert resp.totals.offline.money >= 0 - assert resp.totals.not_participating.money >= 0 - - for resource in resp.accounts.app_resources or []: - assert resource.app_id > 0 - assert len(resource.address) == 58 - if resource.state.local_state: - schema = resource.state.local_state.schema - assert schema.num_uints is not None or schema.num_byte_slices is not None - - for creatable in (resp.creatables or {}).values(): - assert len(creatable.creator) == 58 - assert creatable.creatable_type in (0, 1) - - if resp.tx_ids: - for txid_bytes, tx_info in resp.tx_ids.items(): - assert isinstance(txid_bytes, bytes) - assert len(txid_bytes) == 32 - assert tx_info.last_valid >= block_round + assert isinstance(raw_delta, bytes) + assert raw_delta, "ledger state delta response should not be empty"