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/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
+ )