From 5a7ae929210a1ff51d958940692c936a177019c8 Mon Sep 17 00:00:00 2001 From: Mike Turner Date: Fri, 20 Feb 2026 14:08:31 +0800 Subject: [PATCH 01/13] Add dictionary generation functionality --- src/assertical/fake/generator.py | 165 ++++++++++++++++++++----- tests/fake/test_generator_common.py | 5 + tests/fake/test_generator_dataclass.py | 77 ++++++++++++ 3 files changed, 217 insertions(+), 30 deletions(-) diff --git a/src/assertical/fake/generator.py b/src/assertical/fake/generator.py index 0915bff..27ddb46 100644 --- a/src/assertical/fake/generator.py +++ b/src/assertical/fake/generator.py @@ -55,6 +55,8 @@ class CollectionType(IntEnum): OPTIONAL_LIST = auto() # For type T - represents list[Optional[T]] REQUIRED_SET = auto() # For type T - represents set[T] OPTIONAL_SET = auto() # For type T - represents set[Optional[T]] + REQUIRED_DICT = auto() + OPTIONAL_DICT = auto() @dataclass @@ -85,12 +87,21 @@ class PropertyGenerationDetails: # For example, a list[int] would have type_to_generate as int and this property as REQUIRED_LIST collection_type: Optional[CollectionType] + second_type_to_generate: Optional[type] = None + second_is_primitive_type: bool | None = None + @dataclass class _PlaceholderDataclassBase: """Dataclass has no base class - instead we fall back to using this as a placeholder""" +@dataclass +class _PlaceholderCollectionBase: + """lists, dicts and sets have no base class other than object + - instead we fall back to using this as a placeholder""" + + AnyType = TypeVar("AnyType") @@ -249,6 +260,12 @@ def get_generatable_class_base(t: type) -> Optional[type]: target_type = optional_arg if not inspect.isclass(target_type): + origin_type = get_origin(target_type) + if inspect.isclass(origin_type): + # check for collections + if origin_type in [list, dict, set]: + return _PlaceholderCollectionBase + return None for base_class in inspect.getmro(target_type): @@ -263,6 +280,21 @@ def get_generatable_class_base(t: type) -> Optional[type]: return None +# def get_generatable_collection(t: type) -> Optional[type]: +# target_type = remove_passthrough_type(t) +# +# # we don't consider the Optional[MyType] - only the MyType +# optional_arg = get_optional_type_argument(target_type) +# if optional_arg is not None: +# target_type = optional_arg +# +# if get_origin(t) in [list, set, dict]: +# return t +# +# return None +# + + def get_optional_type_argument(t: type) -> Optional[type]: """If t is Optional[MyType] - return MyType - otherwise return None. @@ -305,7 +337,7 @@ def enumerate_class_properties(t: type) -> Generator[PropertyGenerationDetails, if t_generatable_base is None: raise Exception(f"Type {t} does not inherit from one of {CLASS_INSTANCE_GENERATORS.keys()}") - type_hints = get_type_hints(t) + type_hints = TYPE_HINT_FETCHER[t_generatable_base](t) for member_name in CLASS_MEMBER_FETCHERS[t_generatable_base](t): @@ -320,6 +352,9 @@ def enumerate_class_properties(t: type) -> Generator[PropertyGenerationDetails, collection_type: Optional[CollectionType] = None is_optional: bool = False is_primitive: bool = False + second_type_to_generate: Optional[type] = None + second_is_primitive: Optional[bool] = None + if member_name in type_hints: declared_type = cast(type, type_hints[member_name]) member_type = remove_passthrough_type(declared_type) @@ -332,17 +367,46 @@ def enumerate_class_properties(t: type) -> Generator[PropertyGenerationDetails, collection_type = CollectionType.OPTIONAL_LIST elif get_origin(optional_arg_type) == set: collection_type = CollectionType.OPTIONAL_SET + elif get_origin(optional_arg_type) == dict: + collection_type = CollectionType.OPTIONAL_DICT else: if get_origin(member_type) == list: collection_type = CollectionType.REQUIRED_LIST elif get_origin(member_type) == set: collection_type = CollectionType.REQUIRED_SET + elif get_origin(member_type) == dict: + collection_type = CollectionType.REQUIRED_DICT if collection_type is not None: + original_member_type = member_type member_type = get_args(optional_arg_type)[0] if is_optional else get_args(member_type)[0] optional_arg_type = get_optional_type_argument(member_type) is_optional = optional_arg_type is not None + if collection_type in (CollectionType.OPTIONAL_DICT, CollectionType.REQUIRED_DICT): + second_member_type = ( + get_args(optional_arg_type)[1] if is_optional else get_args(original_member_type)[1] + ) + second_optional_arg_type = get_optional_type_argument(second_member_type) + second_is_optional = optional_arg_type is not None + if collection_type in (CollectionType.OPTIONAL_DICT, CollectionType.REQUIRED_DICT): + if is_generatable_type(second_member_type): + second_type_to_generate = get_first_generatable_primitive( + second_member_type, include_optional=False + ) + assert ( + second_type_to_generate is not None + ), f"Error generating member {member_name}. Couldn't find type for {second_member_type}" + second_is_primitive = True + # elif get_generatable_collection(second_member_type) is not None: + # second_type_to_generate = ( + # second_optional_arg_type if second_is_optional else second_member_type + # ) + elif get_generatable_class_base(second_member_type) is not None: + second_type_to_generate = ( + second_optional_arg_type if second_is_optional else second_member_type + ) + # Work around for SQLAlchemy forward references - hopefully we don't need many of these special cases # # if we are passed a string name of a type (such as SQL Alchemy relationships are want to do) @@ -373,6 +437,8 @@ def enumerate_class_properties(t: type) -> Generator[PropertyGenerationDetails, is_primitive_type=is_primitive, is_optional=is_optional, collection_type=collection_type, + second_type_to_generate=second_type_to_generate, + second_is_primitive_type=second_is_primitive, ) @@ -438,9 +504,36 @@ def generate_class_instance( # noqa: C901 empty_collection: bool = False collection_type: Optional[CollectionType] = member.collection_type + def generate_member(is_primitive_type, type_to_generate, current_seed, empty_collection): + if is_primitive_type: + generated_value = generate_value(type_to_generate, seed=current_seed, optional_is_none=optional_is_none) + current_seed += 1 + else: + generated_value = None + if generate_relationships: + generated_value = generate_class_instance( + type_to_generate, + seed=current_seed, + optional_is_none=optional_is_none, + generate_relationships=generate_relationships, + _visited_type_stack=_visited_type_stack, + ) + + # None can be generated when Type A has child B that includes a backreference to A. in these + # circumstances the visited_types short circuit will just return None from generate_class_instance + # (to stop infinite recursion) The way we handle this is to just generate an empty list (if this is + # a list entity) + if generated_value is None: + empty_collection = True + + # Rather than calculating how many seed values were utilised - set it arbitrarily high + current_seed += 1000 + + return generated_value, current_seed, empty_collection + if optional_is_none and ( - member.collection_type == CollectionType.OPTIONAL_LIST - or member.collection_type == CollectionType.OPTIONAL_SET + member.collection_type + in [CollectionType.OPTIONAL_LIST, CollectionType.OPTIONAL_SET, CollectionType.OPTIONAL_DICT] ): # We can short circuit some generation if we know the top level collection should be None # In this case - we just set everything to None @@ -452,38 +545,32 @@ def generate_class_instance( # noqa: C901 # that are None - so we just add a None to the parent collection (or just generate None) generated_value = None current_seed += 1 - elif member.is_primitive_type: - generated_value = generate_value( - member.type_to_generate, seed=current_seed, optional_is_none=optional_is_none - ) - current_seed += 1 else: - if generate_relationships: - generated_value = generate_class_instance( - member.type_to_generate, - seed=current_seed, - optional_is_none=optional_is_none, - generate_relationships=generate_relationships, - _visited_type_stack=_visited_type_stack, - ) - - # None can be generated when Type A has child B that includes a backreference to A. in these - # circumstances the visited_types short circuit will just return None from generate_class_instance - # (to stop infinite recursion) The way we handle this is to just generate an empty list (if this is - # a list entity) - if generated_value is None: - empty_collection = True - # collection_type = CollectionType.REQUIRED_LIST - else: - # In this case we have a complex type but we aren't generating relationships - throw in a placeholder - empty_collection = True - generated_value = None - current_seed += 1000 # Rather than calculating how many seed values were utilised - set it arbitrarily high + generated_value, current_seed, empty_collection = generate_member( + is_primitive_type=member.is_primitive_type, + type_to_generate=member.type_to_generate, + current_seed=current_seed, + empty_collection=empty_collection, + ) if collection_type == CollectionType.REQUIRED_LIST or collection_type == CollectionType.OPTIONAL_LIST: values[member.name] = [] if empty_collection else [generated_value] elif collection_type == CollectionType.REQUIRED_SET or collection_type == CollectionType.OPTIONAL_SET: values[member.name] = set([]) if empty_collection else set([generated_value]) + elif collection_type == CollectionType.REQUIRED_DICT or collection_type == CollectionType.OPTIONAL_DICT: + if optional_is_none and member.is_optional: + # In this case the parent collection is NOT able to be set to None but does support adding items + # that are None - so we just add a None to the parent collection (or just generate None) + second_generated_value = None + current_seed += 1 + else: + second_generated_value, current_seed, empty_collection = generate_member( + is_primitive_type=member.second_is_primitive_type, + type_to_generate=member.second_type_to_generate, + current_seed=current_seed, + empty_collection=empty_collection, + ) + values[member.name] = {} if empty_collection else {generated_value: second_generated_value} else: values[member.name] = generated_value @@ -492,6 +579,7 @@ def generate_class_instance( # noqa: C901 raise Exception(f"The following kwargs were unused {expected_kwargs_references.difference(kwargs_references)}") _visited_type_stack.pop() # When we finish generating a type, allow recursion back into that type + return CLASS_INSTANCE_GENERATORS[t_generatable_base](t, values) @@ -627,12 +715,18 @@ def register_value_generator(t: type, generator: Callable[[int], Any]) -> None: BASE_CLASS_PUBLIC_MEMBERS: dict[type, set[str]] = {} DEFAULT_CLASS_INSTANCE_GENERATOR: Callable[[type, dict[str, Any]], Any] = lambda target, kwargs: target(**kwargs) DEFAULT_MEMBER_FETCHER: Callable[[type], list[str]] = lambda target: [name for (name, _) in inspect.getmembers(target)] +DEFAULT_PUBLIC_MEMBER_CHECKER: Callable[[str], bool] = is_member_public + +TYPE_HINT_FETCHER: dict[type, Callable[[type], dict[str, type]]] = {} +DEFAULT_TYPE_HINT_FETCHER: Callable[[type], dict[str, type]] = get_type_hints def register_base_type( base_type: type, instance_generator: Callable[[type, dict[str, Any]], Any], member_fetcher: Callable[[type], list[str]], + public_member_checker: Callable[[str], bool] = DEFAULT_PUBLIC_MEMBER_CHECKER, + type_hint_fetcher: Callable[[type], dict[str, type]] = DEFAULT_TYPE_HINT_FETCHER, ) -> None: """Registers a type that will allow all subclasses to be generated/cloned by functions in this module. @@ -646,7 +740,8 @@ def register_base_type( polluting the global registry""" CLASS_INSTANCE_GENERATORS[base_type] = instance_generator CLASS_MEMBER_FETCHERS[base_type] = member_fetcher - BASE_CLASS_PUBLIC_MEMBERS[base_type] = set([m for m in member_fetcher(base_type) if is_member_public(m)]) + BASE_CLASS_PUBLIC_MEMBERS[base_type] = set([m for m in member_fetcher(base_type) if public_member_checker(m)]) + TYPE_HINT_FETCHER[base_type] = type_hint_fetcher # Base type registration @@ -656,6 +751,16 @@ def register_base_type( lambda target: [f.name for f in fields(target) if f.init], ) +# Handling of collections +register_base_type( + _PlaceholderCollectionBase, + lambda target, kwargs: kwargs["self"], + lambda _: ["self"], + lambda _: False, # "base class" doesn't have any public members + lambda target: {"self": target}, +) + + if "pydantic_xml" in sys.modules: register_base_type( BaseXmlModel, diff --git a/tests/fake/test_generator_common.py b/tests/fake/test_generator_common.py index 5170bbc..7b47948 100644 --- a/tests/fake/test_generator_common.py +++ b/tests/fake/test_generator_common.py @@ -87,6 +87,8 @@ def test_generate_value(): generate_value(RandomOtherClass, 1) with pytest.raises(Exception): generate_value(list[int], 1) + with pytest.raises(Exception): + generate_value(dict[str, int], 1) assert generate_value(str, 1, True) == generate_value(str, 1, True) assert generate_value(str, 1, True) is not generate_value(str, 1, True) @@ -230,6 +232,7 @@ def test_is_passthrough_type(): assert not is_passthrough_type(Union[str, int]) assert not is_passthrough_type(str) assert not is_passthrough_type(list[int]) + assert not is_passthrough_type(dict[str, int]) def test_remove_passthrough_type(): @@ -299,6 +302,7 @@ def test_get_first_generatable_primitive(): assert get_first_generatable_primitive(list[str], include_optional=True) is None assert get_first_generatable_primitive(list[int], include_optional=True) is None assert get_first_generatable_primitive(Mapped[list[str]], include_optional=True) is None + assert get_first_generatable_primitive(dict[str, int], include_optional=True) is None # With include_optional disabled assert get_first_generatable_primitive(int, include_optional=False) == int @@ -318,6 +322,7 @@ def test_get_first_generatable_primitive(): assert get_first_generatable_primitive(list[str], include_optional=False) is None assert get_first_generatable_primitive(list[int], include_optional=False) is None assert get_first_generatable_primitive(Mapped[list[str]], include_optional=False) is None + assert get_first_generatable_primitive(dict[str, int], include_optional=False) is None def test_get_first_generatable_primitive_py310_optional(): diff --git a/tests/fake/test_generator_dataclass.py b/tests/fake/test_generator_dataclass.py index 6a586e2..a533472 100644 --- a/tests/fake/test_generator_dataclass.py +++ b/tests/fake/test_generator_dataclass.py @@ -27,6 +27,7 @@ class ParentDataclass: myStr: str myList: list[int] myTime: time + myDict: dict[str, int] @dataclass(frozen=True) @@ -43,11 +44,17 @@ class OptionalCollectionsClass: optional_int_vals: list[Optional[int]] optional_int_list: Optional[list[int]] optional_optional_ints: Optional[list[Optional[int]]] + refs: set[ReferenceDataclass] optional_refs_vals: set[Optional[ReferenceDataclass]] optional_refs_list: Optional[set[ReferenceDataclass]] optional_optional_refs: Optional[set[Optional[ReferenceDataclass]]] + # dict_ints: dict[str, int] + # dict_optional_ints: dict[str, Optional[int]] + # optional_dict: Optional[dict[str, int]] + # optional_dict_optional_ints: Optional[dict[str, Optional[int]]] + @dataclass class InitRestrictionsDataclass: @@ -59,6 +66,76 @@ def __post_init__(self): self.myRestrictedInt2 = 2 +@dataclass +class CollectionsDataclass: + l1: list[int] + l2: list[Optional[int]] + l3: Optional[list[int]] + l4: Optional[list[Optional[int]]] + l5: list[ReferenceDataclass] + l6: list[list[ReferenceDataclass]] + + s1: set[int] + s2: set[Optional[int]] + s3: Optional[set[int]] + s4: Optional[set[Optional[int]]] + s5: set[ReferenceDataclass] + + d1: dict[str, int] + d2: dict[str, ReferenceDataclass] + d3: dict[str, list[int]] + d4: dict[int, dict[str, list[int]]] + d5: dict[str, dict[str, int]] + d6: dict[str, dict[str, ReferenceDataclass]] + + +def test_collections_dataclass(): + value = generate_class_instance(CollectionsDataclass, seed=1, generate_relationships=True) + + +@pytest.mark.parametrize( + "t,optional_is_none,generate_relationships,expected_value", + [ + (list[int], True, True, [1]), + (list[int], True, False, [1]), + (list[int], False, True, [1]), + (list[int], False, False, [1]), + (Optional[list[int]], True, True, None), + (Optional[list[int]], True, False, None), + (Optional[list[int]], False, True, [1]), + (Optional[list[int]], False, False, [1]), + (list[Optional[int]], True, True, [None]), + (list[Optional[int]], True, False, [None]), + (list[Optional[int]], False, True, [1]), + (list[Optional[int]], False, False, [1]), + (Optional[list[Optional[int]]], True, True, None), + (Optional[list[Optional[int]]], True, False, None), + (Optional[list[Optional[int]]], False, True, [1]), + (Optional[list[Optional[int]]], False, False, [1]), + (list[ReferenceDataclass], True, True, [ReferenceDataclass(myOptInt=None, myInt=2)]), + (list[ReferenceDataclass], True, False, []), + (list[ReferenceDataclass], False, True, [ReferenceDataclass(myOptInt=1, myInt=2)]), + (list[ReferenceDataclass], False, False, []), + (dict[str, int], True, True, {"1-str": 2}), + (dict[str, ReferenceDataclass], True, True, {"1-str": ReferenceDataclass(myOptInt=None, myInt=3)}), + (dict[str, list[int]], True, True, {"1-str": [2]}), + (dict[str, dict[str, int]], True, True, {"1-str": {"2-str": 3}}), + (dict[int, dict[str, list[int]]], True, True, {1: {"2-str": [3]}}), + ( + dict[str, dict[str, ReferenceDataclass]], + True, + True, + {"1-str": {"2-str": ReferenceDataclass(myOptInt=None, myInt=4)}}, + ), + ], +) +def test_collections(t: type, optional_is_none: bool, generate_relationships: bool, expected_value): + value = generate_class_instance( + t, seed=1, optional_is_none=optional_is_none, generate_relationships=generate_relationships + ) + assert value == expected_value + + def test_clone_class_instance_dataclass(): original = generate_class_instance(ReferenceDataclass, generate_relationships=True) clone = clone_class_instance(original) From 0effd32b05e14f1b5aecb2f26c8f00f61a17acf9 Mon Sep 17 00:00:00 2001 From: Mike Turner Date: Fri, 20 Feb 2026 14:09:59 +0800 Subject: [PATCH 02/13] Remove unused code --- src/assertical/fake/generator.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/src/assertical/fake/generator.py b/src/assertical/fake/generator.py index 27ddb46..37cc57a 100644 --- a/src/assertical/fake/generator.py +++ b/src/assertical/fake/generator.py @@ -280,21 +280,6 @@ def get_generatable_class_base(t: type) -> Optional[type]: return None -# def get_generatable_collection(t: type) -> Optional[type]: -# target_type = remove_passthrough_type(t) -# -# # we don't consider the Optional[MyType] - only the MyType -# optional_arg = get_optional_type_argument(target_type) -# if optional_arg is not None: -# target_type = optional_arg -# -# if get_origin(t) in [list, set, dict]: -# return t -# -# return None -# - - def get_optional_type_argument(t: type) -> Optional[type]: """If t is Optional[MyType] - return MyType - otherwise return None. @@ -398,10 +383,6 @@ def enumerate_class_properties(t: type) -> Generator[PropertyGenerationDetails, second_type_to_generate is not None ), f"Error generating member {member_name}. Couldn't find type for {second_member_type}" second_is_primitive = True - # elif get_generatable_collection(second_member_type) is not None: - # second_type_to_generate = ( - # second_optional_arg_type if second_is_optional else second_member_type - # ) elif get_generatable_class_base(second_member_type) is not None: second_type_to_generate = ( second_optional_arg_type if second_is_optional else second_member_type From d44cb379a448d6835c6f28842dd571d00aa8de41 Mon Sep 17 00:00:00 2001 From: Mike Turner Date: Fri, 20 Feb 2026 14:13:46 +0800 Subject: [PATCH 03/13] Fixes linter issues --- tests/fake/test_generator_dataclass.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fake/test_generator_dataclass.py b/tests/fake/test_generator_dataclass.py index a533472..ca0635f 100644 --- a/tests/fake/test_generator_dataclass.py +++ b/tests/fake/test_generator_dataclass.py @@ -90,7 +90,7 @@ class CollectionsDataclass: def test_collections_dataclass(): - value = generate_class_instance(CollectionsDataclass, seed=1, generate_relationships=True) + _ = generate_class_instance(CollectionsDataclass, seed=1, generate_relationships=True) @pytest.mark.parametrize( From 15e8a2f44a463ecb35c8851294b94d7b038126fa Mon Sep 17 00:00:00 2001 From: Mike Turner Date: Sat, 21 Feb 2026 16:10:13 +0800 Subject: [PATCH 04/13] Remove artibrary seed increase of 1000 --- src/assertical/fake/generator.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/assertical/fake/generator.py b/src/assertical/fake/generator.py index 37cc57a..74ddae8 100644 --- a/src/assertical/fake/generator.py +++ b/src/assertical/fake/generator.py @@ -428,9 +428,10 @@ def generate_class_instance( # noqa: C901 seed: int = 1, optional_is_none: bool = False, generate_relationships: bool = False, + _return_seed: bool = False, _visited_type_stack: Optional[list[type]] = None, **kwargs: Any, -) -> AnyType: +) -> AnyType | tuple[AnyType, int]: """Given a child class of a key to CLASS_INSTANCE_GENERATORS - generate an instance of that class with all properties being assigned unique values based off of seed. The values will match type hints @@ -454,7 +455,8 @@ def generate_class_instance( # noqa: C901 if _visited_type_stack is None: _visited_type_stack = [] if t in _visited_type_stack: - return None # type: ignore # This only happens in recursion - the top level object will never be None + # This only happens in recursion - the top level object will never be None + return (None, seed) if _return_seed else None # type: ignore _visited_type_stack.append(t) # We can only generate class instances of classes that inherit from a known base @@ -492,12 +494,13 @@ def generate_member(is_primitive_type, type_to_generate, current_seed, empty_col else: generated_value = None if generate_relationships: - generated_value = generate_class_instance( + generated_value, current_seed = generate_class_instance( type_to_generate, seed=current_seed, optional_is_none=optional_is_none, generate_relationships=generate_relationships, _visited_type_stack=_visited_type_stack, + _return_seed=True, ) # None can be generated when Type A has child B that includes a backreference to A. in these @@ -507,9 +510,6 @@ def generate_member(is_primitive_type, type_to_generate, current_seed, empty_col if generated_value is None: empty_collection = True - # Rather than calculating how many seed values were utilised - set it arbitrarily high - current_seed += 1000 - return generated_value, current_seed, empty_collection if optional_is_none and ( @@ -561,7 +561,8 @@ def generate_member(is_primitive_type, type_to_generate, current_seed, empty_col _visited_type_stack.pop() # When we finish generating a type, allow recursion back into that type - return CLASS_INSTANCE_GENERATORS[t_generatable_base](t, values) + instance = CLASS_INSTANCE_GENERATORS[t_generatable_base](t, values) + return (instance, current_seed) if _return_seed else instance def clone_class_instance(obj: AnyType, ignored_properties: Optional[set[str]] = None) -> AnyType: From 88401ea48cd0f20107d67250e8f6a1e0247da0a2 Mon Sep 17 00:00:00 2001 From: Mike Turner Date: Mon, 23 Feb 2026 10:59:44 +0800 Subject: [PATCH 05/13] Reduce strictness on ungenerable types if they will be assigned None values --- src/assertical/fake/generator.py | 8 +++++--- tests/fake/test_generator_dataclass.py | 13 +++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/assertical/fake/generator.py b/src/assertical/fake/generator.py index 74ddae8..9a4716b 100644 --- a/src/assertical/fake/generator.py +++ b/src/assertical/fake/generator.py @@ -479,9 +479,11 @@ def generate_class_instance( # noqa: C901 continue if member.type_to_generate is None: - raise Exception( - f"Type {t} has property {member.name} with type {member.declared_type} that cannot be generated" - ) + # Don't raise exception for ungeneratable types if their value is going to be None + if not (optional_is_none and member.is_optional): + raise Exception( + f"Type {t} has property {member.name} with type {member.declared_type} that cannot be generated" + ) generated_value: Any = None empty_collection: bool = False diff --git a/tests/fake/test_generator_dataclass.py b/tests/fake/test_generator_dataclass.py index ca0635f..465a079 100644 --- a/tests/fake/test_generator_dataclass.py +++ b/tests/fake/test_generator_dataclass.py @@ -4,6 +4,7 @@ from dataclasses import dataclass, field from datetime import datetime, time from typing import Generator, Optional +from pathlib import Path import pytest @@ -89,6 +90,18 @@ class CollectionsDataclass: d6: dict[str, dict[str, ReferenceDataclass]] +def test_dataclass_with_ungeneratable_type(): + @dataclass + class DataclassWithUngeneratableType: + path: Optional[Path] # assertical can't generate Path values + + with pytest.raises(Exception): + generate_class_instance(DataclassWithUngeneratableType) + + # Check optional_is_none allows by-passing ungeneratable types + generate_class_instance(DataclassWithUngeneratableType, optional_is_none=True) + + def test_collections_dataclass(): _ = generate_class_instance(CollectionsDataclass, seed=1, generate_relationships=True) From fd671b295533695f0fa600cf4db3e8b16e73dc26 Mon Sep 17 00:00:00 2001 From: Mike Turner Date: Tue, 24 Feb 2026 14:19:14 +0800 Subject: [PATCH 06/13] Adds clarity around collection types with two parameters --- src/assertical/fake/generator.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/assertical/fake/generator.py b/src/assertical/fake/generator.py index 9a4716b..3950b6e 100644 --- a/src/assertical/fake/generator.py +++ b/src/assertical/fake/generator.py @@ -59,6 +59,9 @@ class CollectionType(IntEnum): OPTIONAL_DICT = auto() +TWO_PARAMETER_COLLECTION_TYPES: set[CollectionType] = {CollectionType.OPTIONAL_DICT, CollectionType.REQUIRED_DICT} + + @dataclass class PropertyGenerationDetails: """Details about a property on a class/type that can be generated""" @@ -368,7 +371,7 @@ def enumerate_class_properties(t: type) -> Generator[PropertyGenerationDetails, optional_arg_type = get_optional_type_argument(member_type) is_optional = optional_arg_type is not None - if collection_type in (CollectionType.OPTIONAL_DICT, CollectionType.REQUIRED_DICT): + if collection_type in TWO_PARAMETER_COLLECTION_TYPES: second_member_type = ( get_args(optional_arg_type)[1] if is_optional else get_args(original_member_type)[1] ) From 293729f3331c8ce3c057953576e1f60dd5bc7771 Mon Sep 17 00:00:00 2001 From: Mike Turner Date: Tue, 24 Feb 2026 14:29:24 +0800 Subject: [PATCH 07/13] Remove unnecesary cast (according to mypy) --- src/assertical/fake/generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/assertical/fake/generator.py b/src/assertical/fake/generator.py index 3950b6e..0217aa0 100644 --- a/src/assertical/fake/generator.py +++ b/src/assertical/fake/generator.py @@ -344,7 +344,7 @@ def enumerate_class_properties(t: type) -> Generator[PropertyGenerationDetails, second_is_primitive: Optional[bool] = None if member_name in type_hints: - declared_type = cast(type, type_hints[member_name]) + declared_type = type_hints[member_name] member_type = remove_passthrough_type(declared_type) optional_arg_type = get_optional_type_argument(member_type) is_optional = optional_arg_type is not None From 8ecdfd8ac0b9ccc859c5fb9aef98e75f6d888f44 Mon Sep 17 00:00:00 2001 From: Mike Turner Date: Tue, 24 Feb 2026 14:29:46 +0800 Subject: [PATCH 08/13] Add type hints to generate_member inner function --- src/assertical/fake/generator.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/assertical/fake/generator.py b/src/assertical/fake/generator.py index 0217aa0..43b149b 100644 --- a/src/assertical/fake/generator.py +++ b/src/assertical/fake/generator.py @@ -492,7 +492,9 @@ def generate_class_instance( # noqa: C901 empty_collection: bool = False collection_type: Optional[CollectionType] = member.collection_type - def generate_member(is_primitive_type, type_to_generate, current_seed, empty_collection): + def generate_member( + is_primitive_type: bool, type_to_generate: type, current_seed: int, empty_collection: bool + ) -> tuple[Any, int, bool]: if is_primitive_type: generated_value = generate_value(type_to_generate, seed=current_seed, optional_is_none=optional_is_none) current_seed += 1 @@ -534,7 +536,7 @@ def generate_member(is_primitive_type, type_to_generate, current_seed, empty_col else: generated_value, current_seed, empty_collection = generate_member( is_primitive_type=member.is_primitive_type, - type_to_generate=member.type_to_generate, + type_to_generate=member.type_to_generate, # type: ignore current_seed=current_seed, empty_collection=empty_collection, ) @@ -551,8 +553,8 @@ def generate_member(is_primitive_type, type_to_generate, current_seed, empty_col current_seed += 1 else: second_generated_value, current_seed, empty_collection = generate_member( - is_primitive_type=member.second_is_primitive_type, - type_to_generate=member.second_type_to_generate, + is_primitive_type=member.second_is_primitive_type, # type: ignore + type_to_generate=member.second_type_to_generate, # type: ignore current_seed=current_seed, empty_collection=empty_collection, ) From cd776354ea48e5a8982370b15d3f4856ef873e93 Mon Sep 17 00:00:00 2001 From: Mike Turner Date: Tue, 24 Feb 2026 14:35:38 +0800 Subject: [PATCH 09/13] Replace | with Optional in type hint --- src/assertical/fake/generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/assertical/fake/generator.py b/src/assertical/fake/generator.py index 43b149b..90f45ae 100644 --- a/src/assertical/fake/generator.py +++ b/src/assertical/fake/generator.py @@ -91,7 +91,7 @@ class PropertyGenerationDetails: collection_type: Optional[CollectionType] second_type_to_generate: Optional[type] = None - second_is_primitive_type: bool | None = None + second_is_primitive_type: Optional[bool] = None @dataclass From 456ff89621a8be2540de768c1be09dbb554ddf49 Mon Sep 17 00:00:00 2001 From: Mike Turner Date: Tue, 24 Feb 2026 14:42:52 +0800 Subject: [PATCH 10/13] Remove unused import --- src/assertical/fake/generator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/assertical/fake/generator.py b/src/assertical/fake/generator.py index 90f45ae..3384527 100644 --- a/src/assertical/fake/generator.py +++ b/src/assertical/fake/generator.py @@ -11,7 +11,6 @@ Optional, TypeVar, Union, - cast, get_args, get_origin, get_type_hints, From 57bb5107ab0e3c8c2af861b2f8b275b9755a60b0 Mon Sep 17 00:00:00 2001 From: Mike Turner Date: Tue, 24 Feb 2026 14:43:18 +0800 Subject: [PATCH 11/13] Replace | with Union in type hint --- src/assertical/fake/generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/assertical/fake/generator.py b/src/assertical/fake/generator.py index 3384527..7101019 100644 --- a/src/assertical/fake/generator.py +++ b/src/assertical/fake/generator.py @@ -433,7 +433,7 @@ def generate_class_instance( # noqa: C901 _return_seed: bool = False, _visited_type_stack: Optional[list[type]] = None, **kwargs: Any, -) -> AnyType | tuple[AnyType, int]: +) -> Union[AnyType, tuple[AnyType, int]]: """Given a child class of a key to CLASS_INSTANCE_GENERATORS - generate an instance of that class with all properties being assigned unique values based off of seed. The values will match type hints From 7dcf617b67de1524e76a94ac8fad711f1f55b997 Mon Sep 17 00:00:00 2001 From: Mike Turner Date: Wed, 25 Feb 2026 10:06:27 +0800 Subject: [PATCH 12/13] Fix bug in handling of collections --- src/assertical/fake/generator.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/assertical/fake/generator.py b/src/assertical/fake/generator.py index 7101019..8d08802 100644 --- a/src/assertical/fake/generator.py +++ b/src/assertical/fake/generator.py @@ -46,6 +46,8 @@ from enum import IntEnum, auto +print("IMPORTING FROM GENERATOR.PY") + class CollectionType(IntEnum): """Describes a type of collection that can hold a type that can be generated""" @@ -58,6 +60,7 @@ class CollectionType(IntEnum): OPTIONAL_DICT = auto() +SUPPORTED_COLLECTION_TYPES = {list, dict, set} TWO_PARAMETER_COLLECTION_TYPES: set[CollectionType] = {CollectionType.OPTIONAL_DICT, CollectionType.REQUIRED_DICT} @@ -261,13 +264,10 @@ def get_generatable_class_base(t: type) -> Optional[type]: if optional_arg is not None: target_type = optional_arg - if not inspect.isclass(target_type): - origin_type = get_origin(target_type) - if inspect.isclass(origin_type): - # check for collections - if origin_type in [list, dict, set]: - return _PlaceholderCollectionBase + if get_origin(target_type) in SUPPORTED_COLLECTION_TYPES: + return _PlaceholderCollectionBase + if not inspect.isclass(target_type): return None for base_class in inspect.getmro(target_type): From 520199be71ae91e07808a380fc764a84f726da63 Mon Sep 17 00:00:00 2001 From: Mike Turner Date: Wed, 25 Feb 2026 12:05:40 +0800 Subject: [PATCH 13/13] Fix up error in dict generation and missing tests cases --- src/assertical/fake/generator.py | 24 +++++----- tests/fake/test_generator_dataclass.py | 64 ++++++++++++++++++++++++-- 2 files changed, 71 insertions(+), 17 deletions(-) diff --git a/src/assertical/fake/generator.py b/src/assertical/fake/generator.py index 8d08802..2f05ee1 100644 --- a/src/assertical/fake/generator.py +++ b/src/assertical/fake/generator.py @@ -46,8 +46,6 @@ from enum import IntEnum, auto -print("IMPORTING FROM GENERATOR.PY") - class CollectionType(IntEnum): """Describes a type of collection that can hold a type that can be generated""" @@ -94,6 +92,7 @@ class PropertyGenerationDetails: second_type_to_generate: Optional[type] = None second_is_primitive_type: Optional[bool] = None + second_is_optional: Optional[bool] = None @dataclass @@ -341,6 +340,7 @@ def enumerate_class_properties(t: type) -> Generator[PropertyGenerationDetails, is_primitive: bool = False second_type_to_generate: Optional[type] = None second_is_primitive: Optional[bool] = None + second_is_optional: Optional[bool] = None if member_name in type_hints: declared_type = type_hints[member_name] @@ -365,17 +365,11 @@ def enumerate_class_properties(t: type) -> Generator[PropertyGenerationDetails, collection_type = CollectionType.REQUIRED_DICT if collection_type is not None: - original_member_type = member_type - member_type = get_args(optional_arg_type)[0] if is_optional else get_args(member_type)[0] - optional_arg_type = get_optional_type_argument(member_type) - is_optional = optional_arg_type is not None - + # Determine second argument (if required) if collection_type in TWO_PARAMETER_COLLECTION_TYPES: - second_member_type = ( - get_args(optional_arg_type)[1] if is_optional else get_args(original_member_type)[1] - ) + second_member_type = get_args(optional_arg_type)[1] if is_optional else get_args(member_type)[1] second_optional_arg_type = get_optional_type_argument(second_member_type) - second_is_optional = optional_arg_type is not None + second_is_optional = second_optional_arg_type is not None if collection_type in (CollectionType.OPTIONAL_DICT, CollectionType.REQUIRED_DICT): if is_generatable_type(second_member_type): second_type_to_generate = get_first_generatable_primitive( @@ -390,6 +384,11 @@ def enumerate_class_properties(t: type) -> Generator[PropertyGenerationDetails, second_optional_arg_type if second_is_optional else second_member_type ) + # Determine first argument + member_type = get_args(optional_arg_type)[0] if is_optional else get_args(member_type)[0] + optional_arg_type = get_optional_type_argument(member_type) + is_optional = optional_arg_type is not None + # Work around for SQLAlchemy forward references - hopefully we don't need many of these special cases # # if we are passed a string name of a type (such as SQL Alchemy relationships are want to do) @@ -422,6 +421,7 @@ def enumerate_class_properties(t: type) -> Generator[PropertyGenerationDetails, collection_type=collection_type, second_type_to_generate=second_type_to_generate, second_is_primitive_type=second_is_primitive, + second_is_optional=second_is_optional, ) @@ -545,7 +545,7 @@ def generate_member( elif collection_type == CollectionType.REQUIRED_SET or collection_type == CollectionType.OPTIONAL_SET: values[member.name] = set([]) if empty_collection else set([generated_value]) elif collection_type == CollectionType.REQUIRED_DICT or collection_type == CollectionType.OPTIONAL_DICT: - if optional_is_none and member.is_optional: + if optional_is_none and member.second_is_optional: # In this case the parent collection is NOT able to be set to None but does support adding items # that are None - so we just add a None to the parent collection (or just generate None) second_generated_value = None diff --git a/tests/fake/test_generator_dataclass.py b/tests/fake/test_generator_dataclass.py index 465a079..2d37ef2 100644 --- a/tests/fake/test_generator_dataclass.py +++ b/tests/fake/test_generator_dataclass.py @@ -8,7 +8,7 @@ import pytest -from assertical.asserts.type import assert_list_type, assert_set_type +from assertical.asserts.type import assert_dict_type, assert_list_type, assert_set_type from assertical.fake.generator import ( CollectionType, PropertyGenerationDetails, @@ -51,10 +51,10 @@ class OptionalCollectionsClass: optional_refs_list: Optional[set[ReferenceDataclass]] optional_optional_refs: Optional[set[Optional[ReferenceDataclass]]] - # dict_ints: dict[str, int] - # dict_optional_ints: dict[str, Optional[int]] - # optional_dict: Optional[dict[str, int]] - # optional_dict_optional_ints: Optional[dict[str, Optional[int]]] + dict_ints: dict[str, int] + dict_optional_ints: dict[str, Optional[int]] + optional_dict: Optional[dict[str, int]] + optional_dict_optional_ints: Optional[dict[str, Optional[int]]] @dataclass @@ -339,6 +339,50 @@ def test_generate_kwargs(): True, CollectionType.OPTIONAL_SET, ), + PropertyGenerationDetails( + "dict_ints", + dict[str, int], + str, + True, + False, + CollectionType.REQUIRED_DICT, + int, + True, + False, + ), + PropertyGenerationDetails( + "dict_optional_ints", + dict[str, Optional[int]], + str, + True, + False, + CollectionType.REQUIRED_DICT, + int, + True, + True, + ), + PropertyGenerationDetails( + "optional_dict", + Optional[dict[str, int]], + str, + True, + False, + CollectionType.OPTIONAL_DICT, + int, + True, + False, + ), + PropertyGenerationDetails( + "optional_dict_optional_ints", + Optional[dict[str, Optional[int]]], + str, + True, + False, + CollectionType.OPTIONAL_DICT, + int, + True, + True, + ), ], ), ], @@ -386,3 +430,13 @@ def test_generate_OptionalCollectionsClass_relationships(): assert len(optional.optional_refs_vals) == 1 and list(optional.optional_refs_vals)[0] is None assert optional.optional_refs_list is None assert optional.optional_optional_refs is None + + assert_dict_type(str, int, all_set.dict_ints, count=1) + assert_dict_type(str, int, all_set.dict_optional_ints, count=1) + assert_dict_type(str, int, all_set.optional_dict, count=1) + assert_dict_type(str, int, all_set.optional_dict_optional_ints, count=1) + + assert_dict_type(str, int, optional.dict_ints, count=1) + assert len(optional.dict_optional_ints) == 1 and list(optional.dict_optional_ints.values()) == [None] + assert optional.optional_dict is None + assert optional.optional_dict_optional_ints is None