Repository: deepset-ai/haystack

Issue:
Make `DeserializationError` error message more descriptive
When creating a new custom component that uses a Haystack `Secret` in it's init method I forgot to create explicit `to_dict` and `from_dict` methods to handle the serialization of the secret. For example, 
```python
@component
class CustomComponent:
    def __init__(self, api_key: Secret = Secret.from_env_var("API_KEY")):
        self.api_key = api_key

    @component.output_types(answers=List[GeneratedAnswer])
    def run(self, query: str) -> Dict[str, List[GeneratedAnswer]]:
        pass
```
then when loading this component from a yaml file definition of a pipeline I got this error message
```bash
    raise DeserializationError(
haystack.core.errors.DeserializationError: Error while unmarshalling serialized pipeline data. This is usually caused by malformed or invalid syntax in the serialized representation.
```
Would it be possible to include in the `DeserializationError` error message some additional information like the name of the Component that failed to deserialize?

I realize that in the stack trace I get more info like this 
```bash
yaml.constructor.ConstructorError: could not determine a constructor for the tag 'tag:yaml.org,2002:python/object:haystack.utils.auth.EnvVarSecret'
  in "<unicode string>", line 4, column 16:
          api_key: !!python/object:haystack.utils.a ... 
```
but this doesn't tell me which component caused the issue which would be helpful.

I simulated this error message by doing this
```python
cc = CustomComponent()
pipe = Pipeline()
pipe.add_component("custom", cc)
test = Pipeline().loads(pipe.dumps())
```

Discussion:

Solution patch:
diff --git a/haystack/components/generators/openai.py b/haystack/components/generators/openai.py
index 83f1a02d96..48180fc321 100644
--- a/haystack/components/generators/openai.py
+++ b/haystack/components/generators/openai.py
@@ -170,6 +170,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "OpenAIGenerator":
     def run(
         self,
         prompt: str,
+        system_prompt: Optional[str] = None,
         streaming_callback: Optional[Callable[[StreamingChunk], None]] = None,
         generation_kwargs: Optional[Dict[str, Any]] = None,
     ):
