diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f350da14..1ee2022b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1. ## [Unreleased] ### Added +- Refactored dataclass string representations: simplified enum detection with `isinstance(value, enum.Enum)` and added `hasattr` in `dataclass_strings_test.py` . +- Refactored token classes to use dataclass-driven `__str__` and `__repr__` generation. [#999](https://github.com/hiero-ledger/hiero-sdk-python/issues/999) - Added unit test for 'endpoint.py' to increase coverage. - Automated assignment guard for `advanced` issues; requires completion of at least one `good first issue` and one `intermediate` issue before assignment (exempts maintainers, committers, and triage members). (#1142) - Added Hbar object support for TransferTransaction HBAR transfers: diff --git a/src/hiero_sdk_python/tokens/custom_fee.py b/src/hiero_sdk_python/tokens/custom_fee.py index 9d53fb4ab..08135f939 100644 --- a/src/hiero_sdk_python/tokens/custom_fee.py +++ b/src/hiero_sdk_python/tokens/custom_fee.py @@ -14,6 +14,8 @@ import typing from abc import ABC, abstractmethod +from hiero_sdk_python.utils.dataclass_strings import _format_value_helper + if typing.TYPE_CHECKING: from hiero_sdk_python.account.account_id import AccountId from hiero_sdk_python.client.client import Client @@ -148,4 +150,30 @@ def __eq__(self, other: object) -> bool: if not isinstance(other, CustomFee): return NotImplemented - return self.fee_collector_account_id == other.fee_collector_account_id and self.all_collectors_are_exempt == other.all_collectors_are_exempt \ No newline at end of file + return self.fee_collector_account_id == other.fee_collector_account_id and self.all_collectors_are_exempt == other.all_collectors_are_exempt + + def __str__(self) -> str: + """Return a dynamic string representation including all public instance attributes. + + This method dynamically inspects all public (non-underscore-prefixed) instance + attributes, ensuring that new fields added to subclasses are automatically + included without manual updates, while avoiding exposure of internal state. + """ + fields = [] + for key, value in self.__dict__.items(): + # Skip private or internal attributes + if key.startswith("_"): + continue + formatted_value = _format_value_helper(value) + fields.append(f"{key}={formatted_value}") + + class_name = self.__class__.__name__ + if len(fields) <= 3: + return f"{class_name}({', '.join(fields)})" + else: + fields_str = ",\n ".join(fields) + return f"{class_name}(\n {fields_str}\n)" + + def __repr__(self) -> str: + """Return a string representation for debugging.""" + return self.__str__() \ No newline at end of file diff --git a/src/hiero_sdk_python/tokens/token_relationship.py b/src/hiero_sdk_python/tokens/token_relationship.py index 5a8d3151a..8f56c167e 100644 --- a/src/hiero_sdk_python/tokens/token_relationship.py +++ b/src/hiero_sdk_python/tokens/token_relationship.py @@ -16,9 +16,10 @@ from hiero_sdk_python.tokens.token_freeze_status import TokenFreezeStatus from hiero_sdk_python.tokens.token_id import TokenId from hiero_sdk_python.tokens.token_kyc_status import TokenKycStatus +from hiero_sdk_python.utils.dataclass_strings import DataclassStringMixin @dataclass -class TokenRelationship: +class TokenRelationship(DataclassStringMixin): """ Represents a relationship between an account and a token. diff --git a/src/hiero_sdk_python/tokens/token_update_transaction.py b/src/hiero_sdk_python/tokens/token_update_transaction.py index e0b867123..6754f4121 100644 --- a/src/hiero_sdk_python/tokens/token_update_transaction.py +++ b/src/hiero_sdk_python/tokens/token_update_transaction.py @@ -23,9 +23,10 @@ ) from hiero_sdk_python.hapi.services import token_update_pb2, transaction_pb2 from hiero_sdk_python.utils.key_utils import Key, key_to_proto +from hiero_sdk_python.utils.dataclass_strings import DataclassStringMixin @dataclass -class TokenUpdateParams: +class TokenUpdateParams(DataclassStringMixin): """ Represents token attributes that can be updated. @@ -46,7 +47,7 @@ class TokenUpdateParams: expiration_time: Optional[Timestamp] = None @dataclass -class TokenUpdateKeys: +class TokenUpdateKeys(DataclassStringMixin): """ Represents cryptographic keys that can be updated for a token. Does not include treasury_key which is for transaction signing. diff --git a/src/hiero_sdk_python/utils/dataclass_strings.py b/src/hiero_sdk_python/utils/dataclass_strings.py new file mode 100644 index 000000000..39ea13713 --- /dev/null +++ b/src/hiero_sdk_python/utils/dataclass_strings.py @@ -0,0 +1,186 @@ +""" +hiero_sdk_python.utils.dataclass_strings +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Provides automatic __str__ and __repr__ generation for dataclasses. + +This module eliminates manual maintenance of string methods by dynamically +generating them based on dataclass fields. When new fields are added, they +are automatically included in the string representation. + +Usage: + @dataclass + class MyTokenClass(DataclassStringMixin): + field1: str + field2: Optional[int] = None + + # Or use the decorator approach: + @auto_str_repr + @dataclass + class MyTokenClass: + field1: str + field2: Optional[int] = None +""" + +import dataclasses +import enum +from typing import Any + + +def _is_enum(value: Any) -> bool: + """Check if a value is an Enum instance.""" + return isinstance(value, enum.Enum) + + +def _format_bytes(value: bytes) -> str: + """Format bytes value with truncation for display.""" + if len(value) > 20: + return f"b'{value[:20].hex()}...'" + return f"b'{value.hex()}'" + + +def _format_value_helper(value: Any) -> str: + """Format a field value for string representation.""" + if value is None: + return "None" + + if isinstance(value, str): + return f"'{value}'" + + if isinstance(value, bytes): + return _format_bytes(value) + + if _is_enum(value): + return f"{value.__class__.__name__}.{value.name}" + + return str(value) + + +class DataclassStringMixin: + """ + Mixin class that provides automatic __str__ and __repr__ implementations + for dataclasses based on their fields. + + This mixin automatically generates string representations that include + all dataclass fields, handling None values and nested objects appropriately. + + Features: + - Zero maintenance: new fields are automatically included + - Proper None handling for optional fields + - Clean formatting (multi-line for >3 fields) + - Enum value formatting (shows EnumClass.VALUE) + - String quoting for clarity + - Dictionary conversion for serialization + + Example: + >>> @dataclass + ... class TokenInfo(DataclassStringMixin): + ... token_id: str + ... symbol: Optional[str] = None + ... balance: int = 0 + ... + >>> token = TokenInfo("0.0.123", "TEST", 1000) + >>> str(token) + "TokenInfo(token_id='0.0.123', symbol='TEST', balance=1000)" + """ + + def __str__(self) -> str: + """Generate string representation dynamically from dataclass fields.""" + if not dataclasses.is_dataclass(self): + return f"{self.__class__.__name__}()" + + field_strings = [] + for field in dataclasses.fields(self.__class__): + field_value = getattr(self, field.name) + formatted_value = self._format_field_value(field_value) + field_strings.append(f"{field.name}={formatted_value}") + + # Choose formatting based on number of fields + class_name = self.__class__.__name__ + if len(field_strings) <= 3: + return f"{class_name}({', '.join(field_strings)})" + else: + fields_str = ",\n ".join(field_strings) + return f"{class_name}(\n {fields_str}\n)" + + def __repr__(self) -> str: + """Return string representation for debugging.""" + return self.__str__() + + def _format_field_value(self, value: Any) -> str: + """Format a field value for string representation.""" + return _format_value_helper(value) + + def to_dict(self) -> dict[str, Any]: + """Convert dataclass to dictionary for serialization. + + Returns: + dict[str, Any]: Dictionary representation of the dataclass. + """ + if not dataclasses.is_dataclass(self): + return {} + + result = {} + for field in dataclasses.fields(self.__class__): + value = getattr(self, field.name) + if hasattr(value, 'to_dict'): + result[field.name] = value.to_dict() + elif dataclasses.is_dataclass(value) and not isinstance(value, type): + result[field.name] = dataclasses.asdict(value) + else: + result[field.name] = value + return result + + +def auto_str_repr(cls): + """ + Class decorator that automatically adds __str__ and __repr__ methods + to dataclasses using dynamic field introspection. + + This decorator is an alternative to using DataclassStringMixin inheritance. + + Usage: + @auto_str_repr + @dataclass + class TokenClass: + field1: str + field2: Optional[int] = None + + Args: + cls: The dataclass to decorate. + + Returns: + The decorated class with __str__ and __repr__ methods. + + Raises: + TypeError: If the decorated class is not a dataclass. + """ + if not dataclasses.is_dataclass(cls): + raise TypeError(f"@auto_str_repr can only be applied to dataclasses, got {cls.__name__}") + + def _format_value(value: Any) -> str: + """Format a field value for string representation.""" + return _format_value_helper(value) + + def __str__(self) -> str: + """Generate string representation dynamically from dataclass fields.""" + field_strings = [] + for field in dataclasses.fields(self.__class__): + field_value = getattr(self, field.name) + formatted_value = _format_value(field_value) + field_strings.append(f"{field.name}={formatted_value}") + + if len(field_strings) <= 3: + return f"{cls.__name__}({', '.join(field_strings)})" + else: + fields_str = ",\n ".join(field_strings) + return f"{cls.__name__}(\n {fields_str}\n)" + + def __repr__(self) -> str: + """Return string representation for debugging.""" + return self.__str__() + + cls.__str__ = __str__ + cls.__repr__ = __repr__ + + return cls diff --git a/tests/unit/dataclass_strings_test.py b/tests/unit/dataclass_strings_test.py new file mode 100644 index 000000000..a20db0065 --- /dev/null +++ b/tests/unit/dataclass_strings_test.py @@ -0,0 +1,393 @@ +""" +Unit tests for the dataclass_strings utility module. + +Tests the DataclassStringMixin and auto_str_repr decorator for automatic +__str__ and __repr__ generation in dataclasses. +""" + +import pytest +from dataclasses import dataclass +from typing import Optional +from enum import Enum + +from hiero_sdk_python.utils.dataclass_strings import DataclassStringMixin, auto_str_repr + + +class SampleEnum(Enum): + """Sample enum for testing.""" + VALUE_A = 1 + VALUE_B = 2 + + +@dataclass +class SimpleDataclass(DataclassStringMixin): + """Simple dataclass with few fields for testing.""" + name: str + value: int + active: bool = True + + +@dataclass +class ComplexDataclass(DataclassStringMixin): + """Complex dataclass with many fields for testing multi-line output.""" + field1: str + field2: Optional[int] = None + field3: Optional[str] = None + field4: bool = False + field5: Optional[bytes] = None + + +@dataclass +class DataclassWithEnum(DataclassStringMixin): + """Dataclass with enum field for testing.""" + id: str + status: Optional[SampleEnum] = None + + +@auto_str_repr +@dataclass +class DecoratedDataclass: + """Dataclass using decorator approach.""" + name: str + count: int = 0 + + +class TestDataclassStringMixin: + """Tests for DataclassStringMixin.""" + + def test_simple_dataclass_str(self): + """Test __str__ for simple dataclass with few fields.""" + obj = SimpleDataclass(name="test", value=42, active=True) + result = str(obj) + + assert "SimpleDataclass(" in result + assert "name='test'" in result + assert "value=42" in result + assert "active=True" in result + # Should be single line for <= 3 fields + assert "\n" not in result + + def test_simple_dataclass_repr(self): + """Test __repr__ returns same as __str__.""" + obj = SimpleDataclass(name="test", value=42) + assert repr(obj) == str(obj) + + def test_complex_dataclass_multiline(self): + """Test multi-line output for dataclass with many fields.""" + obj = ComplexDataclass( + field1="value1", + field2=100, + field3="value3", + field4=True, + field5=b"bytes" + ) + result = str(obj) + + # Should be multi-line for > 3 fields + assert "\n" in result + assert "field1='value1'" in result + assert "field2=100" in result + + def test_none_handling(self): + """Test proper handling of None values.""" + obj = ComplexDataclass(field1="only_this") + result = str(obj) + + assert "field1='only_this'" in result + assert "field2=None" in result + assert "field3=None" in result + + def test_string_quoting(self): + """Test that string values are properly quoted.""" + obj = SimpleDataclass(name="hello", value=1) + result = str(obj) + + assert "name='hello'" in result + # Integer should not be quoted + assert "value=1" in result + + def test_enum_formatting(self): + """Test proper enum value formatting.""" + obj = DataclassWithEnum(id="test", status=SampleEnum.VALUE_A) + result = str(obj) + + assert "SampleEnum.VALUE_A" in result + + def test_bytes_formatting(self): + """Test bytes field formatting.""" + obj = ComplexDataclass(field1="test", field5=b"\x01\x02\x03") + result = str(obj) + + assert "field5=b'" in result + + def test_long_bytes_truncation(self): + """Test that long bytes are truncated.""" + long_bytes = b"a" * 50 + obj = ComplexDataclass(field1="test", field5=long_bytes) + result = str(obj) + + assert "..." in result + + def test_to_dict(self): + """Test to_dict() method.""" + obj = SimpleDataclass(name="test", value=42, active=True) + result = obj.to_dict() + + assert result == {"name": "test", "value": 42, "active": True} + + def test_to_dict_with_none(self): + """Test to_dict() with None values.""" + obj = ComplexDataclass(field1="test") + result = obj.to_dict() + + assert result["field1"] == "test" + assert result["field2"] is None + + +class TestAutoStrReprDecorator: + """Tests for @auto_str_repr decorator.""" + + def test_decorated_dataclass_str(self): + """Test __str__ for decorated dataclass.""" + obj = DecoratedDataclass(name="decorated", count=5) + result = str(obj) + + assert "DecoratedDataclass(" in result + assert "name='decorated'" in result + assert "count=5" in result + + def test_decorated_dataclass_repr(self): + """Test __repr__ for decorated dataclass.""" + obj = DecoratedDataclass(name="test", count=0) + assert repr(obj) == str(obj) + + def test_decorator_on_non_dataclass_raises(self): + """Test that decorator raises TypeError on non-dataclass.""" + with pytest.raises(TypeError, match="can only be applied to dataclasses"): + @auto_str_repr + class NotADataclass: + pass + + +class TestIntegrationWithTokenClasses: + """Integration tests with actual token classes.""" + + def test_token_relationship_str(self): + """Test TokenRelationship string generation.""" + from hiero_sdk_python.tokens.token_relationship import TokenRelationship + from hiero_sdk_python.tokens.token_id import TokenId + + token_id = TokenId(shard=0, realm=0, num=123) + relationship = TokenRelationship( + token_id=token_id, + symbol="TEST", + balance=1000 + ) + + # Protect against breaking changes - verify attributes exist + assert hasattr(relationship, 'token_id') + assert hasattr(relationship, 'symbol') + assert hasattr(relationship, 'balance') + + result = str(relationship) + assert "TokenRelationship(" in result + assert "0.0.123" in result + assert "symbol='TEST'" in result + assert "balance=1000" in result + + def test_token_update_params_str(self): + """Test TokenUpdateParams string generation.""" + from hiero_sdk_python.tokens.token_update_transaction import TokenUpdateParams + from hiero_sdk_python.account.account_id import AccountId + + params = TokenUpdateParams( + treasury_account_id=AccountId(0, 0, 456), + token_name="Updated Token" + ) + + # Protect against breaking changes - verify attributes exist + assert hasattr(params, 'treasury_account_id') + assert hasattr(params, 'token_name') + + result = str(params) + assert "TokenUpdateParams(" in result + assert "0.0.456" in result + assert "token_name='Updated Token'" in result + + def test_token_update_keys_str(self): + """Test TokenUpdateKeys string generation.""" + from hiero_sdk_python.tokens.token_update_transaction import TokenUpdateKeys + + keys = TokenUpdateKeys() + + # Protect against breaking changes - verify admin_key attribute exists + assert hasattr(keys, 'admin_key') + + result = str(keys) + + assert "TokenUpdateKeys(" in result + # All keys should be None + assert "admin_key=None" in result + + def test_custom_fee_subclass_str(self): + """Test CustomFixedFee string generation via CustomFee.__str__.""" + from hiero_sdk_python.tokens.custom_fixed_fee import CustomFixedFee + from hiero_sdk_python.account.account_id import AccountId + + fee = CustomFixedFee( + amount=100, + fee_collector_account_id=AccountId(0, 0, 789) + ) + + # Protect against breaking changes - verify inherited and specific fields exist + assert hasattr(fee, 'fee_collector_account_id') + assert hasattr(fee, 'all_collectors_are_exempt') + assert hasattr(fee, 'amount') + + result = str(fee) + # Verify class name + assert "CustomFixedFee" in result + # Verify inherited fields from CustomFee (custom format has title case) + assert "0.0.789" in result + assert "All Collectors Are Exempt" in result or "all_collectors_are_exempt" in result + # Verify CustomFixedFee-specific fields + assert "100" in result + + +class TestDynamicFieldInclusion: + """Tests verifying that new fields are automatically included.""" + + def test_new_fields_automatically_included(self): + """Verify that adding fields doesn't require __str__ updates.""" + @dataclass + class ExtendableClass(DataclassStringMixin): + original_field: str + # Simulate adding a new field + new_field: Optional[str] = None + another_new_field: int = 0 + + obj = ExtendableClass( + original_field="original", + new_field="new", + another_new_field=42 + ) + + result = str(obj) + + # All fields should be included without any code changes + assert "original_field='original'" in result + assert "new_field='new'" in result + assert "another_new_field=42" in result + +class TestBoundaryConditions: + """Tests for boundary conditions and edge cases.""" + + def test_three_fields_single_line(self): + """Verify 3 fields produce single-line format.""" + obj = SimpleDataclass(name="test", value=1, active=True) + result = str(obj) + assert "\n" not in result + assert "SimpleDataclass(" in result + + def test_four_fields_multiline(self): + """Verify 4+ fields produce multi-line format.""" + obj = ComplexDataclass( + field1="a", + field2=1, + field3="b", + field4=True + ) + result = str(obj) + assert "\n" in result + assert "ComplexDataclass(" in result + + def test_empty_dataclass(self): + """Verify handling of dataclass with no fields.""" + @dataclass + class EmptyDataclass(DataclassStringMixin): + pass + + obj = EmptyDataclass() + result = str(obj) + assert result == "EmptyDataclass()" + + def test_nested_dataclass_to_dict(self): + """Test to_dict() with nested dataclass objects.""" + @dataclass + class Inner(DataclassStringMixin): + inner_value: str + + @dataclass + class Outer(DataclassStringMixin): + outer_value: str + inner: Inner + + inner_obj = Inner(inner_value="inner") + outer_obj = Outer(outer_value="outer", inner=inner_obj) + result = outer_obj.to_dict() + + assert result["outer_value"] == "outer" + assert isinstance(result["inner"], dict) + assert result["inner"]["inner_value"] == "inner" + + def test_decorator_returns_same_class(self): + """Verify decorator modifies class in-place, not wrapping.""" + @auto_str_repr + @dataclass + class MyClass: + value: int + + obj = MyClass(value=42) + assert type(obj).__name__ == "MyClass" + + def test_decorated_methods_return_correct_types(self): + """Verify decorated methods return strings.""" + obj = DecoratedDataclass(name="test", count=5) + assert isinstance(str(obj), str) + assert isinstance(repr(obj), str) + + def test_mixin_repr_equals_str(self): + """Verify __repr__ returns same as __str__ for mixin.""" + obj = SimpleDataclass(name="test", value=42, active=True) + assert repr(obj) == str(obj) + + def test_decorator_repr_equals_str(self): + """Verify __repr__ returns same as __str__ for decorator.""" + obj = DecoratedDataclass(name="test", count=5) + assert repr(obj) == str(obj) + + def test_zero_value_fields(self): + """Test that zero and False values are properly formatted.""" + @dataclass + class ZeroValuesClass(DataclassStringMixin): + count: int = 0 + flag: bool = False + text: str = "" + + obj = ZeroValuesClass() + result = str(obj) + + assert "count=0" in result + assert "flag=False" in result + assert "text=''" in result + + def test_special_string_characters(self): + """Test handling of strings with special characters.""" + obj = SimpleDataclass(name="test'with\"quotes", value=1, active=True) + result = str(obj) + + assert "name=" in result + assert "test" in result + + def test_list_and_dict_fields(self): + """Test handling of list and dict fields.""" + @dataclass + class CollectionsClass(DataclassStringMixin): + items: list = None + mapping: dict = None + + obj = CollectionsClass(items=[1, 2, 3], mapping={"key": "value"}) + result = str(obj) + + assert "items=" in result + assert "mapping=" in result \ No newline at end of file