zettcode-broker 0.3.4__tar.gz

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,37 @@
1
+ **/.venv/
2
+ **/__pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .smoke/
8
+ .coverage
9
+ **/.pytest_cache/
10
+ **/.ruff_cache/
11
+ .zett-tool-output/
12
+ .env
13
+ .DS_Store
14
+
15
+ # viztracer writes its trace here by default.
16
+ /result.json
17
+
18
+ # The docs site: dependencies, build output, and VitePress' cache.
19
+ node_modules/
20
+ docs/.vitepress/dist/
21
+ docs/.vitepress/cache/
22
+ docs/.vitepress/.temp/
23
+
24
+ # The TypeScript client is built from its sources; only dist/ is published.
25
+ clients/typescript/dist/
26
+
27
+ # The gateway's page is built from clients/gateway into the package when a distribution is made (`make gateway-ui`);
28
+ # the build output is not kept in the repository. pyproject's `artifacts` puts it in the wheel all the same.
29
+ packages/zettcode-gateway/src/zettcode/gateway/static/
30
+
31
+ # The palette gallery is generated by `make palettes`: every widget painted in
32
+ # every palette, written by docs/scripts/palette_gallery.py. Regenerate it
33
+ # rather than committing a 2 MB copy that goes stale the moment a colour moves.
34
+ docs/internal/palettes.html
35
+
36
+ # The release order is generated from the pyproject.toml files (`make order`); it is not kept in git.
37
+ packages/order.txt
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.5
2
+ Name: zettcode-broker
3
+ Version: 0.3.4
4
+ Summary: The session broker ZettCode's sessions talk through: a small loopback HTTP server and its client
5
+ Project-URL: Repository, https://github.com/Chang-LeHung/zettcode
6
+ Author: Chang-LeHung
7
+ License-Expression: MIT
8
+ Keywords: broker,sessions,zettcode
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Programming Language :: Python :: 3.15
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: httpx<1,>=0.27
19
+ Requires-Dist: starlette<2,>=0.48
20
+ Requires-Dist: uvicorn<1,>=0.35
21
+ Requires-Dist: websockets<17,>=15
22
+ Requires-Dist: zettcode-engine
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "zettcode-broker"
3
+ description = "The session broker ZettCode's sessions talk through: a small loopback HTTP server and its client"
4
+ requires-python = ">=3.10"
5
+ dynamic = ["version"]
6
+ license = "MIT"
7
+ authors = [{name = "Chang-LeHung"}]
8
+ keywords = ["zettcode", "broker", "sessions"]
9
+ classifiers = [
10
+ "Development Status :: 5 - Production/Stable",
11
+ "Programming Language :: Python :: 3.10",
12
+ "Programming Language :: Python :: 3.11",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Programming Language :: Python :: 3.13",
15
+ "Programming Language :: Python :: 3.14",
16
+ "Programming Language :: Python :: 3.15",
17
+ "Typing :: Typed",
18
+ ]
19
+ dependencies = [
20
+ "zettcode-engine",
21
+ "starlette>=0.48,<2",
22
+ "uvicorn>=0.35,<1",
23
+ "httpx>=0.27,<1",
24
+ "websockets>=15,<17",
25
+ ]
26
+
27
+ [project.urls]
28
+ Repository = "https://github.com/Chang-LeHung/zettcode"
29
+
30
+ [build-system]
31
+ requires = ["hatchling"]
32
+ build-backend = "hatchling.build"
33
+
34
+ [tool.hatch.version]
35
+ # One version for the whole repository: the engine ships the file.
36
+ path = "../zettcode-engine/src/zettcode/version.py"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/zettcode"]
40
+
41
+ [tool.hatch.build.targets.sdist]
42
+ include = ["/src", "/pyproject.toml"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
46
+ asyncio_mode = "auto"
@@ -0,0 +1,22 @@
1
+ """The background process that lets one ZettCode session send a message to another.
2
+
3
+ Every running session registers with one small local process, the *broker*. A session can then ask who else is
4
+ active, send a message to one of them, and be handed the messages sent to it; the shell turns a received message
5
+ into a turn, and the model gets a tool to send one. Anything else on the machine that holds the broker's token can
6
+ send one too.
7
+
8
+ The broker is a Starlette application served by uvicorn on a loopback port, and each part has one job:
9
+
10
+ * what is said and the limits on it — :mod:`.protocol`; who is registered — :mod:`.roster`; cleaning what comes from
11
+ another process — :mod:`.text`;
12
+ * what the broker *decides* — :mod:`.hub`: the sessions, who a message is from, whether it may pass, who is told;
13
+ * how that is put on the web — :mod:`.web`: the routes, the token, the WebSocket of each session;
14
+ * the server's life — :mod:`.server`: bind, serve, say goodbye, stop when nobody needs it; and the process around
15
+ it — :mod:`.runner`: the lock, the note, the signals (``python -m zettcode.broker``);
16
+ * what a session uses to reach it — :mod:`.client`, with :mod:`.finder` (find the broker, start it, replace an older
17
+ one, using :mod:`.files` and :mod:`.process`) and :mod:`.models` (the words the rest of the application uses).
18
+
19
+ The client is imported by the shell with the rest of the application, and it only connects once the first frame is
20
+ up; the server only ever runs as ``python -m zettcode.broker``. Nothing here imports the shell, the agent or the
21
+ terminal, which :mod:`tests.test_structure` holds.
22
+ """
@@ -0,0 +1,10 @@
1
+ """``python -m zettcode.broker``: run the broker in the foreground."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .runner import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
@@ -0,0 +1,282 @@
1
+ """``zettcode broker``: start, stop, or restart the session broker by hand.
2
+
3
+ A broker is normally started by the first session that needs one and stops itself when the last session leaves, so
4
+ this command is for the person who wants to say so directly: after an upgrade, on a machine where a broker is stuck,
5
+ or before a session is opened. It decides nothing about messages; it only turns the process on and off.
6
+
7
+ The broker is found and watched by the lock it holds, never by the note alone (:mod:`zettcode.filelock`): a note can
8
+ outlive the process that wrote it, a held lock cannot. Stopping reuses the same ending as a broker of an older
9
+ protocol (:mod:`.process`), which recognises the process before touching it, asks it to stop, and waits for the lock.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+ import time
17
+ from abc import ABC, abstractmethod
18
+ from collections.abc import Callable
19
+ from pathlib import Path
20
+
21
+ from zettcode.filelock import is_held
22
+
23
+ from . import process
24
+ from .files import MODULE, Note, broker_dir, lock_path, log_path, read_note, remove_note
25
+ from .finder import spawn_broker
26
+ from .protocol import PROTOCOL_VERSION
27
+ from .reload import outdated
28
+
29
+ EXIT_OK = 0
30
+ EXIT_FAILED = 1
31
+
32
+ #: Seconds to wait for a broker that was just started to write the note that says where it listens.
33
+ START_WAIT = 8.0
34
+
35
+ #: How often the note and the lock are looked at while waiting.
36
+ POLL = 0.05
37
+
38
+
39
+ class Companions(ABC):
40
+ """Processes that live on the broker and cannot survive its being replaced, which a restart has to deal with.
41
+
42
+ The broker knows nothing of what they are (background agents, in practice: :mod:`zettcode.daemon`); the command
43
+ that starts it hands one in. A new broker may speak another protocol, and a session still running the old code
44
+ then waits for ever, so they are stopped before the broker and started again after it.
45
+ """
46
+
47
+ @abstractmethod
48
+ def listing(self) -> list[str]:
49
+ """Return one line for each process that is running now; empty when there is none."""
50
+
51
+ @abstractmethod
52
+ def stop(self) -> list[str]:
53
+ """Stop every one of them, remember what is needed to start them again, and return a line for each."""
54
+
55
+ @abstractmethod
56
+ def start(self) -> list[tuple[bool, str]]:
57
+ """Start again what :meth:`stop` stopped; return, for each, whether it worked and a line to show."""
58
+
59
+
60
+ def build_parser() -> argparse.ArgumentParser:
61
+ """Return the parser for ``zettcode broker`` and the three things it can do."""
62
+ parser = argparse.ArgumentParser(
63
+ prog="zettcode broker",
64
+ description=(
65
+ "Start, stop, or restart the session broker: the background process that lets ZettCode sessions on this "
66
+ "machine message each other. A broker starts by itself when a session needs one."
67
+ ),
68
+ )
69
+ parser.add_argument(
70
+ "action",
71
+ choices=("start", "stop", "restart", "reload"),
72
+ help=(
73
+ "start a broker if there is none, stop the one there is, or do both; reload restarts it only when it is "
74
+ "older than this version and no background agent is on it (zettcode runs this by itself after an upgrade)"
75
+ ),
76
+ )
77
+ parser.add_argument(
78
+ "-y",
79
+ "--yes",
80
+ action="store_true",
81
+ help="restart: do not ask before stopping and starting again the background agents running on the broker",
82
+ )
83
+ return parser
84
+
85
+
86
+ def broker_program(argv: list[str] | None = None, companions: Companions | None = None) -> int:
87
+ """Entry point of ``zettcode broker``; the exit status is what the shell reads: 0 done, 1 failed.
88
+
89
+ Args:
90
+ argv: The arguments after ``broker``.
91
+ companions: What has to be stopped and started again with the broker on a ``restart``, if anything.
92
+ """
93
+ args = build_parser().parse_args(argv)
94
+ directory = broker_dir()
95
+ if args.action == "restart":
96
+ return restart(directory, companions, assume_yes=args.yes)
97
+ if args.action == "reload":
98
+ return reload(directory, companions)
99
+ action = {"start": start, "stop": stop}[args.action]
100
+ return action(directory)
101
+
102
+
103
+ def reload(directory: Path, companions: Companions | None = None) -> int:
104
+ """Replace the running broker when it is older than this version, and say nothing when there is nothing to do.
105
+
106
+ This is what runs behind every start of ``zettcode`` after an upgrade (:mod:`.reload`), so it never asks and never
107
+ stops what it should not: a broker with background agents on it is left as it is, with a line that says how to
108
+ restart it knowingly, because stopping them is something a person agrees to (``restart`` asks). A broker that is
109
+ current, newer, or not running is not touched. The status is 0 unless a replacement was tried and failed.
110
+ """
111
+ note = outdated(directory)
112
+ if note is None:
113
+ return EXIT_OK
114
+ if companions is not None and (on_it := companions.listing()):
115
+ count = len(on_it)
116
+ print(
117
+ f"the broker (pid {note.pid}) speaks an older protocol, but {count} background "
118
+ f"agent{'s are' if count != 1 else ' is'} running on it, so it was left alone: "
119
+ "run `zettcode broker restart` when that is fine"
120
+ )
121
+ return EXIT_OK
122
+ # `stop` compares the note itself, in the same step that ends the broker, so what it ends is the broker that was
123
+ # found and never one another start of zettcode has just put in its place.
124
+ if stop(directory, expect=note.pid) != EXIT_OK:
125
+ return EXIT_FAILED
126
+ if running(directory):
127
+ # `stop` left a broker that is not the one that was found: another start replaced it first, and it is theirs.
128
+ return EXIT_OK
129
+ status = start(directory)
130
+ if status == EXIT_OK:
131
+ print(f"replaced broker {note.pid} (protocol {note.version}) with one that speaks {PROTOCOL_VERSION}")
132
+ return status
133
+
134
+
135
+ def running(directory: Path) -> bool:
136
+ """Return whether a broker holds its lock in ``directory``, which is the only reliable sign that one is there."""
137
+ return is_held(lock_path(directory))
138
+
139
+
140
+ def start(directory: Path) -> int:
141
+ """Start a broker unless one is running, and say where it listens."""
142
+ if running(directory):
143
+ note = read_note(directory)
144
+ where = f" (pid {note.pid}, port {note.port})" if note is not None else ""
145
+ print(f"the broker is already running{where}")
146
+ return EXIT_OK
147
+ before = read_note(directory)
148
+ try:
149
+ spawn_broker(directory)
150
+ except OSError as error:
151
+ print(f"the broker could not be started: {error}", file=sys.stderr)
152
+ return EXIT_FAILED
153
+ note = _wait_for_note(directory, before)
154
+ if note is None:
155
+ print(f"the broker did not start; the log is {log_path(directory)}", file=sys.stderr)
156
+ return EXIT_FAILED
157
+ print(f"broker {note.pid} listening on port {note.port}")
158
+ return EXIT_OK
159
+
160
+
161
+ def stop(directory: Path, expect: int | None = None) -> int:
162
+ """Stop the broker that is running, and say what happened.
163
+
164
+ A note whose broker has gone is removed rather than reported: it is what the next session would read, and a
165
+ stale one only sends it to a port nobody is on.
166
+
167
+ An explicit ``stop`` does not compare the broker's protocol version: that rule (never end a newer broker) is for
168
+ a session replacing one automatically, so two versions do not end each other's in turn; a person who asks for
169
+ this wants it stopped, whatever it says it speaks.
170
+
171
+ With ``expect``, only the broker with that process id is ended: the note is read once, here, and a broker it does
172
+ not name (another start of zettcode replaced the one that was meant) is left running. That is a compare and a stop
173
+ in one step, which a check made before calling this cannot be.
174
+ """
175
+ note = read_note(directory)
176
+ if expect is not None and (note is None or note.pid != expect):
177
+ return EXIT_OK
178
+ if not running(directory):
179
+ if note is not None:
180
+ remove_note(directory, note.token)
181
+ print("the broker is not running")
182
+ return EXIT_OK
183
+ if note is None:
184
+ print("a broker holds the lock but left no note, so it cannot be identified", file=sys.stderr)
185
+ return EXIT_FAILED
186
+ if process.end(note.pid, MODULE, released=lambda: not running(directory)):
187
+ print(f"stopped broker {note.pid}")
188
+ return EXIT_OK
189
+ print(f"broker {note.pid} did not stop; it was not recognised as one, or it took too long", file=sys.stderr)
190
+ return EXIT_FAILED
191
+
192
+
193
+ def restart(
194
+ directory: Path,
195
+ companions: Companions | None = None,
196
+ *,
197
+ assume_yes: bool = False,
198
+ ask: Callable[[str], str] = input,
199
+ interactive: Callable[[], bool] = lambda: sys.stdin.isatty(),
200
+ ) -> int:
201
+ """Stop the broker if one is running, then start one; with ``companions`` running, only once the person agrees.
202
+
203
+ Those are stopped first and started again after the broker, because a session that outlives its broker is cut off
204
+ from the new one when the protocol changed. Saying no (or not being able to say anything: no terminal and no
205
+ ``--yes``) changes nothing at all.
206
+
207
+ Args:
208
+ directory: The broker's directory.
209
+ companions: What lives on the broker, or ``None`` for nothing.
210
+ assume_yes: Do not ask.
211
+ ask: Reads the answer, injectable for tests.
212
+ interactive: Whether anybody can be asked.
213
+ """
214
+ running_now = companions.listing() if companions is not None else []
215
+ if companions is not None and running_now and not _agreed(running_now, assume_yes, ask, interactive):
216
+ return EXIT_FAILED
217
+ if companions is not None and running_now:
218
+ for line in companions.stop():
219
+ print(line)
220
+ if running(directory) and stop(directory) != EXIT_OK:
221
+ # The broker is still the old one, so what was stopped for it is brought back rather than left off.
222
+ return _bring_back(companions, running_now, EXIT_FAILED)
223
+ status = start(directory)
224
+ if status != EXIT_OK:
225
+ return _bring_back(companions, running_now, status)
226
+ return _bring_back(companions, running_now, EXIT_OK)
227
+
228
+
229
+ def _agreed(
230
+ running_now: list[str], assume_yes: bool, ask: Callable[[str], str], interactive: Callable[[], bool]
231
+ ) -> bool:
232
+ """Show what a restart will stop, and return whether the person agreed to it."""
233
+ count = len(running_now)
234
+ print(f"{count} background agent{'s are' if count != 1 else ' is'} running on the broker:")
235
+ for line in running_now:
236
+ print(f" {line}")
237
+ print(
238
+ "They are stopped, and started again from their own sessions after the broker is: nothing they wrote is lost."
239
+ )
240
+ if assume_yes:
241
+ return True
242
+ if not interactive():
243
+ print("not restarting: nobody can be asked here. Run it in a terminal, or add --yes.", file=sys.stderr)
244
+ return False
245
+ try:
246
+ answer = ask("Stop them and restart the broker? [y/N] ")
247
+ except (EOFError, KeyboardInterrupt):
248
+ print()
249
+ return False
250
+ if answer.strip().lower() in ("y", "yes"):
251
+ return True
252
+ print("not restarting; nothing was changed.")
253
+ return False
254
+
255
+
256
+ def _bring_back(companions: Companions | None, running_now: list[str], status: int) -> int:
257
+ """Start again what a restart stopped and report each; the status is the broker's unless one of them failed."""
258
+ if companions is None or not running_now:
259
+ return status
260
+ failed = False
261
+ for worked, line in companions.start():
262
+ print(line, file=sys.stdout if worked else sys.stderr)
263
+ failed = failed or not worked
264
+ return EXIT_FAILED if failed else status
265
+
266
+
267
+ def _wait_for_note(directory: Path, before: Note | None) -> Note | None:
268
+ """Wait for a broker to say where it listens: a note different from the one that was there before it started.
269
+
270
+ The token is new on every start, so a note left by a broker that died is not mistaken for the new one; another
271
+ session that won the race to start a broker also writes a new one, and either answer is the broker to report.
272
+ """
273
+ deadline = time.monotonic() + START_WAIT
274
+ while time.monotonic() < deadline:
275
+ note = read_note(directory)
276
+ if note is not None and (before is None or note.token != before.token) and running(directory):
277
+ return note
278
+ time.sleep(POLL)
279
+ return None
280
+
281
+
282
+ __all__ = ["Companions", "broker_program", "build_parser", "restart", "running", "start", "stop"]