@@ -178,6 +179,9 @@ def run(
 
         :param prompt:
             The string prompt to use for text generation.
+        :param system_prompt:
+            The system prompt to use for text generation. If this run time system prompt is omitted, the system
+            prompt, if defined at initialisation time, is used.
         :param streaming_callback:
             A callback function that is called when a new token is received from the stream.
         :param generation_kwargs:
@@ -189,7 +193,9 @@ def run(
         for each response.
         """
         message = ChatMessage.from_user(prompt)
-        if self.system_prompt:
+        if system_prompt is not None:
+            messages = [ChatMessage.from_system(system_prompt), message]
+        elif self.system_prompt:
             messages = [ChatMessage.from_system(self.system_prompt), message]
         else:
             messages = [message]
@@ -237,7 +243,8 @@ def run(
             "meta": [message.meta for message in completions],
         }
 
-    def _connect_chunks(self, chunk: Any, chunks: List[StreamingChunk]) -> ChatMessage:
+    @staticmethod
+    def _connect_chunks(chunk: Any, chunks: List[StreamingChunk]) -> ChatMessage:
         """
         Connects the streaming chunks into a single ChatMessage.
         """
@@ -252,7 +259,8 @@ def _connect_chunks(self, chunk: Any, chunks: List[StreamingChunk]) -> ChatMessa
         )
         return complete_response
 
-    def _build_message(self, completion: Any, choice: Any) -> ChatMessage:
+    @staticmethod
+    def _build_message(completion: Any, choice: Any) -> ChatMessage:
         """
         Converts the response from the OpenAI API to a ChatMessage.
 
@@ -276,7 +284,8 @@ def _build_message(self, completion: Any, choice: Any) -> ChatMessage:
         )
         return chat_message
 
-    def _build_chunk(self, chunk: Any) -> StreamingChunk:
+    @staticmethod
+    def _build_chunk(chunk: Any) -> StreamingChunk:
         """
         Converts the response from the OpenAI API to a StreamingChunk.
 
@@ -293,7 +302,8 @@ def _build_chunk(self, chunk: Any) -> StreamingChunk:
         chunk_message.meta.update({"model": chunk.model, "index": choice.index, "finish_reason": choice.finish_reason})
         return chunk_message
 
-    def _check_finish_reason(self, message: ChatMessage) -> None:
+    @staticmethod
+    def _check_finish_reason(message: ChatMessage) -> None:
         """
         Check the `finish_reason` returned with the OpenAI completions.
 
diff --git a/haystack/components/retrievers/sentence_window_retriever.py b/haystack/components/retrievers/sentence_window_retriever.py
index abc68850c1..fbc389ed3e 100644
--- a/haystack/components/retrievers/sentence_window_retriever.py
+++ b/haystack/components/retrievers/sentence_window_retriever.py
@@ -2,7 +2,7 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 
-from typing import Any, Dict, List
+from typing import Any, Dict, List, Optional
 
 from haystack import Document, component, default_from_dict, default_to_dict
 from haystack.document_stores.types import DocumentStore
@@ -84,7 +84,7 @@ def __init__(self, document_store: DocumentStore, window_size: int = 3):
 
         :param document_store: The Document Store to retrieve the surrounding documents from.
         :param window_size: The number of documents to retrieve before and after the relevant one.
-        For example, `window_size: 2` fetches 2 preceding and 2 following documents.
+                For example, `window_size: 2` fetches 2 preceding and 2 following documents.
         """
         if window_size < 1:
             raise ValueError("The window_size parameter must be greater than 0.")
@@ -144,7 +144,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "SentenceWindowRetriever":
         return default_from_dict(cls, data)
 
     @component.output_types(context_windows=List[str], context_documents=List[List[Document]])
-    def run(self, retrieved_documents: List[Document]):
+    def run(self, retrieved_documents: List[Document], window_size: Optional[int] = None):
         """
         Based on the `source_id` and on the `doc.meta['split_id']` get surrounding documents from the document store.
 
@@ -152,6 +152,8 @@ def run(self, retrieved_documents: List[Document]):
         document from the document store.
 
         :param retrieved_documents: List of retrieved documents from the previous retriever.
+        :param window_size: The number of documents to retrieve before and after the relevant one. This will overwrite
+                            the `window_size` parameter set in the constructor.
         :returns:
             A dictionary with the following keys:
                 - `context_windows`: A list of strings, where each string represents the concatenated text from the
@@ -161,6 +163,10 @@ def run(self, retrieved_documents: List[Document]):
                                      `retrieved_documents`.
 
         """
+        window_size = window_size or self.window_size
+
+        if window_size < 1:
+            raise ValueError("The window_size parameter must be greater than 0.")
 
         if not all("split_id" in doc.meta for doc in retrieved_documents):
             raise ValueError("The retrieved documents must have 'split_id' in the metadata.")
@@ -173,8 +179,8 @@ def run(self, retrieved_documents: List[Document]):
         for doc in retrieved_documents:
             source_id = doc.meta["source_id"]
             split_id = doc.meta["split_id"]
-            min_before = min(list(range(split_id - 1, split_id - self.window_size - 1, -1)))
-            max_after = max(list(range(split_id + 1, split_id + self.window_size + 1, 1)))
+            min_before = min(list(range(split_id - 1, split_id - window_size - 1, -1)))
+            max_after = max(list(range(split_id + 1, split_id + window_size + 1, 1)))
             context_docs = self.document_store.filter_documents(
                 {
                     "operator": "AND",
diff --git a/haystack/core/pipeline/base.py b/haystack/core/pipeline/base.py
index c31f02ba64..3d1b1c0bba 100644
--- a/haystack/core/pipeline/base.py
+++ b/haystack/core/pipeline/base.py
@@ -111,7 +111,7 @@ def to_dict(self) -> Dict[str, Any]:
         """
         components = {}
         for name, instance in self.graph.nodes(data="instance"):  # type:ignore
-            components[name] = component_to_dict(instance)
+            components[name] = component_to_dict(instance, name)
 
         connections = []
         for sender, receiver, edge_data in self.graph.edges.data():
diff --git a/haystack/core/serialization.py b/haystack/core/serialization.py
index 15cff1e2c9..3477f0c806 100644
--- a/haystack/core/serialization.py
+++ b/haystack/core/serialization.py
@@ -6,7 +6,7 @@
 from collections.abc import Callable
 from dataclasses import dataclass
 from importlib import import_module
-from typing import Any, Dict, Optional, Type
+from typing import Any, Dict, Iterable, Optional, Type
 
 from haystack.core.component.component import _hook_component_init, logger
 from haystack.core.errors import DeserializationError, SerializationError
@@ -30,7 +30,7 @@ class DeserializationCallbacks:
     component_pre_init: Optional[Callable] = None
 
 
-def component_to_dict(obj: Any) -> Dict[str, Any]:
+def component_to_dict(obj: Any, name: str) -> Dict[str, Any]:
     """
     Converts a component instance into a dictionary.
 
@@ -38,37 +38,82 @@ def component_to_dict(obj: Any) -> Dict[str, Any]:
 
     :param obj:
         The component to be serialized.
+    :param name:
+        The name of the component.
     :returns:
         A dictionary representation of the component.
 
     :raises SerializationError:
-        If the component doesn't have a `to_dict` method and the values of the init parameters can't be determined.
+        If the component doesn't have a `to_dict` method.
+        If the values of the init parameters can't be determined.
+        If a non-basic Python type is used in the serialized data.
     """
     if hasattr(obj, "to_dict"):
-        return obj.to_dict()
-
-    init_parameters = {}
-    for name, param in inspect.signature(obj.__init__).parameters.items():
-        # Ignore `args` and `kwargs`, used by the default constructor
-        if name in ("args", "kwargs"):
-            continue
-        try:
-            # This only works if the Component constructor assigns the init
-            # parameter to an instance variable or property with the same name
-            param_value = getattr(obj, name)
-        except AttributeError as e:
-            # If the parameter doesn't have a default value, raise an error
-            if param.default is param.empty:
+        data = obj.to_dict()
+    else:
+        init_parameters = {}
+        for param_name, param in inspect.signature(obj.__init__).parameters.items():
+            # Ignore `args` and `kwargs`, used by the default constructor
+            if param_name in ("args", "kwargs"):
+                continue
+            try:
+                # This only works if the Component constructor assigns the init
+                # parameter to an instance variable or property with the same name
+                param_value = getattr(obj, param_name)
+            except AttributeError as e:
+                # If the parameter doesn't have a default value, raise an error
+                if param.default is param.empty:
+                    raise SerializationError(
+                        f"Cannot determine the value of the init parameter '{param_name}' "
+                        f"for the class {obj.__class__.__name__}."
+                        f"You can fix this error by assigning 'self.{param_name} = {param_name}' or adding a "
+                        f"custom serialization method 'to_dict' to the class."
+                    ) from e
+                # In case the init parameter was not assigned, we use the default value
+                param_value = param.default
+            init_parameters[param_name] = param_value
+
+        data = default_to_dict(obj, **init_parameters)
+
+    _validate_component_to_dict_output(obj, name, data)
+    return data
+
+
+def _validate_component_to_dict_output(component: Any, name: str, data: Dict[str, Any]) -> None:
+    # Ensure that only basic Python types are used in the serde data.
+    def is_allowed_type(obj: Any) -> bool:
+        return isinstance(obj, (str, int, float, bool, list, dict, set, tuple, type(None)))
+
+    def check_iterable(l: Iterable[Any]):
+        for v in l:
+            if not is_allowed_type(v):
+                raise SerializationError(
+                    f"Component '{name}' of type '{type(component).__name__}' has an unsupported value "
+                    f"of type '{type(v).__name__}' in the serialized data."
+                )
+            if isinstance(v, (list, set, tuple)):
+                check_iterable(v)
+            elif isinstance(v, dict):
+                check_dict(v)
+
+    def check_dict(d: Dict[str, Any]):
+        if any(not isinstance(k, str) for k in data.keys()):
+            raise SerializationError(
+                f"Component '{name}' of type '{type(component).__name__}' has a non-string key in the serialized data."
+            )
+
+        for k, v in d.items():
+            if not is_allowed_type(v):
                 raise SerializationError(
-                    f"Cannot determine the value of the init parameter '{name}' for the class {obj.__class__.__name__}."
-                    f"You can fix this error by assigning 'self.{name} = {name}' or adding a "
-                    f"custom serialization method 'to_dict' to the class."
-                ) from e
-            # In case the init parameter was not assigned, we use the default value
-            param_value = param.default
-        init_parameters[name] = param_value
-
-    return default_to_dict(obj, **init_parameters)
+                    f"Component '{name}' of type '{type(component).__name__}' has an unsupported value "
+                    f"of type '{type(v).__name__}' in the serialized data under key '{k}'."
+                )
+            if isinstance(v, (list, set, tuple)):
+                check_iterable(v)
+            elif isinstance(v, dict):
+                check_dict(v)
+
+    check_dict(data)
 
 
 def generate_qualified_class_name(cls: Type[object]) -> str:
