From df1412c450185401a794493cb74bb5a04d2057c3 Mon Sep 17 00:00:00 2001 From: Suren Khorenyan Date: Wed, 1 Apr 2026 17:20:40 +0300 Subject: [PATCH 1/5] Create a new minimal templated exception helper --- taskiq/error.py | 70 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 taskiq/error.py diff --git a/taskiq/error.py b/taskiq/error.py new file mode 100644 index 00000000..5e892862 --- /dev/null +++ b/taskiq/error.py @@ -0,0 +1,70 @@ +"""Minimal exception templating used by taskiq exceptions.""" + +from string import Formatter + + +class Error(Exception): + """Base templated exception compatible with taskiq needs.""" + + __template__ = "Exception occurred" + + @classmethod + def _collect_annotations(cls) -> dict[str, object]: + """Collect all annotated fields from the class hierarchy.""" + annotations: dict[str, object] = {} + for class_ in reversed(cls.__mro__): + annotations.update(getattr(class_, "__annotations__", {})) + return annotations + + @classmethod + def _format_fields(cls, names: set[str]) -> str: + """Format field names in a deterministic error message.""" + return ", ".join(f"'{name}'" for name in sorted(names)) + + @classmethod + def _template_fields(cls, template: str) -> set[str]: + """Extract plain field names used in a format template.""" + fields: set[str] = set() + for _, field_name, _, _ in Formatter().parse(template): + if not field_name: + continue + field = field_name.split(".", maxsplit=1)[0].split("[", maxsplit=1)[0] + fields.add(field) + return fields + + def __init__(self, **kwargs: object) -> None: + annotations = self._collect_annotations() + undeclared = set(kwargs) - set(annotations) + if undeclared: + raise TypeError(f"Undeclared arguments: {self._format_fields(undeclared)}") + + missing = { + field + for field in annotations + if field not in kwargs and not hasattr(type(self), field) + } + if missing: + raise TypeError(f"Missing arguments: {self._format_fields(missing)}") + + for key, value in kwargs.items(): + setattr(self, key, value) + + template = getattr(type(self), "__template__", self.__template__) + missing_annotations = self._template_fields(template) - set(annotations) + if missing_annotations: + raise ValueError( + f"Fields must be annotated: {self._format_fields(missing_annotations)}", + ) + + payload = {field: getattr(self, field) for field in annotations} + super().__init__(template.format(**payload)) + + def __repr__(self) -> str: + """Represent exception with all declared fields.""" + annotations = self._collect_annotations() + module = type(self).__module__ + qualname = type(self).__qualname__ + if not annotations: + return f"{module}.{qualname}()" + args = ", ".join(f"{field}={getattr(self, field)!r}" for field in annotations) + return f"{module}.{qualname}({args})" From f8f9457031a071ee8e982a4c8be148b8f2bf9916 Mon Sep 17 00:00:00 2001 From: Suren Khorenyan Date: Wed, 1 Apr 2026 17:33:55 +0300 Subject: [PATCH 2/5] Use new minimal templated exception helper instead of izulu --- taskiq/exceptions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/taskiq/exceptions.py b/taskiq/exceptions.py index 2a305a02..1e6fbb83 100644 --- a/taskiq/exceptions.py +++ b/taskiq/exceptions.py @@ -1,9 +1,9 @@ from typing import Any -from izulu import root +from taskiq.error import Error -class TaskiqError(root.Error): +class TaskiqError(Error): """Base exception for all errors.""" __template__ = "Exception occurred" From ce1033606566317b87109459f78df9bc65ac673f Mon Sep 17 00:00:00 2001 From: Suren Khorenyan Date: Wed, 1 Apr 2026 17:37:31 +0300 Subject: [PATCH 3/5] get rid of izulu --- pyproject.toml | 1 - uv.lock | 11 ----------- 2 files changed, 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 06492122..b108c2ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,6 @@ dependencies = [ "taskiq_dependencies>=1.3.1,<2", "anyio>=4", "packaging>=19", - "izulu==0.50.0", "aiohttp>=3", ] diff --git a/uv.lock b/uv.lock index 1aed9c0a..8d77ba73 100644 --- a/uv.lock +++ b/uv.lock @@ -753,15 +753,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] -[[package]] -name = "izulu" -version = "0.50.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/58/6d6335c78b7ade54d8a6c6dbaa589e5c21b3fd916341d5a16f774c72652a/izulu-0.50.0.tar.gz", hash = "sha256:cc8e252d5e8560c70b95380295008eeb0786f7b745a405a40d3556ab3252d5f5", size = 48558, upload-time = "2025-03-24T15:52:21.51Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/9f/bf9d33546bbb6e5e80ebafe46f90b7d8b4a77410b7b05160b0ca8978c15a/izulu-0.50.0-py3-none-any.whl", hash = "sha256:4e9ae2508844e7c5f62c468a8b9e2deba2f60325ef63f01e65b39fd9a6b3fab4", size = 18095, upload-time = "2025-03-24T15:52:19.667Z" }, -] - [[package]] name = "msgpack" version = "1.1.2" @@ -1820,7 +1811,6 @@ source = { editable = "." } dependencies = [ { name = "aiohttp" }, { name = "anyio" }, - { name = "izulu" }, { name = "packaging" }, { name = "pycron" }, { name = "pydantic" }, @@ -1881,7 +1871,6 @@ requires-dist = [ { name = "anyio", specifier = ">=4" }, { name = "cbor2", marker = "extra == 'cbor'", specifier = ">=5" }, { name = "gitignore-parser", marker = "extra == 'reload'", specifier = ">=0" }, - { name = "izulu", specifier = "==0.50.0" }, { name = "msgpack", marker = "extra == 'msgpack'", specifier = ">=1.0.7" }, { name = "opentelemetry-api", marker = "extra == 'opentelemetry'", specifier = ">=1.38.0,<2.0.0" }, { name = "opentelemetry-instrumentation", marker = "extra == 'opentelemetry'", specifier = ">=0.59b0,<1" }, From 517e2bdb870abd51104e429edace878dcafcdf42 Mon Sep 17 00:00:00 2001 From: Suren Khorenyan Date: Wed, 1 Apr 2026 17:40:32 +0300 Subject: [PATCH 4/5] add tests for errors and exceptional cases --- tests/test_error.py | 88 +++++++++++++++++++++++++++++++++++ tests/test_exceptions_flow.py | 66 ++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 tests/test_error.py create mode 100644 tests/test_exceptions_flow.py diff --git a/tests/test_error.py b/tests/test_error.py new file mode 100644 index 00000000..0dd3504b --- /dev/null +++ b/tests/test_error.py @@ -0,0 +1,88 @@ +import pytest + +from taskiq.error import Error +from taskiq.exceptions import SecurityError, TaskiqResultTimeoutError + + +class SimpleError(Error): + __template__ = "simple" + + +class ValueTemplateError(Error): + __template__ = "value={value}" + value: int + + +class DefaultValueTemplateError(Error): + __template__ = "value={value}" + value: int = 10 + + +class BaseError(Error): + __template__ = "base={base}, child={child}" + base: int = 1 + + +class ChildError(BaseError): + child: str + + +class MissingAnnotationError(Error): + __template__ = "value={value}" + + +class IndexedTemplateError(Error): + __template__ = "{payload[key]}" + payload: dict[str, str] + + +def test_simple_error_message_and_repr() -> None: + error = SimpleError() + assert str(error) == "simple" + assert error.args == ("simple",) + assert repr(error).endswith(".SimpleError()") + + +def test_template_with_required_value() -> None: + error = ValueTemplateError(value=3) + assert str(error) == "value=3" + assert repr(error).endswith(".ValueTemplateError(value=3)") + + +def test_missing_argument_raises_type_error() -> None: + with pytest.raises(TypeError, match="Missing arguments: 'value'"): + ValueTemplateError() + + +def test_undeclared_argument_raises_type_error() -> None: + with pytest.raises(TypeError, match="Undeclared arguments: 'extra'"): + ValueTemplateError(value=1, extra=2) + + +def test_default_value_is_used_without_kwargs() -> None: + error = DefaultValueTemplateError() + assert str(error) == "value=10" + assert repr(error).endswith(".DefaultValueTemplateError(value=10)") + + +def test_annotations_are_collected_from_inheritance() -> None: + error = ChildError(child="ok") + assert str(error) == "base=1, child=ok" + assert repr(error).endswith(".ChildError(base=1, child='ok')") + + +def test_template_fields_must_be_annotated() -> None: + with pytest.raises(ValueError, match="Fields must be annotated: 'value'"): + MissingAnnotationError() + + +def test_indexed_template_field_does_not_require_extra_annotation() -> None: + error = IndexedTemplateError(payload={"key": "value"}) + assert str(error) == "value" + + +def test_taskiq_exceptions_use_error_base_correctly() -> None: + timeout_error = TaskiqResultTimeoutError(timeout=1.5) + security_error = SecurityError(description="boom") + assert str(timeout_error) == "Waiting for task results has timed out, timeout=1.5" + assert str(security_error) == "Security exception occurred: boom" diff --git a/tests/test_exceptions_flow.py b/tests/test_exceptions_flow.py new file mode 100644 index 00000000..8d65194a --- /dev/null +++ b/tests/test_exceptions_flow.py @@ -0,0 +1,66 @@ +import re + +import pytest + +from taskiq import InMemoryBroker +from taskiq.brokers.shared_broker import AsyncSharedBroker +from taskiq.exceptions import ( + SharedBrokerListenError, + SharedBrokerSendTaskError, + TaskBrokerMismatchError, + UnknownTaskError, +) +from taskiq.message import BrokerMessage + + +def _broker_message(task_name: str) -> BrokerMessage: + return BrokerMessage( + task_id="task-id", + task_name=task_name, + message=b"{}", + labels={}, + ) + + +async def test_inmemory_broker_raises_unknown_task_error() -> None: + broker = InMemoryBroker() + + with pytest.raises( + UnknownTaskError, + match=re.escape( + "Cannot send unknown task to the queue, task name - missing.task", + ), + ): + await broker.kick(_broker_message("missing.task")) + + +async def test_shared_broker_raises_send_task_error() -> None: + broker = AsyncSharedBroker() + + with pytest.raises( + SharedBrokerSendTaskError, + match="You cannot use kiq directly on shared task", + ): + await broker.kick(_broker_message("any.task")) + + +async def test_shared_broker_raises_listen_error() -> None: + broker = AsyncSharedBroker() + + with pytest.raises(SharedBrokerListenError, match="Shared broker cannot listen"): + await broker.listen() + + +def test_registering_task_in_another_broker_raises_mismatch_error() -> None: + first_broker = InMemoryBroker() + second_broker = InMemoryBroker() + + @first_broker.task(task_name="test.task") + async def test_task() -> None: + return None + + with pytest.raises( + TaskBrokerMismatchError, + match="Task already has a different broker", + ): + second_broker._register_task(test_task.task_name, test_task) From a37563c35f2058eb238142264caf31038006a296 Mon Sep 17 00:00:00 2001 From: Suren Khorenyan Date: Wed, 1 Apr 2026 20:04:24 +0300 Subject: [PATCH 5/5] apply dataclass_transform to Error --- taskiq/error.py | 12 ++++++++++++ tests/test_error.py | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/taskiq/error.py b/taskiq/error.py index 5e892862..72ccb7ca 100644 --- a/taskiq/error.py +++ b/taskiq/error.py @@ -1,8 +1,20 @@ """Minimal exception templating used by taskiq exceptions.""" +import sys from string import Formatter +if sys.version_info >= (3, 11): + from typing import dataclass_transform +else: + from typing_extensions import dataclass_transform + +@dataclass_transform( + eq_default=False, + order_default=False, + kw_only_default=True, + frozen_default=False, +) class Error(Exception): """Base templated exception compatible with taskiq needs.""" diff --git a/tests/test_error.py b/tests/test_error.py index 0dd3504b..c1a5e4a3 100644 --- a/tests/test_error.py +++ b/tests/test_error.py @@ -51,12 +51,12 @@ def test_template_with_required_value() -> None: def test_missing_argument_raises_type_error() -> None: with pytest.raises(TypeError, match="Missing arguments: 'value'"): - ValueTemplateError() + ValueTemplateError() # type: ignore[call-arg] def test_undeclared_argument_raises_type_error() -> None: with pytest.raises(TypeError, match="Undeclared arguments: 'extra'"): - ValueTemplateError(value=1, extra=2) + ValueTemplateError(value=1, extra=2) # type: ignore[call-arg] def test_default_value_is_used_without_kwargs() -> None: