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.
- asynclet-0.1.0/.gitignore +57 -0
- asynclet-0.1.0/PKG-INFO +125 -0
- asynclet-0.1.0/README.md +96 -0
- asynclet-0.1.0/asynclet/__init__.py +16 -0
- asynclet-0.1.0/asynclet/manager.py +167 -0
- asynclet-0.1.0/asynclet/py.typed +0 -0
- asynclet-0.1.0/asynclet/scheduler.py +18 -0
- asynclet-0.1.0/asynclet/session.py +25 -0
- asynclet-0.1.0/asynclet/task.py +134 -0
- asynclet-0.1.0/asynclet/worker.py +60 -0
- asynclet-0.1.0/pyproject.toml +49 -0
|
@@ -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/
|
asynclet-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
asynclet-0.1.0/README.md
ADDED
|
@@ -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"]
|