diff --git a/haystack/marshal/yaml.py b/haystack/marshal/yaml.py
index c25498a3c5..615cd1916a 100644
--- a/haystack/marshal/yaml.py
+++ b/haystack/marshal/yaml.py
@@ -14,14 +14,33 @@ def construct_python_tuple(self, node: yaml.SequenceNode):
         return tuple(self.construct_sequence(node))
 
 
+class YamlDumper(yaml.SafeDumper):  # pylint: disable=too-many-ancestors
+    def represent_tuple(self, data: tuple):
+        """Represent a Python tuple."""
+        return self.represent_sequence("tag:yaml.org,2002:python/tuple", data)
+
+
+YamlDumper.add_representer(tuple, YamlDumper.represent_tuple)
 YamlLoader.add_constructor("tag:yaml.org,2002:python/tuple", YamlLoader.construct_python_tuple)
 
 
 class YamlMarshaller:
     def marshal(self, dict_: Dict[str, Any]) -> str:
         """Return a YAML representation of the given dictionary."""
-        return yaml.dump(dict_)
+        try:
+            return yaml.dump(dict_, Dumper=YamlDumper)
+        except yaml.representer.RepresenterError as e:
+            raise TypeError(
+                "Error dumping pipeline to YAML - Ensure that all pipeline "
+                "components only serialize basic Python types"
+            ) from e
 
     def unmarshal(self, data_: Union[str, bytes, bytearray]) -> Dict[str, Any]:
         """Return a dictionary from the given YAML data."""
