Skip to content

Commit 02700d4

Browse files
authored
Get rid of izulu (#606)
1 parent dade604 commit 02700d4

File tree

6 files changed

+238
-14
lines changed

6 files changed

+238
-14
lines changed

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ dependencies = [
3535
"taskiq_dependencies>=1.3.1,<2",
3636
"anyio>=4",
3737
"packaging>=19",
38-
"izulu==0.50.0",
3938
"aiohttp>=3",
4039
]
4140

taskiq/error.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Minimal exception templating used by taskiq exceptions."""
2+
3+
import sys
4+
from string import Formatter
5+
6+
if sys.version_info >= (3, 11):
7+
from typing import dataclass_transform
8+
else:
9+
from typing_extensions import dataclass_transform
10+
11+
12+
@dataclass_transform(
13+
eq_default=False,
14+
order_default=False,
15+
kw_only_default=True,
16+
frozen_default=False,
17+
)
18+
class Error(Exception):
19+
"""Base templated exception compatible with taskiq needs."""
20+
21+
__template__ = "Exception occurred"
22+
23+
@classmethod
24+
def _collect_annotations(cls) -> dict[str, object]:
25+
"""Collect all annotated fields from the class hierarchy."""
26+
annotations: dict[str, object] = {}
27+
for class_ in reversed(cls.__mro__):
28+
annotations.update(getattr(class_, "__annotations__", {}))
29+
return annotations
30+
31+
@classmethod
32+
def _format_fields(cls, names: set[str]) -> str:
33+
"""Format field names in a deterministic error message."""
34+
return ", ".join(f"'{name}'" for name in sorted(names))
35+
36+
@classmethod
37+
def _template_fields(cls, template: str) -> set[str]:
38+
"""Extract plain field names used in a format template."""
39+
fields: set[str] = set()
40+
for _, field_name, _, _ in Formatter().parse(template):
41+
if not field_name:
42+
continue
43+
field = field_name.split(".", maxsplit=1)[0].split("[", maxsplit=1)[0]
44+
fields.add(field)
45+
return fields
46+
47+
def __init__(self, **kwargs: object) -> None:
48+
annotations = self._collect_annotations()
49+
undeclared = set(kwargs) - set(annotations)
50+
if undeclared:
51+
raise TypeError(f"Undeclared arguments: {self._format_fields(undeclared)}")
52+
53+
missing = {
54+
field
55+
for field in annotations
56+
if field not in kwargs and not hasattr(type(self), field)
57+
}
58+
if missing:
59+
raise TypeError(f"Missing arguments: {self._format_fields(missing)}")
60+
61+
for key, value in kwargs.items():
62+
setattr(self, key, value)
63+
64+
template = getattr(type(self), "__template__", self.__template__)
65+
missing_annotations = self._template_fields(template) - set(annotations)
66+
if missing_annotations:
67+
raise ValueError(
68+
f"Fields must be annotated: {self._format_fields(missing_annotations)}",
69+
)
70+
71+
payload = {field: getattr(self, field) for field in annotations}
72+
super().__init__(template.format(**payload))
73+
74+
def __repr__(self) -> str:
75+
"""Represent exception with all declared fields."""
76+
annotations = self._collect_annotations()
77+
module = type(self).__module__
78+
qualname = type(self).__qualname__
79+
if not annotations:
80+
return f"{module}.{qualname}()"
81+
args = ", ".join(f"{field}={getattr(self, field)!r}" for field in annotations)
82+
return f"{module}.{qualname}({args})"

taskiq/exceptions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from typing import Any
22

3-
from izulu import root
3+
from taskiq.error import Error
44

55

6-
class TaskiqError(root.Error):
6+
class TaskiqError(Error):
77
"""Base exception for all errors."""
88

99
__template__ = "Exception occurred"

tests/test_error.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import pytest
2+
3+
from taskiq.error import Error
4+
from taskiq.exceptions import SecurityError, TaskiqResultTimeoutError
5+
6+
7+
class SimpleError(Error):
8+
__template__ = "simple"
9+
10+
11+
class ValueTemplateError(Error):
12+
__template__ = "value={value}"
13+
value: int
14+
15+
16+
class DefaultValueTemplateError(Error):
17+
__template__ = "value={value}"
18+
value: int = 10
19+
20+
21+
class BaseError(Error):
22+
__template__ = "base={base}, child={child}"
23+
base: int = 1
24+
25+
26+
class ChildError(BaseError):
27+
child: str
28+
29+
30+
class MissingAnnotationError(Error):
31+
__template__ = "value={value}"
32+
33+
34+
class IndexedTemplateError(Error):
35+
__template__ = "{payload[key]}"
36+
payload: dict[str, str]
37+
38+
39+
def test_simple_error_message_and_repr() -> None:
40+
error = SimpleError()
41+
assert str(error) == "simple"
42+
assert error.args == ("simple",)
43+
assert repr(error).endswith(".SimpleError()")
44+
45+
46+
def test_template_with_required_value() -> None:
47+
error = ValueTemplateError(value=3)
48+
assert str(error) == "value=3"
49+
assert repr(error).endswith(".ValueTemplateError(value=3)")
50+
51+
52+
def test_missing_argument_raises_type_error() -> None:
53+
with pytest.raises(TypeError, match="Missing arguments: 'value'"):
54+
ValueTemplateError() # type: ignore[call-arg]
55+
56+
57+
def test_undeclared_argument_raises_type_error() -> None:
58+
with pytest.raises(TypeError, match="Undeclared arguments: 'extra'"):
59+
ValueTemplateError(value=1, extra=2) # type: ignore[call-arg]
60+
61+
62+
def test_default_value_is_used_without_kwargs() -> None:
63+
error = DefaultValueTemplateError()
64+
assert str(error) == "value=10"
65+
assert repr(error).endswith(".DefaultValueTemplateError(value=10)")
66+
67+
68+
def test_annotations_are_collected_from_inheritance() -> None:
69+
error = ChildError(child="ok")
70+
assert str(error) == "base=1, child=ok"
71+
assert repr(error).endswith(".ChildError(base=1, child='ok')")
72+
73+
74+
def test_template_fields_must_be_annotated() -> None:
75+
with pytest.raises(ValueError, match="Fields must be annotated: 'value'"):
76+
MissingAnnotationError()
77+
78+
79+
def test_indexed_template_field_does_not_require_extra_annotation() -> None:
80+
error = IndexedTemplateError(payload={"key": "value"})
81+
assert str(error) == "value"
82+
83+
84+
def test_taskiq_exceptions_use_error_base_correctly() -> None:
85+
timeout_error = TaskiqResultTimeoutError(timeout=1.5)
86+
security_error = SecurityError(description="boom")
87+
assert str(timeout_error) == "Waiting for task results has timed out, timeout=1.5"
88+
assert str(security_error) == "Security exception occurred: boom"

tests/test_exceptions_flow.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import re
2+
3+
import pytest
4+
5+
from taskiq import InMemoryBroker
6+
from taskiq.brokers.shared_broker import AsyncSharedBroker
7+
from taskiq.exceptions import (
8+
SharedBrokerListenError,
9+
SharedBrokerSendTaskError,
10+
TaskBrokerMismatchError,
11+
UnknownTaskError,
12+
)
13+
from taskiq.message import BrokerMessage
14+
15+
16+
def _broker_message(task_name: str) -> BrokerMessage:
17+
return BrokerMessage(
18+
task_id="task-id",
19+
task_name=task_name,
20+
message=b"{}",
21+
labels={},
22+
)
23+
24+
25+
async def test_inmemory_broker_raises_unknown_task_error() -> None:
26+
broker = InMemoryBroker()
27+
28+
with pytest.raises(
29+
UnknownTaskError,
30+
match=re.escape(
31+
"Cannot send unknown task to the queue, task name - missing.task",
32+
),
33+
):
34+
await broker.kick(_broker_message("missing.task"))
35+
36+
37+
async def test_shared_broker_raises_send_task_error() -> None:
38+
broker = AsyncSharedBroker()
39+
40+
with pytest.raises(
41+
SharedBrokerSendTaskError,
42+
match="You cannot use kiq directly on shared task",
43+
):
44+
await broker.kick(_broker_message("any.task"))
45+
46+
47+
async def test_shared_broker_raises_listen_error() -> None:
48+
broker = AsyncSharedBroker()
49+
50+
with pytest.raises(SharedBrokerListenError, match="Shared broker cannot listen"):
51+
await broker.listen()
52+
53+
54+
def test_registering_task_in_another_broker_raises_mismatch_error() -> None:
55+
first_broker = InMemoryBroker()
56+
second_broker = InMemoryBroker()
57+
58+
@first_broker.task(task_name="test.task")
59+
async def test_task() -> None:
60+
return None
61+
62+
with pytest.raises(
63+
TaskBrokerMismatchError,
64+
match="Task already has a different broker",
65+
):
66+
second_broker._register_task(test_task.task_name, test_task)

uv.lock

Lines changed: 0 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)