Skip to content

Commit 41849ae

Browse files
authored
Merge pull request #56 from taskiq-python/feat-get-rid-of-aiostream
feat: remove aiostream as a dependency
2 parents cf89301 + a1c8e99 commit 41849ae

7 files changed

Lines changed: 172 additions & 49 deletions

File tree

‎LICENSE‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2022-2025 Pavel Kirilin
3+
Copyright (c) 2022-2026 Pavel Kirilin
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

‎Makefile‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,11 @@ help: ## Show this help
1212
.PHONY: clear_rabbit
1313
clear_rabbit: ## Clear RabbitMQ data volume and restart container
1414
@docker stop taskiq_aio_pika_rabbitmq && docker rm taskiq_aio_pika_rabbitmq && docker volume rm taskiq-aio-pika_rabbitmq_data && docker compose up -d
15+
16+
.PHONY: lint
17+
lint: ## Run linting
18+
@uv run ruff check taskiq_aio_pika tests --fix
19+
20+
.PHONY: run_infra
21+
run_infra: ## Run infrastructure
22+
@docker compose up -d rabbitmq redis

‎pyproject.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ requires-python = ">=3.10,<4"
3232
dependencies = [
3333
"taskiq>=0.12.0,<1",
3434
"aio-pika>=9.0.0",
35-
"aiostream>=0.7.1",
35+
"typing-extensions>=4.14.0",
3636
]
3737

3838
[dependency-groups]
@@ -119,7 +119,7 @@ lint.ignore = [
119119
]
120120
lint.mccabe = { max-complexity = 10 }
121121
exclude = [".venv/"]
122-
line-length = 88
122+
line-length = 120
123123

124124
[tool.ruff.lint.per-file-ignores]
125125
"tests/*" = [

‎taskiq_aio_pika/broker.py‎

Lines changed: 15 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import aiormq
88
from aio_pika import DeliveryMode, ExchangeType, Message, connect_robust
99
from aio_pika.abc import AbstractChannel, AbstractQueue, AbstractRobustConnection
10-
from aiostream import stream
1110
from pamqp.common import FieldTable
1211
from taskiq import AckableMessage, AsyncBroker, AsyncResultBackend, BrokerMessage
1312
from typing_extensions import Self
@@ -20,6 +19,7 @@
2019
)
2120
from taskiq_aio_pika.exchange import Exchange
2221
from taskiq_aio_pika.queue import Queue
22+
from taskiq_aio_pika.utils import merge_async_iterables
2323

2424
_T = TypeVar("_T")
2525

