Repository: theandrelima/pydantic-serdes

Issue:
[Investigate] Custom 1:n relationship class
Given SerialBus model classes A & B.
If B has a field that holds a 1:n relationship with A, currently, the only way to implement that is through a python tuple. 
It would be better if there were a '_**OneToMany**_' class available to do that in a more explicit way. 
Such class should have a similar behavior to a tuple object: 

- immutable;
- hashable;
- sequencial;
- typed (in the sense that it would allow only a single type of objects to be stored)

_OBS: uniqueness doesn't seem to be of relevance for this particular field, since it is already ensured by the GLOBAL_DATA_STORE._


Something as simple as a direct inheritance from the `tuple` class with some tweaks to allow only a single type of objects could work.

Also, It might be worth looking at `sortedcontainers` package for an existing implementation that could fit the idea.

**_About customization_**: by the same way SerialBus allows for a custom `HashableDict` class to be passed by the user, it should allow for a custom implementation of a `OneToMany` class as well. 


The benefits of this approach:

1. **Increased explicitness**: Something named `OneToMany` immediately conveys the idea of a **1:n** relationship between two models.
2. **Automatic conversion of sequence types**: `SerialBusBaseModel._normalize_for_validations()` 'private' class method already tries its best to convert list objects to tuples when:
i - the value is a list object; AND
ii - if the key associated with the value matches a filed in the model class; AND
iii - the type associated with the field is of base type 'tuple'.

This is a little tricky and inflexible, as it's done ONLY for lists (not any other sequence type) and also makes a bunch of assumptions. 





Discussion:

Patch:
diff --git a/README.md b/README.md
index b7e6992..837955c 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
 
 pydantic-serdes is a Pydantic-based tool designed to act as a conduit between serialized data and Python objects. It 
 not only facilitates the transformation of complex serialized data into Python objects, but also ensures stringent
-data validation. Think of it as the coach that transports your data from the realm of serialization into the world
+data validation. Think of it as the wizard that transports your data from the realm of serialization into the world
 of Python, and vice-versa, all the while maintaining the integrity and structure of your data. 
 
 > *DISCLAMER*:
diff --git a/pydantic_serdes/__init__.py b/pydantic_serdes/__init__.py
index 78124f0..2b3130f 100644
--- a/pydantic_serdes/__init__.py
+++ b/pydantic_serdes/__init__.py
@@ -1,8 +1,7 @@
+from pydantic_serdes.custom_collections import OneToMany
 from pydantic_serdes.datastore import get_global_data_store
-from pydantic_serdes.models import (
-    PydanticSerdesBaseModel,
-    PydanticSerdesRenderableModel,
-)
+from pydantic_serdes.models import (PydanticSerdesBaseModel,
+                                    PydanticSerdesRenderableModel)
 from pydantic_serdes.utils import HashableDict, HashableDictType
 
 GLOBAL_DATA_STORE = get_global_data_store()
