asynclet 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,57 @@
1
+ # Byte-compiled / optimized
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Virtual environments
7
+ .venv/
8
+ venv/
9
+ ENV/
10
+
11
+ # Distribution / packaging
12
+ build/
13
+ dist/
14
+ *.egg-info/
15
+ *.egg
16
+ .eggs/
17
+ pip-wheel-metadata/
18
+
19
+ # Installer logs
20
+ pip-log.txt
21
+ pip-delete-this-directory.txt
22
+
23
+ # Testing / coverage
24
+ .pytest_cache/
25
+ .coverage
26
+ .coverage.*
27
+ htmlcov/
28
+ .tox/
29
+ .nox/
30
+ coverage.xml
31
+ *.cover
32
+ .hypothesis/
33
+
34
+ # Type checkers / linters
35
+ .mypy_cache/
36
+ .ruff_cache/
37
+ .dmypy.json
38
+ dmypy.json
39
+
40
+ # IDE / editor
41
+ .idea/
42
+ .vscode/
43
+ *.swp
44
+ *.swo
45
+ *~
46
+
47
+ # OS
48
+ .DS_Store
49
+ Thumbs.db
50
+
51
+ # Environment
52
+ .env
53
+ .env.local
54
+ *.local
55
+
56
+ # Hatch / build artifacts (if any stray)
57
+ wheels/
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: asynclet
3
+ Version: 0.1.0
4
+ Summary: Async task layer for Streamlit: background sync/async work, polling, progress, cancellation
5
+ Project-URL: Homepage, https://github.com/asynclet/asynclet
6
+ Author: asynclet contributors
7
+ License-Expression: MIT
8
+ Keywords: async,background,streamlit,tasks
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Requires-Python: >=3.9
18
+ Requires-Dist: asyncer>=0.0.2
19
+ Requires-Dist: janus>=1.0.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
22
+ Requires-Dist: pytest>=7.0; extra == 'dev'
23
+ Requires-Dist: streamlit>=1.33.0; extra == 'dev'
24
+ Provides-Extra: scheduler
25
+ Requires-Dist: apscheduler>=3.10.0; extra == 'scheduler'
26
+ Provides-Extra: streamlit
27
+ Requires-Dist: streamlit>=1.28.0; extra == 'streamlit'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # asynclet
31
+
32
+ Small **async task layer** for [Streamlit](https://streamlit.io/) (and similar “sync main thread + rerun” UIs): run sync or async work on a **dedicated background event loop**, then **poll** status, results, **progress**, and **cancellation** without blocking the UI thread.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install asynclet
38
+ ```
39
+
40
+ Optional extras:
41
+
42
+ - **Streamlit** (for a typical app environment): `pip install 'asynclet[streamlit]'`
43
+ - **APScheduler** (optional timers/jobs): `pip install 'asynclet[scheduler]'`
44
+
45
+ Requires **Python 3.9+**.
46
+
47
+ ## Quick start
48
+
49
+ ```python
50
+ import streamlit as st
51
+ import asynclet
52
+
53
+ task = asynclet.run(fetch_data)
54
+
55
+ if task.done:
56
+ st.write(task.result)
57
+ else:
58
+ st.write("Loading…")
59
+ ```
60
+
61
+ On each rerun, check `task.done` and read `task.result` when finished.
62
+
63
+ ## Public API
64
+
65
+ | Item | Role |
66
+ |------|------|
67
+ | `asynclet.run(func, /, *args, manager=None, **kwargs)` | Submit `func` on the worker; returns a `Task`. |
68
+ | `Task.done` | Whether the result (or error) is ready. |
69
+ | `Task.result` | Result value; raises if not complete. |
70
+ | `Task.status` | `TaskStatus`: `PENDING`, `RUNNING`, `DONE`, `ERROR`, `CANCELLED`. |
71
+ | `Task.error` | Exception object when `status` is `ERROR`, else `None`. |
72
+ | `Task.cancel()` | Request cancellation (running tasks use asyncio cancellation; pending tasks cancel the result future). |
73
+ | `Task.progress` | Non-blocking drain of progress values (see below). |
74
+ | `TaskManager` / `get_default_manager()` | Custom registry and `cleanup()` when you keep many completed tasks. |
75
+ | `session_tasks(session_state)` | Dict stored on `st.session_state` for named tasks. |
76
+
77
+ ## Progress (Janus)
78
+
79
+ For **async** functions only, declare a parameter named **`queue`** or **`progress_queue`**. If it is the **first** parameter, remaining positional arguments to `run()` map to the rest of the signature.
80
+
81
+ ```python
82
+ async def job(queue, steps: int):
83
+ for i in range(steps):
84
+ await queue.async_q.put(i)
85
+ return steps
86
+
87
+ task = asynclet.run(job, 10)
88
+ # Each rerun:
89
+ for x in task.progress:
90
+ st.write(x)
91
+ ```
92
+
93
+ The UI thread reads via `task.progress`, which pulls from the sync side of a [janus](https://github.com/aio-libs/janus) queue.
94
+
95
+ ## Streamlit session state
96
+
97
+ ```python
98
+ import streamlit as st
99
+ import asynclet
100
+
101
+ tasks = asynclet.session_tasks(st.session_state)
102
+ if "load" not in tasks:
103
+ tasks["load"] = asynclet.run(load_data)
104
+
105
+ task = tasks["load"]
106
+ ```
107
+
108
+ ## How it works (short)
109
+
110
+ - One **daemon thread** runs a single **asyncio** event loop.
111
+ - **Async** callables run on that loop; **sync** callables run via [asyncer](https://github.com/tiangolo/asyncer)’s `asyncify` (thread pool).
112
+ - Submissions use `asyncio.run_coroutine_threadsafe`; results are bridged with a `concurrent.futures.Future` for the polling API.
113
+
114
+ ## Development
115
+
116
+ ```bash
117
+ pip install -e '.[dev]'
118
+ pytest
119
+ ```
120
+
121
+ The **dev** extra includes Streamlit so CI can run headless **[AppTest](https://docs.streamlit.io/develop/api-reference/app-testing)** checks in `tests/test_streamlit_apptest.py` against the sample apps under `tests/streamlit_apps/`.
122
+
123
+ ## License
124
+
125
+ MIT.
@@ -0,0 +1,96 @@
1
+ # asynclet
2
+
3
+ Small **async task layer** for [Streamlit](https://streamlit.io/) (and similar “sync main thread + rerun” UIs): run sync or async work on a **dedicated background event loop**, then **poll** status, results, **progress**, and **cancellation** without blocking the UI thread.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install asynclet
9
+ ```
10
+
11
+ Optional extras:
12
+
13
+ - **Streamlit** (for a typical app environment): `pip install 'asynclet[streamlit]'`
14
+ - **APScheduler** (optional timers/jobs): `pip install 'asynclet[scheduler]'`
15
+
16
+ Requires **Python 3.9+**.
17
+
18
+ ## Quick start
19
+
20
+ ```python
21
+ import streamlit as st
22
+ import asynclet
23
+
24
+ task = asynclet.run(fetch_data)
25
+
26
+ if task.done:
27
+ st.write(task.result)
28
+ else:
29
+ st.write("Loading…")
30
+ ```
31
+
32
+ On each rerun, check `task.done` and read `task.result` when finished.
33
+
34
+ ## Public API
35
+
36
+ | Item | Role |
37
+ |------|------|
38
+ | `asynclet.run(func, /, *args, manager=None, **kwargs)` | Submit `func` on the worker; returns a `Task`. |
39
+ | `Task.done` | Whether the result (or error) is ready. |
40
+ | `Task.result` | Result value; raises if not complete. |
41
+ | `Task.status` | `TaskStatus`: `PENDING`, `RUNNING`, `DONE`, `ERROR`, `CANCELLED`. |
42
+ | `Task.error` | Exception object when `status` is `ERROR`, else `None`. |
43
+ | `Task.cancel()` | Request cancellation (running tasks use asyncio cancellation; pending tasks cancel the result future). |
44
+ | `Task.progress` | Non-blocking drain of progress values (see below). |
45
+ | `TaskManager` / `get_default_manager()` | Custom registry and `cleanup()` when you keep many completed tasks. |
46
+ | `session_tasks(session_state)` | Dict stored on `st.session_state` for named tasks. |
47
+
48
+ ## Progress (Janus)
49
+
50
+ For **async** functions only, declare a parameter named **`queue`** or **`progress_queue`**. If it is the **first** parameter, remaining positional arguments to `run()` map to the rest of the signature.
51
+
52
+ ```python
53
+ async def job(queue, steps: int):
54
+ for i in range(steps):
55
+ await queue.async_q.put(i)
56
+ return steps
57
+
58
+ task = asynclet.run(job, 10)
59
+ # Each rerun:
60
+ for x in task.progress:
61
+ st.write(x)
62
+ ```
63
+
64
+ The UI thread reads via `task.progress`, which pulls from the sync side of a [janus](https://github.com/aio-libs/janus) queue.
65
+
66
+ ## Streamlit session state
67
+
68
+ ```python
69
+ import streamlit as st
70
+ import asynclet
71
+
72
+ tasks = asynclet.session_tasks(st.session_state)
73
+ if "load" not in tasks:
74
+ tasks["load"] = asynclet.run(load_data)
75
+
76
+ task = tasks["load"]
77
+ ```
78
+
79
+ ## How it works (short)
80
+
81
+ - One **daemon thread** runs a single **asyncio** event loop.
82
+ - **Async** callables run on that loop; **sync** callables run via [asyncer](https://github.com/tiangolo/asyncer)’s `asyncify` (thread pool).
83
+ - Submissions use `asyncio.run_coroutine_threadsafe`; results are bridged with a `concurrent.futures.Future` for the polling API.
84
+
85
+ ## Development
86
+
87
+ ```bash
88
+ pip install -e '.[dev]'
89
+ pytest
90
+ ```
91
+
92
+ The **dev** extra includes Streamlit so CI can run headless **[AppTest](https://docs.streamlit.io/develop/api-reference/app-testing)** checks in `tests/test_streamlit_apptest.py` against the sample apps under `tests/streamlit_apps/`.
93
+
94
+ ## License
95
+
96
+ MIT.
@@ -0,0 +1,16 @@
1
+ """Async task layer for Streamlit: background execution, polling, progress, cancellation."""
2
+
3
+ from asynclet.manager import TaskManager, get_default_manager, run
4
+ from asynclet.session import session_tasks
5
+ from asynclet.task import Task, TaskStatus
6
+
7
+ __all__ = [
8
+ "Task",
9
+ "TaskStatus",
10
+ "TaskManager",
11
+ "get_default_manager",
12
+ "run",
13
+ "session_tasks",
14
+ ]
15
+
16
+ __version__ = "0.1.0"
@@ -0,0 +1,167 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import queue
6
+ import threading
7
+ from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar
8
+
9
+ from asyncer import asyncify
10
+
11
+ from asynclet.task import Task, TaskStatus, new_task_id
12
+ from asynclet.worker import submit_coro
13
+
14
+ T = TypeVar("T")
15
+
16
+ _default_manager: Optional["TaskManager"] = None
17
+ _default_manager_lock = threading.Lock()
18
+
19
+
20
+ def _bind_progress_queue(
21
+ func: Callable[..., Any],
22
+ queue: Any,
23
+ args: Tuple[Any, ...],
24
+ kwargs: Dict[str, Any],
25
+ ) -> Tuple[Tuple[Any, ...], Dict[str, Any]]:
26
+ """Pass the Janus queue as the first positional arg if named ``queue`` / ``progress_queue``, else as a keyword."""
27
+ sig = inspect.signature(func)
28
+ params = list(sig.parameters.keys())
29
+ if not params:
30
+ return args, kwargs
31
+ first = params[0]
32
+ if first in ("progress_queue", "queue"):
33
+ kw = {k: v for k, v in kwargs.items() if k not in ("progress_queue", "queue")}
34
+ return (queue,) + args, kw
35
+ if "progress_queue" in sig.parameters:
36
+ return args, {**kwargs, "progress_queue": queue}
37
+ if "queue" in sig.parameters:
38
+ return args, {**kwargs, "queue": queue}
39
+ return args, kwargs
40
+
41
+
42
+ def _wants_progress_queue(func: Callable[..., Any]) -> bool:
43
+ if not inspect.iscoroutinefunction(func):
44
+ return False
45
+ sig = inspect.signature(func)
46
+ return "progress_queue" in sig.parameters or "queue" in sig.parameters
47
+
48
+
49
+ class TaskManager:
50
+ """Submits work to the asynclet worker, tracks tasks, and trims completed entries."""
51
+
52
+ def __init__(self, *, max_completed: int = 256) -> None:
53
+ self._tasks: Dict[str, Task[Any]] = {}
54
+ self._lock = threading.Lock()
55
+ self._max_completed = max_completed
56
+
57
+ def submit(self, func: Callable[..., T], /, *args: Any, **kwargs: Any) -> Task[T]:
58
+ task_id = new_task_id()
59
+ task: Task[T] = Task(task_id)
60
+ with self._lock:
61
+ self._tasks[task_id] = task
62
+ submit_coro(self._execute(task, func, args, kwargs))
63
+ self._cleanup_if_needed()
64
+ return task
65
+
66
+ def get(self, task_id: str) -> Optional[Task[Any]]:
67
+ with self._lock:
68
+ return self._tasks.get(task_id)
69
+
70
+ def cleanup(self) -> int:
71
+ """Remove oldest completed tasks if the registry exceeds ``max_completed``."""
72
+ removed = 0
73
+ with self._lock:
74
+ done_ids = [tid for tid, t in self._tasks.items() if t.done]
75
+ overflow = len(done_ids) - self._max_completed
76
+ if overflow <= 0:
77
+ return 0
78
+ for tid in done_ids[:overflow]:
79
+ self._tasks.pop(tid, None)
80
+ removed += 1
81
+ return removed
82
+
83
+ def _cleanup_if_needed(self) -> None:
84
+ with self._lock:
85
+ completed = sum(1 for t in self._tasks.values() if t.done)
86
+ if completed > self._max_completed:
87
+ self.cleanup()
88
+
89
+ async def _execute(
90
+ self,
91
+ task: Task[Any],
92
+ func: Callable[..., Any],
93
+ args: Tuple[Any, ...],
94
+ kwargs: Dict[str, Any],
95
+ ) -> None:
96
+ progress_q = None
97
+ try:
98
+ current = asyncio.current_task()
99
+ if current is None:
100
+ raise RuntimeError("asynclet: missing asyncio task")
101
+ loop = asyncio.get_running_loop()
102
+ task._bind_worker_task(current, loop)
103
+ if task.status == TaskStatus.CANCELLED:
104
+ raise asyncio.CancelledError
105
+
106
+ if inspect.iscoroutinefunction(func):
107
+ if _wants_progress_queue(func):
108
+ import janus
109
+
110
+ progress_q = janus.Queue()
111
+ task._set_progress_queue(progress_q)
112
+ args, kwargs = _bind_progress_queue(func, progress_q, args, kwargs)
113
+ result = await func(*args, **kwargs)
114
+ else:
115
+ runner = asyncify(func)
116
+ result = await runner(*args, **kwargs)
117
+
118
+ if task.status != TaskStatus.CANCELLED:
119
+ task._complete_ok(result)
120
+ except asyncio.CancelledError:
121
+ task._complete_cancelled()
122
+ except Exception as exc:
123
+ if task.status != TaskStatus.CANCELLED:
124
+ task._complete_error(exc)
125
+ finally:
126
+ if progress_q is not None:
127
+ tail: List[Any] = []
128
+ while True:
129
+ try:
130
+ tail.append(progress_q.sync_q.get_nowait())
131
+ except queue.Empty:
132
+ break
133
+ task._buffer_progress_tail(tail)
134
+ progress_q.close()
135
+ await progress_q.wait_closed()
136
+ task._clear_progress_queue_ref()
137
+
138
+ def register_global(self, task: Task[Any], name: str) -> None:
139
+ """Alias a task for shared lookup (e.g. ``get(f'global:{name}')``)."""
140
+ with self._lock:
141
+ self._tasks[f"global:{name}"] = task
142
+
143
+
144
+ def get_default_manager() -> TaskManager:
145
+ global _default_manager
146
+ with _default_manager_lock:
147
+ if _default_manager is None:
148
+ _default_manager = TaskManager()
149
+ return _default_manager
150
+
151
+
152
+ def run(
153
+ func: Callable[..., T],
154
+ /,
155
+ *args: Any,
156
+ manager: Optional[TaskManager] = None,
157
+ **kwargs: Any,
158
+ ) -> Task[T]:
159
+ """
160
+ Run ``func`` on the asynclet worker thread.
161
+
162
+ Async functions run on the worker event loop. Sync functions run via ``asyncer.asyncify``
163
+ (thread pool). If the coroutine declares a ``progress_queue`` or ``queue`` parameter, a
164
+ :class:`janus.Queue` is created and injected for streaming progress to the UI thread.
165
+ """
166
+ m = manager or get_default_manager()
167
+ return m.submit(func, *args, **kwargs)
File without changes
@@ -0,0 +1,18 @@
1
+ """
2
+ Optional APScheduler integration — install with ``pip install 'asynclet[scheduler]'``.
3
+
4
+ To drive timers on the same loop as asynclet tasks, obtain the loop with
5
+ ``asynclet.worker.get_worker_loop()`` and configure ``AsyncIOScheduler`` with that loop
6
+ (see APScheduler docs for ``AsyncIOScheduler(event_loop=...)``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ __all__: list[str] = []
12
+
13
+ try: # pragma: no branch - optional dependency
14
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler
15
+
16
+ __all__.append("AsyncIOScheduler")
17
+ except ImportError:
18
+ AsyncIOScheduler = None # type: ignore[misc,assignment]
@@ -0,0 +1,25 @@
1
+ """Streamlit session helpers (no hard dependency on Streamlit — pass ``st.session_state``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, MutableMapping
6
+
7
+
8
+ def session_tasks(
9
+ session_state: MutableMapping[str, Any],
10
+ key: str = "asynclet_tasks",
11
+ ) -> Dict[str, Any]:
12
+ """
13
+ Return a task registry dict stored on ``session_state[key]``.
14
+
15
+ Example::
16
+
17
+ import streamlit as st
18
+ import asynclet
19
+
20
+ tasks = asynclet.session_tasks(st.session_state)
21
+ tasks["fetch"] = asynclet.run(load_data)
22
+ """
23
+ if key not in session_state:
24
+ session_state[key] = {}
25
+ return session_state[key]
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import queue
5
+ import threading
6
+ import uuid
7
+ from concurrent import futures
8
+ from enum import Enum
9
+ from typing import Any, Generic, List, Optional, TypeVar
10
+
11
+ T = TypeVar("T")
12
+
13
+
14
+ class TaskStatus(Enum):
15
+ PENDING = "pending"
16
+ RUNNING = "running"
17
+ DONE = "done"
18
+ ERROR = "error"
19
+ CANCELLED = "cancelled"
20
+
21
+
22
+ class Task(Generic[T]):
23
+ """Handle for work running on the asynclet worker loop."""
24
+
25
+ def __init__(self, task_id: str) -> None:
26
+ self.id = task_id
27
+ self._status = TaskStatus.PENDING
28
+ self._result_fut: futures.Future[T] = futures.Future()
29
+ self._error: Optional[BaseException] = None
30
+ self._progress_queue: Any = None
31
+ self._progress_tail: List[Any] = []
32
+ self._asyncio_task: Optional[asyncio.Task[Any]] = None
33
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
34
+ self._lock = threading.Lock()
35
+
36
+ @property
37
+ def status(self) -> TaskStatus:
38
+ return self._status
39
+
40
+ @property
41
+ def done(self) -> bool:
42
+ return self._result_fut.done()
43
+
44
+ @property
45
+ def result(self) -> T:
46
+ if not self._result_fut.done():
47
+ raise RuntimeError("Task is not complete")
48
+ return self._result_fut.result()
49
+
50
+ @property
51
+ def error(self) -> Optional[BaseException]:
52
+ return self._error
53
+
54
+ @property
55
+ def progress(self) -> List[Any]:
56
+ """Drain pending progress values (non-blocking) from the Janus queue and any buffered tail."""
57
+ out: List[Any] = []
58
+ q = self._progress_queue
59
+ if q is not None:
60
+ sync_q = q.sync_q
61
+ while True:
62
+ try:
63
+ out.append(sync_q.get_nowait())
64
+ except queue.Empty:
65
+ break
66
+ if self._progress_tail:
67
+ out.extend(self._progress_tail)
68
+ self._progress_tail.clear()
69
+ return out
70
+
71
+ def cancel(self) -> bool:
72
+ """Request cancellation. Returns True if cancellation was scheduled or the result future was cancelled."""
73
+ with self._lock:
74
+ if self._status in (TaskStatus.DONE, TaskStatus.ERROR, TaskStatus.CANCELLED):
75
+ return False
76
+ aio_task = self._asyncio_task
77
+ loop = self._loop
78
+ pending = self._status == TaskStatus.PENDING
79
+ if aio_task is not None and loop is not None and not aio_task.done():
80
+
81
+ def _cancel() -> None:
82
+ aio_task.cancel()
83
+
84
+ loop.call_soon_threadsafe(_cancel)
85
+ return True
86
+ if pending:
87
+ with self._lock:
88
+ self._status = TaskStatus.CANCELLED
89
+ return self._result_fut.cancel()
90
+ return False
91
+
92
+ def _bind_worker_task(self, aio_task: asyncio.Task[Any], loop: asyncio.AbstractEventLoop) -> None:
93
+ with self._lock:
94
+ self._asyncio_task = aio_task
95
+ self._loop = loop
96
+ if self._status != TaskStatus.CANCELLED:
97
+ self._status = TaskStatus.RUNNING
98
+
99
+ def _set_progress_queue(self, queue_obj: Any) -> None:
100
+ self._progress_queue = queue_obj
101
+
102
+ def _clear_progress_queue_ref(self) -> None:
103
+ self._progress_queue = None
104
+
105
+ def _buffer_progress_tail(self, items: List[Any]) -> None:
106
+ if items:
107
+ self._progress_tail.extend(items)
108
+
109
+ def _complete_ok(self, value: T) -> None:
110
+ with self._lock:
111
+ if self._status == TaskStatus.CANCELLED:
112
+ return
113
+ self._status = TaskStatus.DONE
114
+ if not self._result_fut.done():
115
+ self._result_fut.set_result(value)
116
+
117
+ def _complete_error(self, exc: BaseException) -> None:
118
+ with self._lock:
119
+ if self._status == TaskStatus.CANCELLED:
120
+ return
121
+ self._status = TaskStatus.ERROR
122
+ self._error = exc
123
+ if not self._result_fut.done():
124
+ self._result_fut.set_exception(exc)
125
+
126
+ def _complete_cancelled(self) -> None:
127
+ with self._lock:
128
+ self._status = TaskStatus.CANCELLED
129
+ if not self._result_fut.done():
130
+ self._result_fut.cancel()
131
+
132
+
133
+ def new_task_id() -> str:
134
+ return str(uuid.uuid4())
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import atexit
5
+ import concurrent.futures
6
+ import threading
7
+ from typing import Any, Coroutine, Optional, TypeVar
8
+
9
+ T = TypeVar("T")
10
+
11
+ _loop: Optional[asyncio.AbstractEventLoop] = None
12
+ _thread: Optional[threading.Thread] = None
13
+ _lock = threading.Lock()
14
+
15
+
16
+ def _run_loop(loop: asyncio.AbstractEventLoop, ready: threading.Event) -> None:
17
+ asyncio.set_event_loop(loop)
18
+ ready.set()
19
+ loop.run_forever()
20
+
21
+
22
+ def get_worker_loop() -> asyncio.AbstractEventLoop:
23
+ """Return the dedicated asyncio loop (starts the worker thread on first use)."""
24
+ global _loop, _thread
25
+ with _lock:
26
+ if _thread is not None and _thread.is_alive() and _loop is not None:
27
+ return _loop
28
+ ready = threading.Event()
29
+ loop = asyncio.new_event_loop()
30
+ thread = threading.Thread(
31
+ target=_run_loop,
32
+ args=(loop, ready),
33
+ name="asynclet-worker",
34
+ daemon=True,
35
+ )
36
+ thread.start()
37
+ if not ready.wait(timeout=30.0):
38
+ raise RuntimeError("asynclet worker failed to start")
39
+ _loop = loop
40
+ _thread = thread
41
+ return loop
42
+
43
+
44
+ def submit_coro(coro: Coroutine[Any, Any, T]) -> concurrent.futures.Future[T]:
45
+ loop = get_worker_loop()
46
+ return asyncio.run_coroutine_threadsafe(coro, loop)
47
+
48
+
49
+ def shutdown_worker() -> None:
50
+ global _loop, _thread
51
+ with _lock:
52
+ loop, _loop = _loop, None
53
+ th, _thread = _thread, None
54
+ if loop is not None and loop.is_running():
55
+ loop.call_soon_threadsafe(loop.stop)
56
+ if th is not None:
57
+ th.join(timeout=5.0)
58
+
59
+
60
+ atexit.register(shutdown_worker)
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.21.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "asynclet"
7
+ version = "0.1.0"
8
+ description = "Async task layer for Streamlit: background sync/async work, polling, progress, cancellation"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [{ name = "asynclet contributors" }]
13
+ keywords = ["streamlit", "async", "background", "tasks"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ ]
24
+ dependencies = [
25
+ "asyncer>=0.0.2",
26
+ "janus>=1.0.0",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ streamlit = ["streamlit>=1.28.0"]
31
+ scheduler = ["apscheduler>=3.10.0"]
32
+ dev = [
33
+ "pytest>=7.0",
34
+ "pytest-asyncio>=0.21",
35
+ "streamlit>=1.33.0",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/asynclet/asynclet"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["asynclet"]
43
+
44
+ [tool.hatch.build.targets.sdist]
45
+ include = ["asynclet"]
46
+
47
+ [tool.pytest.ini_options]
48
+ asyncio_mode = "auto"
49
+ testpaths = ["tests"]