taskwire 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.
taskwire/__init__.py ADDED
@@ -0,0 +1,135 @@
1
+ """taskwire - progress reporting and awaitable dialogs for long-running operations.
2
+
3
+ The store is the truth; a push is only an accelerator. Every state change is written before anything
4
+ is sent anywhere, so a dropped, coalesced or suppressed push never changes what the next read
5
+ returns - which is what lets the transport be swapped without changing behaviour.
6
+
7
+ The layering is: one **protocol** (the documents of §3 and the state machines of §4, in `models`),
8
+ then three independent **implementations** of it - local (`transport.LocalTransport`), REST
9
+ (`rest`), and WebSocket (`contrib.muxws`) - each with one **adapter** binding it to the world
10
+ outside. Nothing in the core imports a framework, a driver or a UI library (TW-CORE-006).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ from .ambient import ask, current_reporter, progress
18
+ from .conformance import run_conformance
19
+ from .decorators import (
20
+ declared_result_kind,
21
+ declared_result_params,
22
+ dialog_result,
23
+ downloadable_result,
24
+ panel_result,
25
+ )
26
+ from .delivery import Client, OperationRef
27
+ from .headers import CONNECTION_HEADER, TOKEN_HEADER
28
+ from .models import (
29
+ Aggregate,
30
+ Button,
31
+ DialogAnswer,
32
+ DialogReply,
33
+ DialogRequest,
34
+ DialogState,
35
+ Envelope,
36
+ EnvelopeKind,
37
+ Error,
38
+ Input,
39
+ OperationSummary,
40
+ Progress,
41
+ ProgressState,
42
+ Register,
43
+ Result,
44
+ ResultRef,
45
+ Snapshot,
46
+ t,
47
+ Text,
48
+ )
49
+ from .reader import check_taskwire_readers, start_taskwire_reader, stop_taskwire_reader
50
+ from .register import aggregate, build_register
51
+ from .reporter import (
52
+ InertReporter,
53
+ mark_failed,
54
+ mark_queued,
55
+ migrate_namespace,
56
+ new_token,
57
+ operation,
58
+ OperationCancelled,
59
+ Reporter,
60
+ SyncReporter,
61
+ )
62
+ from .settings import configure, configured, reset_configuration, Settings, settings
63
+ from .store import (
64
+ Commit,
65
+ DialogResolution,
66
+ DialogVanished,
67
+ key,
68
+ MemoryStore,
69
+ TaskwireStore,
70
+ )
71
+ from .transport import LocalTransport, NullTransport, TaskwireTransport
72
+
73
+ __all__ = [
74
+ "Aggregate",
75
+ "Button",
76
+ "Client",
77
+ "Commit",
78
+ "DialogAnswer",
79
+ "DialogReply",
80
+ "DialogRequest",
81
+ "DialogResolution",
82
+ "DialogState",
83
+ "DialogVanished",
84
+ "Envelope",
85
+ "EnvelopeKind",
86
+ "Error",
87
+ "InertReporter",
88
+ "Input",
89
+ "LocalTransport",
90
+ "MemoryStore",
91
+ "NullTransport",
92
+ "OperationCancelled",
93
+ "OperationRef",
94
+ "OperationSummary",
95
+ "Progress",
96
+ "ProgressState",
97
+ "Register",
98
+ "Reporter",
99
+ "Result",
100
+ "ResultRef",
101
+ "Settings",
102
+ "Snapshot",
103
+ "SyncReporter",
104
+ "TaskwireStore",
105
+ "TaskwireTransport",
106
+ "Text",
107
+ "__version__",
108
+ "ask",
109
+ "progress",
110
+ "current_reporter",
111
+ "aggregate",
112
+ "build_register",
113
+ "check_taskwire_readers",
114
+ "dialog_result",
115
+ "downloadable_result",
116
+ "panel_result",
117
+ "declared_result_kind",
118
+ "declared_result_params",
119
+ "configure",
120
+ "configured",
121
+ "CONNECTION_HEADER",
122
+ "key",
123
+ "mark_failed",
124
+ "mark_queued",
125
+ "migrate_namespace",
126
+ "new_token",
127
+ "TOKEN_HEADER",
128
+ "operation",
129
+ "reset_configuration",
130
+ "run_conformance",
131
+ "settings",
132
+ "start_taskwire_reader",
133
+ "stop_taskwire_reader",
134
+ "t",
135
+ ]
taskwire/ambient.py ADDED
@@ -0,0 +1,161 @@
1
+ """The ambient reporter: `taskwire.progress` and `taskwire.ask`.
2
+
3
+ Deep in a call stack, threading a `Reporter` through six functions that have no other reason to know
4
+ about progress is the kind of change people decline to make. The ambient proxy is the answer: bind
5
+ once at the top, and `await progress.set(...)` works anywhere underneath.
6
+
7
+ **Python only.** TW-SYM-004 forbids a TypeScript twin: `contextvars` has no browser equivalent, and
8
+ TW-AMB-002 makes an unbound proxy a *silent* no-op - so a proxy that worked under Node and no-opped
9
+ in a browser would fail as a progress bar that never moves, with nothing logged anywhere.
10
+
11
+ ## The trap this module exists to avoid
12
+
13
+ Concurrent siblings MUST each receive their own reporter **explicitly** (TW-NEST-014). The ambient
14
+ binding is one variable; a `gather` is many tasks sharing it.
15
+
16
+ ```python
17
+ # WRONG - every branch writes through one binding, and the ranges interleave into nonsense
18
+ async def wrong(reporter):
19
+ with reporter.subtask(0, 50):
20
+ await asyncio.gather(load_a(), load_b(), load_c()) # all three use `progress`
21
+
22
+ # RIGHT - one reporter each, passed in
23
+ async def right(reporter):
24
+ a, b, c = reporter.split(1, 1, 1)
25
+ await asyncio.gather(load_a(a), load_b(b), load_c(c))
26
+ ```
27
+
28
+ `contextvars` copies its context per task, so the `gather` form is not merely *untidy* - each task
29
+ gets a snapshot of the same binding and their contributions land on one child's range.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import contextvars
35
+
36
+ from typing import Any
37
+
38
+ _current: contextvars.ContextVar[Any] = contextvars.ContextVar("taskwire_reporter", default=None)
39
+
40
+
41
+ def bind(reporter: Any) -> contextvars.Token:
42
+ """Bind `reporter` for this context. Returns the `Token` that `unbind` needs."""
43
+ return _current.set(reporter)
44
+
45
+
46
+ def unbind(token: contextvars.Token | None) -> None:
47
+ """Reset via the `Token` (TW-AMB-005).
48
+
49
+ Resetting rather than overwriting is the whole rule. A raising task that merely wrote `None` back
50
+ would leave the *next* task on the same pooled thread holding a stale binding, and operation A's
51
+ progress would land on operation B's key (TW-INV-011).
52
+ """
53
+ if token is not None:
54
+ _current.reset(token)
55
+
56
+
57
+ def current_reporter() -> Any:
58
+ """The bound reporter, or `None`. Applications should use `progress` instead."""
59
+ return _current.get()
60
+
61
+
62
+ class _AmbientProgress:
63
+ """The module-level singleton exposing the identical `Reporter` surface (TW-AMB-001).
64
+
65
+ Every method is a **silent async no-op when nothing is bound** (TW-AMB-002). Silence is the
66
+ point: library code that reports progress must be callable from a caller that never opened an
67
+ operation, without that caller having to care and without a warning on every line.
68
+ """
69
+
70
+ async def set(self, **kwargs: Any) -> None: # noqa: A003 - mirrors Reporter.set
71
+ reporter = _current.get()
72
+ if reporter is None:
73
+ # Still reject `state`, even unbound. Code that would be rejected inside an operation
74
+ # must not pass merely because it ran outside one (TW-PROG-012).
75
+ if "state" in kwargs:
76
+ raise ValueError("taskwire: Reporter.set() has no `state` keyword (TW-PROG-012)")
77
+ return None
78
+ return await reporter.set(**kwargs)
79
+
80
+ async def ask(self, dialog_id: str, **kwargs: Any) -> Any:
81
+ """TW-AMB-003: `taskwire.ask` is this.
82
+
83
+ Unbound, there is nobody to ask and nothing to wait for, so this returns `None` rather than
84
+ blocking forever - the one place where the no-op is visible in a return value.
85
+ """
86
+ reporter = _current.get()
87
+ if reporter is None:
88
+ return None
89
+ return await reporter.ask(dialog_id, **kwargs)
90
+
91
+ async def done(self) -> None:
92
+ reporter = _current.get()
93
+ if reporter is None:
94
+ return None
95
+ return await reporter.done()
96
+
97
+ async def fail(self, code: str, message: Any, retryable: bool = False) -> None:
98
+ reporter = _current.get()
99
+ if reporter is None:
100
+ return None
101
+ return await reporter.fail(code, message, retryable)
102
+
103
+ async def flush(self) -> None:
104
+ reporter = _current.get()
105
+ if reporter is None:
106
+ return None
107
+ return await reporter.flush()
108
+
109
+ async def set_result(self, result: Any) -> None:
110
+ reporter = _current.get()
111
+ if reporter is None:
112
+ return None
113
+ return await reporter.set_result(result)
114
+
115
+ async def was_aborted(self) -> bool:
116
+ reporter = _current.get()
117
+ if reporter is None:
118
+ return False
119
+ return await reporter.was_aborted()
120
+
121
+ def subtask(self, start: float, end: float) -> Any:
122
+ """Unbound, this returns an inert reporter so `with progress.subtask(...)` still works."""
123
+ reporter = _current.get()
124
+ if reporter is None:
125
+ from .reporter import InertReporter
126
+
127
+ return InertReporter()
128
+ return reporter.subtask(start, end)
129
+
130
+ def split(self, *weights: float) -> tuple[Any, ...]:
131
+ reporter = _current.get()
132
+ if reporter is None:
133
+ from .reporter import InertReporter
134
+
135
+ return tuple(InertReporter() for _ in weights)
136
+ return reporter.split(*weights)
137
+
138
+ @property
139
+ def cancelled(self) -> bool:
140
+ reporter = _current.get()
141
+ return bool(reporter is not None and reporter.cancelled)
142
+
143
+ @property
144
+ def token(self) -> str | None:
145
+ reporter = _current.get()
146
+ return reporter.token if reporter is not None else None
147
+
148
+ @property
149
+ def sync(self) -> Any:
150
+ """`progress.sync` mirrors `reporter.sync` (TW-API-003)."""
151
+ from .reporter import InertReporter, SyncReporter
152
+
153
+ reporter = _current.get()
154
+ return SyncReporter(reporter if reporter is not None else InertReporter())
155
+
156
+
157
+ progress = _AmbientProgress()
158
+ """The ambient reporter (TW-AMB-001). Bound by `operation()` and by `with reporter.subtask(a, b):`."""
159
+
160
+ ask = progress.ask
161
+ """TW-AMB-003: `taskwire.ask` is `progress.ask`, and is not a second mechanism."""