@@ -11,6 +10,7 @@ GLOBAL_DATA_STORE = get_global_data_store()
 __all__ = [
     "PydanticSerdesBaseModel",
     "PydanticSerdesRenderableModel",
+    "OneToMany",
     "HashableDict",
     "HashableDictType",
     "GLOBAL_DATA_STORE",
diff --git a/pydantic_serdes/config.py b/pydantic_serdes/config.py
index 1e6c4fa..8fecfd2 100644
--- a/pydantic_serdes/config.py
+++ b/pydantic_serdes/config.py
@@ -2,7 +2,7 @@ import importlib
 import inspect
 import os
 from types import ModuleType
-from typing import List, Dict, Type
+from typing import Dict, List, Type
 
 from pydantic_serdes.exceptions import PydanticSerdesImportError
 
@@ -101,10 +101,10 @@ class PydanticSerdesConfig:
         to the corresponding PydanticSerdesBaseModel subclass as values.
 
         Notice that this property returns self._directive_to_model_mapping, which will
-        be set once and always return that same value. 
+        be set once and always return that same value.
 
         It's only if self._directive_to_model_mapping is empty that this method will
-        try to populate it: 
+        try to populate it:
             - 1st: by importing all modules in self.models_modules; and
             - 2nd: by iterating over all subclasses of PydanticSerdesBaseModel that
               have the '_directive' field set.
diff --git a/pydantic_serdes/custom_collections.py b/pydantic_serdes/custom_collections.py
index 76aaaa0..254a902 100644
--- a/pydantic_serdes/custom_collections.py
+++ b/pydantic_serdes/custom_collections.py
@@ -1,5 +1,61 @@
+from collections.abc import Sequence
+from typing import Generic, Tuple, TypeVar
+
 from sortedcontainers import SortedSet
 
+T = TypeVar("T")
+
+
+class OneToMany(Tuple[T, ...], Generic[T]):
+    """
+    This class is provided as a means to map One-to-Many relationships between two model objects.
+
+    It is nothing but a custom tuple subclass that enforces certain constraints on its elements:
+        - The input iterable must be a sequence.
+        - The sequence must not be empty.
+        - All elements in the sequence must be of the same type.
+        - Each element in the sequence must be hashable.
+
+    If any of these constraints are not met, the class raises an appropriate error.
+
+    Raises:
+        TypeError: If the input is not a sequence, if the elements are not of the same type, or if an element
+        is not hashable.
+        ValueError: If the sequence is empty.
+
+    Note:
+        This class uses the `__new__` method for initialization, following the immutability principle of the
+        tuple superclass.
+    """
+
+    def __new__(cls, iterable):
+        if not isinstance(iterable, Sequence):
+            raise TypeError(
+                f"The OneToMany initialization iterable must be a sequence, but got {type(iterable).__name__}"
+            )
+
+        # Check if the sequence is non-empty
+        if not iterable:
+            raise ValueError("The OneToMany initialization iterable can't be empty")
+
+        # Check if all elements are of the same type
+        first_type = type(iterable[0])
+        if not all(isinstance(item, first_type) for item in iterable):
+            raise TypeError(
+                "All elements of a OneToMany initialization iterable must be of the same type"
+            )
+
+        # Check if each element in the sequence is hashable
+        for item in iterable:
+            try:
+                hash(item)
+            except TypeError:
+                raise TypeError(
+                    f"Each element in the OneToMany initialization iterable must be hashable, but {item} isn't."
+                )
+
+        return super().__new__(cls, iterable)
+
 
 class HashableDict(dict):
     """
@@ -25,7 +81,8 @@ class HashableDict(dict):
 
 
 class PydanticSerdesSortedSet(SortedSet):
-    """This class implements a custom SortedSet with the 'key' argument
+    """
+    This class implements a custom SortedSet with the 'key' argument
     set to a function that returns a model object's `key` property.
 
     NOTE: the `key` property returns a tuple containing the values associated
diff --git a/pydantic_serdes/datastore.py b/pydantic_serdes/datastore.py
index faa2223..0913066 100644
--- a/pydantic_serdes/datastore.py
+++ b/pydantic_serdes/datastore.py
@@ -4,12 +4,10 @@ from functools import cache
 from typing import TYPE_CHECKING, Any, DefaultDict, Dict, Optional, Type, Union
 
 from pydantic_serdes.custom_collections import PydanticSerdesSortedSet
-from pydantic_serdes.exceptions import (
-    DataStoreDirectAssignmentError,
-    ModelInstanceAlreadyExistsError,
-    ModelInstanceDoesNotExistError,
-    MultipleModelInstancesReturnedError,
-)
+from pydantic_serdes.exceptions import (DataStoreDirectAssignmentError,
+                                        ModelInstanceAlreadyExistsError,
+                                        ModelInstanceDoesNotExistError,
+                                        MultipleModelInstancesReturnedError)
 
 if TYPE_CHECKING:
     from pydantic_serdes.models import PydanticSerdesBaseModel
@@ -93,7 +91,9 @@ class ModelsGlobalStore:
 
         self.records[cls_name].add(obj)
 
-    def _get_cls_name(self, obj: Union[Type["PydanticSerdesBaseModel"], "PydanticSerdesBaseModel"]) -> str:
+    def _get_cls_name(
+        self, obj: Union[Type["PydanticSerdesBaseModel"], "PydanticSerdesBaseModel"]
+    ) -> str:
         """
         Returns the name of the class of the given object.
 
@@ -106,7 +106,7 @@ class ModelsGlobalStore:
         """
         if inspect.isclass(obj):
             return obj.__name__
-        
+
         return obj.__class__.__name__
 
     def _search(
@@ -116,15 +116,14 @@ class ModelsGlobalStore:
     ) -> PydanticSerdesSortedSet:
         """
         Searches the records of a given model class based on the search_params.
-        Currently, only one k:v pair is supported in the search_params.
 
         Args:
             model_class (Type["PydanticSerdesBaseModel"]): the class of the model to
             be searched.
-            search_params (Optional[Dict[Any, Any]], optional): a dictionary with
-            keys being the attributes of the model and the values being the values
-            to be searched for. If `None`, returns the entire PydanticSerdesSortedSet
-            associated with `model_class` key. Defaults to None.
+            search_params (Optional[Dict[Any, Any]]): a dictionary with keys being the
+            attributes of the model and the values being the values to be searched for.
+            If `None`, returns the entire PydanticSerdesSortedSet associated with
+            `model_class` key. Defaults to None.
 
         Returns:
             PydanticSerdesSortedSet: the PydanticSerdesSortedSet containing the records
@@ -134,7 +133,11 @@ class ModelsGlobalStore:
 
         if search_params:
             return PydanticSerdesSortedSet(
-                [x for x in self.records[cls_name] if all(getattr(x, k) == v for k, v in search_params.items())]
+                [
+                    x
+                    for x in self.records[cls_name]
+                    if all(getattr(x, k) == v for k, v in search_params.items())
+                ]
             )
 
         return self.records[cls_name]
@@ -146,8 +149,7 @@ class ModelsGlobalStore:
     ) -> PydanticSerdesSortedSet:
         """
         Avails of self._search() to filter the records of a given model class
-        based on the search_params. Currently, only one k:v pair is supported
-        in the search_params.
+        based on the search_params.
 
         Args:
             model_class (Type["PydanticSerdesBaseModel"]): the class of the
@@ -170,8 +172,6 @@ class ModelsGlobalStore:
         Avails of self.filter() method to get a single model instance from
         the global store based on the search_params.
 
-        Currently, only one k:v pair is supported in the search_params.
-
         Args:
             model_class (Type["PydanticSerdesBaseModel"]): the class of the model
             to be filtered.
diff --git a/pydantic_serdes/decorators.py b/pydantic_serdes/decorators.py
new file mode 100644
index 0000000..5dca14c
--- /dev/null
+++ b/pydantic_serdes/decorators.py
@@ -0,0 +1,113 @@
+import io
+from pathlib import Path
+from typing import Any, Callable, Dict, Optional, TextIO, Union
+
+from pydantic import field_validator
+
+from pydantic_serdes.custom_collections import OneToMany
+from pydantic_serdes.exceptions import PydanticSerdesDumperError
+
+
+def onetomany_validators(cls):
+    """
+    A decorator that adds field validators to a class for fields of type OneToMany.
+
+    This decorator iterates over the class's annotations, checking for fields that are of type OneToMany.
+    For each OneToMany field, it defines a validator function that checks if all items in the field are instances
+    of the expected type. If not, it raises a TypeError. This validator function is then added to the class
+    using the `field_validator` function from Pydantic.
+
+    Args:
+        cls (type): The class to which the validators will be added.
+
+    Returns:
+        type: The input class with added validators for OneToMany fields.
+
+    Raises:
+        TypeError: If any item in a OneToMany field is not an instance of the expected type.
+    """
+    for field_name, field_type in cls.__annotations__.items():
+        if getattr(field_type, "__origin__", None) is OneToMany:
+            expected_type = field_type.__args__[0]
+            def onetomany_validator(cls, v):
+                if not all(isinstance(item, expected_type) for item in v):
+                    raise TypeError(
+                        f"Each item in {field_name} must be a {expected_type.__name__} instance"
+                    )
+                return v
+
+            setattr(
+                cls,
+                f"validate_{field_name}",
+                field_validator(field_name)(onetomany_validator),
+            )
+
+    return cls
+
+
+def stream_dumper(dump_func: Callable[..., None]):
+    def standard_dumper_func(
+        data: Dict[Any, Any],
+        file_path: Optional[Union[Path, str]] = None,
+        *args,
+        **kwargs,
+    ) -> str:
+        """
+        Decorator to dump data to a stream and optionally to a file.
+
+        Args:
+            data (Dict[Any, Any]): The data to be written to the serialized stream.
+            file_path (Optional[Union[Path, str]], optional): The path to the
+            file where the serialized data will be written. If provided, the data
+            will be written to the specified file. If not provided, the data
+            will only be written to the stream. Defaults to None.
+
+        Returns:
+            str: The string containing the written serialized data.
+
+        Raises:
+            PydanticSerdesDumperError: If any exception occurs while dumping the data.
+        """
+        try:
+            stream = io.StringIO()
+            dump_func(data, stream, *args, **kwargs)
+
+            if file_path is not None:
+                with open(file_path, "w") as file:
+                    file.write(stream.getvalue())
+
+            return stream.getvalue()
+        except Exception as e:
+            raise PydanticSerdesDumperError(str(e))
+
+    return standard_dumper_func
+
+
+def stream_loader(load_func: Callable[..., Dict]):
+    def standard_loader_func(source: Union[Path, str, TextIO], *args, **kwargs) -> dict:
+        """
+        This decorator wraps a loader function to allow it to handle different types of input sources.
+        The input source can be a file path (as a string or Path object) or a TextIO object.
+        If the source is a file path, the decorator opens the file and passes the file stream to the
+        loader function. If the source is a TextIO object, it is passed directly to the loader function.
+
+        Args:
+            source (Union[Path, str, TextIO]): The source of the data to be loaded. This can be a file path (as a
+            string or Path object), in which case the file will be opened and its contents passed to `load_func`.
+            It can also be a TextIO object, in which case it will be passed directly to `load_func`.
+            *args: Variable length argument list to be passed to `load_func`.
+            **kwargs: Arbitrary keyword arguments to be passed to `load_func`.
+
+        Returns:
+            Callable: The decorated loader function.
+        """
+
+        if isinstance(source, (str, Path)):
+            with open(source, "r") as stream:
+                dictionary = load_func(stream, *args, **kwargs)
+        else:
+            dictionary = load_func(source, *args, **kwargs)
+
+        return dictionary
+
+    return standard_loader_func
diff --git a/pydantic_serdes/dumpers.py b/pydantic_serdes/dumpers.py
index fd2cf1f..79d6986 100644
--- a/pydantic_serdes/dumpers.py
+++ b/pydantic_serdes/dumpers.py
@@ -18,81 +18,34 @@ for the desired serialization format.
 import configparser
 import io
 import json
-from pathlib import Path
-from typing import Any, Callable, Dict, Optional, Union
+from typing import Any, Dict
 
 import toml
 import yaml
 
-from pydantic_serdes.exceptions import PydanticSerdesDumperError
-
-
-def stream_dumper(dump_func: Callable[..., None]):
-    def standard_dumper_func(
-        data: Dict[Any, Any],
-        file_path: Optional[Union[Path, str]] = None,
-        *args,
-        **kwargs
-    ) -> str:
-        """
-        Decorator to dump data to a stream and optionally to a file.
-
-        Args:
-            data (Dict[Any, Any]): The data to be written to the serialized stream.
-            file_path (Optional[Union[Path, str]], optional): The path to the
-            file where the serialized data will be written. If provided, the data
-            will be written to the specified file. If not provided, the data
-            will only be written to the stream. Defaults to None.
-
-        Returns:
-            str: The string containing the written serialized data.
-
-        Raises:
-            PydanticSerdesDumperError: If any exception occurs while dumping the data.
-        """
-        try:
-            stream = io.StringIO()
-            dump_func(data, stream, *args, **kwargs)
-
-            if file_path is not None:
-                with open(file_path, "w") as file:
-                    file.write(stream.getvalue())
-
-            return stream.getvalue()
-        except Exception as e:
-            raise PydanticSerdesDumperError(str(e))
-
-    return standard_dumper_func
+from pydantic_serdes.decorators import stream_dumper
 
 
 @stream_dumper
-def json_dumper(
-    data: Dict[Any, Any], stream: io.StringIO, *args, **kwargs
-) -> str:
+def json_dumper(data: Dict[Any, Any], stream: io.StringIO, *args, **kwargs) -> str:
     """Writes data to a JSON stream and optionally to a file."""
     json.dump(data, stream, *args, **kwargs)
 
 
 @stream_dumper
-def yaml_dumper(
-    data: Dict[Any, Any], stream: io.StringIO, *args, **kwargs
-) -> str:
+def yaml_dumper(data: Dict[Any, Any], stream: io.StringIO, *args, **kwargs) -> str:
     """Writes data to a YAML stream and optionally to a file."""
     yaml.dump(data, stream, *args, **kwargs)
 
 
 @stream_dumper
-def yml_dumper(
-    data: Dict[Any, Any], stream: io.StringIO, *args, **kwargs
-) -> str:
+def yml_dumper(data: Dict[Any, Any], stream: io.StringIO, *args, **kwargs) -> str:
     """Writes data to a YAML stream and optionally to a file."""
     yaml.dump(data, stream, *args, **kwargs)
 
 
 @stream_dumper
-def ini_dumper(
-    data: Dict[Any, Any], stream: io.StringIO, *args, **kwargs
-) -> str:
+def ini_dumper(data: Dict[Any, Any], stream: io.StringIO, *args, **kwargs) -> str:
     """Writes data to an INI stream and optionally to a file."""
     config = configparser.ConfigParser()
     config.read_dict(data)
@@ -100,8 +53,6 @@ def ini_dumper(
 
 
 @stream_dumper
-def toml_dumper(
-    data: Dict[Any, Any], stream: io.StringIO, *args, **kwargs
-) -> str:
+def toml_dumper(data: Dict[Any, Any], stream: io.StringIO, *args, **kwargs) -> str:
     """Writes data to a TOML stream and optionally to a file."""
     toml.dump(data, stream, *args, **kwargs)
diff --git a/pydantic_serdes/exceptions.py b/pydantic_serdes/exceptions.py
index 44ed812..8b5a6a6 100644
--- a/pydantic_serdes/exceptions.py
+++ b/pydantic_serdes/exceptions.py
@@ -32,6 +32,7 @@ class ModelInstanceDoesNotExistError(PydanticSerdesBaseException):
 class ModelInstanceAlreadyExistsError(PydanticSerdesBaseException):
     """Model already exists in the datastore."""
 
+
 class MultipleModelInstancesReturnedError(PydanticSerdesBaseException):
     """More than one model instance found in the datastore matching search criteria, when only one should."""
 
diff --git a/pydantic_serdes/loaders.py b/pydantic_serdes/loaders.py
index 2447741..9a15eb5 100644
--- a/pydantic_serdes/loaders.py
+++ b/pydantic_serdes/loaders.py
@@ -24,50 +24,12 @@ data itself. After parsing that data, your function must return a dictionary.
 import configparser
 import json
 from pathlib import Path
-from typing import Callable, Dict, Union, TextIO
+from typing import TextIO, Union
 
 import toml
 import yaml
 
-
-def stream_loader(load_func: Callable[..., Dict]):
-    """
-    This decorator wraps a loader function to allow it to handle different types of input sources.
-
-    Args:
-        load_func (Callable[..., Dict]): The function that will be used to load and parse the data.
-        This function should take a TextIO object as its first argument, which represents the stream of data to be
-        loaded.
-        It may also accept additional arguments, which will be passed through from the `standard_loader_func`.
-        The function should return a dictionary representing the parsed data.
-    """
-    def standard_loader_func(source: Union[Path, str, TextIO], *args, **kwargs) -> dict:
-        """
-        This decorator wraps a loader function to allow it to handle different types of input sources.
-        The input source can be a file path (as a string or Path object) or a TextIO object.
-        If the source is a file path, the decorator opens the file and passes the file stream to the
-        loader function. If the source is a TextIO object, it is passed directly to the loader function.
-
-        Args:
-            source (Union[Path, str, TextIO]): The source of the data to be loaded. This can be a file path (as a
-            string or Path object), in which case the file will be opened and its contents passed to `load_func`.
-            It can also be a TextIO object, in which case it will be passed directly to `load_func`.
-            *args: Variable length argument list to be passed to `load_func`.
-            **kwargs: Arbitrary keyword arguments to be passed to `load_func`.
-
-        Returns:
-            Callable: The decorated loader function.
-        """
-
-        if isinstance(source, (str, Path)):
-            with open(source, "r") as stream:
-                dictionary = load_func(stream, *args, **kwargs)
-        else:
-            dictionary = load_func(source, *args, **kwargs)
-
-        return dictionary
-
-    return standard_loader_func
+from pydantic_serdes.decorators import stream_loader
 
 
 @stream_loader
diff --git a/pydantic_serdes/models.py b/pydantic_serdes/models.py
index 62d8b38..4f1ffe4 100644
--- a/pydantic_serdes/models.py
+++ b/pydantic_serdes/models.py
@@ -1,28 +1,25 @@
 import re
-from functools import total_ordering
-from typing import Any, Dict, List, Optional, Tuple, Union, ClassVar
+from collections.abc import Iterable
+from typing import Any, ClassVar, Dict, List, Optional, Tuple, Union
 
 from jinja2 import Environment, FileSystemLoader, Template
 from jinja2.exceptions import TemplateNotFound
 from pydantic import BaseModel, ConfigDict
 
 from pydantic_serdes.config import get_config
-from pydantic_serdes.datastore import (
-    PydanticSerdesSortedSet,
-    ModelsGlobalStore,
-    get_global_data_store,
-)
-from pydantic_serdes.exceptions import (
-    PydanticSerdesTypeError,
-    ModelInitializationError,
-    RenderableTemplateError,
-)
+from pydantic_serdes.custom_collections import OneToMany
+from pydantic_serdes.datastore import (ModelsGlobalStore,
+                                       PydanticSerdesSortedSet,
+                                       get_global_data_store)
+from pydantic_serdes.decorators import onetomany_validators
+from pydantic_serdes.exceptions import (ModelInitializationError,
+                                        PydanticSerdesTypeError,
+                                        RenderableTemplateError)
 from pydantic_serdes.utils import convert_dict_to_hashabledict
 
 GLOBAL_CONFIGS = get_config()
 
 
-@total_ordering
 class PydanticSerdesBaseModel(BaseModel):
     """A pydantic BaseModel class that provides the basic
     functionality for all pydantic-serdes Models classes.