@@ -69,25 +69,20 @@ def __init__(
6969
"""
7070
Construct a new broker.
7171
72-
:param url: url to rabbitmq. If None,
73-
the default "amqp://guest:guest@localhost:5672" is used.
72+
:param url: url to rabbitmq. If None, the default "amqp://guest:guest@localhost:5672" is used.
7473
:param result_backend: custom result backend.
7574
:param task_id_generator: custom task_id generator.
7675
:param qos: number of messages that worker can prefetch.
7776
:param loop: specific even loop.
7877
:param exchange: parameters of exchange that used to send messages.
79-
:param task_queues: parameters of queues
80-
that will be used to get incoming messages.
78+
:param task_queues: parameters of queues that will be used to get incoming messages.
8179
:param dead_letter_queue: parameters of dead-letter queue.
8280
:param delay_queue: parameters of queue for simple delay implementation.
83-
:param delayed_message_exchange_plugin: turn on or disable
84-
delayed-message-exchange rabbitmq plugin.
85-
:param delayed_message_exchange: parameters of exchange
86-
that used to send messages with delay.
81+
:param delayed_message_exchange_plugin: turn on or disable delayed-message-exchange rabbitmq plugin.
82+
:param delayed_message_exchange: parameters of exchange that used to send messages with delay.
8783
:param label_for_routing: label name to use for routing key selection.
8884
:param label_for_priority: label name to use for message priority.
89-
:param connection_kwargs: additional keyword arguments,
90-
for connect_robust method of aio-pika.
85+
:param connection_kwargs: additional keyword arguments, for connect_robust method of aio-pika.
9186
"""
9287
super().__init__(result_backend, task_id_generator)
9388

@@ -241,10 +236,8 @@ async def _declare_queues(
241236
"""
242237
Declare all queues.
243238
244-
It's useful since aio-pika have automatic
245-
recover mechanism, which works only if
246-
the queue, you're going to listen was
247-
declared by aio-pika.
239+
It's useful since aio-pika have automatic recover mechanism, which works only if the queue, you're going to
240+
listen was declared by aio-pika.
248241
249242
:param channel: channel to used for declaration.
250243
:return: list of declared queues and their consumer arguments.
@@ -323,8 +316,7 @@ def with_queue(self, queue: Queue) -> Self:
323316
"""
324317
Add new queue to the broker.
325318
326-
This method should be called before startup,
327-
otherwise the new queue won't be declared and bound to exchange.
319+
This method should be called before startup, otherwise the new queue won't be declared and bound to exchange.
328320
329321
:param queue: queue to add.
330322
:return: self.
@@ -346,12 +338,8 @@ async def kick(self, message: BrokerMessage) -> None:
346338
"""
347339
Send message to the exchange.
348340
349-
This function constructs rmq message
350-
and sends it.
351-
352-
The message has task_id and task_name and labels
353-
in headers. And message's routing key is the same
354-
as the task_name.
341+
This function constructs rmq message and sends it. The message has task_id and task_name and labels in headers.
342+
And message's routing key is the same as the task_name.
355343
356344
:raises NoStartupError: if startup wasn't called.
357345
:raises IncorrectRoutingKeyError: if routing key is incorrect.
@@ -420,8 +408,7 @@ async def listen(self) -> AsyncGenerator[AckableMessage, None]:
420408
"""
421409
Listen to queue.
422410
423-
This function listens to queue and
424-
yields every new message.
411+
This function listens to queue and yields every new message.
425412
426413
:raises NoStartupError: if startup wasn't called.
427414
:yields: parsed broker message.
@@ -446,17 +433,14 @@ async def body(
446433
# Suppress errors during iterator cleanup if channel is being closed
447434
logger.info("Queue iterator closed during shutdown")
448435

449-
combine = stream.merge(
436+
async for message in merge_async_iterables(
450437
*[
451438
body(queue, consumer_args)
452439
for queue, consumer_args in queue_with_consumer_args_list
453440
if not self._delay_queue or queue.name != self._delay_queue.name
454441
],
455-
)
456-
457-
async with combine.stream() as streamer:
458-
async for message in streamer:
459-
yield message
442+
):
443+
yield message
460444

461445
async def shutdown(self) -> None:
462446
"""Close all connections on shutdown."""

‎taskiq_aio_pika/utils.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import asyncio
2+
from collections.abc import AsyncGenerator
3+
from typing import Any, TypeVar
4+
5+
from typing_extensions import Sentinel
6+
7+
_T = TypeVar("_T")
8+
_SENTINEL = Sentinel("_SENTINEL")
9+
10+
11+
async def merge_async_iterables(
12+
*iterables: AsyncGenerator[_T, None],
13+
) -> AsyncGenerator[_T, None]:
14+
"""
15+
Merge multiple async generators into a single one.
16+
17+
Items are yielded as soon as they're produced by any of the source generators, in the order they arrive.
18+
19+
:param iterables: async generators to merge.
20+
:yields: items produced by any of the source generators.
21+
"""
22+
queue: asyncio.Queue[Any] = asyncio.Queue()
23+
24+
async def _pump(iterable: AsyncGenerator[_T, None]) -> None:
25+
try:
26+
async for item in iterable:
27+
await queue.put(item)
28+
except BaseException as exc:
29+
await queue.put(exc)
30+
else:
31+
await queue.put(_SENTINEL)
32+
33+
tasks = [asyncio.ensure_future(_pump(iterable)) for iterable in iterables]
34+
remaining = len(tasks)
35+
try:
36+
while remaining:
37+
item = await queue.get()
38+
if item is _SENTINEL:
39+
remaining -= 1
40+
elif isinstance(item, BaseException):
41+
raise item
42+
else:
43+
yield item
44+
finally:
45+
for task in tasks:
46+
task.cancel()
47+
await asyncio.gather(*tasks, return_exceptions=True)

‎tests/test_utils.py‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import asyncio
2+
from collections.abc import AsyncGenerator
3+
4+
import pytest
5+
6+
from taskiq_aio_pika.utils import merge_async_iterables
7+
8+
9+
async def _gen(*items: int, delay: float = 0.0) -> AsyncGenerator[int, None]:
10+
for item in items:
11+
if delay:
12+
await asyncio.sleep(delay)
13+
yield item
14+
15+
16+
async def test_when_multiple_generators_passed__then_all_items_are_yielded() -> None:
17+
result = [
18+
item
19+
async for item in merge_async_iterables(
20+
_gen(1, 2, 3),
21+
_gen(4, 5),
22+
)
23+
]
24+
25+
assert sorted(result) == [1, 2, 3, 4, 5]
26+
27+
28+
async def test_when_single_generator_passed__then_its_items_are_yielded_in_order() -> (
29+
None
30+
):
31+
result = [item async for item in merge_async_iterables(_gen(1, 2, 3))]
32+
33+
assert result == [1, 2, 3]
34+
35+
36+
async def test_when_no_generators_passed__then_nothing_is_yielded() -> None:
37+
result = [item async for item in merge_async_iterables()]
38+
39+
assert result == []
40+
41+
42+
async def test_when_one_generator_raises__then_exception_is_propagated() -> None:
43+
async def failing_gen() -> AsyncGenerator[int, None]:
44+
yield 1
45+
raise ValueError("boom")
46+
47+
with pytest.raises(ValueError, match="boom"):
48+
async for _ in merge_async_iterables(failing_gen(), _gen(2, delay=1)):
49+
pass
50+
51+
52+
async def test_when_one_generator_raises__then_other_generators_are_cancelled() -> None:
53+
other_was_cancelled = False
54+
55+
async def slow_gen() -> AsyncGenerator[int, None]:
56+
nonlocal other_was_cancelled
57+
try:
58+
await asyncio.sleep(10)
59+
yield 1
60+
except asyncio.CancelledError:
61+
other_was_cancelled = True
62+
raise
63+
64+
async def failing_gen() -> AsyncGenerator[int, None]:
65+
yield 1
66+
raise ValueError("boom")
67+
68+
with pytest.raises(ValueError, match="boom"):
69+
async for _ in merge_async_iterables(failing_gen(), slow_gen()):
70+
pass
71+
72+
assert other_was_cancelled
73+
74+
75+
async def test_when_consumer_stops_early__then_remaining_generators_are_cancelled() -> (
76+
None
77+
):
78+
other_was_cancelled = False
79+
80+
async def slow_gen() -> AsyncGenerator[int, None]:
81+
nonlocal other_was_cancelled
82+
try:
83+
await asyncio.sleep(10)
84+
yield 1
85+
except asyncio.CancelledError:
86+
other_was_cancelled = True
87+
raise
88+
89+
merged = merge_async_iterables(_gen(1), slow_gen())
90+
try:
91+
async for _ in merged:
92+
break
93+
finally:
94+
await merged.aclose()
95+
96+
assert other_was_cancelled

‎uv.lock‎

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

0 commit comments

Comments
 (0)