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 |
| 2 | sockets on localhost.""" |
| 3 | |
| 4 | import json |
| 5 | import time |
| 6 | import urllib.request |
| 7 | |
| 8 | import pytest |
| 9 | |
| 10 | from pyrtan import Registry, RecordedTransport, __version__ |
| 11 | from pyrtan.hub import Hub |
| 12 | from pyrtan.service import Server, Service, http_json |
| 13 | from pyrtan.tree import load_tree_text |
| 14 | from conftest import EXAMPLES, FIXTURES |
| 15 | |
| 16 | ECHO = """ |
| 17 | name = "echo" |
| 18 | description = "echoes" |
| 19 | start = "say" |
| 20 | |
| 21 | [[triggers]] |
| 22 | pattern = "echo {text}" |
| 23 | |
| 24 | [inputs.text] |
| 25 | type = "str" |
| 26 | |
| 27 | [nodes.say] |
| 28 | kind = "done" |
| 29 | say = "{{ inputs.text }}" |
| 30 | output = { text = "{{ inputs.text }}" } |
| 31 | """ |
| 32 | |
| 33 | NEEDS_WEATHER = """ |
| 34 | name = "picnic" |
| 35 | description = "decides on a picnic by asking the swarm for weather" |
| 36 | start = "weather" |
| 37 | |
| 38 | [[triggers]] |
| 39 | pattern = "picnic in {place}" |
| 40 | |
| 41 | [inputs.place] |
| 42 | type = "str" |
| 43 | |
| 44 | [nodes.weather] |
| 45 | kind = "delegate" |
| 46 | intent = "weather in {{ inputs.place }}" |
| 47 | into = "w" |
| 48 | next = "ok" |
| 49 | |
| 50 | [nodes.ok] |
| 51 | kind = "decide" |
| 52 | on = "w.status" |
| 53 | branches = [ { when = { eq = "done" }, then = "sky" }, { else = "unknown" } ] |
| 54 | |
| 55 | [nodes.sky] |
| 56 | kind = "decide" |
| 57 | on = "w.output.sky" |
| 58 | branches = [ { when = { in = ["clear", "cloudy"] }, then = "yes" }, { else = "no" } ] |
| 59 | |
| 60 | [nodes.yes] |
| 61 | kind = "done" |
| 62 | say = "Picnic: yes, it is {{ state.w.output.sky }}." |
| 63 | |
| 64 | [nodes.no] |
| 65 | kind = "done" |
| 66 | say = "Picnic: no, it is {{ state.w.output.sky }}." |
| 67 | |
| 68 | [nodes.unknown] |
| 69 | kind = "fail" |
| 70 | reason = "no-weather" |
| 71 | say = "Could not get the weather: {{ state.w.say }}" |
| 72 | """ |
| 73 | |
| 74 | |
| 75 | @pytest.fixture |
| 76 | def 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 | |
| 84 | def 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 | |
| 111 | def 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 | |
| 122 | def 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 | |
| 139 | def 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 | |
| 209 | def 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 | |
| 217 | def 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 | |
| 238 | def 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() |