@@ -31,9 +28,6 @@ class PydanticSerdesBaseModel(BaseModel):
     .create_from_loaded_data() or .create() class methods provided.
     Instantiation by using __init__() directly is discouraged.
 
-    This class implements functools.total_ordering to allow its instances
-    to be sorted accordingly inside a datastore.PydanticSerdesSortedSet.
-
     Because datastore.PydanticSerdesSortedSet is a python Set, all
     pydantic-serdes models MUST be hashable. This has a twofold consequence:
         - Model classes must implement the __hash__() method;
@@ -82,6 +76,9 @@ class PydanticSerdesBaseModel(BaseModel):
         super().__init_subclass__(**kwargs)
         cls._subclasses.append(cls)
 
+        # Ensure 'onetomany_validators' decorator will be applied to all subclasses
+        onetomany_validators(cls)
+
     def __hash__(self):
         return hash((type(self),) + tuple(self.__dict__.values()))
 
@@ -90,7 +87,7 @@ class PydanticSerdesBaseModel(BaseModel):
 
     def __eq__(self, other):
         return str(self) == str(other)
-    
+
     @classmethod
     def _normalize_for_validations(cls, dict_args: Dict[str, Any]) -> Dict[str, Any]:
         """
@@ -98,10 +95,10 @@ class PydanticSerdesBaseModel(BaseModel):
             1 - the value is a list object; AND
             2 - if the key associated with the value matches a filed in the model class; AND
             3 - the type associated with the field is of base type 'tuple'.
-        
+
         This is done in the spirit of best-effort to ensure the hash-ability of the model
         instance in a seamless way. Although this helps, ideally, users should
-        guarantee this by themselves by passing tuples instead of lists when the field of 
+        guarantee this by themselves by passing tuples instead of lists when the field of
         the model class is of type tuple.
 
         TODO: expand on this to allow greater flexibility. For now, it's just a
@@ -116,13 +113,18 @@ class PydanticSerdesBaseModel(BaseModel):
         converted_dict = {}
 
         for key, value in dict_args.items():
-            if isinstance(value, list) and key in cls.__annotations__ and getattr(cls.__annotations__[key], '__origin__', None) is tuple:
-                value = tuple(value)
-                    
+            if (
+                isinstance(value, Iterable)
+                and not isinstance(value, OneToMany)
+                and key in cls.__annotations__
+                and getattr(cls.__annotations__[key], "__origin__", None) is OneToMany
+            ):
+                value = OneToMany(value)
+
             converted_dict[key] = value
 
         return converted_dict
-    
+
     @property
     def directive(self) -> str:
         """
