Skip to content

Commit 227d114

Browse files
aorumbayevdaniel-makerx
authored andcommitted
fix: refinements in decoding msgpack and minor bug fixes API generator (#215)
* chore: syncing with ts specs and regen * refactor: preserve map keys and fix state-proof serialization * refactor: refines msgpack decoding in api generator and boolean fields in block model Improves msgpack decoding in Algod, Indexer and KMD clients by handling byte keys and values. This prevents decoding errors when encountering non-UTF-8 byte sequences. Additionally, adds decoding for boolean fields in block models to correctly interpret raw values as booleans. This addresses issues with inconsistent data representation. * test: eliminate test warnings by adding markers * refactor: remove unnecessary cast * fix: minor tweaks in state delta model after verifying with go algorand --------- Co-authored-by: Daniel McGregor <[email protected]>
1 parent 92455bf commit 227d114

38 files changed

+1772
-329
lines changed

api/oas-generator/src/oas_generator/builder.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,8 @@ def _build_model(self, entry: SchemaEntry) -> ctx.ModelDescriptor: # noqa: C901
240240

241241
for prop_name in sorted(properties):
242242
prop_schema = properties[prop_name] or {}
243-
wire_name = prop_schema.get("x-algokit-field-rename") or prop_name
243+
wire_name = prop_name
244+
python_name_hint = prop_schema.get("x-algokit-field-rename") or prop_name
244245
type_info = self.resolver.resolve(prop_schema, hint=entry.python_name + self.sanitizer.pascal(prop_name))
245246
if type_info.is_signed_transaction:
246247
self.uses_signed_transaction = True
@@ -250,7 +251,7 @@ def _build_model(self, entry: SchemaEntry) -> ctx.ModelDescriptor: # noqa: C901
250251
annotation = f"{annotation} | None"
251252
annotation = self._apply_forward_reference_annotation(annotation, entry, type_info)
252253
field = ctx.ModelField(
253-
name=self.sanitizer.snake(wire_name),
254+
name=self.sanitizer.snake(python_name_hint),
254255
wire_name=wire_name,
255256
type_hint=annotation,
256257
required=prop_name in required,
@@ -693,4 +694,5 @@ def build_client_descriptor(
693694
uses_signed_transaction=uses_signed_txn,
694695
uses_msgpack=operation_builder.uses_msgpack,
695696
include_block_models=operation_builder.uses_block_models,
697+
include_ledger_state_delta_models="LedgerStateDelta" in registry.entries,
696698
)

api/oas-generator/src/oas_generator/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,4 @@ class ClientDescriptor:
146146
uses_signed_transaction: bool = False
147147
uses_msgpack: bool = False
148148
include_block_models: bool = False
149+
include_ledger_state_delta_models: bool = False

api/oas-generator/src/oas_generator/renderer/engine.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,31 @@ class TemplateRenderer:
2626
"Block",
2727
"GetBlock",
2828
]
29+
LEDGER_STATE_DELTA_EXPORTS: ClassVar[list[str]] = [
30+
"LedgerTealValue",
31+
"LedgerStateSchema",
32+
"LedgerAppParams",
33+
"LedgerAppLocalState",
34+
"LedgerAppLocalStateDelta",
35+
"LedgerAppParamsDelta",
36+
"LedgerAppResourceRecord",
37+
"LedgerAssetHolding",
38+
"LedgerAssetHoldingDelta",
39+
"LedgerAssetParams",
40+
"LedgerAssetParamsDelta",
41+
"LedgerAssetResourceRecord",
42+
"LedgerVotingData",
43+
"LedgerAccountBaseData",
44+
"LedgerAccountData",
45+
"LedgerBalanceRecord",
46+
"LedgerAccountDeltas",
47+
"LedgerKvValueDelta",
48+
"LedgerIncludedTransactions",
49+
"LedgerModifiedCreatable",
50+
"LedgerAlgoCount",
51+
"LedgerAccountTotals",
52+
"LedgerStateDelta",
53+
]
2954

3055
def __init__(self, template_dir: Path | None = None) -> None:
3156
if template_dir:
@@ -56,6 +81,8 @@ def render(self, client: ctx.ClientDescriptor, config: GeneratorConfig) -> dict[
5681
files[models_dir / "__init__.py"] = self._render_template("models/__init__.py.j2", context)
5782
files[models_dir / "_serde_helpers.py"] = self._render_template("models/_serde_helpers.py.j2", context)
5883
for model in context["client"].models:
84+
if context["client"].include_ledger_state_delta_models and model.name == "LedgerStateDelta":
85+
continue
5986
model_context = {**context, "model": model}
6087
files[models_dir / f"{model.module_name}.py"] = self._render_template("models/model.py.j2", model_context)
6188
for enum in context["client"].enums:
@@ -67,7 +94,11 @@ def render(self, client: ctx.ClientDescriptor, config: GeneratorConfig) -> dict[
6794
"models/type_alias.py.j2", alias_context
6895
)
6996
if client.include_block_models:
70-
files[models_dir / "block.py"] = self._render_template("models/block.py.j2", context)
97+
files[models_dir / "_block.py"] = self._render_template("models/block.py.j2", context)
98+
if client.include_ledger_state_delta_models:
99+
files[models_dir / "_ledger_state_delta.py"] = self._render_template(
100+
"models/ledger_state_delta.py.j2", context
101+
)
71102
files[target / "py.typed"] = ""
72103
return files
73104

@@ -85,6 +116,10 @@ def _build_context(self, client: ctx.ClientDescriptor, config: GeneratorConfig)
85116
for name in self.BLOCK_MODEL_EXPORTS:
86117
if name not in model_exports:
87118
model_exports.append(name)
119+
if client.include_ledger_state_delta_models:
120+
for name in self.LEDGER_STATE_DELTA_EXPORTS:
121+
if name not in model_exports:
122+
model_exports.append(name)
88123
metadata_usage = self._collect_metadata_usage(client)
89124
model_modules = [{"module": model.module_name, "name": model.name} for model in client.models]
90125
enum_modules = [{"module": enum.module_name, "name": enum.name} for enum in client.enums]
@@ -105,6 +140,7 @@ def _build_context(self, client: ctx.ClientDescriptor, config: GeneratorConfig)
105140
"needs_datetime": any(model.requires_datetime for model in client.models),
106141
"client_needs_datetime": self._client_requires_datetime(client),
107142
"block_exports": self.BLOCK_MODEL_EXPORTS,
143+
"ledger_state_delta_exports": self.LEDGER_STATE_DELTA_EXPORTS,
108144
"needs_literal": needs_literal,
109145
}
110146

api/oas-generator/src/oas_generator/renderer/templates/client.py.j2

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ class {{ client.class_name }}:
248248
return response.content
249249
content_type = response.headers.get("content-type", "application/json")
250250
if "msgpack" in content_type:
251-
data = msgpack.unpackb(response.content, raw=False, strict_map_key=False)
251+
data = msgpack.unpackb(response.content, raw=True, strict_map_key=False)
252252
data = self._normalize_msgpack(data)
253253
elif content_type.startswith("application/json"):
254254
data = response.json()
@@ -266,13 +266,13 @@ class {{ client.class_name }}:
266266
if isinstance(value, dict):
267267
normalized: dict[object, object] = {}
268268
for key, item in value.items():
269-
normalized[self._ensure_str_key(key)] = self._normalize_msgpack(item)
269+
normalized[self._coerce_msgpack_key(key)] = self._normalize_msgpack(item)
270270
return normalized
271271
if isinstance(value, list):
272272
return [self._normalize_msgpack(item) for item in value]
273273
return value
274274

275-
def _ensure_str_key(self, key: object) -> object:
275+
def _coerce_msgpack_key(self, key: object) -> object:
276276
if isinstance(key, bytes):
277277
try:
278278
return key.decode("utf-8")

api/oas-generator/src/oas_generator/renderer/templates/models/__init__.py.j2

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11

22

33
{% if client.uses_signed_transaction %}from algokit_transact.models.signed_transaction import SignedTransaction
4-
{% endif %}{% for item in model_modules %}from .{{ item.module }} import {{ item.name }}
5-
{% endfor %}{% for item in enum_modules %}from .{{ item.module }} import {{ item.name }}
4+
{% endif %}{% for item in model_modules %}{% if not (client.include_ledger_state_delta_models and item.name == "LedgerStateDelta") %}from .{{ item.module }} import {{ item.name }}
5+
{% endif %}{% endfor %}{% for item in enum_modules %}from .{{ item.module }} import {{ item.name }}
66
{% endfor %}{% for item in alias_modules %}from .{{ item.module }} import {{ item.name }}
7-
{% endfor %}{% if client.include_block_models %}from .block import (
7+
{% endfor %}{% if client.include_block_models %}from ._block import (
88
{{ block_exports | join(',\n ') }}
99
)
10+
{% endif %}{% if client.include_ledger_state_delta_models %}from ._ledger_state_delta import (
11+
{{ ledger_state_delta_exports | join(',\n ') }}
12+
)
1013
{% endif %}
1114

1215
__all__ = [
1316
{% for name in model_exports %}"{{ name }}",
1417
{% endfor %}
1518
]
16-

api/oas-generator/src/oas_generator/renderer/templates/models/_serde_helpers.py.j2

Lines changed: 62 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ from typing import Callable, TypeAlias, TypeVar
88

99
from algokit_common.serde import from_wire, to_wire
1010

11-
T = TypeVar("T")
12-
E = TypeVar("E", bound=Enum)
11+
DecodedT = TypeVar("DecodedT")
12+
EnumValueT = TypeVar("EnumValueT", bound=Enum)
13+
MapKeyT = TypeVar("MapKeyT")
1314
BytesLike: TypeAlias = bytes | bytearray | memoryview
1415

1516

@@ -36,6 +37,20 @@ def decode_bytes_base64(raw: object) -> bytes:
3637
raise TypeError(f"Unsupported value for bytes field: {type(raw)!r}")
3738

3839

40+
def decode_bytes_map_key(raw: object) -> bytes:
41+
if isinstance(raw, bytes | bytearray | memoryview):
42+
return bytes(raw)
43+
if isinstance(raw, str):
44+
try:
45+
return decode_bytes_base64(raw)
46+
except ValueError:
47+
try:
48+
return raw.encode("utf-8")
49+
except UnicodeEncodeError as fallback_exc:
50+
raise ValueError("Invalid bytes map key") from fallback_exc
51+
raise TypeError(f"Unsupported map key for bytes field: {type(raw)!r}")
52+
53+
3954
def encode_bytes_sequence(values: Iterable[BytesLike | None] | None) -> list[str | None] | None:
4055
if values is None:
4156
return None
@@ -73,11 +88,11 @@ def encode_model_sequence(values: Iterable[object] | None) -> list[dict[str, obj
7388
return encoded or None
7489

7590

76-
def decode_model_sequence(cls_factory: Callable[[], type[T]], raw: object) -> list[T] | None:
91+
def decode_model_sequence(cls_factory: Callable[[], type[DecodedT]], raw: object) -> list[DecodedT] | None:
7792
if not isinstance(raw, list):
7893
return None
7994
cls = cls_factory()
80-
decoded: list[T] = []
95+
decoded: list[DecodedT] = []
8196
for item in raw:
8297
if isinstance(item, Mapping):
8398
decoded.append(from_wire(cls, item))
@@ -95,11 +110,11 @@ def encode_enum_sequence(values: Iterable[object] | None) -> list[object] | None
95110
return encoded or None
96111

97112

98-
def decode_enum_sequence(enum_factory: Callable[[], type[E]], raw: object) -> list[E] | None:
113+
def decode_enum_sequence(enum_factory: Callable[[], type[EnumValueT]], raw: object) -> list[EnumValueT] | None:
99114
if not isinstance(raw, list):
100115
return None
101116
enum_cls = enum_factory()
102-
decoded: list[E] = []
117+
decoded: list[EnumValueT] = []
103118
for item in raw:
104119
try:
105120
decoded.append(enum_cls(item))
@@ -109,7 +124,10 @@ def decode_enum_sequence(enum_factory: Callable[[], type[E]], raw: object) -> li
109124

110125

111126
def encode_model_mapping(
112-
factory: Callable[[], type[T]], mapping: Mapping[str, object] | None
127+
factory: Callable[[], type[DecodedT]],
128+
mapping: Mapping[object, object] | None,
129+
*,
130+
key_encoder: Callable[[object], str] | None = None,
113131
) -> dict[str, object] | None:
114132
if mapping is None:
115133
return None
@@ -118,35 +136,60 @@ def encode_model_mapping(
118136
for key, value in mapping.items():
119137
if value is None:
120138
continue
139+
encoded_key: str
140+
if key_encoder is not None:
141+
encoded_key = key_encoder(key)
142+
elif isinstance(key, str):
143+
encoded_key = key
144+
else:
145+
encoded_key = str(key)
121146
if isinstance(value, cls) or is_dataclass(value):
122-
encoded[str(key)] = to_wire(value)
147+
encoded[encoded_key] = to_wire(value)
123148
else:
124-
encoded[str(key)] = value
149+
encoded[encoded_key] = value
125150
return encoded or None
126151

127152

128-
def decode_model_mapping(factory: Callable[[], type[T]], raw: object) -> dict[str, T] | None:
153+
def decode_model_mapping(
154+
factory: Callable[[], type[DecodedT]],
155+
raw: object,
156+
*,
157+
key_decoder: Callable[[object], MapKeyT] | None = None,
158+
) -> dict[MapKeyT, DecodedT] | None:
129159
if not isinstance(raw, Mapping):
130160
return None
131161
cls = factory()
132-
decoded: dict[str, T] = {}
162+
decoded: dict[MapKeyT, DecodedT] = {}
133163
for key, value in raw.items():
134164
if isinstance(value, Mapping):
135-
decoded[str(key)] = from_wire(cls, value)
165+
decoded_key = key_decoder(key) if key_decoder is not None else key
166+
decoded[decoded_key] = from_wire(cls, value)
136167
return decoded or None
137168

138169

170+
def decode_optional_bool(raw: object) -> bool | None:
171+
if raw is None:
172+
return None
173+
return bool(raw)
174+
175+
139176
def mapping_encoder(
140-
factory: Callable[[], type[T]],
141-
) -> Callable[[Mapping[str, object] | None], dict[str, object] | None]:
142-
def _encode(mapping: Mapping[str, object] | None) -> dict[str, object] | None:
143-
return encode_model_mapping(factory, mapping)
177+
factory: Callable[[], type[DecodedT]],
178+
*,
179+
key_encoder: Callable[[object], str] | None = None,
180+
) -> Callable[[Mapping[object, object] | None], dict[str, object] | None]:
181+
def _encode(mapping: Mapping[object, object] | None) -> dict[str, object] | None:
182+
return encode_model_mapping(factory, mapping, key_encoder=key_encoder)
144183

145184
return _encode
146185

147186

148-
def mapping_decoder(factory: Callable[[], type[T]]) -> Callable[[object], dict[str, T] | None]:
149-
def _decode(raw: object) -> dict[str, T] | None:
150-
return decode_model_mapping(factory, raw)
187+
def mapping_decoder(
188+
factory: Callable[[], type[DecodedT]],
189+
*,
190+
key_decoder: Callable[[object], MapKeyT] | None = None,
191+
) -> Callable[[object], dict[MapKeyT, DecodedT] | None]:
192+
def _decode(raw: object) -> dict[MapKeyT, DecodedT] | None:
193+
return decode_model_mapping(factory, raw, key_decoder=key_decoder)
151194

152195
return _decode

0 commit comments

Comments
 (0)