Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]

steps:
- uses: actions/checkout@v5
Expand Down
19 changes: 12 additions & 7 deletions async_tkinter_loop/async_tkinter_loop.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import _tkinter
import asyncio
import tkinter as tk
from collections.abc import Coroutine
from collections.abc import Callable, Coroutine
from functools import wraps
from typing import Any, Callable
from typing import Any

from typing_extensions import ParamSpec

Expand Down Expand Up @@ -31,22 +31,24 @@ async def main_loop(root: tk.Tk) -> None:

def get_event_loop() -> asyncio.AbstractEventLoop:
"""
A helper function which returns an event loop using current event loop policy.
A helper function which returns a running event loop.

Returns:
event loop
"""
return asyncio.get_event_loop_policy().get_event_loop()
return asyncio.get_running_loop()


def async_mainloop(root: tk.Tk) -> None:
def async_mainloop(root: tk.Tk, event_loop: asyncio.AbstractEventLoop | None = None) -> None:
"""
A function, which is a substitute to the standard `root.mainloop()`.

Args:
root: tkinter root object
event_loop: asyncio event loop (optional)
"""
get_event_loop().run_until_complete(main_loop(root))
event_loop = event_loop or asyncio.new_event_loop()
event_loop.run_until_complete(main_loop(root))


P = ParamSpec("P")
Expand All @@ -55,6 +57,7 @@ def async_mainloop(root: tk.Tk) -> None:
def async_handler(
async_function: Callable[P, Coroutine[Any, Any, None]],
*args: Any, # noqa: ANN401
event_loop: asyncio.AbstractEventLoop | None = None,
**kwargs: Any, # noqa: ANN401
) -> Callable[P, None]:
"""
Expand All @@ -64,6 +67,7 @@ def async_handler(
Args:
async_function: async function
args: positional parameters which will be passed to the async function
event_loop: asyncio event loop (optional, for testing purposes)
kwargs: keyword parameters which will be passed to the async function

Returns:
Expand Down Expand Up @@ -102,9 +106,10 @@ async def some_async_function():
button = tk.Button("Press me", command=some_async_function)
```
"""
event_loop = event_loop or get_event_loop()

@wraps(async_function)
def wrapper(*handler_args) -> None:
get_event_loop().create_task(async_function(*handler_args, *args, **kwargs))
event_loop.create_task(async_function(*handler_args, *args, **kwargs))

return wrapper
7 changes: 3 additions & 4 deletions examples/start_stop_counter.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ def start_stop():
event = asyncio.Event()

# Start background task
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
task = loop.create_task(counter())
event_loop = asyncio.new_event_loop()
task = event_loop.create_task(counter())

async_mainloop(root)
async_mainloop(root, event_loop)
60 changes: 4 additions & 56 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ classifiers = [
]

[tool.poetry.dependencies]
python = "^3.9"
python = "^3.10"
Pillow = {version = ">=10.3.0,<12.0.0", optional = true}
httpx = {version = ">=0.23.1,<0.29.0", optional = true}
customtkinter = {version = "^5.2.1", optional = true}
Expand All @@ -44,7 +44,7 @@ requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

[tool.ruff]
target-version = "py39"
target-version = "py310"
line-length = 120

[tool.ruff.lint]
Expand Down
31 changes: 16 additions & 15 deletions tests/test_async_tk_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,6 @@
TIMEOUT = 60


@pytest.mark.timeout(TIMEOUT)
def test_destroy():
root = Tk()
root.destroy()
async_mainloop(root)


@pytest.mark.timeout(TIMEOUT)
def test_async_command():
root = Tk()
Expand All @@ -25,9 +18,10 @@ async def button_pressed():
await asyncio.sleep(0.1)
root.destroy()

async_handler(button_pressed)()
event_loop = asyncio.new_event_loop()
async_handler(button_pressed, event_loop=event_loop)()

async_mainloop(root)
async_mainloop(root, event_loop)


@pytest.mark.timeout(TIMEOUT)
Expand All @@ -38,35 +32,42 @@ async def on_click(_event):
await asyncio.sleep(0.1)
root.destroy()

async_handler(on_click)(Mock("Event"))
event_loop = asyncio.new_event_loop()
async_handler(on_click, event_loop=event_loop)(Mock("Event"))

async_mainloop(root)
async_mainloop(root, event_loop)


@pytest.mark.timeout(TIMEOUT)
def test_async_command_as_decorator():
root = Tk()

# Simulate a click on a button which closes the window with some delay
@async_handler
# @async_handler
async def button_pressed():
await asyncio.sleep(0.1)
root.destroy()

event_loop = asyncio.new_event_loop()
button_pressed = async_handler(button_pressed, event_loop=event_loop)
button_pressed()

async_mainloop(root)
async_mainloop(root, event_loop)


@pytest.mark.timeout(TIMEOUT)
def test_async_event_handler_as_decorator():
root = Tk()

@async_handler
event_loop = asyncio.new_event_loop()

# @async_handler
async def on_click(_event):
await asyncio.sleep(0.1)
root.destroy()

event_loop = asyncio.new_event_loop()
on_click = async_handler(on_click, event_loop=event_loop)
on_click(Mock("Event"))

async_mainloop(root)
async_mainloop(root, event_loop)