-        return yaml.load(data_, Loader=YamlLoader)
+        try:
+            return yaml.load(data_, Loader=YamlLoader)
+        except yaml.constructor.ConstructorError as e:
+            raise TypeError(
+                "Error loading pipeline from YAML - Ensure that all pipeline "
+                "components only serialize basic Python types"
+            ) from e
diff --git a/pyproject.toml b/pyproject.toml
index 0959ed2a64..c53b77939a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -116,7 +116,7 @@ extra-dependencies = [
 
   # Tracing
   "opentelemetry-sdk",
-  "ddtrace",
+  "ddtrace==2.15.0rc2",
 
   # Structured logging
   "structlog",
@@ -138,7 +138,7 @@ extra-dependencies = [
   "python-multipart",
   "psutil",
   "mypy",
-  "pip",  # mypy needs pip to install missing stub packages
+  "pip",                     # mypy needs pip to install missing stub packages
   "pylint",
   "ipython",
 ]
diff --git a/releasenotes/notes/add-window-size-parameter-runtime-e841c4471f9d6b9c.yaml b/releasenotes/notes/add-window-size-parameter-runtime-e841c4471f9d6b9c.yaml
new file mode 100644
index 0000000000..e9d5aa2ed7
--- /dev/null
+++ b/releasenotes/notes/add-window-size-parameter-runtime-e841c4471f9d6b9c.yaml
@@ -0,0 +1,4 @@
+---
+enhancements:
+  - |
+    The SentenceWindowRetriever now supports the `window_size` parameter at run time, overwriting the value set in the constructor.
diff --git a/releasenotes/notes/enforce-python-types-serde-0c5cd0a716bab7d0.yaml b/releasenotes/notes/enforce-python-types-serde-0c5cd0a716bab7d0.yaml
new file mode 100644
index 0000000000..79e3db4074
--- /dev/null
+++ b/releasenotes/notes/enforce-python-types-serde-0c5cd0a716bab7d0.yaml
@@ -0,0 +1,8 @@
+---
+features:
+  - |
+    Improved serialization/deserialization errors to provide extra context about the delinquent components when possible.
+
+fixes:
+  - |
+    Serialized data of components are now explicitly enforced to be one of the following basic Python datatypes: str, int, float, bool, list, dict, set, tuple or None.


Test patch:
diff --git a/test/components/generators/test_openai.py b/test/components/generators/test_openai.py
index a164fa5e0e..047299b199 100644
--- a/test/components/generators/test_openai.py
+++ b/test/components/generators/test_openai.py
@@ -49,23 +49,6 @@ def test_init_with_parameters(self, monkeypatch):
         assert component.client.timeout == 40.0
         assert component.client.max_retries == 1
 
-    def test_init_with_parameters(self, monkeypatch):
-        monkeypatch.setenv("OPENAI_TIMEOUT", "100")
-        monkeypatch.setenv("OPENAI_MAX_RETRIES", "10")
-        component = OpenAIGenerator(
-            api_key=Secret.from_token("test-api-key"),
-            model="gpt-4o-mini",
-            streaming_callback=print_streaming_chunk,
-            api_base_url="test-base-url",
-            generation_kwargs={"max_tokens": 10, "some_test_param": "test-params"},
-        )
-        assert component.client.api_key == "test-api-key"
-        assert component.model == "gpt-4o-mini"
-        assert component.streaming_callback is print_streaming_chunk
-        assert component.generation_kwargs == {"max_tokens": 10, "some_test_param": "test-params"}
-        assert component.client.timeout == 100.0
-        assert component.client.max_retries == 10
-
     def test_to_dict_default(self, monkeypatch):
         monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
         component = OpenAIGenerator()
@@ -331,3 +314,22 @@ def __call__(self, chunk: StreamingChunk) -> None:
 
         assert callback.counter > 1
         assert "Paris" in callback.responses
+
+    @pytest.mark.skipif(
+        not os.environ.get("OPENAI_API_KEY", None),
+        reason="Export an env var called OPENAI_API_KEY containing the OpenAI API key to run this test.",
+    )
+    @pytest.mark.integration
+    def test_run_with_system_prompt(self):
+        generator = OpenAIGenerator(
+            model="gpt-4o-mini",
+            system_prompt="You answer in Portuguese, regardless of the language on which a question is asked",
+        )
+        result = generator.run("Can you explain the Pitagoras therom?")
+        assert "teorema" in result["replies"][0]
+
+        result = generator.run(
+            "Can you explain the Pitagoras therom?",
+            system_prompt="You answer in German, regardless of the language on which a question is asked.",
+        )
+        assert "Pythagoras" in result["replies"][0]
diff --git a/test/components/retrievers/test_sentence_window_retriever.py b/test/components/retrievers/test_sentence_window_retriever.py
index d05f478023..786d278dd6 100644
--- a/test/components/retrievers/test_sentence_window_retriever.py
+++ b/test/components/retrievers/test_sentence_window_retriever.py
@@ -1,10 +1,10 @@
 import pytest
 
-from haystack import Document, DeserializationError, Pipeline
+from haystack import DeserializationError, Document, Pipeline
+from haystack.components.preprocessors import DocumentSplitter
 from haystack.components.retrievers import InMemoryBM25Retriever
 from haystack.components.retrievers.sentence_window_retriever import SentenceWindowRetriever
 from haystack.document_stores.in_memory import InMemoryDocumentStore
-from haystack.components.preprocessors import DocumentSplitter
 
 
 class TestSentenceWindowRetriever:
@@ -118,9 +118,32 @@ def test_document_without_source_id(self):
             retriever = SentenceWindowRetriever(document_store=InMemoryDocumentStore(), window_size=3)
             retriever.run(retrieved_documents=docs)
 
+    def test_run_invalid_window_size(self):
+        docs = [Document(content="This is a text with some words. There is a ", meta={"id": "doc_0", "split_id": 0})]
+        with pytest.raises(ValueError):
+            retriever = SentenceWindowRetriever(document_store=InMemoryDocumentStore(), window_size=0)
+            retriever.run(retrieved_documents=docs)
+
+    def test_constructor_parameter_does_not_change(self):
+        retriever = SentenceWindowRetriever(InMemoryDocumentStore(), window_size=5)
+        assert retriever.window_size == 5
+
+        doc = {
+            "id": "doc_0",
+            "content": "This is a text with some words. There is a ",
+            "source_id": "c5d7c632affc486d0cfe7b3c0f4dc1d3896ea720da2b538d6d10b104a3df5f99",
+            "page_number": 1,
+            "split_id": 0,
+            "split_idx_start": 0,
+            "_split_overlap": [{"doc_id": "doc_1", "range": (0, 23)}],
+        }
+
+        retriever.run(retrieved_documents=[Document.from_dict(doc)], window_size=1)
+        assert retriever.window_size == 5
+
     @pytest.mark.integration
     def test_run_with_pipeline(self):
-        splitter = DocumentSplitter(split_length=10, split_overlap=5, split_by="word")
+        splitter = DocumentSplitter(split_length=1, split_overlap=0, split_by="sentence")
         text = (
             "This is a text with some words. There is a second sentence. And there is also a third sentence. "
             "It also contains a fourth sentence. And a fifth sentence. And a sixth sentence. And a seventh sentence"
@@ -139,11 +162,17 @@ def test_run_with_pipeline(self):
         result = pipe.run({"bm25_retriever": {"query": "third"}})
 
         assert result["sentence_window_retriever"]["context_windows"] == [
-            "some words. There is a second sentence. And there is also a third sentence. It also "
-            "contains a fourth sentence. And a fifth sentence. And a sixth sentence. And a "
+            "This is a text with some words. There is a second sentence. And there is also a third sentence. "
+            "It also contains a fourth sentence. And a fifth sentence."
         ]
         assert len(result["sentence_window_retriever"]["context_documents"][0]) == 5
 
+        result = pipe.run({"bm25_retriever": {"query": "third"}, "sentence_window_retriever": {"window_size": 1}})
+        assert result["sentence_window_retriever"]["context_windows"] == [
+            " There is a second sentence. And there is also a third sentence. It also contains a fourth sentence."
+        ]
+        assert len(result["sentence_window_retriever"]["context_documents"][0]) == 3
+
     @pytest.mark.integration
     def test_serialization_deserialization_in_pipeline(self):
         doc_store = InMemoryDocumentStore()
diff --git a/test/core/test_serialization.py b/test/core/test_serialization.py
index d6a3980bf3..fd83122c33 100644
--- a/test/core/test_serialization.py
+++ b/test/core/test_serialization.py
@@ -10,13 +10,14 @@
 
 from haystack.core.pipeline import Pipeline
 from haystack.core.component import component
-from haystack.core.errors import DeserializationError
+from haystack.core.errors import DeserializationError, SerializationError
 from haystack.testing import factory
 from haystack.core.serialization import (
     default_to_dict,
     default_from_dict,
     generate_qualified_class_name,
     import_class_by_name,
+    component_to_dict,
 )
 
 
@@ -106,3 +107,25 @@ def test_import_class_by_name_no_valid_class():
     data = "some.invalid.class"
     with pytest.raises(ImportError):
         import_class_by_name(data)
+
+
+class CustomData:
+    def __init__(self, a: int, b: str) -> None:
+        self.a = a
+        self.b = b
+
+
+@component()
+class UnserializableClass:
+    def __init__(self, a: int, b: str, c: CustomData) -> None:
+        self.a = a
+        self.b = b
+        self.c = c
+
+    def run(self):
+        pass
+
+
+def test_component_to_dict_invalid_type():
+    with pytest.raises(SerializationError, match="unsupported value of type 'CustomData'"):
+        component_to_dict(UnserializableClass(1, "s", CustomData(99, "aa")), "invalid_component")
diff --git a/test/marshal/test_yaml.py b/test/marshal/test_yaml.py
index 552370e9ac..1c8e550215 100644
--- a/test/marshal/test_yaml.py
+++ b/test/marshal/test_yaml.py
@@ -6,11 +6,23 @@
 from haystack.marshal.yaml import YamlMarshaller
 
 
+class InvalidClass:
+    def __init__(self) -> None:
+        self.a = 1
+        self.b = None
+        self.c = "string"
+
+
 @pytest.fixture
 def yaml_data():
     return {"key": "value", 1: 0.221, "list": [1, 2, 3], "tuple": (1, None, True), "dict": {"set": {False}}}
 
 
+@pytest.fixture
+def invalid_yaml_data():
+    return {"key": "value", 1: 0.221, "list": [1, 2, 3], "tuple": (1, InvalidClass(), True), "dict": {"set": {False}}}
+
+
 @pytest.fixture
 def serialized_yaml_str():
     return """key: value
@@ -36,6 +48,12 @@ def test_yaml_marshal(yaml_data, serialized_yaml_str):
     assert marshalled.strip().replace("\n", "") == serialized_yaml_str.strip().replace("\n", "")
 
 
+def test_yaml_marshal_invalid_type(invalid_yaml_data):
+    with pytest.raises(TypeError, match="basic Python types"):
+        marshaller = YamlMarshaller()
+        marshalled = marshaller.marshal(invalid_yaml_data)
+
+
 def test_yaml_unmarshal(yaml_data, serialized_yaml_str):
     marshaller = YamlMarshaller()
     unmarshalled = marshaller.unmarshal(serialized_yaml_str)