Skip to content

Commit 88dfa98

Browse files
committed
feat: remove aiostream as a dependency
1 parent cf89301 commit 88dfa98

7 files changed

Lines changed: 182 additions & 50 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 ; python_version < '3.15'",
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: 21 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import sys
23
from collections.abc import AsyncGenerator, Callable
34
from datetime import timedelta
45
from logging import getLogger
@@ -7,10 +8,13 @@
78
import aiormq
89
from aio_pika import DeliveryMode, ExchangeType, Message, connect_robust
910
from aio_pika.abc import AbstractChannel, AbstractQueue, AbstractRobustConnection
10-
from aiostream import stream
1111
from pamqp.common import FieldTable
1212
from taskiq import AckableMessage, AsyncBroker, AsyncResultBackend, BrokerMessage
13-
from typing_extensions import Self
13+
14+
if sys.version_info >= (3, 11):
15+
from typing import Self
16+
else:
17+
from typing_extensions import Self
1418

1519
from taskiq_aio_pika.exceptions import (
1620
ExchangeNotDeclaredError,
@@ -20,6 +24,7 @@
2024
)
2125
from taskiq_aio_pika.exchange import Exchange
2226
from taskiq_aio_pika.queue import Queue
27+
from taskiq_aio_pika.utils import merge_async_iterables
2328

2429
_T = TypeVar("_T")
2530

@@ -69,25 +74,20 @@ def __init__(
6974
"""
7075
Construct a new broker.
7176
72-
:param url: url to rabbitmq. If None,
73-
the default "amqp://guest:guest@localhost:5672" is used.
77+
:param url: url to rabbitmq. If None, the default "amqp://guest:guest@localhost:5672" is used.
7478
:param result_backend: custom result backend.
7579
:param task_id_generator: custom task_id generator.
7680
:param qos: number of messages that worker can prefetch.
7781
:param loop: specific even loop.
7882
: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.
83+
:param task_queues: parameters of queues that will be used to get incoming messages.
8184
:param dead_letter_queue: parameters of dead-letter queue.
8285
: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.
86+
:param delayed_message_exchange_plugin: turn on or disable delayed-message-exchange rabbitmq plugin.
87+
:param delayed_message_exchange: parameters of exchange that used to send messages with delay.
8788
:param label_for_routing: label name to use for routing key selection.
8889
: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.
90+
:param connection_kwargs: additional keyword arguments, for connect_robust method of aio-pika.
9191
"""
9292
super().__init__(result_backend, task_id_generator)
9393

@@ -241,10 +241,8 @@ async def _declare_queues(
241241
"""
242242
Declare all queues.
243243
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.
244+
It's useful since aio-pika have automatic recover mechanism, which works only if the queue, you're going to
245+
listen was declared by aio-pika.
248246
249247
:param channel: channel to used for declaration.
250248
:return: list of declared queues and their consumer arguments.
@@ -323,8 +321,7 @@ def with_queue(self, queue: Queue) -> Self:
323321
"""
324322
Add new queue to the broker.
325323
326-
This method should be called before startup,
327-
otherwise the new queue won't be declared and bound to exchange.
324+
This method should be called before startup, otherwise the new queue won't be declared and bound to exchange.
328325
329326
:param queue: queue to add.
330327
:return: self.
@@ -346,12 +343,8 @@ async def kick(self, message: BrokerMessage) -> None:
346343
"""
347344
Send message to the exchange.
348345
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.
346+
This function constructs rmq message and sends it. The message has task_id and task_name and labels in headers.
347+
And message's routing key is the same as the task_name.
355348
356349
:raises NoStartupError: if startup wasn't called.
357350
:raises IncorrectRoutingKeyError: if routing key is incorrect.
@@ -420,8 +413,7 @@ async def listen(self) -> AsyncGenerator[AckableMessage, None]:
420413
"""
421414
Listen to queue.
422415
423-
This function listens to queue and
424-
yields every new message.
416+
This function listens to queue and yields every new message.
425417
426418
:raises NoStartupError: if startup wasn't called.
427419
:yields: parsed broker message.
@@ -446,17 +438,14 @@ async def body(
446438
# Suppress errors during iterator cleanup if channel is being closed
447439
logger.info("Queue iterator closed during shutdown")
448440

449-
combine = stream.merge(
441+
async for message in merge_async_iterables(
450442
*[
451443
body(queue, consumer_args)
452444
for queue, consumer_args in queue_with_consumer_args_list
453445
if not self._delay_queue or queue.name != self._delay_queue.name
454446
],
455-
)
456-
457-
async with combine.stream() as streamer:
458-
async for message in streamer:
459-
yield message
447+
):
448+
yield message
460449

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

‎taskiq_aio_pika/utils.py‎

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