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 +135 -0
- taskwire/ambient.py +161 -0
- taskwire/conformance.py +474 -0
- taskwire/contrib/__init__.py +17 -0
- taskwire/contrib/asgi.py +91 -0
- taskwire/contrib/celery.py +352 -0
- taskwire/contrib/fastapi.py +82 -0
- taskwire/contrib/muxws.py +370 -0
- taskwire/contrib/redis_store.py +575 -0
- taskwire/contrib/schema.py +204 -0
- taskwire/contrib/threads.py +43 -0
- taskwire/contrib/viewsets.py +292 -0
- taskwire/decorators.py +88 -0
- taskwire/delivery.py +123 -0
- taskwire/headers.py +36 -0
- taskwire/models.py +683 -0
- taskwire/py.typed +0 -0
- taskwire/reader.py +120 -0
- taskwire/register.py +182 -0
- taskwire/reporter.py +1162 -0
- taskwire/rest.py +312 -0
- taskwire/settings.py +126 -0
- taskwire/store.py +647 -0
- taskwire/transport.py +95 -0
- taskwire-0.1.0.dist-info/METADATA +138 -0
- taskwire-0.1.0.dist-info/RECORD +28 -0
- taskwire-0.1.0.dist-info/WHEEL +4 -0
- taskwire-0.1.0.dist-info/licenses/LICENSE +21 -0
taskwire/reader.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""The reader: **the only module in this package that may call `transport.notify()`**.
|
|
2
|
+
|
|
3
|
+
TW-CORE-003 and TW-CORE-004 put it this way: a `Reporter` writes to the store and calls
|
|
4
|
+
`store.publish()`; `store.publish()` drives a reader; the reader is the only caller of
|
|
5
|
+
`transport.notify()`. `delivery_test.py::test_only_reader_calls_notify` enforces it statically, by
|
|
6
|
+
walking every non-test source file.
|
|
7
|
+
|
|
8
|
+
The rule looks like tidiness and is not. A reporter that pushed directly "because the transport is
|
|
9
|
+
right there" would, in a single-process deployment that later gains Redis and a socket, deliver every
|
|
10
|
+
envelope twice: once from the reporter and once from the reader consuming the backplane. Duplicate
|
|
11
|
+
`dialog.open` envelopes re-render answered dialogs (TW-INV-002).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import logging
|
|
18
|
+
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from .models import Envelope
|
|
22
|
+
from .settings import configured, settings
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("taskwire.reader")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def dispatch(ns: str, envelope: Envelope) -> None:
|
|
28
|
+
"""Hand one envelope to the configured transport.
|
|
29
|
+
|
|
30
|
+
Swallows everything. A push is a notification that state already changed (TW-CORE-002), so a
|
|
31
|
+
transport that is missing, broken or slow costs latency and nothing else - and `publish()` is
|
|
32
|
+
forbidden from raising into its caller (TW-STORE-008).
|
|
33
|
+
"""
|
|
34
|
+
transport = configured.transport
|
|
35
|
+
if transport is None:
|
|
36
|
+
return
|
|
37
|
+
try:
|
|
38
|
+
await asyncio.wait_for(transport.notify(ns, envelope), timeout=settings.push_timeout)
|
|
39
|
+
except Exception: # noqa: BLE001 - TW-TR-002/003: every failure is a dropped push
|
|
40
|
+
logger.debug("taskwire: dropped a push for %s", ns, exc_info=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_running: list[Any] = []
|
|
44
|
+
"""The readers started in this process. A list rather than a flag, because a process may hold more
|
|
45
|
+
than one store - a test suite certainly does."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def start_taskwire_reader() -> None:
|
|
49
|
+
"""Reader lifecycle (§5.4). Starts a backplane reader when the store has one.
|
|
50
|
+
|
|
51
|
+
A single-process deployment needs none: `MemoryStore.publish` drives `dispatch` directly, so
|
|
52
|
+
calling this is harmless and does nothing. A `RedisStore` gets a long-lived consumer of
|
|
53
|
+
`twx:{ns}`, which is then the only caller of `transport.notify()` in that process
|
|
54
|
+
(TW-CORE-004, TW-BP-005).
|
|
55
|
+
"""
|
|
56
|
+
store = configured.store
|
|
57
|
+
reader = getattr(store, "make_reader", None)
|
|
58
|
+
if reader is None:
|
|
59
|
+
return None
|
|
60
|
+
instance = reader()
|
|
61
|
+
await instance.start()
|
|
62
|
+
_running.append(instance)
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def stop_taskwire_reader() -> None:
|
|
67
|
+
"""Stop every reader this process started."""
|
|
68
|
+
while _running:
|
|
69
|
+
await _running.pop().stop()
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def watch_namespace(ns: str) -> None:
|
|
74
|
+
"""Tell this process's readers that it now holds a connection for `ns` (TW-BP-004).
|
|
75
|
+
|
|
76
|
+
**A web process subscribes only to the namespaces it currently holds connections for**, and that
|
|
77
|
+
is the ONLY discrimination at the channel level - it is per *process*, never per token and never
|
|
78
|
+
per tab (TW-BP-002). Without this call a started reader is subscribed to nothing and no envelope
|
|
79
|
+
ever arrives: the backplane is connected and silent, and polling covers for it well enough that
|
|
80
|
+
the deployment looks fine.
|
|
81
|
+
|
|
82
|
+
A no-op when the store publishes in-process, because there is no channel to join.
|
|
83
|
+
"""
|
|
84
|
+
for reader in list(_running):
|
|
85
|
+
await reader.watch(ns)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def unwatch_namespace(ns: str) -> None:
|
|
89
|
+
"""...and unsubscribe when it holds none.
|
|
90
|
+
|
|
91
|
+
Not doing so is not a correctness bug - the envelopes would simply be delivered to nobody - but
|
|
92
|
+
it makes every process a subscriber to every namespace any of them ever saw, which is the cost
|
|
93
|
+
the per-process rule exists to avoid.
|
|
94
|
+
"""
|
|
95
|
+
for reader in list(_running):
|
|
96
|
+
await reader.unwatch(ns)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def check_taskwire_readers() -> None:
|
|
100
|
+
"""Warn when a transport is configured but no reader is running (TW-BP-006).
|
|
101
|
+
|
|
102
|
+
The message names the exact call the user forgot to make, because the symptom otherwise is
|
|
103
|
+
"pushes silently stop arriving and polling quietly covers for it" - a latency regression nobody
|
|
104
|
+
attributes to a missing line in their startup code, and one that polling hides well enough that
|
|
105
|
+
it can survive a release.
|
|
106
|
+
"""
|
|
107
|
+
store = configured.store
|
|
108
|
+
if configured.transport is None or store is None:
|
|
109
|
+
return None
|
|
110
|
+
if getattr(store, "make_reader", None) is None:
|
|
111
|
+
return None # a single-process store publishes directly; there is no reader to miss
|
|
112
|
+
if not _running:
|
|
113
|
+
logger.warning(
|
|
114
|
+
"taskwire: a transport and a cross-process store are configured but no reader is "
|
|
115
|
+
"running, so pushes will never leave this process. Call "
|
|
116
|
+
"`await taskwire.start_taskwire_reader()` during startup (TW-BP-006). Polling will "
|
|
117
|
+
"cover for it, which is why this is a warning and not an error - and why it is easy to "
|
|
118
|
+
"miss."
|
|
119
|
+
)
|
|
120
|
+
return None
|
taskwire/register.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""The namespace register and its percentage-free aggregate.
|
|
2
|
+
|
|
3
|
+
**The aggregate carries no percentage and no rule may reintroduce one** (TW-REG-011). There is no
|
|
4
|
+
unit in which two operations' self-reported percentages are commensurable - one is 40 % through a
|
|
5
|
+
row count, the other 40 % through a byte count - so any figure derived across them is invented. A
|
|
6
|
+
footer that wants a bar draws one *selected* operation's own percentage (TW-REG-013), and this
|
|
7
|
+
module computes that selection server-side so a non-JavaScript consumer gets the same answer as the
|
|
8
|
+
shipped client.
|
|
9
|
+
|
|
10
|
+
The aggregate is built as its own dataclass rather than a `Progress` with a null percent, because a
|
|
11
|
+
shared shape is precisely how a percentage gets added back by someone reading `percent: float | None`
|
|
12
|
+
as an invitation (TW-REG-009).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from .delivery import Client, effective_delivery, OperationRef
|
|
18
|
+
from .models import (
|
|
19
|
+
ACTIVE_STATES,
|
|
20
|
+
Aggregate,
|
|
21
|
+
now_iso,
|
|
22
|
+
OperationSummary,
|
|
23
|
+
ProgressState,
|
|
24
|
+
Register,
|
|
25
|
+
Snapshot,
|
|
26
|
+
Text,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
BASE_POLL_MS = 400
|
|
30
|
+
"""§8.2 base interval, while anything is `queued` or `running`."""
|
|
31
|
+
|
|
32
|
+
CEILING_POLL_MS = 5000
|
|
33
|
+
"""§8.2 ceiling: also the cadence while a dialog is open and while idle. Polling never stops."""
|
|
34
|
+
|
|
35
|
+
_DOMINANCE = {
|
|
36
|
+
ProgressState.QUEUED: 0,
|
|
37
|
+
ProgressState.RUNNING: 1,
|
|
38
|
+
ProgressState.WAITING_INPUT: 2,
|
|
39
|
+
}
|
|
40
|
+
"""TW-REG-012: `waiting_input > running > queued`, by domination and never by majority.
|
|
41
|
+
|
|
42
|
+
`waiting_input` dominating is what lights the indicator: one operation blocked on a question among
|
|
43
|
+
nine running ones is the whole reason a user needs to look at the widget.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _entry_state(snapshot: Snapshot) -> ProgressState:
|
|
48
|
+
"""The state the register reasons about, **derived** rather than read off the document.
|
|
49
|
+
|
|
50
|
+
TW-REG-004 and TW-PROG-010 both say the same thing from different directions: `waiting_input`
|
|
51
|
+
comes from the entry's own open dialogs and its uncollected result, never from whatever the last
|
|
52
|
+
writer left in `progress.state`. Reading the stored state instead would make the indicator agree
|
|
53
|
+
with a stale write.
|
|
54
|
+
"""
|
|
55
|
+
if snapshot.needs_attention:
|
|
56
|
+
return ProgressState.WAITING_INPUT
|
|
57
|
+
state = ProgressState(snapshot.progress.state)
|
|
58
|
+
return state if state in ACTIVE_STATES else ProgressState.RUNNING
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def poll_after_ms(snapshots: list[Snapshot]) -> int:
|
|
62
|
+
"""The server's advice, which may only ever **slow a client down** (TW-REST-014).
|
|
63
|
+
|
|
64
|
+
A server that advertises `0` is a server asking to be flooded; the client clamps upward anyway,
|
|
65
|
+
but the value emitted here never goes below the base interval.
|
|
66
|
+
|
|
67
|
+
"Active" is the **derived** state, not the stored one (TW-REG-004, TW-PROG-010). Reading
|
|
68
|
+
`progress.state` here instead would break TW-REST-010's "poll at the ceiling while a dialog is
|
|
69
|
+
open": an operation holding an open question keeps `running` on its document - that is exactly
|
|
70
|
+
what TW-PROG-010 requires - so the base interval would be advertised for the whole life of every
|
|
71
|
+
dialog, which is the one situation the cadence singles out for the ceiling. A blocked worker
|
|
72
|
+
would then be polled twelve times a second per tab instead of once every five seconds.
|
|
73
|
+
|
|
74
|
+
NOTE: not a spec rule - §8.2 fixes the base and the ceiling, and choosing between them on this
|
|
75
|
+
test is this module's.
|
|
76
|
+
"""
|
|
77
|
+
active = any(_entry_state(s) in ACTIVE_STATES for s in snapshots)
|
|
78
|
+
return BASE_POLL_MS if active else CEILING_POLL_MS
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def select_fifo(snapshots: list[Snapshot]) -> Snapshot | None:
|
|
82
|
+
"""TW-REG-013's shipped default: the oldest entry that is `queued` or `running`.
|
|
83
|
+
|
|
84
|
+
It advances to the next one on its own when the selected operation leaves the register, which is
|
|
85
|
+
the whole of the behaviour - there is no `aggregate(ops, {weights})` helper and no averaging
|
|
86
|
+
anywhere.
|
|
87
|
+
|
|
88
|
+
Candidacy is decided on the **derived** state, exactly as `aggregate()` and `poll_after_ms()`
|
|
89
|
+
decide theirs (TW-REG-004, TW-PROG-010). An entry parked on an uncollected result, or blocked on
|
|
90
|
+
an open question, keeps `running` on its stored document; selecting it anyway would put a footer
|
|
91
|
+
in the position of drawing a parked operation's frozen percentage while the aggregate beside it
|
|
92
|
+
reports `waiting_input`.
|
|
93
|
+
"""
|
|
94
|
+
active = [s for s in snapshots if _entry_state(s) in ACTIVE_STATES]
|
|
95
|
+
if not active:
|
|
96
|
+
return None
|
|
97
|
+
return min(active, key=lambda s: s.progress.created_at)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _label_for(snapshot: Snapshot | None) -> Text | None:
|
|
101
|
+
"""Name the selected entry, preferring its title and falling back to its current step."""
|
|
102
|
+
if snapshot is None:
|
|
103
|
+
return None
|
|
104
|
+
return snapshot.progress.title or snapshot.progress.label
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def aggregate(snapshots: list[Snapshot]) -> Aggregate | None:
|
|
108
|
+
"""Counts and a dominated state over **every** entry in the register (TW-REG-010).
|
|
109
|
+
|
|
110
|
+
Returns `None` for an empty register so the widget can hide itself rather than render a zeroed
|
|
111
|
+
document (TW-REG-017).
|
|
112
|
+
|
|
113
|
+
The server never emits `done` or `failed` here (TW-REG-015): a terminal entry with nothing to
|
|
114
|
+
collect left the register in the same step that made it terminal, so no count and no domination
|
|
115
|
+
can ever see one.
|
|
116
|
+
"""
|
|
117
|
+
if not snapshots:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
states = [_entry_state(s) for s in snapshots]
|
|
121
|
+
counts = {
|
|
122
|
+
"total": len(snapshots),
|
|
123
|
+
"queued": sum(1 for s in states if s is ProgressState.QUEUED),
|
|
124
|
+
"running": sum(1 for s in states if s is ProgressState.RUNNING),
|
|
125
|
+
"waiting_input": sum(1 for s in states if s is ProgressState.WAITING_INPUT),
|
|
126
|
+
}
|
|
127
|
+
dominant = max(states, key=lambda s: _DOMINANCE[s])
|
|
128
|
+
return Aggregate(
|
|
129
|
+
state=dominant,
|
|
130
|
+
data=counts,
|
|
131
|
+
title=Text(key="taskwire.aggregate.title"),
|
|
132
|
+
label=_label_for(select_fifo(snapshots)),
|
|
133
|
+
updated_at=now_iso(),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def sort_entries(snapshots: list[Snapshot]) -> list[Snapshot]:
|
|
138
|
+
"""TW-REG-005: `needs_attention` first, then `created_at` ascending. There is no third key.
|
|
139
|
+
|
|
140
|
+
The consequence is accepted rather than worked around: an old uncollected result sorts above a
|
|
141
|
+
fresh question. The result holds no worker, and the aggregate's `waiting_input` state lights the
|
|
142
|
+
indicator either way, so a third key would buy nothing and would have to be explained.
|
|
143
|
+
"""
|
|
144
|
+
return sorted(snapshots, key=lambda s: (not s.needs_attention, s.progress.created_at))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def summarize(snapshot: Snapshot, client: Client) -> OperationSummary:
|
|
148
|
+
"""One register row: the whole snapshot, plus what a list view needs lifted out of it.
|
|
149
|
+
|
|
150
|
+
Carrying the entire snapshot is deliberate (TW-REG-002) - one request renders an operations UI
|
|
151
|
+
with no follow-up per-token GETs.
|
|
152
|
+
"""
|
|
153
|
+
progress = snapshot.progress
|
|
154
|
+
return OperationSummary(
|
|
155
|
+
token=snapshot.token,
|
|
156
|
+
snapshot=snapshot,
|
|
157
|
+
result_kind=progress.result_kind,
|
|
158
|
+
origin_session=progress.origin_session,
|
|
159
|
+
result=progress.result,
|
|
160
|
+
needs_attention=snapshot.needs_attention,
|
|
161
|
+
progress_delivery=effective_delivery(client, OperationRef(snapshot.token, progress)),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def build_register(snapshots: list[Snapshot], client: Client) -> Register:
|
|
166
|
+
"""Assemble the document `GET {prefix}` returns.
|
|
167
|
+
|
|
168
|
+
Always the whole register (TW-REG-003). There is no parameter by which a caller could ask for
|
|
169
|
+
less, and therefore none that could name another namespace. A suppressed operation still appears
|
|
170
|
+
here in full, with its state, its questions and its result - the delivery decision governs
|
|
171
|
+
delivery, never existence (TW-DEL-013, TW-INV-017).
|
|
172
|
+
"""
|
|
173
|
+
ordered = sort_entries(snapshots)
|
|
174
|
+
advice = poll_after_ms(snapshots)
|
|
175
|
+
for snapshot in ordered:
|
|
176
|
+
snapshot.poll_after_ms = advice
|
|
177
|
+
return Register(
|
|
178
|
+
operations=[summarize(s, client) for s in ordered],
|
|
179
|
+
aggregate=aggregate(snapshots),
|
|
180
|
+
server_time=now_iso(),
|
|
181
|
+
poll_after_ms=advice,
|
|
182
|
+
)
|