atask 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.
- atask-0.1.0/LICENSE +21 -0
- atask-0.1.0/PKG-INFO +23 -0
- atask-0.1.0/README.md +1 -0
- atask-0.1.0/pyproject.toml +41 -0
- atask-0.1.0/src/atask/__init__.py +343 -0
- atask-0.1.0/src/atask/py.typed +0 -0
atask-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rocky Haotian Du
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
atask-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: atask
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AsyncTask
|
|
5
|
+
Author: Rocky Haotian Du
|
|
6
|
+
Author-email: Rocky Haotian Du <rocky_d@yeah.net>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Project-URL: Homepage, https://pypi.org/project/atask/
|
|
20
|
+
Project-URL: Repository, https://github.com/rocky-d/atask
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# atask
|
atask-0.1.0/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# atask
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.8.17,<0.9.0"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "atask"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "AsyncTask"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Rocky Haotian Du", email = "rocky_d@yeah.net" }]
|
|
13
|
+
requires-python = ">=3.12"
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
]
|
|
25
|
+
dependencies = []
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://pypi.org/project/atask/"
|
|
29
|
+
Repository = "https://github.com/rocky-d/atask"
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
dev = [
|
|
33
|
+
"ruff>=0.15.9",
|
|
34
|
+
"ty>=0.0.28",
|
|
35
|
+
"pytest>=8.0.0",
|
|
36
|
+
"pytest-asyncio>=1.0.0",
|
|
37
|
+
"pytest-cov>=6.0.0",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""Lightweight async task abstraction over `asyncio`.
|
|
2
|
+
|
|
3
|
+
- [Homepage](https://pypi.org/project/atask/)
|
|
4
|
+
- [Repository](https://github.com/rocky-d/atask)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio as aio
|
|
8
|
+
from contextvars import Context
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
from typing import Any, Awaitable, Generator, Iterable, Self, Type, final
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AsyncTask",
|
|
14
|
+
"AsyncTaskGroup",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AsyncTask[T](Awaitable[T]):
|
|
19
|
+
"""Abstract base for an async task with lifecycle-managed resources.
|
|
20
|
+
|
|
21
|
+
Bridges `asyncio.Task` with class resource management.
|
|
22
|
+
|
|
23
|
+
Lifecycle:
|
|
24
|
+
`__init__` -> `start` -> `_run` -> (`join` | `cancel`) -> `stop`
|
|
25
|
+
|
|
26
|
+
Subclasses override `_run` to define task logic. Parameters are passed via
|
|
27
|
+
`__init__`, not `_run`. Override `start`, `cancel`, `stop` for resource
|
|
28
|
+
management (always call `super()`).
|
|
29
|
+
|
|
30
|
+
Supports `await`, `async with`, and manual control flow.
|
|
31
|
+
|
|
32
|
+
Examples:
|
|
33
|
+
Define a task:
|
|
34
|
+
|
|
35
|
+
class MyTask(AsyncTask[int]):
|
|
36
|
+
async def _run(self) -> int:
|
|
37
|
+
await asyncio.sleep(1.0)
|
|
38
|
+
return 22
|
|
39
|
+
|
|
40
|
+
Manual lifecycle:
|
|
41
|
+
|
|
42
|
+
task = MyTask()
|
|
43
|
+
await task.start()
|
|
44
|
+
await task.join()
|
|
45
|
+
task.result # 22
|
|
46
|
+
await task.stop()
|
|
47
|
+
|
|
48
|
+
Async context manager:
|
|
49
|
+
|
|
50
|
+
async with MyTask() as task:
|
|
51
|
+
result = await task # 22
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
*,
|
|
57
|
+
name: str | None = None,
|
|
58
|
+
context: Context | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Initializes the task in a finished, unstarted state.
|
|
61
|
+
|
|
62
|
+
The internal future starts done with `asyncio.InvalidStateError` so
|
|
63
|
+
`start` is the sole transition into running state.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
name: Optional name forwarded to `asyncio.create_task`.
|
|
67
|
+
context: Optional `contextvars.Context` for the task.
|
|
68
|
+
"""
|
|
69
|
+
self._name = name
|
|
70
|
+
self._context = context
|
|
71
|
+
self._fut = aio.Future()
|
|
72
|
+
self._fut.set_exception(aio.InvalidStateError())
|
|
73
|
+
self._fut.exception()
|
|
74
|
+
self._started = False
|
|
75
|
+
|
|
76
|
+
@final
|
|
77
|
+
def __await__(
|
|
78
|
+
self,
|
|
79
|
+
) -> Generator[Any, None, T]:
|
|
80
|
+
"""Awaits task completion and returns the result."""
|
|
81
|
+
yield from self.join().__await__()
|
|
82
|
+
return self.result
|
|
83
|
+
|
|
84
|
+
@final
|
|
85
|
+
async def __aenter__(
|
|
86
|
+
self,
|
|
87
|
+
) -> Self:
|
|
88
|
+
"""Starts the task for use as an async context manager."""
|
|
89
|
+
await self.start()
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
@final
|
|
93
|
+
async def __aexit__(
|
|
94
|
+
self,
|
|
95
|
+
exc_type: Type[BaseException] | None,
|
|
96
|
+
exc_value: BaseException | None,
|
|
97
|
+
exc_traceback: TracebackType | None,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Stops the task upon exiting the async context."""
|
|
100
|
+
await self.stop(exc_type, exc_value, exc_traceback)
|
|
101
|
+
|
|
102
|
+
@final
|
|
103
|
+
@property
|
|
104
|
+
def name(
|
|
105
|
+
self,
|
|
106
|
+
) -> str | None:
|
|
107
|
+
"""The task name forwarded to `asyncio.create_task`."""
|
|
108
|
+
return self._name
|
|
109
|
+
|
|
110
|
+
@final
|
|
111
|
+
@property
|
|
112
|
+
def context(
|
|
113
|
+
self,
|
|
114
|
+
) -> Context | None:
|
|
115
|
+
"""The `contextvars.Context` for the task."""
|
|
116
|
+
return self._context
|
|
117
|
+
|
|
118
|
+
@final
|
|
119
|
+
@property
|
|
120
|
+
def started(
|
|
121
|
+
self,
|
|
122
|
+
) -> bool:
|
|
123
|
+
"""Whether resources have been acquired and not yet released."""
|
|
124
|
+
return self._started
|
|
125
|
+
|
|
126
|
+
@final
|
|
127
|
+
@property
|
|
128
|
+
def done(
|
|
129
|
+
self,
|
|
130
|
+
) -> bool:
|
|
131
|
+
"""Whether the underlying future has completed."""
|
|
132
|
+
return self._fut.done()
|
|
133
|
+
|
|
134
|
+
@final
|
|
135
|
+
@property
|
|
136
|
+
def cancelled(
|
|
137
|
+
self,
|
|
138
|
+
) -> bool:
|
|
139
|
+
"""Whether the task was cancelled."""
|
|
140
|
+
return self._fut.cancelled()
|
|
141
|
+
|
|
142
|
+
@final
|
|
143
|
+
@property
|
|
144
|
+
def running(
|
|
145
|
+
self,
|
|
146
|
+
) -> bool:
|
|
147
|
+
"""Whether the task is currently executing (`started` and not `done`)."""
|
|
148
|
+
return self.started and not self.done
|
|
149
|
+
|
|
150
|
+
@final
|
|
151
|
+
@property
|
|
152
|
+
def result(
|
|
153
|
+
self,
|
|
154
|
+
) -> T:
|
|
155
|
+
"""The task result. Raises if not done or if the task failed."""
|
|
156
|
+
return self._fut.result()
|
|
157
|
+
|
|
158
|
+
@final
|
|
159
|
+
@property
|
|
160
|
+
def exception(
|
|
161
|
+
self,
|
|
162
|
+
) -> BaseException | None:
|
|
163
|
+
"""The exception raised by the task, or `None` on success."""
|
|
164
|
+
return self._fut.exception()
|
|
165
|
+
|
|
166
|
+
async def _run(
|
|
167
|
+
self,
|
|
168
|
+
) -> T:
|
|
169
|
+
"""Defines the task's async logic.
|
|
170
|
+
|
|
171
|
+
Subclasses must override this. Accepts no arguments beyond `self`;
|
|
172
|
+
configure parameters via `__init__` instead.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
The task result of type `T`.
|
|
176
|
+
|
|
177
|
+
Raises:
|
|
178
|
+
NotImplementedError: If not overridden.
|
|
179
|
+
"""
|
|
180
|
+
raise NotImplementedError()
|
|
181
|
+
|
|
182
|
+
async def start(
|
|
183
|
+
self,
|
|
184
|
+
) -> None:
|
|
185
|
+
"""Acquires resources and starts the task.
|
|
186
|
+
|
|
187
|
+
No-op if already started. Overrides should acquire resources before
|
|
188
|
+
calling `super().start()`.
|
|
189
|
+
"""
|
|
190
|
+
if self.started:
|
|
191
|
+
return
|
|
192
|
+
self._started = True
|
|
193
|
+
self._fut = aio.create_task(self._run(), name=self._name, context=self._context)
|
|
194
|
+
|
|
195
|
+
@final
|
|
196
|
+
async def join(
|
|
197
|
+
self,
|
|
198
|
+
) -> None:
|
|
199
|
+
"""Waits for the task to complete. No-op if not started."""
|
|
200
|
+
if not self.started:
|
|
201
|
+
return
|
|
202
|
+
await self._fut
|
|
203
|
+
|
|
204
|
+
async def cancel(
|
|
205
|
+
self,
|
|
206
|
+
msg: Any | None = None,
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Cancels the running task.
|
|
209
|
+
|
|
210
|
+
No-op if not started. Overrides should cancel managed resources
|
|
211
|
+
before calling `super().cancel()`.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
msg: Optional cancellation message.
|
|
215
|
+
"""
|
|
216
|
+
if not self.started:
|
|
217
|
+
return
|
|
218
|
+
self._fut.cancel(msg=msg)
|
|
219
|
+
try:
|
|
220
|
+
await self._fut
|
|
221
|
+
except aio.CancelledError:
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
async def stop(
|
|
225
|
+
self,
|
|
226
|
+
exc_type: Type[BaseException] | None = None,
|
|
227
|
+
exc_value: BaseException | None = None,
|
|
228
|
+
exc_traceback: TracebackType | None = None,
|
|
229
|
+
) -> None:
|
|
230
|
+
"""Releases resources after the task has finished.
|
|
231
|
+
|
|
232
|
+
Requires the task to be started and done. Raises if the task is
|
|
233
|
+
still running. Overrides should release managed resources before
|
|
234
|
+
calling `super().stop()`.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
exc_type: Exception type from the async context manager, if any.
|
|
238
|
+
exc_value: Exception value from the async context manager, if any.
|
|
239
|
+
exc_traceback: Traceback from the async context manager, if any.
|
|
240
|
+
|
|
241
|
+
Raises:
|
|
242
|
+
asyncio.InvalidStateError: If the task is still running.
|
|
243
|
+
"""
|
|
244
|
+
if not self.started:
|
|
245
|
+
return
|
|
246
|
+
if not self.done:
|
|
247
|
+
raise aio.InvalidStateError() from exc_value
|
|
248
|
+
self._started = False
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class AsyncTaskGroup[T](AsyncTask[list[T]]):
|
|
252
|
+
"""An `AsyncTask` subclass that runs multiple tasks concurrently.
|
|
253
|
+
|
|
254
|
+
Delegates lifecycle operations to all contained tasks. Results are
|
|
255
|
+
collected as a list preserving insertion order.
|
|
256
|
+
|
|
257
|
+
Examples:
|
|
258
|
+
Run tasks concurrently:
|
|
259
|
+
|
|
260
|
+
group = AsyncTaskGroup([TaskA(), TaskB()])
|
|
261
|
+
async with group as g:
|
|
262
|
+
results = await g # [result_a, result_b]
|
|
263
|
+
"""
|
|
264
|
+
|
|
265
|
+
def __init__(
|
|
266
|
+
self,
|
|
267
|
+
atsks: Iterable[AsyncTask[T]],
|
|
268
|
+
*,
|
|
269
|
+
name: str | None = None,
|
|
270
|
+
context: Context | None = None,
|
|
271
|
+
) -> None:
|
|
272
|
+
"""Initializes the group with the given tasks.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
atsks: `AsyncTask` instances to run concurrently.
|
|
276
|
+
name: Optional group name forwarded to `asyncio.create_task`.
|
|
277
|
+
context: Optional `contextvars.Context` for the group task.
|
|
278
|
+
"""
|
|
279
|
+
super().__init__(name=name, context=context)
|
|
280
|
+
self._atsks = list(atsks)
|
|
281
|
+
|
|
282
|
+
async def _run(
|
|
283
|
+
self,
|
|
284
|
+
) -> list[T]:
|
|
285
|
+
"""Joins all subtasks concurrently and collects their results.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
List of results in insertion order.
|
|
289
|
+
"""
|
|
290
|
+
async with aio.TaskGroup() as tg:
|
|
291
|
+
for atsk in self._atsks:
|
|
292
|
+
tg.create_task(atsk.join())
|
|
293
|
+
return [atsk.result for atsk in self._atsks]
|
|
294
|
+
|
|
295
|
+
async def start(
|
|
296
|
+
self,
|
|
297
|
+
) -> None:
|
|
298
|
+
"""Starts all subtasks concurrently, then starts the group task."""
|
|
299
|
+
if self.started:
|
|
300
|
+
return
|
|
301
|
+
async with aio.TaskGroup() as tg:
|
|
302
|
+
for atsk in self._atsks:
|
|
303
|
+
tg.create_task(atsk.start())
|
|
304
|
+
await super().start()
|
|
305
|
+
|
|
306
|
+
async def cancel(
|
|
307
|
+
self,
|
|
308
|
+
msg: Any | None = None,
|
|
309
|
+
) -> None:
|
|
310
|
+
"""Cancels all subtasks concurrently, then cancels the group task.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
msg: Optional cancellation message.
|
|
314
|
+
"""
|
|
315
|
+
if not self.started:
|
|
316
|
+
return
|
|
317
|
+
async with aio.TaskGroup() as tg:
|
|
318
|
+
for atsk in self._atsks:
|
|
319
|
+
tg.create_task(atsk.cancel(msg))
|
|
320
|
+
await super().cancel(msg)
|
|
321
|
+
|
|
322
|
+
async def stop(
|
|
323
|
+
self,
|
|
324
|
+
exc_type: Type[BaseException] | None = None,
|
|
325
|
+
exc_value: BaseException | None = None,
|
|
326
|
+
exc_traceback: TracebackType | None = None,
|
|
327
|
+
) -> None:
|
|
328
|
+
"""Stops all subtasks concurrently, then stops the group task.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
exc_type: Exception type from the async context manager, if any.
|
|
332
|
+
exc_value: Exception value from the async context manager, if any.
|
|
333
|
+
exc_traceback: Traceback from the async context manager, if any.
|
|
334
|
+
|
|
335
|
+
Raises:
|
|
336
|
+
asyncio.InvalidStateError: If any task is still running.
|
|
337
|
+
"""
|
|
338
|
+
if not self.started:
|
|
339
|
+
return
|
|
340
|
+
async with aio.TaskGroup() as tg:
|
|
341
|
+
for atsk in self._atsks:
|
|
342
|
+
tg.create_task(atsk.stop(exc_type, exc_value, exc_traceback))
|
|
343
|
+
await super().stop(exc_type, exc_value, exc_traceback)
|
|
File without changes
|