gentletask 0.1.0__py3-none-any.whl
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.
- gentletask-0.1.0.dist-info/METADATA +642 -0
- gentletask-0.1.0.dist-info/RECORD +5 -0
- gentletask-0.1.0.dist-info/WHEEL +4 -0
- gentletask-0.1.0.dist-info/licenses/LICENSE +21 -0
- gentletask.py +962 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gentletask
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Stoppable task hierarchies and semantic context (the throughline) for Python concurrency.
|
|
5
|
+
Project-URL: Homepage, https://github.com/acq4/gentletask
|
|
6
|
+
Project-URL: Repository, https://github.com/acq4/gentletask
|
|
7
|
+
Author: Martin Chase, Luke Campagnola
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 Martin Chase and Luke Campagnola
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Keywords: cancellation,concurrency,contextvars,tasks,threading
|
|
31
|
+
Classifier: Development Status :: 3 - Alpha
|
|
32
|
+
Classifier: Intended Audience :: Developers
|
|
33
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
34
|
+
Classifier: Operating System :: OS Independent
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
40
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
41
|
+
Requires-Python: >=3.10
|
|
42
|
+
Provides-Extra: dev
|
|
43
|
+
Requires-Dist: black; extra == 'dev'
|
|
44
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
45
|
+
Description-Content-Type: text/markdown
|
|
46
|
+
|
|
47
|
+
# gentletask
|
|
48
|
+
|
|
49
|
+
[](https://pypi.org/project/gentletask/)
|
|
50
|
+
[](https://pypi.org/project/gentletask/)
|
|
51
|
+
|
|
52
|
+
**Stoppable task hierarchies and a semantic-context "throughline" for Python concurrency.**
|
|
53
|
+
|
|
54
|
+
Python's concurrency primitives are capable but cumbersome. `concurrent.futures`
|
|
55
|
+
gives you threads and results; it does not give you a way to stop a hierarchy of
|
|
56
|
+
running tasks, propagate that stop signal inward, or know — when something goes
|
|
57
|
+
wrong three threads deep — what your program was actually trying to do when it
|
|
58
|
+
failed. `gentletask` fills those gaps without adding complexity that isn't
|
|
59
|
+
earned. A gentle task doesn't spin up a thread it doesn't need; when you ask it
|
|
60
|
+
to stop, it stops, asks its children to stop too, and waits for them. And it
|
|
61
|
+
carries a narrative of meaning across every boundary it crosses, so your logs
|
|
62
|
+
tell a story instead of a list of disconnected events.
|
|
63
|
+
|
|
64
|
+
Two problems, two primitives:
|
|
65
|
+
|
|
66
|
+
- **Stopability.** A `Task` can be stopped cooperatively. Stop signals propagate
|
|
67
|
+
down through hierarchies. Replacement blocking primitives (`sleep`, `Queue`,
|
|
68
|
+
`Event`, `poll`) respect the stop signal, so you don't have to instrument
|
|
69
|
+
every wait site by hand.
|
|
70
|
+
- **Semantic context.** A `SemanticStack` carries labeled context — *what* your
|
|
71
|
+
program is doing, not just *where* it is — across thread (and, when values are
|
|
72
|
+
pickleable, process) boundaries. The module singleton is called `throughline`:
|
|
73
|
+
a continuous thread of meaning running through all your concurrent execution.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Installation
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pip install gentletask
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`gentletask` is pure Python, requires **Python >= 3.10**, and has **no runtime
|
|
84
|
+
dependencies**.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Core concepts
|
|
89
|
+
|
|
90
|
+
### Task hierarchies and cooperative stopping
|
|
91
|
+
|
|
92
|
+
A **Task** is anything that satisfies the `Task` protocol — a unit of work that
|
|
93
|
+
can report whether it is `is_done` / `is_stopped`, be `wait()`-ed on, and be
|
|
94
|
+
asked to `stop()`. The two built-in implementations are `ThreadTask` (runs a
|
|
95
|
+
callable in its own daemon thread) and `WorkTask` (one job queued to a
|
|
96
|
+
long-lived `WorkerThread`).
|
|
97
|
+
|
|
98
|
+
Tasks form a hierarchy automatically: when a task is created or waited on from
|
|
99
|
+
*inside* another running task, it registers as a **child** of that task. Calling
|
|
100
|
+
`stop()` on a parent cascades the stop to every child, and when a task finishes
|
|
101
|
+
(normally or by exception) it stops any still-running children — unless they
|
|
102
|
+
were explicitly `detach()`-ed.
|
|
103
|
+
|
|
104
|
+
Stopping is **cooperative**: a task is never interrupted mid-statement. Instead,
|
|
105
|
+
the stop-aware blocking primitives (`sleep`, `check_stop`, `Queue.get`,
|
|
106
|
+
`Event.wait`, `poll`, and `Task.wait`) notice the stop at their wait site and
|
|
107
|
+
raise `Stopped`, which unwinds normally so your `finally` blocks run.
|
|
108
|
+
|
|
109
|
+
Stop propagation is **poll-free**: `stop()` *pushes* a notification rather than
|
|
110
|
+
relying on a polling loop. Before a primitive parks, it registers a zero-arg
|
|
111
|
+
callback via `add_stop_callback`; `stop()` fires those callbacks (exactly once,
|
|
112
|
+
on the first stop) to wake the waiter the instant it is requested. An idle wait
|
|
113
|
+
consumes no CPU, and stop latency is independent of any polling interval.
|
|
114
|
+
|
|
115
|
+
### The throughline / semantic stack
|
|
116
|
+
|
|
117
|
+
A `SemanticStack` is a context-local stack of *frames*, where each frame is an
|
|
118
|
+
ordered collection of key/value pairs. It is backed by a single `ContextVar`,
|
|
119
|
+
and each frame is stored as an immutable `tuple[tuple[str, Any], ...]`, so no
|
|
120
|
+
defensive copying is needed and returned dicts are always fresh.
|
|
121
|
+
|
|
122
|
+
The library keeps two `SemanticStack` singletons, each with one declared job:
|
|
123
|
+
|
|
124
|
+
- **`throughline`** — the public, human-readable narrative. It requires a `name`
|
|
125
|
+
on every frame and, by convention, carries nothing else. `task_chain()` and
|
|
126
|
+
`ThroughlineNameFilter` read this, and it is what shows up in your logs.
|
|
127
|
+
- **`_task_stack`** — private to the module. It requires a `task` on every frame
|
|
128
|
+
and exists only to track which `Task` is running; `current_task()` reads it.
|
|
129
|
+
Keeping the task object off the throughline keeps the narrative clean and
|
|
130
|
+
name-only.
|
|
131
|
+
|
|
132
|
+
Task code enters both stacks at once through the `task_context(task, name)`
|
|
133
|
+
helper. Context travels across thread boundaries automatically: `ThreadTask`
|
|
134
|
+
copies the calling context with `contextvars.copy_context()`, while `WorkTask`
|
|
135
|
+
*snapshots* both stacks at `submit()` time and restores them when the worker
|
|
136
|
+
picks up the job — so a job inherits the context of the code that caused it to
|
|
137
|
+
be submitted, not the worker's.
|
|
138
|
+
|
|
139
|
+
### `asynch` and `synch`
|
|
140
|
+
|
|
141
|
+
These are inverses that let a single call site choose how a function runs.
|
|
142
|
+
|
|
143
|
+
- **`asynch(fn, ...)`** returns a *launcher*: calling the launcher starts `fn`
|
|
144
|
+
in a new `ThreadTask` and returns the task immediately.
|
|
145
|
+
- **`synch(fn)`** returns a *synchronous* version that always yields a concrete
|
|
146
|
+
value. It flattens two layers of asynchrony: if `fn` was produced by
|
|
147
|
+
`asynch()`, it is de-wrapped to the original callable so the work runs inline
|
|
148
|
+
(no extra thread); and if the call returns something implementing the `Task`
|
|
149
|
+
protocol, `synch` waits for that task and returns its result instead of the
|
|
150
|
+
task. A plain function returning a plain value is simply called — so `synch`
|
|
151
|
+
is safe to apply either way.
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## Quick start
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
import time
|
|
159
|
+
from gentletask import ThreadTask, sleep, Stopped
|
|
160
|
+
|
|
161
|
+
def counter(progress):
|
|
162
|
+
n = 0
|
|
163
|
+
while True:
|
|
164
|
+
n += 1
|
|
165
|
+
progress.append(n)
|
|
166
|
+
sleep(0.02) # stop-aware: raises Stopped when the task is stopped
|
|
167
|
+
|
|
168
|
+
progress = []
|
|
169
|
+
task = ThreadTask(counter, args=(progress,), name="counter")
|
|
170
|
+
|
|
171
|
+
time.sleep(0.1) # let it tick a few times
|
|
172
|
+
task.stop() # request a cooperative stop
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
task.wait()
|
|
176
|
+
except Stopped:
|
|
177
|
+
print("counter stopped after", len(progress), "ticks")
|
|
178
|
+
|
|
179
|
+
print("is_stopped:", task.is_stopped)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Guide
|
|
185
|
+
|
|
186
|
+
### `SemanticStack` basics
|
|
187
|
+
|
|
188
|
+
Each `with` block pushes a frame; leaving the block pops it. `get(key)` returns
|
|
189
|
+
the **innermost** value, `collect(key)` returns **all** values outermost-first
|
|
190
|
+
(skipping frames without the key), and `frames()` hands back fresh dicts.
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
from gentletask import SemanticStack
|
|
194
|
+
|
|
195
|
+
ctx = SemanticStack()
|
|
196
|
+
with ctx(operation="abc123", user="alice"):
|
|
197
|
+
with ctx(operation="resize"):
|
|
198
|
+
print(ctx.get("operation")) # "resize" (innermost)
|
|
199
|
+
print(ctx.get("user")) # "alice" (from the outer frame)
|
|
200
|
+
print(ctx.collect("operation")) # ('abc123', 'resize')
|
|
201
|
+
for f in ctx.frames():
|
|
202
|
+
print(f)
|
|
203
|
+
# {'operation': 'abc123', 'user': 'alice'}
|
|
204
|
+
# {'operation': 'resize'}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
A stack can require certain keys on every frame. `required` is a floor, not a
|
|
208
|
+
schema — extra keys are always welcome.
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
labeled = SemanticStack(required=("name",))
|
|
212
|
+
|
|
213
|
+
with labeled(name="ok", extra="also fine"): # extra keys welcome
|
|
214
|
+
...
|
|
215
|
+
|
|
216
|
+
labeled(extra="but no name") # raises ValueError: missing "name"
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Snapshots
|
|
220
|
+
|
|
221
|
+
`snapshot()` captures the current frames; the returned `SemanticSnapshot`'s
|
|
222
|
+
`restore()` is a context manager that reinstalls them for a block. This is the
|
|
223
|
+
machinery that carries context onto worker threads.
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
from gentletask import throughline
|
|
227
|
+
|
|
228
|
+
with throughline(name="request_handler", request_id="r-42"):
|
|
229
|
+
snap = throughline.snapshot()
|
|
230
|
+
|
|
231
|
+
print(throughline.collect("name")) # () — back outside, the stack is empty
|
|
232
|
+
|
|
233
|
+
with snap.restore():
|
|
234
|
+
print(throughline.collect("name"), throughline.get("request_id"))
|
|
235
|
+
# ('request_handler',) r-42
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### `ThreadTask`
|
|
239
|
+
|
|
240
|
+
`ThreadTask` runs a callable in a new daemon thread. `wait()` blocks and returns
|
|
241
|
+
the result (or re-raises the worker's exception); `result` is shorthand for
|
|
242
|
+
`wait()`.
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
from gentletask import ThreadTask
|
|
246
|
+
|
|
247
|
+
t = ThreadTask(lambda: 6 * 7)
|
|
248
|
+
print(t.wait()) # 42
|
|
249
|
+
print(t.is_done) # True
|
|
250
|
+
|
|
251
|
+
# Exceptions surface through wait()
|
|
252
|
+
def boom():
|
|
253
|
+
raise ValueError("kaboom")
|
|
254
|
+
|
|
255
|
+
failed = ThreadTask(boom)
|
|
256
|
+
try:
|
|
257
|
+
failed.wait()
|
|
258
|
+
except ValueError as e:
|
|
259
|
+
print("caught:", e)
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
It accepts positional/keyword args, an explicit `name`, and an `on_finish`
|
|
263
|
+
callback invoked with `(result, exception)` when the task finishes.
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
log_lines = []
|
|
267
|
+
t = ThreadTask(
|
|
268
|
+
lambda x, y: x + y,
|
|
269
|
+
args=(3,),
|
|
270
|
+
kwargs={"y": 4},
|
|
271
|
+
name="adder",
|
|
272
|
+
on_finish=lambda result, exc: log_lines.append(f"adder finished -> {result}"),
|
|
273
|
+
)
|
|
274
|
+
print(t.wait()) # 7
|
|
275
|
+
print(log_lines) # ['adder finished -> 7']
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### `asynch` and `synch`
|
|
279
|
+
|
|
280
|
+
```python
|
|
281
|
+
from gentletask import asynch, synch, ThreadTask
|
|
282
|
+
|
|
283
|
+
add = asynch(lambda x, y: x + y, name="async-add")
|
|
284
|
+
task = add(10, 20) # starts a ThreadTask
|
|
285
|
+
print(task.wait()) # 30
|
|
286
|
+
|
|
287
|
+
def process(x):
|
|
288
|
+
return x * 10
|
|
289
|
+
|
|
290
|
+
job = asynch(process) # job(...) -> ThreadTask
|
|
291
|
+
print(synch(job)(5)) # 50 — de-wrapped, runs inline, no thread
|
|
292
|
+
print(synch(process)(5)) # 50 — plain function passes through
|
|
293
|
+
|
|
294
|
+
def schedule(x): # a function that hands back a task
|
|
295
|
+
return ThreadTask(lambda: process(x), name="scheduled")
|
|
296
|
+
|
|
297
|
+
print(synch(schedule)(5)) # 50 — synch waits for the returned task
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### Cooperative stop and stop-aware primitives
|
|
301
|
+
|
|
302
|
+
`sleep`, `check_stop`, `poll`, `Queue.get`, and `Event.wait` all check
|
|
303
|
+
`current_task()` at their wait site and raise `Stopped` when a stop has been
|
|
304
|
+
requested. Called outside any task, they behave like their stdlib equivalents.
|
|
305
|
+
|
|
306
|
+
```python
|
|
307
|
+
from gentletask import sleep, check_stop, Queue, Event, poll
|
|
308
|
+
|
|
309
|
+
def worker(q: Queue, done: Event):
|
|
310
|
+
while True:
|
|
311
|
+
check_stop() # surrender at a known-safe point (like sleep(0))
|
|
312
|
+
item = q.get() # raises Stopped if stopped while waiting
|
|
313
|
+
process(item)
|
|
314
|
+
if done.wait(0.01): # raises Stopped if stopped while waiting
|
|
315
|
+
break
|
|
316
|
+
|
|
317
|
+
# poll() samples a predicate on `interval`, but the inter-sample wait is
|
|
318
|
+
# poll-free with respect to stop:
|
|
319
|
+
poll(lambda: connection.ready, interval=0.05, timeout=5.0)
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### Stop cascades to children
|
|
323
|
+
|
|
324
|
+
Stopping a parent propagates to every child it started; when a task finishes it
|
|
325
|
+
also stops any still-running children.
|
|
326
|
+
|
|
327
|
+
```python
|
|
328
|
+
import time
|
|
329
|
+
from gentletask import ThreadTask, sleep, Stopped
|
|
330
|
+
|
|
331
|
+
child_ticks = []
|
|
332
|
+
|
|
333
|
+
def child():
|
|
334
|
+
while True:
|
|
335
|
+
child_ticks.append(1)
|
|
336
|
+
sleep(0.02)
|
|
337
|
+
|
|
338
|
+
def parent():
|
|
339
|
+
c = ThreadTask(child, name="child")
|
|
340
|
+
c.wait() # parent blocks on the child
|
|
341
|
+
|
|
342
|
+
p = ThreadTask(parent, name="parent")
|
|
343
|
+
time.sleep(0.1)
|
|
344
|
+
p.stop() # cascades into the child
|
|
345
|
+
try:
|
|
346
|
+
p.wait()
|
|
347
|
+
except Stopped:
|
|
348
|
+
pass
|
|
349
|
+
print("child ticks frozen at", len(child_ticks))
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
`detach()` opts a child out of the cascade, so it keeps running after the parent
|
|
353
|
+
stops:
|
|
354
|
+
|
|
355
|
+
```python
|
|
356
|
+
def parent_detaching():
|
|
357
|
+
c = ThreadTask(long_child, name="detached-child")
|
|
358
|
+
c.detach() # parent.stop() will NOT reach this child
|
|
359
|
+
sleep(10)
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### `WorkerThread` and `WorkTask` — serialized jobs with inherited context
|
|
363
|
+
|
|
364
|
+
A `WorkerThread` is a long-lived thread that runs submitted jobs one at a time.
|
|
365
|
+
`submit()` returns a `WorkTask` immediately and snapshots **both stacks at submit
|
|
366
|
+
time**, so the job inherits the context of the code that caused it — not the
|
|
367
|
+
worker's.
|
|
368
|
+
|
|
369
|
+
```python
|
|
370
|
+
from gentletask import WorkerThread, throughline, task_chain
|
|
371
|
+
|
|
372
|
+
worker = WorkerThread(name="io-worker")
|
|
373
|
+
captured = {}
|
|
374
|
+
|
|
375
|
+
def job(label):
|
|
376
|
+
captured[label] = task_chain()
|
|
377
|
+
return label.upper()
|
|
378
|
+
|
|
379
|
+
# Submitted inside a frame -> the job inherits it
|
|
380
|
+
with throughline(name="request_handler"):
|
|
381
|
+
a = worker.submit(job, ("a",), name="job-a")
|
|
382
|
+
a.wait()
|
|
383
|
+
|
|
384
|
+
# Submitted outside any frame -> the job sees only its own name
|
|
385
|
+
b = worker.submit(job, ("b",), name="job-b")
|
|
386
|
+
b.wait()
|
|
387
|
+
|
|
388
|
+
print(captured["a"]) # ('request_handler', 'job-a')
|
|
389
|
+
print(captured["b"]) # ('job-b',)
|
|
390
|
+
print(a.result) # 'A'
|
|
391
|
+
worker.stop() # already-queued jobs drain, then the thread shuts down
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
After `stop()`, the worker drains any jobs already queued and then exits;
|
|
395
|
+
further `submit()` calls raise `RuntimeError`.
|
|
396
|
+
|
|
397
|
+
### Logging integration
|
|
398
|
+
|
|
399
|
+
`ThroughlineNameFilter` injects `throughline.collect("name")` onto every log
|
|
400
|
+
record as `record.throughline`, giving each line its full task ancestry — across
|
|
401
|
+
thread boundaries, with no manual plumbing.
|
|
402
|
+
|
|
403
|
+
```python
|
|
404
|
+
import logging, sys
|
|
405
|
+
from gentletask import ThreadTask, ThroughlineNameFilter
|
|
406
|
+
|
|
407
|
+
logger = logging.getLogger("gentletask.demo")
|
|
408
|
+
logger.setLevel(logging.INFO)
|
|
409
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
410
|
+
handler.setFormatter(logging.Formatter("%(throughline)s | %(message)s"))
|
|
411
|
+
handler.addFilter(ThroughlineNameFilter())
|
|
412
|
+
logger.addHandler(handler)
|
|
413
|
+
logger.propagate = False
|
|
414
|
+
|
|
415
|
+
def calibrate():
|
|
416
|
+
logger.info("measuring offset")
|
|
417
|
+
|
|
418
|
+
def acquire():
|
|
419
|
+
logger.info("starting acquisition")
|
|
420
|
+
ThreadTask(calibrate, name="calibrate").wait()
|
|
421
|
+
logger.info("acquisition complete")
|
|
422
|
+
|
|
423
|
+
ThreadTask(acquire, name="acquisition").wait()
|
|
424
|
+
# ('acquisition',) | starting acquisition
|
|
425
|
+
# ('acquisition', 'calibrate') | measuring offset
|
|
426
|
+
# ('acquisition',) | acquisition complete
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
### A custom `Task`
|
|
430
|
+
|
|
431
|
+
`Task` is a `runtime_checkable` structural `Protocol` — any object with the right
|
|
432
|
+
shape qualifies, no base class required. Wrap your work in `task_context(self,
|
|
433
|
+
name)` so `current_task()` and the log chain line up without touching either
|
|
434
|
+
stack directly. For *poll-free* stop, implement `add_stop_callback` /
|
|
435
|
+
`remove_stop_callback` and fire the registered callbacks exactly once from
|
|
436
|
+
`stop()`: those are the hooks the blocking primitives use to wake without
|
|
437
|
+
polling.
|
|
438
|
+
|
|
439
|
+
```python
|
|
440
|
+
import threading
|
|
441
|
+
from gentletask import task_context, sleep, Task
|
|
442
|
+
|
|
443
|
+
class CountdownTask:
|
|
444
|
+
"""A minimal hand-rolled Task that counts down on a background thread."""
|
|
445
|
+
|
|
446
|
+
def __init__(self, n):
|
|
447
|
+
self._n = n
|
|
448
|
+
self.is_stopped = False
|
|
449
|
+
self.is_done = False
|
|
450
|
+
self._result = None
|
|
451
|
+
# Internal plumbing uses plain threading primitives, NOT gentletask's.
|
|
452
|
+
# These coordinate the task's own lifecycle and are read from outside
|
|
453
|
+
# the task (e.g. wait() is usually called by a parent), so they must
|
|
454
|
+
# not raise Stopped based on the caller's current_task().
|
|
455
|
+
self._done = threading.Event() # completion signal
|
|
456
|
+
self._lock = threading.Lock() # guards the stop-callback list
|
|
457
|
+
self._stop_callbacks = []
|
|
458
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
459
|
+
self._thread.start()
|
|
460
|
+
|
|
461
|
+
def _run(self):
|
|
462
|
+
with task_context(self, "countdown"):
|
|
463
|
+
try:
|
|
464
|
+
while self._n > 0 and not self.is_stopped:
|
|
465
|
+
self._n -= 1
|
|
466
|
+
# The actual work waits with gentletask.sleep so a stop
|
|
467
|
+
# aborts it cooperatively (poll-free, raises Stopped).
|
|
468
|
+
sleep(0.01)
|
|
469
|
+
self._result = "liftoff" if not self.is_stopped else "aborted"
|
|
470
|
+
finally:
|
|
471
|
+
self.is_done = True
|
|
472
|
+
self._done.set()
|
|
473
|
+
|
|
474
|
+
def wait(self, timeout=None):
|
|
475
|
+
self._done.wait(timeout)
|
|
476
|
+
return self._result
|
|
477
|
+
|
|
478
|
+
@property
|
|
479
|
+
def result(self):
|
|
480
|
+
return self.wait()
|
|
481
|
+
|
|
482
|
+
def stop(self):
|
|
483
|
+
with self._lock:
|
|
484
|
+
if self.is_stopped:
|
|
485
|
+
return
|
|
486
|
+
self.is_stopped = True
|
|
487
|
+
callbacks, self._stop_callbacks = list(self._stop_callbacks), []
|
|
488
|
+
for cb in callbacks:
|
|
489
|
+
cb()
|
|
490
|
+
|
|
491
|
+
def add_finish_callback(self, fn):
|
|
492
|
+
self.wait()
|
|
493
|
+
fn(self._result, None)
|
|
494
|
+
|
|
495
|
+
def add_stop_callback(self, fn):
|
|
496
|
+
with self._lock:
|
|
497
|
+
if not self.is_stopped:
|
|
498
|
+
self._stop_callbacks.append(fn)
|
|
499
|
+
return
|
|
500
|
+
fn()
|
|
501
|
+
|
|
502
|
+
def remove_stop_callback(self, fn):
|
|
503
|
+
with self._lock:
|
|
504
|
+
if fn in self._stop_callbacks:
|
|
505
|
+
self._stop_callbacks.remove(fn)
|
|
506
|
+
|
|
507
|
+
def detach(self):
|
|
508
|
+
pass
|
|
509
|
+
|
|
510
|
+
cd = CountdownTask(5)
|
|
511
|
+
print(isinstance(cd, Task)) # True
|
|
512
|
+
print(cd.wait()) # 'liftoff'
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
> **Note.** `runtime_checkable` only verifies attribute *presence*, not full
|
|
516
|
+
> signatures — so an object can pass `isinstance(obj, Task)` while still missing
|
|
517
|
+
> the behavior the primitives rely on. Implement all of the protocol members,
|
|
518
|
+
> especially the stop-callback hooks, for a task that participates fully in
|
|
519
|
+
> poll-free stopping.
|
|
520
|
+
|
|
521
|
+
### `threading` or `gentletask`? When to use which
|
|
522
|
+
|
|
523
|
+
The example above deliberately mixes the two, and the choice is not arbitrary.
|
|
524
|
+
The rule of thumb:
|
|
525
|
+
|
|
526
|
+
- Use the **`gentletask`** primitives (`sleep`, `Event`, `Queue`, `poll`,
|
|
527
|
+
`check_stop`) for the **work a task performs** — the wait points that should
|
|
528
|
+
abort with `Stopped` when *that* task is stopped. These are stop-aware: they
|
|
529
|
+
consult `current_task()` and raise `Stopped` so a cooperative stop unwinds the
|
|
530
|
+
work cleanly. Reach for these whenever you are blocking *inside* a task and
|
|
531
|
+
want the block to be interruptible.
|
|
532
|
+
- Use the plain **`threading`** primitives (`threading.Event`, `Lock`,
|
|
533
|
+
`Thread`, `queue.Queue`) for a task's **own lifecycle machinery** — completion
|
|
534
|
+
signals, internal locks, the worker thread itself. This plumbing is read and
|
|
535
|
+
driven from *outside* the running task (a parent calls `wait()`, `stop()` may
|
|
536
|
+
fire from any thread), so it must **not** be stop-aware: a `gentletask.Event`
|
|
537
|
+
used for `self._done` would raise `Stopped` if `wait()` happened to be called
|
|
538
|
+
from some unrelated stopped task, corrupting the task's own bookkeeping.
|
|
539
|
+
|
|
540
|
+
In short: **`gentletask` primitives for interruptible work; `threading`
|
|
541
|
+
primitives for the scaffolding that makes a task a task.** The built-in
|
|
542
|
+
`ThreadTask` and `WorkTask` follow exactly this split internally — their
|
|
543
|
+
done/stop signaling is `threading`-based, while the work you hand them is free
|
|
544
|
+
to use the stop-aware primitives.
|
|
545
|
+
|
|
546
|
+
---
|
|
547
|
+
|
|
548
|
+
## API reference
|
|
549
|
+
|
|
550
|
+
### Exceptions
|
|
551
|
+
|
|
552
|
+
| Name | Description |
|
|
553
|
+
| --- | --- |
|
|
554
|
+
| `Stopped` | Raised inside a task when `stop()` has been requested. Unwinds normally; `finally` blocks run. |
|
|
555
|
+
|
|
556
|
+
### Tasks
|
|
557
|
+
|
|
558
|
+
| Name | Description |
|
|
559
|
+
| --- | --- |
|
|
560
|
+
| `Task` | `runtime_checkable` structural `Protocol` for a stoppable, waitable unit of work. |
|
|
561
|
+
| `ThreadTask(fn, args=(), kwargs=None, *, name=None, detach=False, on_finish=None)` | Runs `fn` in a new daemon thread; implements `Task`. |
|
|
562
|
+
| `WorkTask(fn, args, kwargs, name=None)` | One job queued to a `WorkerThread`; implements `Task`. Usually created via `WorkerThread.submit`. |
|
|
563
|
+
| `WorkerThread(name=None)` | Long-lived worker thread that serializes submitted jobs. `submit(...)` returns a `WorkTask`; `stop()` drains queued jobs and shuts down. |
|
|
564
|
+
| `asynch(fn, name=None, detach=False, on_finish=None)` | Returns a launcher that starts `fn` in a new `ThreadTask` when called. |
|
|
565
|
+
| `synch(fn)` | Returns a synchronous version of `fn` that de-wraps `asynch` and awaits returned tasks, yielding a concrete value. |
|
|
566
|
+
|
|
567
|
+
### `Task` protocol members
|
|
568
|
+
|
|
569
|
+
| Member | Description |
|
|
570
|
+
| --- | --- |
|
|
571
|
+
| `is_done: bool` | Whether the task has finished. |
|
|
572
|
+
| `is_stopped: bool` | Whether a stop has been requested. |
|
|
573
|
+
| `result` | Property; shorthand for `wait()`. |
|
|
574
|
+
| `wait(timeout=None)` | Block until done, re-raising any worker exception. A stop on the calling parent propagates here and raises `Stopped`. |
|
|
575
|
+
| `stop()` | Request cooperative stop; fire stop callbacks once; cascade to children. |
|
|
576
|
+
| `add_finish_callback(fn)` | Call `fn(result, exception)` when the task finishes (immediately if already finished). |
|
|
577
|
+
| `add_stop_callback(fn)` | Call zero-arg `fn()` once when the task is stopped (immediately if already stopped). |
|
|
578
|
+
| `remove_stop_callback(fn)` | Unregister a stop callback; no-op if absent. |
|
|
579
|
+
| `detach()` | Remove this task from its parent's stop propagation. Parent-only: the caller must be the task whose children include this one (a task cannot detach itself); otherwise raises `RuntimeError`. |
|
|
580
|
+
|
|
581
|
+
### Context / throughline
|
|
582
|
+
|
|
583
|
+
| Name | Description |
|
|
584
|
+
| --- | --- |
|
|
585
|
+
| `SemanticStack(name="semantic_stack", *, required=())` | Context-local stack of labeled frames backed by a `ContextVar`. |
|
|
586
|
+
| `SemanticStack.__call__(**kwargs)` | Context manager that pushes a frame for the block (raises `ValueError` if a required key is missing). |
|
|
587
|
+
| `SemanticStack.get(key, default=None)` | Value of `key` from the innermost frame that has it. |
|
|
588
|
+
| `SemanticStack.collect(key)` | Tuple of all values for `key`, outermost-first. |
|
|
589
|
+
| `SemanticStack.walk(fn)` | Apply `fn` to each frame dict, outermost-first; return a tuple of results. |
|
|
590
|
+
| `SemanticStack.frames()` | Full stack as fresh dicts, outermost-first. |
|
|
591
|
+
| `SemanticStack.snapshot()` | Capture current state as a `SemanticSnapshot`. |
|
|
592
|
+
| `SemanticSnapshot.restore()` | Context manager that installs the captured frames for the block. |
|
|
593
|
+
| `throughline` | Module singleton `SemanticStack("throughline", required=("name",))` — the human-readable narrative. |
|
|
594
|
+
| `task_context(task, name)` | Context manager that enters `name` on the throughline and `task` on the private task stack. |
|
|
595
|
+
| `current_task()` | The innermost running `Task`, or `None` outside any task. |
|
|
596
|
+
| `task_chain()` | Tuple of task names, outermost-first (`throughline.collect("name")`). |
|
|
597
|
+
| `ThroughlineNameFilter` | `logging.Filter` that injects `task_chain()` onto each record as `record.throughline`. |
|
|
598
|
+
|
|
599
|
+
### Stop-aware blocking primitives
|
|
600
|
+
|
|
601
|
+
| Name | Description |
|
|
602
|
+
| --- | --- |
|
|
603
|
+
| `sleep(seconds)` | Drop-in for `time.sleep`; raises `Stopped` if the current task is stopped (poll-free). |
|
|
604
|
+
| `check_stop()` | Raise `Stopped` if the current task is stopped; like `sleep(0)`. |
|
|
605
|
+
| `poll(fn, *, interval=0.05, timeout=None)` | Sample `fn` until truthy; stop-aware inter-sample wait. Returns `fn`'s truthy value, or the last falsy value on timeout. |
|
|
606
|
+
| `Queue(maxsize=0)` | Drop-in for `queue.Queue`; `get()` raises `Stopped` if the current task is stopped. |
|
|
607
|
+
| `Event()` | Drop-in for `threading.Event`; `wait()` raises `Stopped` if the current task is stopped. |
|
|
608
|
+
|
|
609
|
+
---
|
|
610
|
+
|
|
611
|
+
## Testing
|
|
612
|
+
|
|
613
|
+
The test suite runs under `pytest`:
|
|
614
|
+
|
|
615
|
+
```bash
|
|
616
|
+
pip install -e ".[dev]"
|
|
617
|
+
pytest
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
---
|
|
621
|
+
|
|
622
|
+
## Contributing
|
|
623
|
+
|
|
624
|
+
Contributions are welcome. Please open an issue to discuss substantial changes
|
|
625
|
+
first. Keep changes small and focused, match the surrounding code style, and add
|
|
626
|
+
tests covering new behavior. The library is intentionally dependency-free and
|
|
627
|
+
pure Python — please keep it that way.
|
|
628
|
+
|
|
629
|
+
---
|
|
630
|
+
|
|
631
|
+
## License
|
|
632
|
+
|
|
633
|
+
MIT License. See the [LICENSE](LICENSE) file for details.
|
|
634
|
+
|
|
635
|
+
---
|
|
636
|
+
|
|
637
|
+
## Authors
|
|
638
|
+
|
|
639
|
+
- **Martin Chase**
|
|
640
|
+
- **Luke Campagnola**
|
|
641
|
+
|
|
642
|
+
Copyright © Martin Chase and Luke Campagnola.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
gentletask.py,sha256=3KilNgSWMniyZig-aB7TjVDP2ZcrVeqmnEmQFkACm6Y,34152
|
|
2
|
+
gentletask-0.1.0.dist-info/METADATA,sha256=sJm10v7S_gGHrgOS5hkHZZ-p9iMgOtAslAccuhgR6AI,24761
|
|
3
|
+
gentletask-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
4
|
+
gentletask-0.1.0.dist-info/licenses/LICENSE,sha256=BrgZDj_Fo2iZnoAvKPvHBZNItOG9RaINwJYEaTmGrYU,1089
|
|
5
|
+
gentletask-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Martin Chase and Luke Campagnola
|
|
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.
|