From 70d06a189bf65b58f9eab04c9b3ff3f1ddcece3e Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 21 May 2025 09:34:13 +0200 Subject: [PATCH 1/3] Refactor(clan_lib): move serde tests next to serde module --- .../api/serde_deserialize_test.py} | 0 .../test_serializers.py => clan_lib/api/serde_serialize_test.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename pkgs/clan-cli/{clan_cli/tests/test_deserializers.py => clan_lib/api/serde_deserialize_test.py} (100%) rename pkgs/clan-cli/{clan_cli/tests/test_serializers.py => clan_lib/api/serde_serialize_test.py} (100%) diff --git a/pkgs/clan-cli/clan_cli/tests/test_deserializers.py b/pkgs/clan-cli/clan_lib/api/serde_deserialize_test.py similarity index 100% rename from pkgs/clan-cli/clan_cli/tests/test_deserializers.py rename to pkgs/clan-cli/clan_lib/api/serde_deserialize_test.py diff --git a/pkgs/clan-cli/clan_cli/tests/test_serializers.py b/pkgs/clan-cli/clan_lib/api/serde_serialize_test.py similarity index 100% rename from pkgs/clan-cli/clan_cli/tests/test_serializers.py rename to pkgs/clan-cli/clan_lib/api/serde_serialize_test.py From ffc82928a7e8fcda1b61b2b85554643774d664ba Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 21 May 2025 09:42:11 +0200 Subject: [PATCH 2/3] docs: add doc-string to api serde utilities --- pkgs/clan-cli/clan_lib/api/serde.py | 49 +++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/pkgs/clan-cli/clan_lib/api/serde.py b/pkgs/clan-cli/clan_lib/api/serde.py index aeefe9402..d85700dee 100644 --- a/pkgs/clan-cli/clan_lib/api/serde.py +++ b/pkgs/clan-cli/clan_lib/api/serde.py @@ -50,9 +50,8 @@ from clan_lib.errors import ClanError def sanitize_string(s: str) -> str: - # Using the native string sanitizer to handle all edge cases - # Remove the outer quotes '"string"' - # return json.dumps(s)[1:-1] + # Currently, this is a no-op + # but it can be extended to escape special characters if we need it return s @@ -86,6 +85,23 @@ def get_enum_value(obj: Any) -> Any: def dataclass_to_dict(obj: Any, *, use_alias: bool = True) -> Any: + """ + Converts objects to dictionaries. + + This function is round trip safe. + Meaning that if you convert the object to a dict and then back to a dataclass using 'from_dict' + + List of supported types: + - dataclass + - list + - tuple + - set + - dict + - Path: Gets converted to string + - Enum: Gets converted to its value + + """ + def _to_dict(obj: Any) -> Any: """ Utility function to convert dataclasses to dictionaries @@ -158,6 +174,26 @@ def construct_value( ) -> Any: """ Construct a field value from a type hint and a field value. + + The following types are supported and matched in this order: + + - None + - dataclass + - Path: Constructed from a string, Error if value is not string + - dict + - str + - int, float: Constructed from any value, Error if value is string + - bool: Constructed from any value, Error if value is not boolean + - Union: Construct the value of the first non-None type. Example: 'None | Path | str' -> Path + - list: Construct Members recursively from inner type of the list. Error if value not a list + - dict: Construct Members recursively from inner type of the dict. Error if value not a dict + - Literal: Check if the value is one of the valid values. Error if value not in valid values + - Enum: Construct the Enum by passing the value into the enum constructor. Error is Enum cannot be constructed + - Annotated: Unwrap the type and construct the value + - TypedDict: Construct the TypedDict by passing the value into the TypedDict constructor. Error if value not a dict + - Unknown: Return the field value as is, type reserved 'class Unknown' + + - Otherwise: Raise a ClanError """ if loc is None: loc = [] @@ -276,6 +312,8 @@ def construct_dataclass( """ type t MUST be a dataclass Dynamically instantiate a data class from a dictionary, handling nested data classes. + + Constructs the field values from the data dictionary using 'construct_value' """ if path is None: path = [] @@ -326,6 +364,11 @@ def construct_dataclass( def from_dict( t: type | UnionType, data: dict[str, Any] | Any, path: list[str] | None = None ) -> Any: + """ + Dynamically instantiate a data class from a dictionary, handling nested data classes. + + This function is round trip safe in conjunction with 'dataclass_to_dict' + """ if path is None: path = [] if is_dataclass(t): From 6c1f8638f543786971385afa6b393d838549f147 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 21 May 2025 10:01:27 +0200 Subject: [PATCH 3/3] chore(clan_lib) add api.serde tests for typed_dict --- .../clan_lib/api/serde_deserialize_test.py | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/pkgs/clan-cli/clan_lib/api/serde_deserialize_test.py b/pkgs/clan-cli/clan_lib/api/serde_deserialize_test.py index 58b24c36b..62497e774 100644 --- a/pkgs/clan-cli/clan_lib/api/serde_deserialize_test.py +++ b/pkgs/clan-cli/clan_lib/api/serde_deserialize_test.py @@ -1,6 +1,6 @@ from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Literal +from typing import Any, Literal, TypedDict import pytest from clan_cli.machines import machines @@ -228,6 +228,36 @@ def test_roundtrip_escape() -> None: assert dataclass_to_dict(from_dict(str, "\\n")) == "\\n" +def test_roundtrip_typed_dict() -> None: + class Person(TypedDict): + name: str + + data = {"name": "John"} + person = Person(name="John") + + # Check that the functions are the inverses of each other + # f(g(x)) == x + # and + # g(f(x)) == x + assert from_dict(Person, dataclass_to_dict(person)) == person + assert dataclass_to_dict(from_dict(Person, data)) == person + + +def test_construct_typed_dict() -> None: + class Person(TypedDict): + name: str + + data = {"name": "John"} + person = Person(name="John") + + # Check that the from_dict function works with TypedDict + assert from_dict(Person, data) == person + + with pytest.raises(ClanError): + # Not a valid value + from_dict(Person, None) + + def test_path_field() -> None: @dataclass class Person: