pyrtan / sourcev0.14.0 · main

pyrtan / tests / test_service_hub.py

10,881 bytes · last changed 2026-10-27 in Merge pull request #19 from b-i-t-8/claude/sleepy-mendel-h22689

1"""A service, a hub, and a swarm of two services behind one hub, over real
2sockets on localhost."""
3
4import json
5import time
6import urllib.request
7
8import pytest
9
10from pyrtan import Registry, RecordedTransport, __version__
11from pyrtan.hub import Hub
12from pyrtan.service import Server, Service, http_json
13from pyrtan.tree import load_tree_text
14from conftest import EXAMPLES, FIXTURES
15
16ECHO = """
17name = "echo"
18description = "echoes"
19start = "say"
20
21[[triggers]]
22pattern = "echo {text}"
23
24[inputs.text]
25type = "str"
26
27[nodes.say]
28kind = "done"
29say = "{{ inputs.text }}"
30output = { text = "{{ inputs.text }}" }
31"""
32
33NEEDS_WEATHER = """
34name = "picnic"
35description = "decides on a picnic by asking the swarm for weather"
36start = "weather"
37
38[[triggers]]
39pattern = "picnic in {place}"
40
41[inputs.place]
42type = "str"
43
44[nodes.weather]
45kind = "delegate"
46intent = "weather in {{ inputs.place }}"
47into = "w"
48next = "ok"
49
50[nodes.ok]
51kind = "decide"
52on = "w.status"
53branches = [ { when = { eq = "done" }, then = "sky" }, { else = "unknown" } ]
54
55[nodes.sky]
56kind = "decide"
57on = "w.output.sky"
58branches = [ { when = { in = ["clear", "cloudy"] }, then = "yes" }, { else = "no" } ]
59
60[nodes.yes]
61kind = "done"
62say = "Picnic: yes, it is {{ state.w.output.sky }}."
63
64[nodes.no]
65kind = "done"
66say = "Picnic: no, it is {{ state.w.output.sky }}."
67
68[nodes.unknown]
69kind = "fail"
70reason = "no-weather"
71say = "Could not get the weather: {{ state.w.say }}"
72"""
73
74
75@pytest.fixture
76def example_service():
77 reg = Registry.from_paths([EXAMPLES])
78 svc = Service(reg, name="examples", transport=RecordedTransport.from_file(FIXTURES / "examples.json"))
79 server = Server(svc.route, quiet=True).start()
80 yield svc, server
81 server.stop()
82
83
84def test_service_endpoints(example_service):
85 svc, server = example_service
86 status, health = http_json("GET", f"{server.url}/health")
87 assert status == 200 and health["status"] == "ok" and "weather" in health["trees"]
88 _, caps = http_json("GET", f"{server.url}/capabilities")
89 assert {t["name"] for t in caps["trees"]} >= {"weather", "hacker-news"}
90 _, out = http_json("POST", f"{server.url}/intent", {"text": "weather in Oslo"})
91 assert out["status"] == "done" and out["output"]["sky"] == "raining"
92 assert out["trace"]["steps"][0]["kind"] == "intent" and out["trace"]["steps"][1]["node"] == "geocode"
93 _, out = http_json("POST", f"{server.url}/run", {"tree": "hacker-news-item", "inputs": {"id": 101}, "trace": False})
94 assert out["status"] == "done" and "trace" not in out
95 _, out = http_json("POST", f"{server.url}/intent", {"text": "nothing matches this"})
96 assert out["reason"] == "no-match"
97 assert out["output"]["proposal"]["trigger"] == "nothing matches this"
98 assert "pyrtan new nothing-matches-this --from" in out["say"]
99 _, un = http_json("GET", f"{server.url}/unanswered")
100 assert [(u["text"], u["count"]) for u in un["unanswered"]] == [("nothing matches this", 1)]
101 _, health = http_json("GET", f"{server.url}/health")
102 assert health["unanswered"] == 1
103 status, out = http_json("GET", f"{server.url}/nowhere")
104 assert status == 404
105 _, out = http_json("POST", f"{server.url}/run", {"tree": "site-status", "inputs": {"host": "teapot.example"}})
106 assert out["status"] == "done"
107 _, gaps = http_json("GET", f"{server.url}/gaps")
108 assert gaps["gaps"] == []
109
110
111def test_service_rejects_bad_json(example_service):
112 _, server = example_service
113 req = urllib.request.Request(f"{server.url}/intent", data=b"not json", method="POST", headers={"Content-Type": "application/json"})
114 try:
115 urllib.request.urlopen(req)
116 except urllib.error.HTTPError as e:
117 assert e.code == 400
118 else:
119 pytest.fail("expected 400")
120
121
122def test_hot_reload_picks_up_a_new_tree(tmp_path):
123 reg = Registry.from_paths([tmp_path])
124 svc = Service(reg, name="hot")
125 server = Server(svc.route, quiet=True).start()
126 try:
127 _, out = http_json("POST", f"{server.url}/intent", {"text": "echo hi"})
128 assert out["reason"] == "no-match"
129 (tmp_path / "echo.toml").write_text(ECHO)
130 _, out = http_json("POST", f"{server.url}/intent", {"text": "echo hi"})
131 assert out["status"] == "done" and out["say"] == "hi"
132 (tmp_path / "echo.toml").unlink()
133 _, health = http_json("GET", f"{server.url}/health")
134 assert health["trees"] == []
135 finally:
136 server.stop()
137
138
139def test_swarm_of_two_behind_a_hub(tmp_path):
140 # Service A: the examples (holds `weather`). Service B: `picnic`, which
141 # delegates "weather in X" to the hub, which routes it to A.
142 reg_a = Registry.from_paths([EXAMPLES])
143 svc_a = Service(reg_a, name="a", transport=RecordedTransport.from_file(FIXTURES / "examples.json"))
144 srv_a = Server(svc_a.route, quiet=True).start()
145
146 hub = Hub(poll_every=0.2)
147 hub_srv = Server(hub.route, quiet=True).start()
148
149 (tmp_path / "picnic.toml").write_text(NEEDS_WEATHER)
150 reg_b = Registry.from_paths([tmp_path])
151 svc_b = Service(reg_b, name="b", hub=hub_srv.url)
152 srv_b = Server(svc_b.route, quiet=True).start()
153 try:
154 assert svc_b.register(srv_b.url)
155 status, reg = http_json("POST", f"{hub_srv.url}/register", {"url": srv_a.url})
156 assert status == 200 and reg["peer"]["link"] == "up"
157
158 _, health = http_json("GET", f"{hub_srv.url}/health")
159 assert health["peers"] == 2 and health["up"] == 2 and health["trees"] == 12
160
161 _, caps = http_json("GET", f"{hub_srv.url}/capabilities")
162 assert {t["name"] for t in caps["trees"]} == {"weather", "hacker-news", "hacker-news-item", "site-status", "github-release", "picnic", "disk-space", "log-errors", "room-temperature", "rendered-headline", "modem-check", "small-talk"}
163
164 # Routed by the hub to B, which delegates back through the hub to A.
165 _, out = http_json("POST", f"{hub_srv.url}/intent", {"text": "picnic in Oslo"})
166 assert out["status"] == "done", out
167 assert out["say"] == "Picnic: no, it is raining."
168 assert out["peer"] == srv_b.url
169 nodes = [s["node"] for s in out["trace"]["steps"]]
170 assert "geocode" in nodes and "sky" in nodes # A's steps carried back inside B's trace
171
172 # Direct run through the hub to whoever holds the tree.
173 _, out = http_json("POST", f"{hub_srv.url}/run", {"tree": "hacker-news-item", "inputs": {"id": 101}})
174 assert out["status"] == "done" and out["peer"] == srv_a.url
175
176 _, out = http_json("POST", f"{hub_srv.url}/run", {"tree": "nope"})
177 assert out["reason"] == "no-such-tree"
178
179 # A sentence nobody answers: the hub says so, proposes the tree, and
180 # remembers. B, which has a hub, leaves the record to the hub.
181 _, out = http_json("POST", f"{hub_srv.url}/intent", {"text": "trace route to 10.0.0.1"})
182 assert out["reason"] == "no-match" and out["output"]["seen"] == 1
183 assert out["say"].startswith("No service in the swarm answers 'trace route to 10.0.0.1'. To write the tree that would: pyrtan new trace-route --from")
184 _, out = http_json("POST", f"{srv_b.url}/intent", {"text": "trace route to 10.0.0.1"})
185 assert out["reason"] == "no-match"
186 assert http_json("GET", f"{srv_b.url}/unanswered")[1]["unanswered"] == []
187 _, un = http_json("GET", f"{hub_srv.url}/unanswered")
188 assert [(u["text"], u["count"]) for u in un["unanswered"]] == [("trace route to 10.0.0.1", 2)]
189 assert un["unanswered"][0]["proposal"]["inputs"]["host"]["type"] == "str"
190
191 # A peer that dies is marked and skipped.
192 srv_a.stop()
193 hub.refresh_all()
194 _, peers = http_json("GET", f"{hub_srv.url}/peers")
195 a = next(p for p in peers["peers"] if p["url"] == srv_a.url)
196 assert a["last_error"] is not None
197 _, out = http_json("POST", f"{hub_srv.url}/run", {"tree": "weather", "inputs": {"place": "Oslo"}})
198 assert out["reason"] in ("peer-unreachable", "no-such-tree")
199 finally:
200 hub.stop()
201 hub_srv.stop()
202 srv_b.stop()
203 try:
204 srv_a.stop()
205 except Exception:
206 pass
207
208
209def test_hub_health_link_states():
210 hub = Hub()
211 p = hub.add_peer("http://127.0.0.1:9") # nothing listens there
212 assert p.link == "down" and p.last_error
213 assert hub.health()["down"] == 1
214 assert hub.route("POST", "/register", {"url": "ftp://x"})[0] == 400
215
216
217def test_demo_page_is_served_by_service_and_hub(example_service):
218 _, server = example_service
219 with urllib.request.urlopen(f"{server.url}/") as resp:
220 assert resp.headers.get_content_type() == "text/html"
221 page = resp.read()
222 assert b"<title>Pyrtan</title>" in page and f"console v{__version__}".encode() in page
223 # The wordmark's two halves pick their own colours; the picker is inline, not another request.
224 assert b'data-theme-part="pyr">pyr<' in page and b'data-theme-part="tan">tan<' in page
225 assert b"__THEME__" not in page and b'localStorage.getItem(KEY)' in page
226 status, api = http_json("GET", f"{server.url}/api")
227 assert status == 200 and api["pyrtan"] == "service"
228 hub = Hub()
229 hub_srv = Server(hub.route, quiet=True).start()
230 try:
231 with urllib.request.urlopen(f"{hub_srv.url}/index.html") as resp:
232 assert resp.headers.get_content_type() == "text/html"
233 assert http_json("GET", f"{hub_srv.url}/api")[1]["pyrtan"] == "hub"
234 finally:
235 hub_srv.stop()
236
237
238def test_hub_reads_sentences_with_the_tree_language_and_puts_the_reading_in_the_trace():
239 reg = Registry.from_paths([EXAMPLES])
240 svc = Service(reg, name="examples", transport=RecordedTransport.from_file(FIXTURES / "examples.json"))
241 srv = Server(svc.route, quiet=True).start()
242 hub = Hub(poll_every=60)
243 hub_srv = Server(hub.route, quiet=True).start()
244 try:
245 hub.add_peer(srv.url)
246 _, caps = http_json("GET", f"{hub_srv.url}/capabilities")
247 weather = next(t for t in caps["trees"] if t["name"] == "weather")
248 assert weather["language"] == {"synonyms": {"weather": ["forecast", "temperature"]}}
249 # The synonym lives in the tree; the hub applies it when it routes.
250 _, out = http_json("POST", f"{hub_srv.url}/intent", {"text": "Hey, what's the forecast in Oslo?"})
251 assert out["status"] == "done" and out["output"]["sky"] == "raining"
252 first = out["trace"]["steps"][0]
253 assert first["kind"] == "intent" and first["next"] == "geocode"
254 assert first["observed"]["cleaned"] == "what is the forecast in Oslo"
255 assert out["trace"]["steps"][1]["node"] == "geocode"
256 _, out = http_json("POST", f"{hub_srv.url}/intent", {"text": "please do nothing"})
257 assert out["reason"] == "no-match" and out["output"]["reading"]["cleaned"] == "do nothing"
258 finally:
259 hub.stop()
260 hub_srv.stop()
261 srv.stop()