diff --git a/pydantic_serdes/utils.py b/pydantic_serdes/utils.py
index 5e624ed..26bc882 100644
--- a/pydantic_serdes/utils.py
+++ b/pydantic_serdes/utils.py
@@ -1,12 +1,11 @@
 import importlib
 from pathlib import Path
 from typing import TYPE_CHECKING, Optional, Type, TypeVar, Union
+
 from pydantic_serdes.config import get_config
-from pydantic_serdes.exceptions import (
-    PydanticSerdesImportError,
-    PydanticSerdesTypeError,
-    UnsupportedFileFormatError,
-)
+from pydantic_serdes.exceptions import (PydanticSerdesImportError,
+                                        PydanticSerdesTypeError,
+                                        UnsupportedFileFormatError)
 
 GLOBAL_CONFIGS = get_config()
 
@@ -41,7 +40,7 @@ def check_support_by_extension(file_path: Path) -> bool:
 
     Args:
         file_path (Path): the path to the file to be checked.
-    
+
     Raises:
         PydanticSerdesTypeError: If `file_path` does not represent a file.
 
@@ -219,9 +218,9 @@ def generate_from_file(file_path: Union[str, Path]) -> None:
         3 - create the models from the processed data using `create_all_models_from_dict`
 
     Args:
-        file_path (Union[str, Path]): The Path object or a str that can be converted to a 
-        Path object to the file containing serialized data to be loaded and parsed into a 
-        dictionary. 
+        file_path (Union[str, Path]): The Path object or a str that can be converted to a
+        Path object to the file containing serialized data to be loaded and parsed into a
+        dictionary.
     """
     loaded_data = load_file_to_dict(file_path)
     generate_from_dict(loaded_dict=loaded_data)


Test patch:
diff --git a/tests/models.py b/tests/models.py
index 198150d..289a97f 100644
--- a/tests/models.py
+++ b/tests/models.py
@@ -1,12 +1,16 @@
-from typing import Tuple
 from pydantic import EmailStr, Field
-from pydantic_serdes.models import PydanticSerdesBaseModel, PydanticSerdesRenderableModel
+
+from pydantic_serdes import OneToMany
+from pydantic_serdes.models import (PydanticSerdesBaseModel,
+                                    PydanticSerdesRenderableModel)
 
 
 class ProductModel(PydanticSerdesBaseModel):
     _key = ("prod_id",)  # fields that should be used to uniquely ID a model instance
     _directive = "products"  # yaml key that indicates data for instances of this model
-    _err_on_duplicate = True  # if data leading to duplicates are present, should we error?
+    _err_on_duplicate = (
+        True  # if data leading to duplicates are present, should we error?
+    )
 
     prod_id: str
     name: str
@@ -22,7 +26,7 @@ class CustomerModel(PydanticSerdesRenderableModel):
     age: int = Field(ge=18, le=100)
     send_ads: bool
     email: EmailStr
-    flagged_interests: Tuple[ProductModel, ...]
+    flagged_interests: OneToMany[ProductModel, ...]
 
     @classmethod
     def create(cls, dict_args, *args, **kwargs):
@@ -31,7 +35,8 @@ class CustomerModel(PydanticSerdesRenderableModel):
         for category in dict_args["flagged_interests"]:
             interests += [prod for prod in ProductModel.filter({"category": category})]
 
-        # pydantic_serdes will take care of ultimately converting this to a tuple.
+        # even though `interestes` is a list object, pydantic_serdes will
+        # take care of ultimately converting this to a OneToMany object.
         dict_args["flagged_interests"] = interests
 
         super().create(dict_args, *args, **kwargs)
diff --git a/tests/test_data_conversion.py b/tests/test_data_conversion.py
index d6ac77a..396a5c6 100644
--- a/tests/test_data_conversion.py
+++ b/tests/test_data_conversion.py
@@ -1,8 +1,10 @@
 import os
+
 import pytest
-from pydantic_serdes.utils import convert_src_file_to
+
 from pydantic_serdes.config import get_config
 from pydantic_serdes.exceptions import PydanticSerdesDumperError
+from pydantic_serdes.utils import convert_src_file_to
 
 # Define the supported formats
 CONFIG = get_config()
@@ -25,12 +27,16 @@ def dst_file(request):
 def test_format_conversion(src_file, dst_file):
     if src_file != dst_file:
         # Convert the source file to the destination format
-        dst_format = os.path.splitext(dst_file)[1].lstrip('.')
-        if dst_format == 'ini':
+        dst_format = os.path.splitext(dst_file)[1].lstrip(".")
+        if dst_format == "ini":
             with pytest.raises(PydanticSerdesDumperError):
-                convert_src_file_to(src_file=src_file, dst_format=dst_format, dst_file=dst_file)
+                convert_src_file_to(
+                    src_file=src_file, dst_format=dst_format, dst_file=dst_file
+                )
         else:
-            convert_src_file_to(src_file=src_file, dst_format=dst_format, dst_file=dst_file)
+            convert_src_file_to(
+                src_file=src_file, dst_format=dst_format, dst_file=dst_file
+            )
 
             # Compare the created file with the expected file
             with open(dst_file, "r") as f_created:
diff --git a/tests/test_models.py b/tests/test_models.py
index c32c2de..0c06fe2 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -1,14 +1,16 @@
 import os
+
 import pytest
 
+from pydantic_serdes import GLOBAL_DATA_STORE as data_store
+from pydantic_serdes.utils import generate_from_file, load_file_to_dict
 # Because one of the tests will need access to CustomerModel class,
 # it's imported here. And because it's imported before we ever try to
 # create any model instance, the `MODELS_MODULES` env var doesn't need
 # to be set.
 from tests.models import CustomerModel
-from pydantic_serdes import GLOBAL_DATA_STORE as data_store
-from pydantic_serdes.utils import generate_from_file, load_file_to_dict
-from tests.utils import get_customer_model_records_for_assertion, get_customer_data_for_assertion
+from tests.utils import (get_customer_data_for_assertion,
+                         get_customer_model_records_for_assertion)
 
 
 ### FIXTURES ###
@@ -44,12 +46,12 @@ def rendered_customers_txt_file():
 
 @pytest.fixture
 def products_data(products_yaml_file):
-    return load_file_to_dict(products_yaml_file)['products']
+    return load_file_to_dict(products_yaml_file)["products"]
 
 
 @pytest.fixture
 def customers_data(customers_json_file):
-    return load_file_to_dict(customers_json_file)['customers']
+    return load_file_to_dict(customers_json_file)["customers"]
 
 
 ### TESTS ###
@@ -65,15 +67,23 @@ def test_generate_models(products_yaml_file, customers_json_file):
 
 
 def test_ds_records_keys():
-    assert len(data_store.records.keys()) == 2, "data_store.records should have exactly two keys"
-    assert 'ProductModel' in data_store.records, "data_store.records should contain a key named 'ProductModel'"
-    assert 'CustomerModel' in data_store.records, "data_store.records should contain a key named 'CustomerModel'"
+    assert (
+        len(data_store.records.keys()) == 2
+    ), "data_store.records should have exactly two keys"
+    assert (
+        "ProductModel" in data_store.records
+    ), "data_store.records should contain a key named 'ProductModel'"
+    assert (
+        "CustomerModel" in data_store.records
+    ), "data_store.records should contain a key named 'CustomerModel'"
 
 
 def test_ds_records_values(products_data, customers_data):
-    assert data_store.as_dict()['ProductModel'] == products_data
+    assert data_store.as_dict()["ProductModel"] == products_data
 
-    _customer_records_for_assertion = get_customer_model_records_for_assertion(data_store.as_dict()['CustomerModel'])
+    _customer_records_for_assertion = get_customer_model_records_for_assertion(
+        data_store.as_dict()["CustomerModel"]
+    )
     _customer_data = get_customer_data_for_assertion(customers_data)
     assert len(_customer_records_for_assertion) == len(_customer_data)
 
@@ -84,11 +94,13 @@ def test_ds_records_values(products_data, customers_data):
 def test_rendering(rendered_customers_txt_file):
     os.environ["TEMPLATES_DIR"] = "tests/templates"
 
-    rendered_str = ''
+    rendered_str = ""
 
     for customer in CustomerModel.get_all():
         rendered_str += f"Rendered template for customer: {customer.name}:\n"
-        rendered_str += (customer.get_rendered_str() or "Customer doesn't have send_ads set to True")
+        rendered_str += (
+            customer.get_rendered_str() or "Customer doesn't have send_ads set to True"
+        )
         rendered_str += f"\n{'-' * 50}\n"
 
     with open(rendered_customers_txt_file, "r") as f:
diff --git a/tests/utils.py b/tests/utils.py
index 1739092..d7e817a 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -15,7 +15,8 @@ def sort_lists_in_dict(d):
 def get_customer_model_records_for_assertion(customer_model_records):
     for customer_dict in customer_model_records:
         customer_dict["flagged_interests"] = list(
-            {interest['category'] for interest in customer_dict["flagged_interests"]})
+            {interest["category"] for interest in customer_dict["flagged_interests"]}
+        )
         sort_lists_in_dict(customer_dict)
 
     return customer_model_records