Skip to content

Commit 3b7d32e

Browse files
committed
feat(cli): add event loop factory option
1 parent ced1909 commit 3b7d32e

11 files changed

Lines changed: 256 additions & 9 deletions

File tree

docs/guide/cli.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ The number of signals before a hard kill can be configured with the `--hardkill-
167167
* `--no-propagate-errors` - if this parameter is enabled, exceptions won't be thrown in generator dependencies.
168168
* `--receiver` - python path to custom receiver class.
169169
* `--receiver_arg` - custom args for receiver.
170+
* `--loop-factory` - python path to an event loop factory in `module:variable` format. When set, this overrides automatic uvloop selection.
170171
* `--ack-type` - Type of acknowledgement. This parameter is used to set when to acknowledge the task. Possible values are `when_received`, `when_executed`, `when_saved`, `manual`. Default is `when_saved`.
171172
* `--max-tasks-per-child` - maximum number of tasks to be executed by a single worker process before restart.
172173
* `--max-fails` - Maximum number of child process exits.
@@ -200,5 +201,6 @@ Path to scheduler is the only required argument.
200201
- `--fs-discover` or `-fsd`. This option enables search of task files in current directory recursively, using the given pattern.
201202
- `--no-configure-logging` - use this parameter if your application configures custom logging.
202203
- `--log-level` is used to set a log level (default `INFO`).
204+
- `--loop-factory` - python path to an event loop factory in `module:variable` format.
203205
- `--skip-first-run` - skip first run of scheduler. This option skips running tasks immediately after scheduler start.
204206
- `--update-interval` - interval in seconds to check for new tasks. By default scheduler will check for new scheduled tasks every first second of the minute.

taskiq/cli/scheduler/args.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class SchedulerArgs:
2020
skip_first_run: bool = False
2121
update_interval: int | None = None
2222
loop_interval: int | None = None
23+
loop_factory: str | None = None
2324

2425
@classmethod
2526
def from_cli(cls, args: Sequence[str] | None = None) -> "SchedulerArgs":
@@ -111,6 +112,15 @@ def from_cli(cls, args: Sequence[str] | None = None) -> "SchedulerArgs":
111112
"If not specified, scheduler will run once a second."
112113
),
113114
)
115+
parser.add_argument(
116+
"--loop-factory",
117+
default=None,
118+
help=(
119+
"Where to search for an event loop factory. "
120+
"This string must be specified in "
121+
"'module.module:variable' format."
122+
),
123+
)
114124

115125
namespace = parser.parse_args(args)
116126
# If there are any patterns specified, remove default.

taskiq/cli/scheduler/cmd.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import asyncio
22
from collections.abc import Sequence
3+
from functools import partial
4+
5+
import anyio
36

47
from taskiq.abc.cmd import TaskiqCMD
58
from taskiq.cli.scheduler.args import SchedulerArgs
69
from taskiq.cli.scheduler.run import run_scheduler
10+
from taskiq.cli.utils import create_event_loop, resolve_loop_factory
711

812

913
class SchedulerCMD(TaskiqCMD):
@@ -23,4 +27,17 @@ def exec(self, args: Sequence[str]) -> None:
2327
:param args: CLI arguments.
2428
"""
2529
parsed = SchedulerArgs.from_cli(args)
26-
asyncio.run(run_scheduler(parsed))
30+
if parsed.loop_factory is None:
31+
asyncio.run(run_scheduler(parsed))
32+
return
33+
loop_factory = resolve_loop_factory(
34+
parsed.loop_factory,
35+
app_dir=parsed.app_dir,
36+
)
37+
anyio.run(
38+
run_scheduler,
39+
parsed,
40+
backend_options={
41+
"loop_factory": partial(create_event_loop, loop_factory),
42+
},
43+
)

taskiq/cli/utils.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import asyncio
12
import os
23
import sys
3-
from collections.abc import Generator, Sequence
4+
from collections.abc import Callable, Generator, Sequence
45
from contextlib import contextmanager
56
from importlib import import_module
67
from logging import getLogger
@@ -9,6 +10,8 @@
910

1011
logger = getLogger("taskiq.worker")
1112

13+
LoopFactory = Callable[[], asyncio.AbstractEventLoop]
14+
1215

1316
@contextmanager
1417
def add_cwd_in_path() -> Generator[None, None, None]:
@@ -55,6 +58,38 @@ def import_object(object_spec: str, app_dir: str | None = None) -> Any:
5558
return getattr(module, import_spec[1])
5659

5760

61+
def resolve_loop_factory(
62+
loop_factory: str,
63+
app_dir: str | None = None,
64+
) -> LoopFactory:
65+
"""
66+
Resolve an event loop factory from a callable or import string.
67+
68+
:param loop_factory: path in `module:variable` format.
69+
:param app_dir: directory to add in sys.path for importing.
70+
:raises ValueError: if the resolved object is not callable.
71+
:return: event loop factory.
72+
"""
73+
factory = import_object(loop_factory, app_dir=app_dir)
74+
if not callable(factory):
75+
raise ValueError("Event loop factory must be callable.")
76+
return factory
77+
78+
79+
def create_event_loop(loop_factory: LoopFactory) -> asyncio.AbstractEventLoop:
80+
"""
81+
Create and validate an event loop from a factory.
82+
83+
:param loop_factory: event loop factory.
84+
:raises ValueError: if the factory does not return an event loop.
85+
:return: created event loop.
86+
"""
87+
loop = loop_factory()
88+
if not isinstance(loop, asyncio.AbstractEventLoop):
89+
raise ValueError("Event loop factory must return an event loop.")
90+
return loop
91+
92+
5893
def import_from_modules(modules: list[str]) -> None:
5994
"""
6095
Import all modules from modules variable.

taskiq/cli/worker/args.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ class WorkerArgs:
5555
wait_tasks_timeout: float | None = None
5656
hardkill_count: int = 3
5757
use_process_pool: bool = False
58+
loop_factory: str | None = None
5859

5960
@classmethod
6061
def from_cli(
@@ -281,6 +282,15 @@ def from_cli(
281282
default=None,
282283
help="Maximum number of processes in process pool.",
283284
)
285+
parser.add_argument(
286+
"--loop-factory",
287+
default=None,
288+
help=(
289+
"Where to search for an event loop factory. "
290+
"This string must be specified in "
291+
"'module.module:variable' format."
292+
),
293+
)
284294

285295
namespace = parser.parse_args(
286296
args,

taskiq/cli/worker/run.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@
1010
from typing import Any
1111

1212
from taskiq.abc.broker import AsyncBroker
13-
from taskiq.cli.utils import import_object, import_tasks
13+
from taskiq.cli.utils import (
14+
create_event_loop,
15+
import_object,
16+
import_tasks,
17+
resolve_loop_factory,
18+
)
1419
from taskiq.cli.worker.args import WorkerArgs
1520
from taskiq.cli.worker.process_manager import ProcessManager
1621
from taskiq.receiver import Receiver
@@ -29,6 +34,16 @@
2934
logger = logging.getLogger("taskiq.worker")
3035

3136

37+
def _create_worker_event_loop(args: WorkerArgs) -> asyncio.AbstractEventLoop:
38+
if args.loop_factory is not None:
39+
loop_factory = resolve_loop_factory(args.loop_factory, app_dir=args.app_dir)
40+
return create_event_loop(loop_factory)
41+
if uvloop is not None:
42+
logger.debug("UVLOOP found. Using it as async runner")
43+
return uvloop.new_event_loop() # type: ignore
44+
return asyncio.new_event_loop()
45+
46+
3247
async def shutdown_broker(broker: AsyncBroker, timeout: float) -> None:
3348
"""
3449
This function used to shutdown broker.
@@ -120,11 +135,7 @@ def interrupt_handler(signum: int, _frame: Any) -> None:
120135
if sys.platform != "win32":
121136
signal.signal(signal.SIGHUP, interrupt_handler)
122137

123-
if uvloop is not None:
124-
logger.debug("UVLOOP found. Using it as async runner")
125-
loop = uvloop.new_event_loop() # type: ignore
126-
else:
127-
loop = asyncio.new_event_loop()
138+
loop = _create_worker_event_loop(args)
128139

129140
asyncio.set_event_loop(loop)
130141

tests/cli/scheduler/test_cmd.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import asyncio
2+
from unittest.mock import patch
3+
4+
from taskiq.cli.scheduler.args import SchedulerArgs
5+
from taskiq.cli.scheduler.cmd import SchedulerCMD
6+
7+
8+
def test_scheduler_runs_on_configured_event_loop() -> None:
9+
parsed = SchedulerArgs(
10+
scheduler="example:scheduler",
11+
modules=[],
12+
loop_factory="asyncio:SelectorEventLoop",
13+
)
14+
running_loop: asyncio.AbstractEventLoop | None = None
15+
16+
async def run_scheduler(_args: SchedulerArgs) -> None:
17+
nonlocal running_loop
18+
running_loop = asyncio.get_running_loop()
19+
20+
with (
21+
patch.object(SchedulerArgs, "from_cli", return_value=parsed),
22+
patch("taskiq.cli.scheduler.cmd.run_scheduler", new=run_scheduler),
23+
):
24+
SchedulerCMD().exec([])
25+
26+
assert isinstance(running_loop, asyncio.SelectorEventLoop)
27+
28+
29+
def test_scheduler_uses_default_event_loop_without_factory() -> None:
30+
parsed = SchedulerArgs(
31+
scheduler="example:scheduler",
32+
modules=[],
33+
)
34+
running_loop: asyncio.AbstractEventLoop | None = None
35+
36+
async def run_scheduler(_args: SchedulerArgs) -> None:
37+
nonlocal running_loop
38+
running_loop = asyncio.get_running_loop()
39+
40+
with (
41+
patch.object(SchedulerArgs, "from_cli", return_value=parsed),
42+
patch("taskiq.cli.scheduler.cmd.run_scheduler", new=run_scheduler),
43+
patch("taskiq.cli.scheduler.cmd.anyio.run") as anyio_run,
44+
):
45+
SchedulerCMD().exec([])
46+
47+
assert running_loop is not None
48+
anyio_run.assert_not_called()
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from taskiq.cli.scheduler.args import SchedulerArgs
2+
3+
4+
def test_loop_factory_accepts_import_string() -> None:
5+
args = SchedulerArgs.from_cli(
6+
["example:scheduler", "--loop-factory", "asyncio:SelectorEventLoop"],
7+
)
8+
9+
assert args.loop_factory == "asyncio:SelectorEventLoop"

tests/cli/test_utils.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,27 @@
1+
import asyncio
12
from contextlib import suppress
23
from pathlib import Path
34
from unittest.mock import patch
45

5-
from taskiq.cli.utils import import_tasks
6+
import pytest
7+
8+
from taskiq.cli.utils import create_event_loop, import_tasks, resolve_loop_factory
9+
10+
11+
def test_resolve_loop_factory_from_import_string() -> None:
12+
assert resolve_loop_factory("asyncio:new_event_loop") is asyncio.new_event_loop
13+
14+
15+
def test_resolve_loop_factory_rejects_non_callable() -> None:
16+
with pytest.raises(ValueError, match="must be callable"):
17+
resolve_loop_factory("asyncio:ALL_COMPLETED")
18+
19+
20+
def test_create_event_loop_rejects_invalid_result() -> None:
21+
factory = resolve_loop_factory("builtins:object")
22+
23+
with pytest.raises(ValueError, match="must return an event loop"):
24+
create_event_loop(factory)
625

726

827
def test_import_tasks_list_pattern() -> None:

tests/cli/worker/test_args.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,11 @@ def test_max_prefetch_rejects_negative_default(
3535

3636
assert exc_info.value.code == 2
3737
assert "max_prefetch cannot be negative" in capsys.readouterr().err
38+
39+
40+
def test_loop_factory_accepts_import_string() -> None:
41+
args = WorkerArgs.from_cli(
42+
["example:broker", "--loop-factory", "asyncio:SelectorEventLoop"],
43+
)
44+
45+
assert args.loop_factory == "asyncio:SelectorEventLoop"

0 commit comments

Comments
 (0)