Repository: apache/airflow

Issue:
Timetable registration a la OperatorLinks
Currently (as implemented in #17414), timetables are serialised by their classes’ full import path. This works most of the time, but not in some cases, including:

* Nested in class or function
* Declared directly in a DAG file without a valid import name (e.g. `12345.py`)

It’s fundamentally impossible to fix some of the cases (e.g. function-local class declaration) due to how Python works, but by requiring the user to explicitly register the timetable class, we can at least expose that problem so users don’t attempt to do that.

However, since the timetable actually would work a lot of times without any additional mechanism, I’m also wondering if we should _require_ registration.

1. Always require registration. A DAG using an unregistered timetable class fails to serialise.
2. Only require registration when the timetable class has wonky import path. “Normal” classes work out of the box without registering, and user sees a serialisation error asking for registration otherwise.
3. Don’t require registration. If a class cannot be correctly serialised, tell the user we can’t do it and the timetable must be declared another way.


Discussion:
The main reason I've previously gone down the "require classes the scheduler/webserver will load to require registration" is I  have medium term plans to allow a dag definition to be submitted via  an API (i.e. an Airflow user to post a JSON blob) -- and I don't want to open that up to allowing reverse-shell or similar by creating an instance of a random class.
So it seems like method 1 is desired? I don’t think it technically changes the equation, but sounds like always requiring registration is the safest route. (Although it does make writing customised timtable more difficult since now you can only use pre-defined timetable classes instead of inventing things right inside a DAG file.)
> Although it does make writing customised timtable more difficult since now you can only use pre-defined timetable classes instead of inventing things right inside a DAG file.

Since the scheduler needs to load the timetable class, and Airflow tries very hard to never load dag code in to a long running process, I think not being able to define it in a DAG file is, sadly, desired behaviour.

You'd have to define it in a plugin which can live in `plugins/` along side `dags/`, so users won't have to create a Python dist etc.

Resolving patch:
diff --git a/airflow/plugins_manager.py b/airflow/plugins_manager.py
--- a/airflow/plugins_manager.py
+++ b/airflow/plugins_manager.py
@@ -34,9 +34,11 @@
 from airflow import settings
 from airflow.utils.entry_points import entry_points_with_dist
 from airflow.utils.file import find_path_from_directory
+from airflow.utils.module_loading import as_importable_string
 
 if TYPE_CHECKING:
     from airflow.hooks.base import BaseHook
+    from airflow.timetables.base import Timetable
 
 log = logging.getLogger(__name__)
 
@@ -58,6 +60,7 @@
 global_operator_extra_links: Optional[List[Any]] = None
 operator_extra_links: Optional[List[Any]] = None
 registered_operator_link_classes: Optional[Dict[str, Type]] = None
+timetable_classes: Optional[Dict[str, Type["Timetable"]]] = None
 """Mapping of class names to class of OperatorLinks registered by plugins.
 
 Used by the DAG serialization code to only allow specific classes to be created
@@ -146,6 +149,9 @@ class AirflowPlugin:
     # buttons.
     operator_extra_links: List[Any] = []
 
+    # A list of timetable classes that can be used for DAG scheduling.
+    timetables: List[Type["Timetable"]] = []
+
     @classmethod
     def validate(cls):
         """Validates that plugin has a name."""
@@ -373,6 +379,27 @@ def initialize_extra_operators_links_plugins():
         )
 
 
+def initialize_timetables_plugins():
+    """Collect timetable classes registered by plugins."""
+    global timetable_classes
+
+    if timetable_classes is not None:
+        return
+
+    ensure_plugins_loaded()
+
+    if plugins is None:
+        raise AirflowPluginException("Can't load plugins.")
+
+    log.debug("Initialize extra timetables plugins")
+
+    timetable_classes = {
+        as_importable_string(timetable_class): timetable_class
+        for plugin in plugins
+        for timetable_class in plugin.timetables
+    }
+
+
 def integrate_executor_plugins() -> None:
     """Integrate executor plugins to the context."""
     global plugins
diff --git a/airflow/serialization/serialized_objects.py b/airflow/serialization/serialized_objects.py
--- a/airflow/serialization/serialized_objects.py
+++ b/airflow/serialization/serialized_objects.py
@@ -21,20 +21,14 @@
 import logging
 from dataclasses import dataclass
 from inspect import Parameter, signature
-from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Set, Union
+from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Set, Type, Union
 
 import cattr
 import pendulum
 from dateutil import relativedelta
-
-try:
-    from functools import cache
-except ImportError:
-    from functools import lru_cache
-
-    cache = lru_cache(maxsize=None)
 from pendulum.tz.timezone import FixedTimezone, Timezone
 
+from airflow.compat.functools import cache
 from airflow.configuration import conf
 from airflow.exceptions import AirflowException, SerializationError
 from airflow.models.baseoperator import BaseOperator, BaseOperatorLink
@@ -128,22 +122,47 @@ def decode_timezone(var: Union[str, int]) -> Timezone:
     return pendulum.timezone(var)
 
 
-def encode_timetable(var: Timetable) -> Dict[str, Any]:
+def _get_registered_timetable(importable_string: str) -> Optional[Type[Timetable]]:
+    from airflow import plugins_manager
+
+    if importable_string.startswith("airflow.timetables."):
+        return import_string(importable_string)
+    plugins_manager.initialize_timetables_plugins()
+    return plugins_manager.timetable_classes.get(importable_string)
+
+
+class _TimetableNotRegistered(ValueError):
+    def __init__(self, type_string: str) -> None:
+        self.type_string = type_string
+
+    def __str__(self) -> str:
+        return f"Timetable class {self.type_string!r} is not registered"
+
+
+def _encode_timetable(var: Timetable) -> Dict[str, Any]:
     """Encode a timetable instance.
 
     This delegates most of the serialization work to the type, so the behavior
     can be completely controlled by a custom subclass.
     """
-    return {"type": as_importable_string(type(var)), "value": var.serialize()}
+    timetable_class = type(var)
+    importable_string = as_importable_string(timetable_class)
+    if _get_registered_timetable(importable_string) != timetable_class:
+        raise _TimetableNotRegistered(importable_string)
+    return {"__type": importable_string, "__var": var.serialize()}
 
 
-def decode_timetable(var: Dict[str, Any]) -> Timetable:
+def _decode_timetable(var: Dict[str, Any]) -> Timetable:
     """Decode a previously serialized timetable.
 
     Most of the deserialization logic is delegated to the actual type, which
     we import from string.
     """
-    return import_string(var["type"]).deserialize(var["value"])
+    importable_string = var["__type"]
+    timetable_class = _get_registered_timetable(importable_string)
+    if timetable_class is None:
+        raise _TimetableNotRegistered(importable_string)
+    return timetable_class.deserialize(var["__var"])
 
 
 class BaseSerialization:
@@ -248,7 +267,7 @@ def serialize_to_json(
             if key in decorated_fields:
                 serialized_object[key] = cls._serialize(value)
             elif key == "timetable":
-                serialized_object[key] = encode_timetable(value)
+                serialized_object[key] = _encode_timetable(value)
             else:
                 value = cls._serialize(value)
                 if isinstance(value, dict) and "__type" in value:
@@ -758,8 +777,8 @@ def serialize_dag(cls, dag: DAG) -> dict:
             return serialize_dag
         except SerializationError:
             raise
-        except Exception:
-            raise SerializationError(f'Failed to serialize dag {dag.dag_id!r}')
+        except Exception as e:
+            raise SerializationError(f'Failed to serialize DAG {dag.dag_id!r}: {e}')
 
     @classmethod
     def deserialize_dag(cls, encoded_dag: Dict[str, Any]) -> 'SerializedDAG':
@@ -785,7 +804,7 @@ def deserialize_dag(cls, encoded_dag: Dict[str, Any]) -> 'SerializedDAG':
                 # Value structure matches exactly
                 pass
             elif k == "timetable":
-                v = decode_timetable(v)
+                v = _decode_timetable(v)
             elif k in cls._decorated_fields:
                 v = cls._deserialize(v)
             # else use v as it is


Test patch:
diff --git a/tests/serialization/test_dag_serialization.py b/tests/serialization/test_dag_serialization.py
--- a/tests/serialization/test_dag_serialization.py
+++ b/tests/serialization/test_dag_serialization.py
@@ -23,7 +23,6 @@
 import importlib.util
 import multiprocessing
 import os
-import unittest
 from datetime import datetime, timedelta, timezone
 from glob import glob
 from unittest import mock
@@ -32,8 +31,8 @@
 import pytest
 from dateutil.relativedelta import FR, relativedelta
 from kubernetes.client import models as k8s
-from parameterized import parameterized
 
+from airflow.exceptions import SerializationError
 from airflow.hooks.base import BaseHook
 from airflow.kubernetes.pod_generator import PodGenerator
 from airflow.models import DAG, Connection, DagBag, TaskInstance
@@ -160,6 +159,11 @@
     os.path.join(os.path.dirname(os.path.realpath(__file__)), os.pardir, os.pardir)
 )
 
+CUSTOM_TIMETABLE_SERIALIZED = {
+    "__type": "tests.test_utils.timetables.CustomSerializationTimetable",
+    "__var": {"value": "foo"},
+}
+
 
 def make_example_dags(module_path):
     """Loads DAGs from a module for test."""
@@ -267,11 +271,24 @@ def serialize_subprocess(queue, dag_folder):
     queue.put(None)
 
 
-class TestStringifiedDAGs(unittest.TestCase):
+@pytest.fixture()
+def timetable_plugin(monkeypatch):
+    """Patch plugins manager to always and only return our custom timetable."""
+    from airflow import plugins_manager
+
+    monkeypatch.setattr(plugins_manager, "initialize_timetables_plugins", lambda: None)
+    monkeypatch.setattr(
+        plugins_manager,
+        "timetable_classes",
+        {"tests.test_utils.timetables.CustomSerializationTimetable": CustomSerializationTimetable},
+    )
+
+
+class TestStringifiedDAGs:
     """Unit tests for stringified DAGs."""
 
-    def setUp(self):
-        super().setUp()
+    def setup_method(self):
+        self.backup_base_hook_get_connection = BaseHook.get_connection
         BaseHook.get_connection = mock.Mock(
             return_value=Connection(
                 extra=(
@@ -288,6 +305,9 @@ def setUp(self):
         )
         self.maxDiff = None
 
+    def teardown_method(self):
+        BaseHook.get_connection = self.backup_base_hook_get_connection
+
     def test_serialization(self):
         """Serialization and deserialization should work for every DAG and Operator."""
         dags = collect_dags()
@@ -300,24 +320,23 @@ def test_serialization(self):
         # Compares with the ground truth of JSON string.
         self.validate_serialized_dag(serialized_dags['simple_dag'], serialized_simple_dag_ground_truth)
 
-    @parameterized.expand(
+    @pytest.mark.parametrize(
+        "timetable, serialized_timetable",
         [
             (
                 cron_timetable("0 0 * * *"),
                 {
-                    "type": "airflow.timetables.interval.CronDataIntervalTimetable",
-                    "value": {"expression": "0 0 * * *", "timezone": "UTC"},
+                    "__type": "airflow.timetables.interval.CronDataIntervalTimetable",
+                    "__var": {"expression": "0 0 * * *", "timezone": "UTC"},
                 },
             ),
             (
                 CustomSerializationTimetable("foo"),
-                {
-                    "type": "tests.test_utils.timetables.CustomSerializationTimetable",
-                    "value": {"value": "foo"},
-                },
+                CUSTOM_TIMETABLE_SERIALIZED,
             ),
         ],
     )
+    @pytest.mark.usefixtures("timetable_plugin")
     def test_dag_serialization_to_timetable(self, timetable, serialized_timetable):
         """Verify a timetable-backed schedule_interval is excluded in serialization."""
         dag = get_timetable_based_simple_dag(timetable)
@@ -330,6 +349,19 @@ def test_dag_serialization_to_timetable(self, timetable, serialized_timetable):
 
         self.validate_serialized_dag(serialized_dag, expected)
 
+    def test_dag_serialization_unregistered_custom_timetable(self):
+        """Verify serialization fails without timetable registration."""
+        dag = get_timetable_based_simple_dag(CustomSerializationTimetable("bar"))
+        with pytest.raises(SerializationError) as ctx:
+            SerializedDAG.to_dict(dag)
+
+        message = (
+            "Failed to serialize DAG 'simple_dag': Timetable class "
+            "'tests.test_utils.timetables.CustomSerializationTimetable' "
+            "is not registered"
+        )
+        assert str(ctx.value) == message
+
     def validate_serialized_dag(self, json_dag, ground_truth_dag):
         """Verify serialized DAGs match the ground truth."""
         assert json_dag['dag']['fileloc'].split('/')[-1] == 'test_dag_serialization.py'
@@ -390,7 +422,11 @@ def test_roundtrip_provider_example_dags(self):
             serialized_dag = SerializedDAG.from_json(SerializedDAG.to_json(dag))
             self.validate_deserialized_dag(serialized_dag, dag)
 
-    @parameterized.expand([(cron_timetable("0 0 * * *"),), (CustomSerializationTimetable("foo"),)])
+    @pytest.mark.parametrize(
+        "timetable",
+        [cron_timetable("0 0 * * *"), CustomSerializationTimetable("foo")],
+    )
+    @pytest.mark.usefixtures("timetable_plugin")
     def test_dag_roundtrip_from_timetable(self, timetable):
         """Verify a timetable-backed serialization can be deserialized."""
         dag = get_timetable_based_simple_dag(timetable)
@@ -485,7 +521,8 @@ def validate_deserialized_task(
         else:
             assert serialized_task.subdag is None
 
-    @parameterized.expand(
+    @pytest.mark.parametrize(
+        "dag_start_date, task_start_date, expected_task_start_date",
         [
             (datetime(2019, 8, 1, tzinfo=timezone.utc), None, datetime(2019, 8, 1, tzinfo=timezone.utc)),
             (
@@ -499,7 +536,7 @@ def validate_deserialized_task(
                 datetime(2019, 8, 1, tzinfo=timezone.utc),
             ),
             (pendulum.datetime(2019, 8, 1, tz='UTC'), None, pendulum.datetime(2019, 8, 1, tz='UTC')),
-        ]
+        ],
     )
     def test_deserialization_start_date(self, dag_start_date, task_start_date, expected_task_start_date):
         dag = DAG(dag_id='simple_dag', start_date=dag_start_date)
@@ -523,7 +560,8 @@ def test_deserialization_with_dag_context(self):
             # should not raise RuntimeError: dictionary changed size during iteration
             SerializedDAG.to_dict(dag)
 
-    @parameterized.expand(
+    @pytest.mark.parametrize(
+        "dag_end_date, task_end_date, expected_task_end_date",
         [
             (datetime(2019, 8, 1, tzinfo=timezone.utc), None, datetime(2019, 8, 1, tzinfo=timezone.utc)),
             (
@@ -536,7 +574,7 @@ def test_deserialization_with_dag_context(self):
                 datetime(2019, 7, 30, tzinfo=timezone.utc),
                 datetime(2019, 7, 30, tzinfo=timezone.utc),
             ),
-        ]
+        ],
     )
     def test_deserialization_end_date(self, dag_end_date, task_end_date, expected_task_end_date):
         dag = DAG(dag_id='simple_dag', start_date=datetime(2019, 8, 1), end_date=dag_end_date)
@@ -554,26 +592,29 @@ def test_deserialization_end_date(self, dag_end_date, task_end_date, expected_ta
         simple_task = dag.task_dict["simple_task"]
         assert simple_task.end_date == expected_task_end_date
 
-    @parameterized.expand(
+    @pytest.mark.parametrize(
+        "serialized_timetable, expected_timetable",
         [
-            ({"type": "airflow.timetables.simple.NullTimetable", "value": {}}, NullTimetable()),
+            ({"__type": "airflow.timetables.simple.NullTimetable", "__var": {}}, NullTimetable()),
             (
                 {
-                    "type": "airflow.timetables.interval.CronDataIntervalTimetable",
-                    "value": {"expression": "@weekly", "timezone": "UTC"},
+                    "__type": "airflow.timetables.interval.CronDataIntervalTimetable",
+                    "__var": {"expression": "@weekly", "timezone": "UTC"},
                 },
                 cron_timetable("0 0 * * 0"),
             ),
-            ({"type": "airflow.timetables.simple.OnceTimetable", "value": {}}, OnceTimetable()),
+            ({"__type": "airflow.timetables.simple.OnceTimetable", "__var": {}}, OnceTimetable()),
             (
                 {
-                    "type": "airflow.timetables.interval.DeltaDataIntervalTimetable",
-                    "value": {"delta": 86400.0},
+                    "__type": "airflow.timetables.interval.DeltaDataIntervalTimetable",
+                    "__var": {"delta": 86400.0},
                 },
                 delta_timetable(timedelta(days=1)),
             ),
-        ]
+            (CUSTOM_TIMETABLE_SERIALIZED, CustomSerializationTimetable("foo")),
+        ],
     )
+    @pytest.mark.usefixtures("timetable_plugin")
     def test_deserialization_timetable(
         self,
         serialized_timetable,
@@ -594,7 +635,30 @@ def test_deserialization_timetable(
         dag = SerializedDAG.from_dict(serialized)
         assert dag.timetable == expected_timetable
 
-    @parameterized.expand(
+    def test_deserialization_timetable_unregistered(self):
+        serialized = {
+            "__version": 1,
+            "dag": {
+                "default_args": {"__type": "dict", "__var": {}},
+                "_dag_id": "simple_dag",
+                "fileloc": __file__,
+                "tasks": [],
+                "timezone": "UTC",
+                "timetable": CUSTOM_TIMETABLE_SERIALIZED,
+            },
+        }
+        SerializedDAG.validate_schema(serialized)
+        with pytest.raises(ValueError) as ctx:
+            SerializedDAG.from_dict(serialized)
+        message = (
+            "Timetable class "
+            "'tests.test_utils.timetables.CustomSerializationTimetable' "
+            "is not registered"
+        )
+        assert str(ctx.value) == message
+
+    @pytest.mark.parametrize(
+        "serialized_schedule_interval, expected_timetable",
         [
             (None, NullTimetable()),
             ("@weekly", cron_timetable("0 0 * * 0")),
@@ -603,7 +667,7 @@ def test_deserialization_timetable(
                 {"__type": "timedelta", "__var": 86400.0},
                 delta_timetable(timedelta(days=1)),
             ),
-        ]
+        ],
     )
     def test_deserialization_schedule_interval(
         self,
@@ -627,7 +691,8 @@ def test_deserialization_schedule_interval(
         dag = SerializedDAG.from_dict(serialized)
         assert dag.timetable == expected_timetable
 
-    @parameterized.expand(
+    @pytest.mark.parametrize(
+        "val, expected",
         [
             (relativedelta(days=-1), {"__type": "relativedelta", "__var": {"days": -1}}),
             (relativedelta(month=1, days=-1), {"__type": "relativedelta", "__var": {"month": 1, "days": -1}}),
@@ -635,7 +700,7 @@ def test_deserialization_schedule_interval(
             (relativedelta(weekday=FR), {"__type": "relativedelta", "__var": {"weekday": [4]}}),
             # Every second friday
             (relativedelta(weekday=FR(2)), {"__type": "relativedelta", "__var": {"weekday": [4, 2]}}),
-        ]
+        ],
     )
     def test_roundtrip_relativedelta(self, val, expected):
         serialized = SerializedDAG._serialize(val)
@@ -644,11 +709,12 @@ def test_roundtrip_relativedelta(self, val, expected):
         round_tripped = SerializedDAG._deserialize(serialized)
         assert val == round_tripped
 
-    @parameterized.expand(
+    @pytest.mark.parametrize(
+        "val, expected_val",
         [
             (None, {}),
             ({"param_1": "value_1"}, {"param_1": "value_1"}),
-        ]
+        ],
     )
     def test_dag_params_roundtrip(self, val, expected_val):
         """
@@ -668,11 +734,12 @@ def test_dag_params_roundtrip(self, val, expected_val):
         assert expected_val == deserialized_dag.params
         assert expected_val == deserialized_simple_task.params
 
-    @parameterized.expand(
+    @pytest.mark.parametrize(
+        "val, expected_val",
         [
             (None, {}),
             ({"param_1": "value_1"}, {"param_1": "value_1"}),
-        ]
+        ],
     )
     def test_task_params_roundtrip(self, val, expected_val):
         """
@@ -736,7 +803,7 @@ def test_extra_serialized_field_and_operator_links(self):
         google_link_from_plugin = simple_task.get_extra_links(test_date, GoogleLink.name)
         assert "https://www.google.com" == google_link_from_plugin
 
-    def test_extra_operator_links_logs_error_for_non_registered_extra_links(self):
+    def test_extra_operator_links_logs_error_for_non_registered_extra_links(self, caplog):
         """
         Assert OperatorLinks not registered via Plugins and if it is not an inbuilt Operator Link,
         it can still deserialize the DAG (does not error) but just logs an error
@@ -763,14 +830,13 @@ def execute(self, context):
 
         serialized_dag = SerializedDAG.to_dict(dag)
 
-        with self.assertLogs("airflow.serialization.serialized_objects", level="ERROR") as log_output:
+        with caplog.at_level("ERROR", logger="airflow.serialization.serialized_objects"):
             SerializedDAG.from_dict(serialized_dag)
-            received_logs = log_output.output[0]
-            expected_err_msg = (
-                "Operator Link class 'tests.serialization.test_dag_serialization.TaskStateLink' "
-                "not registered"
-            )
-            assert expected_err_msg in received_logs
+
+        expected_err_msg = (
+            "Operator Link class 'tests.serialization.test_dag_serialization.TaskStateLink' not registered"
+        )
+        assert expected_err_msg in caplog.text
 
     def test_extra_serialized_field_and_multiple_operator_links(self):
         """
@@ -849,7 +915,8 @@ def __eq__(self, other):
         def __ne__(self, other):
             return not self.__eq__(other)
 
-    @parameterized.expand(
+    @pytest.mark.parametrize(
+        "templated_field, expected_field",
         [
             (None, None),
             ([], []),
@@ -889,7 +956,7 @@ def __ne__(self, other):
                 "'nested2': ClassWithCustomAttributes({'att3': '{{ task.task_id }}', 'att4': "
                 "'{{ task.task_id }}', 'template_fields': ['att3']}), 'template_fields': ['nested1']})",
             ),
-        ]
+        ],
     )
     def test_templated_fields_exist_in_serialized_dag(self, templated_field, expected_field):
         """
@@ -1187,11 +1254,12 @@ def test_edge_info_serialization(self):
 
         assert serialized_dag.edge_info == dag.edge_info
 
-    @parameterized.expand(
+    @pytest.mark.parametrize(
+        "mode, expect_custom_deps",
         [
             ("poke", False),
             ("reschedule", True),
-        ]
+        ],
     )
     def test_serialize_sensor(self, mode, expect_custom_deps):
         from airflow.sensors.base import BaseSensorOperator
@@ -1213,11 +1281,12 @@ def poke(self, context):
 
         assert op.deps == serialized_op.deps
 
-    @parameterized.expand(
+    @pytest.mark.parametrize(
+        "passed_success_callback, expected_value",
         [
             ({"on_success_callback": lambda x: print("hi")}, True),
             ({}, False),
-        ]
+        ],
     )
     def test_dag_on_success_callback_roundtrip(self, passed_success_callback, expected_value):
         """
@@ -1240,11 +1309,12 @@ def test_dag_on_success_callback_roundtrip(self, passed_success_callback, expect
 
         assert deserialized_dag.has_on_success_callback is expected_value
 
-    @parameterized.expand(
+    @pytest.mark.parametrize(
+        "passed_failure_callback, expected_value",
         [
             ({"on_failure_callback": lambda x: print("hi")}, True),
             ({}, False),
-        ]
+        ],
     )
     def test_dag_on_failure_callback_roundtrip(self, passed_failure_callback, expected_value):
         """
@@ -1267,7 +1337,8 @@ def test_dag_on_failure_callback_roundtrip(self, passed_failure_callback, expect
 
         assert deserialized_dag.has_on_failure_callback is expected_value
 
-    @parameterized.expand(
+    @pytest.mark.parametrize(
+        "object_to_serialized, expected_output",
         [
             (
                 ['task_1', 'task_5', 'task_2', 'task_4'],
@@ -1308,7 +1379,7 @@ def test_dag_on_failure_callback_roundtrip(self, passed_failure_callback, expect
                 ('task_1', 'task_5', 'task_2', 3, ["x", "y"]),
                 ['task_1', 'task_5', 'task_2', 3, ["x", "y"]],
             ),
-        ]
+        ],
     )
     def test_serialized_objects_are_sorted(self, object_to_serialized, expected_output):
         """Test Serialized Sets are sorted while list and tuple preserve order"""
diff --git a/tests/test_utils/timetables.py b/tests/test_utils/timetables.py
--- a/tests/test_utils/timetables.py
+++ b/tests/test_utils/timetables.py
@@ -36,6 +36,12 @@ def __init__(self, value: str):
     def deserialize(cls, data):
         return cls(data["value"])
 
+    def __eq__(self, other) -> bool:
+        """Only for testing purposes."""
+        if not isinstance(other, CustomSerializationTimetable):
+            return False
+        return self.value == other.value
+
     def serialize(self):
         return {"value": self.value}