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.
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/gentletask.svg)](https://pypi.org/project/gentletask/)
50
+ [![Python versions](https://img.shields.io/pypi/pyversions/gentletask.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.