diff --git a/app.py b/app.py index 13180b736a4cde2c4cf2a10db2e142accce3688b..c30ea5ec8d159f3084d23d52b5e70a581a3c7e44 100644 --- a/app.py +++ b/app.py @@ -472,6 +472,7 @@ def _mount_bus_endpoints(app) -> None: app.routes.insert(0, app.routes.pop(_i)) break + # 3) Patch App.create_app to inject the StaticFiles mount after Gradio routes if _webagent_dir.exists(): try: diff --git a/hearthnet/bus/registry.py b/hearthnet/bus/registry.py index fabf1f4c1e152da1190c3230db4e2abbc89a1a2c..0e35d069650e74e9590c6c42106e19f184c10447 100644 --- a/hearthnet/bus/registry.py +++ b/hearthnet/bus/registry.py @@ -21,6 +21,7 @@ class RegistryEvent: kind in {"added", "removed", "updated"} """ + kind: str entry: CapabilityEntry @@ -49,6 +50,7 @@ class Registry: def add_remote(self, peer: PeerRecord, descriptor: CapabilityDescriptor) -> CapabilityEntry: endpoint = peer.endpoints[0] if peer.endpoints else None + # Use a general params-compatibility check for remote entries so that # corpus/model/lang routing works across the mesh without needing to # transfer Python callables over the wire. diff --git a/hearthnet/bus/trace.py b/hearthnet/bus/trace.py index 084e7ab0a3041fd0c77a8b8256dfa4241d163ff7..e3a9fa016a40ba3f5b74a32a952a2691eb5b84fb 100644 --- a/hearthnet/bus/trace.py +++ b/hearthnet/bus/trace.py @@ -32,5 +32,6 @@ class TraceHook: def record(self, event: CallTraceEvent) -> None: if self._ring is not None: from contextlib import suppress + with suppress(Exception): self._ring.push(event) diff --git a/hearthnet/civdef/service.py b/hearthnet/civdef/service.py index 5da0cd2846a50e878ecb3eb789777c5303e48a7e..8b16bc67224a8da9dca5d253d5c1876151c5d433 100644 --- a/hearthnet/civdef/service.py +++ b/hearthnet/civdef/service.py @@ -312,4 +312,3 @@ class CivilDefenseService: async def handle_audit(self, req: Any) -> dict: return {"output": self.export_audit(), "meta": {}} - diff --git a/hearthnet/cli.py b/hearthnet/cli.py index 70ee86a7695c85da82ab3c50c4a989ccb6433b81..d2f8068301a0da8e7e04698a9731476b64d9f1a0 100644 --- a/hearthnet/cli.py +++ b/hearthnet/cli.py @@ -420,7 +420,12 @@ def log(follow: bool, level: str, component: str | None, host: str, port: int) - if component and entry.get("component", "") != component: continue entry_level = entry.get("level", "INFO").upper() - if ["DEBUG", "INFO", "WARNING", "ERROR"].index(entry_level) < ["DEBUG", "INFO", "WARNING", "ERROR"].index(level): + if ["DEBUG", "INFO", "WARNING", "ERROR"].index(entry_level) < [ + "DEBUG", + "INFO", + "WARNING", + "ERROR", + ].index(level): continue ts = entry.get("ts", "?") msg = entry.get("message") or entry.get("capability") or json.dumps(entry) @@ -436,7 +441,9 @@ def log(follow: bool, level: str, component: str | None, host: str, port: int) - @main.command() -@click.option("--keep-keys", is_flag=True, help="Keep Ed25519 identity keys, erase everything else.") +@click.option( + "--keep-keys", is_flag=True, help="Keep Ed25519 identity keys, erase everything else." +) @click.option("--yes", is_flag=True, help="Skip confirmation prompt.") def erase(keep_keys: bool, yes: bool) -> None: """Erase all local HearthNet data. @@ -460,8 +467,10 @@ def erase(keep_keys: bool, yes: bool) -> None: key_backup = None if key_file.exists(): import tempfile + key_backup = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".key").name) import shutil as _sh + _sh.copy2(key_file, key_backup) shutil.rmtree(config_dir) if key_backup and key_backup.exists(): @@ -518,9 +527,13 @@ def rag_ingest(path: str, corpus: str, host: str, port: int) -> None: continue data_b64 = __import__("base64").b64encode(f.read_bytes()).decode() try: - result = _bus_call(host, port, "rag.ingest", (1, 0), { - "input": {"corpus": corpus, "filename": f.name, "data_b64": data_b64} - }) + result = _bus_call( + host, + port, + "rag.ingest", + (1, 0), + {"input": {"corpus": corpus, "filename": f.name, "data_b64": data_b64}}, + ) err = result.get("error") if err: click.echo(f" SKIP {f.name}: {err}") @@ -575,9 +588,13 @@ def invite() -> None: def invite_create(node_id: str, level: str, ttl: int, host: str, port: int) -> None: """Create an invite link for a new member.""" try: - result = _bus_call(host, port, "community.invite", (1, 0), { - "input": {"invitee_node_id": node_id, "initial_level": level, "ttl_seconds": ttl} - }) + result = _bus_call( + host, + port, + "community.invite", + (1, 0), + {"input": {"invitee_node_id": node_id, "initial_level": level, "ttl_seconds": ttl}}, + ) except ConnectionError: click.echo(f"Node not reachable at {host}:{port}") sys.exit(3) @@ -598,9 +615,9 @@ def invite_redeem(text_or_path: str, host: str, port: int) -> None: p = Path(text_or_path) invite_text = p.read_text().strip() if p.exists() else text_or_path.strip() try: - result = _bus_call(host, port, "community.redeem", (1, 0), { - "input": {"invite_text": invite_text} - }) + result = _bus_call( + host, port, "community.redeem", (1, 0), {"input": {"invite_text": invite_text}} + ) except ConnectionError: click.echo(f"Node not reachable at {host}:{port}") sys.exit(3) @@ -622,6 +639,7 @@ def version_cmd() -> None: """Print HearthNet version and exit.""" try: from importlib.metadata import version as _v + ver = _v("hearthnet") except Exception: try: @@ -749,9 +767,9 @@ def model_list() -> None: if not model_dir.is_dir(): continue - size_mb = sum( - f.stat().st_size for f in model_dir.rglob("*") if f.is_file() - ) / (1024 * 1024) + size_mb = sum(f.stat().st_size for f in model_dir.rglob("*") if f.is_file()) / ( + 1024 * 1024 + ) file_count = len(list(model_dir.rglob("*"))) @@ -803,6 +821,7 @@ def health(detailed: bool) -> None: # 1. Python version import sys + py_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" click.echo(f"✅ Python: {py_version}") checks_passed += 1 @@ -840,6 +859,7 @@ def health(detailed: bool) -> None: # 4. GPU support try: import torch + has_gpu = torch.cuda.is_available() if has_gpu: gpu_name = torch.cuda.get_device_name(0) diff --git a/hearthnet/config.py b/hearthnet/config.py index 2b350303563ca043f4cea6e39f2cb382333e193f..21b5ac5807644acb8ee057f55d30e4ef5fe1baef 100644 --- a/hearthnet/config.py +++ b/hearthnet/config.py @@ -542,6 +542,7 @@ def save(config: Config, path: Path | None = None) -> None: fh.write(content) os.replace(tmp, cfg_path) except Exception: - from contextlib import suppress - with suppress(OSError): - os.unlink(tmp) + from contextlib import suppress + + with suppress(OSError): + os.unlink(tmp) diff --git a/hearthnet/conformance/runner.py b/hearthnet/conformance/runner.py index ac0fe9ea7ce51dde3be91f84f6134059430457e5..31079fc5170f8dd4f75416083daf137d9571d235 100644 --- a/hearthnet/conformance/runner.py +++ b/hearthnet/conformance/runner.py @@ -14,64 +14,217 @@ from typing import Any # Check definitions # --------------------------------------------------------------------------- + @dataclass(frozen=True) class Check: capability: str version: tuple[int, int] body: dict - suite: str # "1.0", "2.0", "3.0" + suite: str # "1.0", "2.0", "3.0" expected_output_fields: list[str] = field(default_factory=list) - expect_error: str | None = None # if set, pass only when this error is returned + expect_error: str | None = None # if set, pass only when this error is returned description: str = "" # Phase 1 checks (suite 1.0) — derived from CAPABILITY_CONTRACT.md §3.2 _CHECKS: list[Check] = [ # Identity / protocol - Check("protocol.version.list", (1, 0), {"input": {}}, "1.0", ["contract_versions"], description="protocol.version.list returns supported versions"), - Check("protocol.conformance.report", (1, 0), {"input": {"suite_version": "1.0", "fast": True}}, "1.0", ["passed", "total"], description="protocol.conformance.report can self-report"), - + Check( + "protocol.version.list", + (1, 0), + {"input": {}}, + "1.0", + ["contract_versions"], + description="protocol.version.list returns supported versions", + ), + Check( + "protocol.conformance.report", + (1, 0), + {"input": {"suite_version": "1.0", "fast": True}}, + "1.0", + ["passed", "total"], + description="protocol.conformance.report can self-report", + ), # Embedding - Check("embed.text", (1, 0), {"input": {"texts": ["conformance ping"]}}, "1.0", ["vectors"], description="embed.text returns vectors"), - + Check( + "embed.text", + (1, 0), + {"input": {"texts": ["conformance ping"]}}, + "1.0", + ["vectors"], + description="embed.text returns vectors", + ), # RAG - Check("rag.query", (1, 0), {"input": {"query": "ping", "corpus": "demo", "k": 1}}, "1.0", [], description="rag.query responds"), - Check("rag.list_corpora", (1, 0), {"input": {}}, "1.0", ["corpora"], description="rag.list_corpora returns list"), - + Check( + "rag.query", + (1, 0), + {"input": {"query": "ping", "corpus": "demo", "k": 1}}, + "1.0", + [], + description="rag.query responds", + ), + Check( + "rag.list_corpora", + (1, 0), + {"input": {}}, + "1.0", + ["corpora"], + description="rag.list_corpora returns list", + ), # Files - Check("file.list", (1, 0), {"input": {}}, "1.0", ["files"], description="file.list returns files list"), - Check("file.put", (1, 0), {"input": {"data_b64": "aGVsbG8=", "filename": "x09.txt"}}, "1.0", ["cid"], description="file.put returns cid"), - + Check( + "file.list", + (1, 0), + {"input": {}}, + "1.0", + ["files"], + description="file.list returns files list", + ), + Check( + "file.put", + (1, 0), + {"input": {"data_b64": "aGVsbG8=", "filename": "x09.txt"}}, + "1.0", + ["cid"], + description="file.put returns cid", + ), # Marketplace - Check("market.list", (1, 0), {"input": {}}, "1.0", ["posts"], description="market.list returns posts"), - + Check( + "market.list", + (1, 0), + {"input": {}}, + "1.0", + ["posts"], + description="market.list returns posts", + ), # LLM - Check("llm.complete", (1, 0), {"input": {"prompt": "x09 conformance", "max_tokens": 1}}, "1.0", [], description="llm.complete responds"), - + Check( + "llm.complete", + (1, 0), + {"input": {"prompt": "x09 conformance", "max_tokens": 1}}, + "1.0", + [], + description="llm.complete responds", + ), # Chat - Check("chat.send", (1, 0), {"input": {"to": "self", "body": "x09", "client_id": "x09_conformance"}}, "1.0", [], description="chat.send accepts message"), - + Check( + "chat.send", + (1, 0), + {"input": {"to": "self", "body": "x09", "client_id": "x09_conformance"}}, + "1.0", + [], + description="chat.send accepts message", + ), # MoE (Phase 3 but bus-registered in all nodes) - Check("moe.list", (1, 0), {"input": {}}, "1.0", ["experts"], description="moe.list returns experts"), - Check("moe.route", (1, 0), {"input": {"query": "conformance test"}}, "1.0", ["candidates"], description="moe.route returns candidates"), - + Check( + "moe.list", + (1, 0), + {"input": {}}, + "1.0", + ["experts"], + description="moe.list returns experts", + ), + Check( + "moe.route", + (1, 0), + {"input": {"query": "conformance test"}}, + "1.0", + ["candidates"], + description="moe.route returns candidates", + ), # Model distribution - Check("model.list", (1, 0), {"input": {}}, "1.0", ["models"], description="model.list returns models"), - + Check( + "model.list", + (1, 0), + {"input": {}}, + "1.0", + ["models"], + description="model.list returns models", + ), # Tool: plant (validates input handling) - Check("tool.plant_identify", (1, 0), {"input": {}}, "1.0", [], expect_error="bad_request", description="tool.plant_identify rejects missing image"), - + Check( + "tool.plant_identify", + (1, 0), + {"input": {}}, + "1.0", + [], + expect_error="bad_request", + description="tool.plant_identify rejects missing image", + ), # Phase 2 (suite 2.0) — only if registered - Check("ocr.image", (1, 0), {"input": {"image_cid": "blake3:00000000"}}, "2.0", [], description="ocr.image endpoint exists"), - Check("trans.text", (1, 0), {"input": {"text": "hello", "from": "en", "to": "de"}}, "2.0", [], description="trans.text responds"), - Check("rerank.text", (1, 0), {"input": {"query": "test", "documents": [{"id": "d1", "text": "test"}]}}, "2.0", [], description="rerank.text responds"), - Check("img.describe", (1, 0), {"input": {"image_cid": "blake3:00000000", "task": "caption"}}, "2.0", [], description="img.describe responds"), - Check("stt.transcribe", (1, 0), {"input": {"audio_cid": "blake3:00000000"}}, "2.0", [], description="stt.transcribe responds"), - Check("tts.synthesize", (1, 0), {"input": {"text": "ping", "speed": 1.0, "format": "wav"}}, "2.0", [], description="tts.synthesize responds"), - + Check( + "ocr.image", + (1, 0), + {"input": {"image_cid": "blake3:00000000"}}, + "2.0", + [], + description="ocr.image endpoint exists", + ), + Check( + "trans.text", + (1, 0), + {"input": {"text": "hello", "from": "en", "to": "de"}}, + "2.0", + [], + description="trans.text responds", + ), + Check( + "rerank.text", + (1, 0), + {"input": {"query": "test", "documents": [{"id": "d1", "text": "test"}]}}, + "2.0", + [], + description="rerank.text responds", + ), + Check( + "img.describe", + (1, 0), + {"input": {"image_cid": "blake3:00000000", "task": "caption"}}, + "2.0", + [], + description="img.describe responds", + ), + Check( + "stt.transcribe", + (1, 0), + {"input": {"audio_cid": "blake3:00000000"}}, + "2.0", + [], + description="stt.transcribe responds", + ), + Check( + "tts.synthesize", + (1, 0), + {"input": {"text": "ping", "speed": 1.0, "format": "wav"}}, + "2.0", + [], + description="tts.synthesize responds", + ), # Phase 3 experimental (suite 3.0) - Check("moe.register", (1, 0), {"input": {"expert_id": "model:x09", "expert_type": "model", "topic_tags": ["x09"], "confidence_score": 0.5, "community_id": "x09"}}, "3.0", ["registered"], description="moe.register accepts expert"), - Check("model.status", (1, 0), {"input": {}}, "3.0", ["jobs"], description="model.status returns jobs"), + Check( + "moe.register", + (1, 0), + { + "input": { + "expert_id": "model:x09", + "expert_type": "model", + "topic_tags": ["x09"], + "confidence_score": 0.5, + "community_id": "x09", + } + }, + "3.0", + ["registered"], + description="moe.register accepts expert", + ), + Check( + "model.status", + (1, 0), + {"input": {}}, + "3.0", + ["jobs"], + description="model.status returns jobs", + ), ] @@ -79,6 +232,7 @@ _CHECKS: list[Check] = [ # Report # --------------------------------------------------------------------------- + @dataclass class CheckResult: capability: str @@ -131,6 +285,7 @@ class ConformanceReport: # Runner # --------------------------------------------------------------------------- + class ConformanceRunner: """Runs the X09 conformance suite against a local bus or remote HTTP node. @@ -226,7 +381,9 @@ class ConformanceRunner: suite=check.suite, passed=passed, skipped=False, - error="" if passed else f"expected_error={check.expect_error}, got={error_code}", + error="" + if passed + else f"expected_error={check.expect_error}, got={error_code}", duration_ms=ms, description=check.description, ) diff --git a/hearthnet/constants.py b/hearthnet/constants.py index 8966cd65dcfca3fef4971975a6bae141fd270af8..58036e767a94457762e5c6f7385174783fc61ef3 100644 --- a/hearthnet/constants.py +++ b/hearthnet/constants.py @@ -136,7 +136,7 @@ CIVDEF_ALERT_BODY_MAX_CHARS: int = 1000 CIVDEF_HEARTBEAT_SECONDS: int = 60 # ── Tensor transport (X08) ─────────────────────────────────────────────────── -TENSOR_CHUNK_BYTES: int = 1 * 1024 * 1024 # 1 MiB +TENSOR_CHUNK_BYTES: int = 1 * 1024 * 1024 # 1 MiB TENSOR_FLOW_CONTROL_WINDOW: int = 16 TENSOR_COMPRESSION_THRESHOLD_BYTES: int = 64 * 1024 TENSOR_KEEPALIVE_SECONDS: int = 30 diff --git a/hearthnet/discovery/mdns.py b/hearthnet/discovery/mdns.py index c648d5dbeb2fa2e1687f2ee79cfc7baebdc0ea8f..ddf8ea1813938eb3fb3386fddacd209f6bca4c94 100644 --- a/hearthnet/discovery/mdns.py +++ b/hearthnet/discovery/mdns.py @@ -95,7 +95,9 @@ class MdnsBrowser: pass def _on_service_state_change(self, zeroconf, service_type, name, state_change) -> None: - self._state_change_task = asyncio.create_task(self._handle_change(zeroconf, service_type, name, state_change)) + self._state_change_task = asyncio.create_task( + self._handle_change(zeroconf, service_type, name, state_change) + ) async def _handle_change(self, zeroconf, service_type, name, state_change) -> None: try: @@ -132,6 +134,7 @@ class MdnsBrowser: async def stop(self) -> None: if self._zeroconf: - from contextlib import suppress - with suppress(Exception): - await self._zeroconf.async_close() + from contextlib import suppress + + with suppress(Exception): + await self._zeroconf.async_close() diff --git a/hearthnet/discovery/peers.py b/hearthnet/discovery/peers.py index 83022a6a49eacc39f252f5933a33f480b83618ea..40b98c0583102ee6d2a371eb409797c3429c40c9 100644 --- a/hearthnet/discovery/peers.py +++ b/hearthnet/discovery/peers.py @@ -141,7 +141,8 @@ class PeerRegistry: return gen() def _notify(self, event: PeerEvent) -> None: - from contextlib import suppress - for q in list(self._subscribers): - with suppress(asyncio.QueueFull): - q.put_nowait(event) + from contextlib import suppress + + for q in list(self._subscribers): + with suppress(asyncio.QueueFull): + q.put_nowait(event) diff --git a/hearthnet/discovery/udp.py b/hearthnet/discovery/udp.py index 8bbed38847f12dbabb525d3bda7b4d2f6ed80f8c..7f570006f9285af2aa2c522f6de756f09ac7c62f 100644 --- a/hearthnet/discovery/udp.py +++ b/hearthnet/discovery/udp.py @@ -46,6 +46,7 @@ class UdpAnnouncer: if self._task: self._task.cancel() from contextlib import suppress + with suppress(asyncio.CancelledError): await self._task @@ -107,6 +108,7 @@ class UdpListener: if self._task: self._task.cancel() from contextlib import suppress + with suppress(asyncio.CancelledError): await self._task @@ -118,6 +120,7 @@ class UdpListener: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) from contextlib import suppress + with suppress(AttributeError, OSError): sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) # type: ignore[attr-defined] sock.bind(("", self._port)) diff --git a/hearthnet/emergency/state.py b/hearthnet/emergency/state.py index b5f5704639264097d2cb3686e04ccad2f8755744..438b0a27f0ad7f0594ee6ec6782553b897a265f4 100644 --- a/hearthnet/emergency/state.py +++ b/hearthnet/emergency/state.py @@ -74,7 +74,11 @@ class StateBus: self._transition_times = [ t for t in self._transition_times if now - t < EMERGENCY_ANTI_FLAP_WINDOW_SECONDS ] - if len(self._transition_times) >= EMERGENCY_ANTI_FLAP_MAX_TRANSITIONS and old_mode in ("degraded", "offline") and new_mode == "online": + if ( + len(self._transition_times) >= EMERGENCY_ANTI_FLAP_MAX_TRANSITIONS + and old_mode in ("degraded", "offline") + and new_mode == "online" + ): # Too many flaps — hold pessimistic new_mode = old_mode # don't restore yet diff --git a/hearthnet/events/log.py b/hearthnet/events/log.py index 805ed21b932ee5c5c8c3c0de59b564f6cf1e1a43..b410b3d8bf8f7eca867d1439b4ae29e900e2e3df 100644 --- a/hearthnet/events/log.py +++ b/hearthnet/events/log.py @@ -16,10 +16,10 @@ import json import sqlite3 import threading from collections.abc import AsyncIterator -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path -UTC = timezone.utc +UTC = UTC from typing import Any from .lamport import LamportClock diff --git a/hearthnet/events/replay.py b/hearthnet/events/replay.py index 5af6d8f874bd176da1b3f0fbff61a55e6aecb05b..ab0d1b47bddfa6808a47861927fe652748fd15d6 100644 --- a/hearthnet/events/replay.py +++ b/hearthnet/events/replay.py @@ -83,7 +83,7 @@ class ReplayEngine: def replay_since(self, lamport: int) -> None: """Replay (without reset) all views for events at lamport >= *lamport*.""" # Collect all event types across views - for (view, ft) in self._views.values(): + for view, ft in self._views.values(): event_types = list(ft) if ft is not None else None for event in self.log.replay(since_lamport=lamport, event_types=event_types): # type: ignore[arg-type] view.apply(event) @@ -94,7 +94,7 @@ class ReplayEngine: def _on_event(self, event: Event) -> None: """Route a newly-arrived event to all subscribed views.""" - for (view, ft) in self._views.values(): + for view, ft in self._views.values(): if ft is None or event.event_type in ft: view.apply(event) diff --git a/hearthnet/events/snapshot.py b/hearthnet/events/snapshot.py index e4cc393642183d2ff9cb217d0acc0bf7c65b7cea..5a22efedb2917275610094b5ea7bc76531475afb 100644 --- a/hearthnet/events/snapshot.py +++ b/hearthnet/events/snapshot.py @@ -5,11 +5,11 @@ import contextlib import json import os from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import TYPE_CHECKING, Any -UTC = timezone.utc +UTC = UTC if TYPE_CHECKING: from .log import EventLog @@ -156,7 +156,7 @@ def build_snapshot( at_lamport = max(0, head - _SNAPSHOT_LAG_LAMPORT) # Rebuild all views up to at_lamport - for (view, ft) in engine._views.values(): + for view, ft in engine._views.values(): view.reset() event_types = list(ft) if ft is not None else None for event in log.replay(since_lamport=0, event_types=event_types): # type: ignore[arg-type] diff --git a/hearthnet/evidence/service.py b/hearthnet/evidence/service.py index d3f797fe35e20c6f1e12796f4bb051a9fe4d19c8..9601cee42931be8e2316617466d1c9263f5f259b 100644 --- a/hearthnet/evidence/service.py +++ b/hearthnet/evidence/service.py @@ -131,9 +131,7 @@ class EvidenceService: claim_id = ClaimID(str(inp.get("claim_id", ""))) if self._store.get_claim(claim_id) is None: return {"error": "not_found", "message": "unknown claim_id"} - self._store.attest( - Attestation(claim_id=claim_id, attested_by=str(req.caller or "unknown")) - ) + self._store.attest(Attestation(claim_id=claim_id, attested_by=str(req.caller or "unknown"))) return { "output": { "claim_id": claim_id, diff --git a/hearthnet/identity/keys.py b/hearthnet/identity/keys.py index c5b0da459b8f7371b64f2eb236a5c5dc255891df..3b6d800b9c94a5f5c090e1df0860914f8373f717 100644 --- a/hearthnet/identity/keys.py +++ b/hearthnet/identity/keys.py @@ -246,6 +246,7 @@ def save(kp: KeyPair, keys_dir: Path) -> None: priv_path.write_bytes(base64.urlsafe_b64encode(sk_bytes).rstrip(b"=") + b"\n") # Restrict permissions on POSIX from contextlib import suppress + with suppress(AttributeError): os.chmod(priv_path, stat.S_IRUSR | stat.S_IWUSR) # 0600 # Write public key diff --git a/hearthnet/identity/manifest.py b/hearthnet/identity/manifest.py index 827358b3687dec99ae8e31edacf4d033954960fb..441be8c0c7d1fc15f9c98946ac7e026293caf855 100644 --- a/hearthnet/identity/manifest.py +++ b/hearthnet/identity/manifest.py @@ -1,10 +1,10 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from typing import Any -UTC = timezone.utc +UTC = UTC from hearthnet.identity.keys import ( IdentityError, @@ -177,6 +177,7 @@ class NodeManifest: @dataclass(frozen=True) class RevokedEntry: """A revoked member entry in a community manifest.""" + node_id: str revoked_at: str reason: str = "" @@ -185,6 +186,7 @@ class RevokedEntry: @dataclass(frozen=True) class CommunityMember: """A member record in a community manifest.""" + node_id: str display_name: str level: str # "root" | "trusted" | "moderator" | "member" @@ -195,6 +197,7 @@ class CommunityMember: @dataclass(frozen=True) class CommunityPolicy: """Community governance policy embedded in CommunityManifest.""" + allow_public_join: bool = False require_invite: bool = True max_members: int = 500 diff --git a/hearthnet/node.py b/hearthnet/node.py index 4a2b4f3b5b69b382f9805a647b2193921f8732fe..e646aa25a1fef42054edf215ccc7eebae2f56c85 100644 --- a/hearthnet/node.py +++ b/hearthnet/node.py @@ -520,7 +520,7 @@ class HearthNode: await self._http_server.start() _log.info("HTTP server listening on %s:%d", host, port) - # Wire StateBus → WebSocket pubsub (X06) + # Wire StateBus -> WebSocket pubsub (X06) if self._http_server._ws_pubsub is not None: self._pubsub_task = asyncio.create_task( self._state_bus_to_pubsub(), name="state-pubsub" diff --git a/hearthnet/observability/doctor.py b/hearthnet/observability/doctor.py index 3c501e7d2c0e8dc46ab2be2329d8f4bf57827c95..6c77e0b782f2045115e9f5b71b4d045a6b309d08 100644 --- a/hearthnet/observability/doctor.py +++ b/hearthnet/observability/doctor.py @@ -304,4 +304,3 @@ def run_one(name: str) -> DoctorResult: # --------------------------------------------------------------------------- CheckResult = DoctorResult - diff --git a/hearthnet/observability/federated.py b/hearthnet/observability/federated.py index 2c18d6eb77f65278e4c3bbb3b609826d8b49ade7..e7db19c2194e2fa47517a17296ef886c7305373d 100644 --- a/hearthnet/observability/federated.py +++ b/hearthnet/observability/federated.py @@ -257,9 +257,7 @@ class MetricsAggregator: """Return the latest community-wide aggregate.""" now = time.time() online_cutoff = now - 120 # consider online if tick within 2 min - latest_ticks: list[NodeMetricsTick] = [ - d[-1] for d in self._ticks.values() if d - ] + latest_ticks: list[NodeMetricsTick] = [d[-1] for d in self._ticks.values() if d] online = [t for t in latest_ticks if t.tick_at >= online_cutoff] total_epm = sum(t.events_per_min for t in online) diff --git a/hearthnet/observability/logging.py b/hearthnet/observability/logging.py index 9683785f5625e5c56f4f2e30979a218226b5e3bd..513a3b0ef1db749e4c97ad3c188d9f9ca0d76c16 100644 --- a/hearthnet/observability/logging.py +++ b/hearthnet/observability/logging.py @@ -59,9 +59,7 @@ class JsonFormatter(logging.Formatter): "exc_text", "message", } - payload.update( - {key: val for key, val in record.__dict__.items() if key not in _SKIP} - ) + payload.update({key: val for key, val in record.__dict__.items() if key not in _SKIP}) if record.exc_info: payload["exc"] = self.formatException(record.exc_info) diff --git a/hearthnet/observability/metrics.py b/hearthnet/observability/metrics.py index 8c2a29e4c0f713d3340189f4859ccd570148df2f..cf0b2d3a78a65e09dfaafd13f787d885a0fd8a69 100644 --- a/hearthnet/observability/metrics.py +++ b/hearthnet/observability/metrics.py @@ -271,6 +271,7 @@ class TrackioExporter: def _try_init(self) -> None: try: import trackio # type: ignore[import] + self._run = trackio.init(project=self._project, name=self._run_name) self._enabled = True except ImportError: @@ -295,27 +296,30 @@ class TrackioExporter: if not self._enabled or self._run is None: return with contextlib.suppress(Exception): - self._run.log({ - "latency_ms": latency_ms, - "tokens_in": tokens_in, - "tokens_out": tokens_out, - "model": model, - "backend": backend, - "result": result, - }) + self._run.log( + { + "latency_ms": latency_ms, + "tokens_in": tokens_in, + "tokens_out": tokens_out, + "model": model, + "backend": backend, + "result": result, + } + ) def log_topology(self, mesh_size: int, online: bool, cap_count: int) -> None: if not self._enabled or self._run is None: return with contextlib.suppress(Exception): - self._run.log({ - "mesh_size": mesh_size, - "online": int(online), - "capability_count": cap_count, - }) + self._run.log( + { + "mesh_size": mesh_size, + "online": int(online), + "capability_count": cap_count, + } + ) def close(self) -> None: if self._run is not None: with contextlib.suppress(Exception): self._run.finish() - diff --git a/hearthnet/observability/otlp_export.py b/hearthnet/observability/otlp_export.py index 1c3dc910709de2aa212f0bba2f4401b7138f6493..6fb921edc0dd03915869f154fa19b705a36c2909 100644 --- a/hearthnet/observability/otlp_export.py +++ b/hearthnet/observability/otlp_export.py @@ -13,16 +13,20 @@ logger = logging.getLogger(__name__) # Optional OpenTelemetry imports try: - from opentelemetry import metrics as otel_metrics # type: ignore[import] - from opentelemetry.exporter.otlp.proto.http.metric_exporter import ( # type: ignore[import] - OTLPMetricExporter, - ) - from opentelemetry.sdk.metrics import MeterProvider # type: ignore[import] - from opentelemetry.sdk.metrics.export import ( # type: ignore[import] - PeriodicExportingMetricReader, - ) + from importlib.util import find_spec - HAS_OTEL_METRICS = True + HAS_OTEL_METRICS = ( + find_spec("opentelemetry.metrics") is not None + and find_spec("opentelemetry.exporter.otlp.proto.http.metric_exporter") is not None + ) + if HAS_OTEL_METRICS: + from opentelemetry.exporter.otlp.proto.http.metric_exporter import ( # type: ignore[import] + OTLPMetricExporter, + ) + from opentelemetry.sdk.metrics import MeterProvider # type: ignore[import] + from opentelemetry.sdk.metrics.export import ( # type: ignore[import] + PeriodicExportingMetricReader, + ) except ImportError: HAS_OTEL_METRICS = False @@ -160,6 +164,7 @@ class OtlpExporter: async def shutdown(self) -> None: """Flush and shut down the underlying providers.""" from contextlib import suppress + if self._meter_provider is not None: with suppress(Exception): self._meter_provider.shutdown() # type: ignore[union-attr] diff --git a/hearthnet/relay/push_subscriber.py b/hearthnet/relay/push_subscriber.py index 8de2bae5f66073f4467abe16511b26c69e053a18..66cec8b772ce72d9e21ebeb9ea6d6f2543923bd2 100644 --- a/hearthnet/relay/push_subscriber.py +++ b/hearthnet/relay/push_subscriber.py @@ -101,6 +101,7 @@ class PushSubscriber: async def close(self) -> None: """Close the internal httpx client.""" from contextlib import suppress + if self._httpx_client is not None: with suppress(Exception): await self._httpx_client.aclose() # type: ignore[union-attr] diff --git a/hearthnet/services/chat/service.py b/hearthnet/services/chat/service.py index f01bb3a22851b0f1f8259cc0a4a0fde43a91595c..c1d1b8a02e4e30e091b16e3be5d5e675cccd3111 100644 --- a/hearthnet/services/chat/service.py +++ b/hearthnet/services/chat/service.py @@ -1,11 +1,11 @@ from __future__ import annotations import uuid -from datetime import datetime, timezone +from datetime import UTC, datetime from hearthnet.bus.capability import CapabilityDescriptor, RouteRequest -UTC = timezone.utc +UTC = UTC from hearthnet.services.chat.delivery import DeliveryManager from hearthnet.services.chat.views import ChatView diff --git a/hearthnet/services/chat/thread_views.py b/hearthnet/services/chat/thread_views.py index 2862a52cb4e10a4417a9082db9df108c0fb59ef5..021a4d31b04395674347d64c0c2b19964e569fb1 100644 --- a/hearthnet/services/chat/thread_views.py +++ b/hearthnet/services/chat/thread_views.py @@ -273,15 +273,15 @@ class ThreadViewStore: ) return result results = [] - for tid, members in self._members.items(): - if member_id in members: + for tid, member_set in self._members.items(): + if member_id in member_set: t = self._threads.get(tid) if t: results.append( Thread( thread_id=t["thread_id"], name=t["name"], - members=list(members), + members=list(member_set), created_at=t["created_at"], archived=t["archived"], e2e_enabled=t["e2e_enabled"], diff --git a/hearthnet/services/llm/backends/base.py b/hearthnet/services/llm/backends/base.py index 3179f0e21050a4ebe78af440ce6ec037c74a7131..ed104a5ed373f4e712f27c1ccd87fad882cfb6ff 100644 --- a/hearthnet/services/llm/backends/base.py +++ b/hearthnet/services/llm/backends/base.py @@ -8,8 +8,9 @@ from typing import Any, Protocol @dataclass(frozen=True) class Token: text: str - logprob: float = 0.0 + logprob: float | None = None stop: bool = False + finish_reason: str | None = None @dataclass(frozen=True) diff --git a/hearthnet/services/llm/backends/hf_api.py b/hearthnet/services/llm/backends/hf_api.py index 004bf7a6bd103d2437175e3298d09c9086ee519d..6c548008e72748880c8672b674cf5c9c0a2edc6c 100644 --- a/hearthnet/services/llm/backends/hf_api.py +++ b/hearthnet/services/llm/backends/hf_api.py @@ -67,10 +67,12 @@ class HfApiBackend: prompt += "\nAssistant:" url = f"{self._base_url}/models/{self._model}" - payload = json.dumps({ - "inputs": prompt, - "parameters": {"max_new_tokens": max_tokens, "return_full_text": False}, - }).encode() + payload = json.dumps( + { + "inputs": prompt, + "parameters": {"max_new_tokens": max_tokens, "return_full_text": False}, + } + ).encode() req = urllib.request.Request( # nosec B310 url, data=payload, diff --git a/hearthnet/services/llm/backends/llama_cpp.py b/hearthnet/services/llm/backends/llama_cpp.py index 66d14eab599cdf69844a7275f47b97f6d8e13c9d..2060711fc8d93c8a1d0b04c87a7946a27ea5ad3a 100644 --- a/hearthnet/services/llm/backends/llama_cpp.py +++ b/hearthnet/services/llm/backends/llama_cpp.py @@ -30,11 +30,10 @@ class LlamaCppBackend: def is_available(self) -> bool: try: + from importlib.util import find_spec from pathlib import Path - import llama_cpp - - return Path(self._model_path).exists() + return Path(self._model_path).exists() and find_spec("llama_cpp") is not None except ImportError: return False diff --git a/hearthnet/services/llm/backends/modal_backend.py b/hearthnet/services/llm/backends/modal_backend.py index 1d3ed92050df72406403888c3066b7b8eb7031a6..5aece9f40c2a5e504f884476853f6d8ad9b49f87 100644 --- a/hearthnet/services/llm/backends/modal_backend.py +++ b/hearthnet/services/llm/backends/modal_backend.py @@ -61,12 +61,8 @@ class ModalBackend: model: str | None = None, api_token: str | None = None, ) -> None: - self._endpoint = ( - (endpoint or os.getenv("MODAL_ENDPOINT", "")).rstrip("/") - ) - self._model = model or os.getenv( - "MODAL_MODEL", "HuggingFaceTB/SmolLM2-1.7B-Instruct" - ) + self._endpoint = (endpoint or os.getenv("MODAL_ENDPOINT", "")).rstrip("/") + self._model = model or os.getenv("MODAL_MODEL", "HuggingFaceTB/SmolLM2-1.7B-Instruct") self._token = api_token or os.getenv("MODAL_TOKEN", "") self.models: list[BackendModel] = [] diff --git a/hearthnet/services/llm/backends/ollama.py b/hearthnet/services/llm/backends/ollama.py index 2076963edfc5a371ceb0d900e6535f6e41f8c0a2..9527b16b79d82b80006f13092c575374db8fe340 100644 --- a/hearthnet/services/llm/backends/ollama.py +++ b/hearthnet/services/llm/backends/ollama.py @@ -96,7 +96,10 @@ class OllamaBackend: import httpx - async with httpx.AsyncClient(timeout=120.0) as client, client.stream("POST", f"{self._base_url}/api/chat", json=payload) as resp: + async with ( + httpx.AsyncClient(timeout=120.0) as client, + client.stream("POST", f"{self._base_url}/api/chat", json=payload) as resp, + ): async for line in resp.aiter_lines(): if line: try: diff --git a/hearthnet/services/llm/backends/openai_compat.py b/hearthnet/services/llm/backends/openai_compat.py index 4ff7904e3ed1fb10e6e7d5c4a3c1a3d3350aa3a7..332656d0327921dc4fbfa825c9d03bfa3b6bc679 100644 --- a/hearthnet/services/llm/backends/openai_compat.py +++ b/hearthnet/services/llm/backends/openai_compat.py @@ -106,12 +106,15 @@ class OpenAICompatBackend: import httpx payload["stream"] = True - async with httpx.AsyncClient(timeout=60.0) as client, client.stream( - "POST", - f"{self._base_url}/chat/completions", - json=payload, - headers=headers, - ) as resp: + async with ( + httpx.AsyncClient(timeout=60.0) as client, + client.stream( + "POST", + f"{self._base_url}/chat/completions", + json=payload, + headers=headers, + ) as resp, + ): async for line in resp.aiter_lines(): if line.startswith("data: "): raw = line[6:] diff --git a/hearthnet/services/llm/service.py b/hearthnet/services/llm/service.py index 39d286ba8f8a2533a87f19210d1e1cbdcf43245d..461811106fb8079138756c905c3c93e54cd6fe5e 100644 --- a/hearthnet/services/llm/service.py +++ b/hearthnet/services/llm/service.py @@ -249,18 +249,6 @@ class _EchoBackend: def health(self) -> dict: return {"status": "ok", "note": "echo-backend-tests-only"} - async def warm(self) -> None: - pass - - async def close(self) -> None: - pass - - def health(self) -> dict: - return {"backend": "echo", "status": "ok"} - - def is_available(self) -> bool: - return True - def _model_matches(offered: dict, requested: dict) -> bool: req = requested.get("model") diff --git a/hearthnet/services/marketplace/post.py b/hearthnet/services/marketplace/post.py index 75dc57a28b636837b240034000c5aa68f3d87734..e256456b76b24410e55693e770b4dae89f14db94 100644 --- a/hearthnet/services/marketplace/post.py +++ b/hearthnet/services/marketplace/post.py @@ -1,10 +1,10 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Literal -UTC = timezone.utc +UTC = UTC Category = Literal["offer", "request", "info", "emergency"] diff --git a/hearthnet/services/marketplace/service.py b/hearthnet/services/marketplace/service.py index d1cb5b90817c43b03d2ffc38922fe327a450e0b6..e29558d0a40789d7017bedbeafa400e1027a4443 100644 --- a/hearthnet/services/marketplace/service.py +++ b/hearthnet/services/marketplace/service.py @@ -1,11 +1,11 @@ from __future__ import annotations import uuid -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from hearthnet.bus.capability import CapabilityDescriptor, RouteRequest -UTC = timezone.utc +UTC = UTC from hearthnet.constants import MARKET_DEFAULT_TTL_SECONDS from hearthnet.services.marketplace.views import MarketplaceView diff --git a/hearthnet/services/marketplace/views.py b/hearthnet/services/marketplace/views.py index 227efc4a788af27dcda366b3aca85a1fff1b33e7..1f687d071b839a1cecec5748c67219a3cadefb5f 100644 --- a/hearthnet/services/marketplace/views.py +++ b/hearthnet/services/marketplace/views.py @@ -1,9 +1,9 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any -UTC = timezone.utc +UTC = UTC from hearthnet.services.marketplace.post import Location, Post diff --git a/hearthnet/services/moe/service.py b/hearthnet/services/moe/service.py index 32fd94aa82fb1e727c2d74f73415dd02db863073..905777225f33ccd71881eb95a4243c586bbd400a 100644 --- a/hearthnet/services/moe/service.py +++ b/hearthnet/services/moe/service.py @@ -145,14 +145,14 @@ class MoeService: """Register an expert descriptor. input: - expert_id: str — "human:" | "model:" | "service:" - expert_type: str — "human" | "model" | "service" | "external" - topic_tags: list[str] — topic tags for matching - confidence_score: float — 0.0–1.0 self-reported + expert_id: str - "human:" | "model:" | "service:" + expert_type: str - "human" | "model" | "service" | "external" + topic_tags: list[str] - topic tags for matching + confidence_score: float - 0.0-1.0 self-reported community_id: str name: str = "" description: str = "" - ttl_seconds: float = 3600 — 0 = never expires + ttl_seconds: float = 3600 - 0 = never expires """ inp = req.body.get("input", {}) expert_id = inp.get("expert_id", "") diff --git a/hearthnet/services/protocol/service.py b/hearthnet/services/protocol/service.py index 1b8f1a00f2fb949bef2a44693e13e36e183d3101..34a2dbed5f6e4693f7cc57eaf9a6b21ebaa1f2e3 100644 --- a/hearthnet/services/protocol/service.py +++ b/hearthnet/services/protocol/service.py @@ -43,8 +43,25 @@ _SUITE_V1: list[tuple[str, tuple[int, int], dict, str]] = [ ("file.put", (1, 0), {"input": {"data_b64": "cGluZw==", "filename": "ping.txt"}}, "cid"), ("file.list", (1, 0), {"input": {}}, "files"), ("market.list", (1, 0), {"input": {}}, "posts"), - ("market.post", (1, 0), {"input": {"title": "__conformance__", "body": "test", "category": "other", "client_id": "__x09__"}}, ""), - ("chat.send", (1, 0), {"input": {"to": "self", "body": "ping", "client_id": "__x09_chat__"}}, ""), + ( + "market.post", + (1, 0), + { + "input": { + "title": "__conformance__", + "body": "test", + "category": "other", + "client_id": "__x09__", + } + }, + "", + ), + ( + "chat.send", + (1, 0), + {"input": {"to": "self", "body": "ping", "client_id": "__x09_chat__"}}, + "", + ), ("moe.list", (1, 0), {"input": {}}, "experts"), ("moe.route", (1, 0), {"input": {"query": "ping"}}, "candidates"), ("model.list", (1, 0), {"input": {}}, "models"), @@ -55,12 +72,30 @@ _SUITE_V2: list[tuple[str, tuple[int, int], dict, str]] = [ # Phase 2 — only checked if those services are registered ("ocr.image", (1, 0), {"input": {"image_cid": "blake3:test"}}, ""), ("trans.text", (1, 0), {"input": {"text": "hello", "from": "en", "to": "de"}}, ""), - ("rerank.text", (1, 0), {"input": {"query": "test", "documents": [{"id": "d1", "text": "test"}]}}, ""), + ( + "rerank.text", + (1, 0), + {"input": {"query": "test", "documents": [{"id": "d1", "text": "test"}]}}, + "", + ), ] _SUITE_V3: list[tuple[str, tuple[int, int], dict, str]] = [ # Phase 3 experimental - ("moe.register", (1, 0), {"input": {"expert_id": "model:x09", "expert_type": "model", "topic_tags": ["test"], "confidence_score": 0.5, "community_id": "test"}}, "registered"), + ( + "moe.register", + (1, 0), + { + "input": { + "expert_id": "model:x09", + "expert_type": "model", + "topic_tags": ["test"], + "confidence_score": 0.5, + "community_id": "test", + } + }, + "registered", + ), ("tool.plant_identify", (1, 0), {"input": {}}, ""), # expects error: bad_request ] @@ -145,9 +180,7 @@ class ProtocolService: }, "started": bool(self._node and getattr(self._node, "_started", False)), "event_log_head": ( - self._node._event_log.head() - if self._node and self._node._event_log - else None + self._node._event_log.head() if self._node and self._node._event_log else None ), }, "meta": {"ms": 0}, @@ -187,7 +220,9 @@ class ProtocolService: for cap_name, version_req, body, expected_field in checks: if bus is None: - results.append({"capability": cap_name, "passed": False, "skipped": True, "error": "no_bus"}) + results.append( + {"capability": cap_name, "passed": False, "skipped": True, "error": "no_bus"} + ) skipped += 1 continue @@ -196,7 +231,14 @@ class ProtocolService: try: local = bus.registry.find(cap_name, version_req) if not local: - results.append({"capability": cap_name, "passed": False, "skipped": True, "error": "not_registered"}) + results.append( + { + "capability": cap_name, + "passed": False, + "skipped": True, + "error": "not_registered", + } + ) skipped += 1 continue except Exception: @@ -206,9 +248,13 @@ class ProtocolService: result = await bus.call(cap_name, version_req, body) # A capability passes if it doesn't return a top-level "error" key # AND (if expected_field is set) the output contains that field. - has_error = "error" in result and result["error"] not in ( - "bad_request", # some capabilities intentionally return bad_request for empty input - None, + has_error = ( + "error" in result + and result["error"] + not in ( + "bad_request", # some capabilities intentionally return bad_request for empty input + None, + ) ) output_ok = True if expected_field and not has_error: @@ -218,13 +264,24 @@ class ProtocolService: if has_error: error_msg = result.get("error", result.get("message", "unknown")) - results.append({"capability": cap_name, "passed": False, "skipped": False, "error": str(error_msg)}) + results.append( + { + "capability": cap_name, + "passed": False, + "skipped": False, + "error": str(error_msg), + } + ) failed += 1 else: - results.append({"capability": cap_name, "passed": True, "skipped": False, "error": ""}) + results.append( + {"capability": cap_name, "passed": True, "skipped": False, "error": ""} + ) passed += 1 except Exception as exc: - results.append({"capability": cap_name, "passed": False, "skipped": False, "error": str(exc)}) + results.append( + {"capability": cap_name, "passed": False, "skipped": False, "error": str(exc)} + ) failed += 1 duration_ms = round((time.time() - t0) * 1000, 1) diff --git a/hearthnet/services/rag/federated.py b/hearthnet/services/rag/federated.py index ab80cf3139e24cfc1db137ccc2af4a44ca96b14f..008b6287142e1c4f7dfdb6c6d1d7a97ac3718904 100644 --- a/hearthnet/services/rag/federated.py +++ b/hearthnet/services/rag/federated.py @@ -21,7 +21,7 @@ from hearthnet.bus.capability import CapabilityDescriptor, RouteRequest _log = logging.getLogger(__name__) -_DEFAULT_CONFIDENCE = 0.5 # local-first threshold (C) +_DEFAULT_CONFIDENCE = 0.5 # local-first threshold (C) _DEFAULT_FANOUT_TIMEOUT = 4.0 # seconds per remote call (B) _DEFAULT_K = 5 @@ -93,14 +93,10 @@ class FederatedRagService: return {"output": {"chunks": []}, "meta": {"corpus": corpus, "federated": False}} # ── Strategy C: local-first ──────────────────────────────────────── - local_chunks, local_node_id, best_local_score = await self._query_local( - query, k, corpus - ) + local_chunks, local_node_id, best_local_score = await self._query_local(query, k, corpus) if best_local_score >= threshold and local_chunks: - _log.debug( - "federated_query: local-first short-circuit score=%.3f", best_local_score - ) + _log.debug("federated_query: local-first short-circuit score=%.3f", best_local_score) _add_source(local_chunks, local_node_id) return { "output": {"chunks": local_chunks[:k]}, @@ -132,11 +128,13 @@ class FederatedRagService: # Reorder by MoE priority if we got one if peer_priority: + def _priority_key(item: tuple[str, dict]) -> int: try: return peer_priority.index(item[0]) except ValueError: return len(peer_priority) + all_results.sort(key=_priority_key) # ── Merge local + remote ─────────────────────────────────────────── @@ -159,9 +157,7 @@ class FederatedRagService: rerank_body = { "input": { "query": query, - "docs": [ - {"id": str(i), "text": c["text"]} for i, c in enumerate(merged) - ], + "docs": [{"id": str(i), "text": c["text"]} for i, c in enumerate(merged)], "top_k": k, } } @@ -212,9 +208,7 @@ class FederatedRagService: _log.debug("local rag.query failed: %s", exc) return [], self._bus.node_id_full, 0.0 - async def _moe_peer_priority( - self, query: str, corpus: str | None - ) -> list[str] | None: + async def _moe_peer_priority(self, query: str, corpus: str | None) -> list[str] | None: """Ask moe.route to rank which expert peers to prefer. Returns node_ids or None.""" tags = [corpus] if corpus else [] try: @@ -233,6 +227,7 @@ class FederatedRagService: # Utilities # --------------------------------------------------------------------------- + def _add_source(chunks: list[dict], node_id: str) -> None: """Attach source_node provenance to each chunk in-place.""" for chunk in chunks: diff --git a/hearthnet/services/rag/replication.py b/hearthnet/services/rag/replication.py index 708c89b73bb0a69358245f1f2263484569aecbb0..04eb172b9bd2ff4466eb43c563f13f7e0c947df0 100644 --- a/hearthnet/services/rag/replication.py +++ b/hearthnet/services/rag/replication.py @@ -72,9 +72,7 @@ class CorpusReplicator: _log.info("CorpusReplicator started (local_node=%s)", self._local_node_id[:16]) try: async for event in self._event_log.subscribe(["rag.document.ingested"]): - asyncio.create_task( - self._handle_event(event), name="corpus-repl-event" - ) + asyncio.create_task(self._handle_event(event), name="corpus-repl-event") except asyncio.CancelledError: _log.info("CorpusReplicator stopped") raise diff --git a/hearthnet/services/rag/service.py b/hearthnet/services/rag/service.py index 58c94c397c72ccf2ee1b8e9224daac70f3fd4f75..7d7dcb89f3f33636f97adc8adab71b990c668f88 100644 --- a/hearthnet/services/rag/service.py +++ b/hearthnet/services/rag/service.py @@ -124,11 +124,7 @@ class RagService: # Emit rag.document.ingested event so peers learn a new doc exists (X02). if not result.was_duplicate and self._event_log is not None: try: - author = ( - self._bus.node_id_full - if self._bus is not None - else "unknown" - ) + author = self._bus.node_id_full if self._bus is not None else "unknown" payload: dict = { "corpus": self._corpus, "doc_cid": result.doc_cid, diff --git a/hearthnet/services/speech/backends/base.py b/hearthnet/services/speech/backends/base.py index 137c03d6f7c6d4debba359e4dbb50fa89149709f..0839aa77de526f113685daf2ea14ca53577d901b 100644 --- a/hearthnet/services/speech/backends/base.py +++ b/hearthnet/services/speech/backends/base.py @@ -61,7 +61,7 @@ class TtsBackend(Protocol): text: str, voice: str | None = None, language: str = "de", - format: str = "ogg_vorbis", + audio_format: str = "ogg_vorbis", ) -> TtsResult: ... def health(self) -> dict: ... diff --git a/hearthnet/services/speech/backends/edge_tts.py b/hearthnet/services/speech/backends/edge_tts.py index b9169f4887ea44298aafe08f42b6f411049133f2..eaa2fb380001bfb1bbf2bc6991308bee61cca8bb 100644 --- a/hearthnet/services/speech/backends/edge_tts.py +++ b/hearthnet/services/speech/backends/edge_tts.py @@ -33,7 +33,7 @@ class EdgeTtsBackend: text: str, voice: str | None = "de-DE-KatjaNeural", language: str = "de", - format: str = "ogg_vorbis", + audio_format: str = "ogg_vorbis", ) -> Any: from hearthnet.services.speech.backends.base import TtsResult diff --git a/hearthnet/services/tools/plant.py b/hearthnet/services/tools/plant.py index c33ec7796fec40183218cac22e3bb58abde0c7b6..dba7c10576807dde30149a22a85143148e49e698 100644 --- a/hearthnet/services/tools/plant.py +++ b/hearthnet/services/tools/plant.py @@ -152,9 +152,7 @@ class PlantIdentificationService: # Backend: local vision.describe + llm.complete # ------------------------------------------------------------------ - async def _try_local_vision( - self, image_b64: str, hints: list[str] - ) -> dict | None: + async def _try_local_vision(self, image_b64: str, hints: list[str]) -> dict | None: if self._bus is None: return None @@ -179,9 +177,7 @@ class PlantIdentificationService: return None description_raw = ( - desc_resp.get("output", {}).get("description", "") - or desc_resp.get("output", "") - or "" + desc_resp.get("output", {}).get("description", "") or desc_resp.get("output", "") or "" ) if not description_raw: return None @@ -214,20 +210,14 @@ class PlantIdentificationService: "care_tips": [], } - text = ( - llm_resp.get("output", {}).get("text", "") - or llm_resp.get("output", "") - or "" - ) + text = llm_resp.get("output", {}).get("text", "") or llm_resp.get("output", "") or "" return _parse_llm_json(text, description_raw) # ------------------------------------------------------------------ # Backend: HF Inference API # ------------------------------------------------------------------ - async def _try_hf_api( - self, image_bytes: bytes, hints: list[str], token: str - ) -> dict | None: + async def _try_hf_api(self, image_bytes: bytes, hints: list[str], token: str) -> dict | None: """Call the public plant.id HF Space via the Inference API. The space used is: 'hf-vision/plant-identification' if it exists; @@ -288,7 +278,7 @@ class PlantIdentificationService: def _build_parse_prompt(description: str, hints: list[str]) -> str: - hints_text = (f"\nAdditional context: {', '.join(hints)}" if hints else "") + hints_text = f"\nAdditional context: {', '.join(hints)}" if hints else "" return f"""You are a botanist. Based on this plant description, return a JSON object with these fields: - name: latin binomial (string, e.g. "Urtica dioica") or "Unknown" - common_name: common English name (string) diff --git a/hearthnet/transport/backpressure.py b/hearthnet/transport/backpressure.py index 9eb799a2a6a0be1d716ab5f0e6920f07ce6e8eb2..7e46d8d3f9023ac6cafa6fa477e6ef28b333a971 100644 --- a/hearthnet/transport/backpressure.py +++ b/hearthnet/transport/backpressure.py @@ -92,6 +92,7 @@ class RateCheck: def check(self, now: float | None = None) -> bool: import time + t = now if now is not None else time.monotonic() cutoff = t - self._window self._calls = [c for c in self._calls if c > cutoff] @@ -122,6 +123,7 @@ class RateLimiter: async def acquire(self) -> None: import time + while True: async with self._lock: t = time.monotonic() diff --git a/hearthnet/transport/client.py b/hearthnet/transport/client.py index 248d00570434430064ea12cbe34d15f82c54a5e1..1b17ca4944745871ce1a169a690886d32d56b7c1 100644 --- a/hearthnet/transport/client.py +++ b/hearthnet/transport/client.py @@ -7,9 +7,9 @@ import json import secrets from collections.abc import AsyncIterator from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime -UTC = timezone.utc +UTC = UTC try: import httpx @@ -110,12 +110,15 @@ class HttpClient: headers = self._make_headers(payload) headers["Accept"] = "text/event-stream" try: - async with httpx.AsyncClient(verify=self._verify_ssl) as client, client.stream( - "POST", - f"{self._base_url}/bus/v1/call", - json=payload, - headers=headers, - ) as resp: + async with ( + httpx.AsyncClient(verify=self._verify_ssl) as client, + client.stream( + "POST", + f"{self._base_url}/bus/v1/call", + json=payload, + headers=headers, + ) as resp, + ): async for line in resp.aiter_lines(): if line.startswith("data: "): with contextlib.suppress(json.JSONDecodeError): diff --git a/hearthnet/transport/server.py b/hearthnet/transport/server.py index bb4ad554b64881513d2bd585d3ddcd3783d899a0..4468c08776a7ef0e1ec4e3655213c1244d5138aa 100644 --- a/hearthnet/transport/server.py +++ b/hearthnet/transport/server.py @@ -21,10 +21,10 @@ from __future__ import annotations import asyncio from collections.abc import Callable -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any -UTC = timezone.utc +UTC = UTC try: import uvicorn diff --git a/hearthnet/transport/websocket.py b/hearthnet/transport/websocket.py index 3a27ae797475185f1fe5d739241fd7c704460b9c..32b3b01ccc6ab931d82b6220f1373a2a66cb8c14 100644 --- a/hearthnet/transport/websocket.py +++ b/hearthnet/transport/websocket.py @@ -24,6 +24,10 @@ except ImportError: HAS_WEBSOCKETS = False # Optional FastAPI/Starlette WebSocket import (server-side) +WebSocket: Any +WebSocketDisconnect: Any +WebSocketState: Any + try: from starlette.websockets import ( # type: ignore[import] WebSocket, diff --git a/hearthnet/types.py b/hearthnet/types.py index 877b0b0078008780fa6cc90d2dc3c35df2a4eb46..b3b4ac076d143ebc8c9c1311c7765718b1e94c3e 100644 --- a/hearthnet/types.py +++ b/hearthnet/types.py @@ -46,16 +46,16 @@ class HearthNetError(Exception): # ── Phase 3 type aliases ───────────────────────────────────────────────────── -ShardID = NewType("ShardID", str) # ":-[:tier]" -ExpertID = NewType("ExpertID", str) # "human:..." | "model:..." | "service:..." | "external:..." +ShardID = NewType("ShardID", str) # ":-[:tier]" +ExpertID = NewType("ExpertID", str) # "human:..." | "model:..." | "service:..." | "external:..." ExpertKind = Literal["human", "model", "service", "external"] -ClaimID = NewType("ClaimID", str) # base32 of SHA-256 canonical claim +ClaimID = NewType("ClaimID", str) # base32 of SHA-256 canonical claim SourceID = NewType("SourceID", str) EvidenceLevel = Literal["unverified", "cited", "cross_referenced", "attested", "disputed"] -RoundID = NewType("RoundID", str) # ULID — fedlearn round -LoraBeaconID = NewType("LoraBeaconID", str) # 8-byte hex, hardware-issued +RoundID = NewType("RoundID", str) # ULID — fedlearn round +LoraBeaconID = NewType("LoraBeaconID", str) # 8-byte hex, hardware-issued LoraDeviceID = NewType("LoraDeviceID", str) -AlertID = NewType("AlertID", str) # ULID +AlertID = NewType("AlertID", str) # ULID AlertSeverity = Literal["info", "advisory", "warning", "emergency", "extreme"] AckStatus = Literal["received", "acting", "need_help", "standing_down", "mistaken"] diff --git a/hearthnet/ui/app.py b/hearthnet/ui/app.py index 5671e62e5937e79ccdcac4f5b4ada1470346be91..1e496c00296570890ccdd2ead3ec8cce58bc641f 100644 --- a/hearthnet/ui/app.py +++ b/hearthnet/ui/app.py @@ -27,7 +27,7 @@ _EGG_HTML = """
- +
diff --git a/hearthnet/ui/mobile/static.py b/hearthnet/ui/mobile/static.py index 6e372e093a0ac1ed8cff78bdaccab8295e6a5fbd..0ce01074fc08bf44c80584513de161c61b1a019f 100644 --- a/hearthnet/ui/mobile/static.py +++ b/hearthnet/ui/mobile/static.py @@ -163,7 +163,7 @@ def build_mobile_html(node_url: str = "", node_name: str = "HearthNet") -> str:

{node_name}

Community-owned local AI mesh. Works even without internet.

- Open HearthNet + Open HearthNet
Checking node status…
diff --git a/hearthnet/ui/onboarding.py b/hearthnet/ui/onboarding.py index eed0acac3dd3839602f5961de6b4722eede3cee1..8ff9aeac84c990fa74ffb401d0e02de300ac7b1e 100644 --- a/hearthnet/ui/onboarding.py +++ b/hearthnet/ui/onboarding.py @@ -6,9 +6,9 @@ import base64 import contextlib import json from dataclasses import dataclass -from datetime import timezone +from datetime import UTC -UTC = timezone.utc +UTC = UTC from hearthnet.constants import INVITE_DEFAULT_TTL_SECONDS @@ -154,12 +154,12 @@ def redeem_invite( event_log=None, ) -> dict: """Verify invite, emit member.joined event, return community manifest stub.""" - if blob.invitee_node_id not in (kp.node_id_full, kp.node_id_short) and blob.invitee_node_id: # "" means open invite + if ( + blob.invitee_node_id not in (kp.node_id_full, kp.node_id_short) and blob.invitee_node_id + ): # "" means open invite raise OnboardingError( "invitee_mismatch", - reason=( - f"invite was for {blob.invitee_node_id[:20]}, we are {kp.node_id_full[:20]}" - ), + reason=(f"invite was for {blob.invitee_node_id[:20]}, we are {kp.node_id_full[:20]}"), ) if event_log is not None: @@ -252,4 +252,3 @@ def _iso_after(seconds: int) -> str: # Spec-mandated name (M13 §3.1) build_onboarding = build_onboarding_ui - diff --git a/hearthnet/ui/pwa.py b/hearthnet/ui/pwa.py index 608e810934b26cd8a7042659b38f595e28aaea0b..569b36a95791a2b1f89baae55e28d1db85671c50 100644 --- a/hearthnet/ui/pwa.py +++ b/hearthnet/ui/pwa.py @@ -90,6 +90,7 @@ def setup_pwa(app: FastAPI, static_dir: Path) -> None: # Create new response with modified content from starlette.responses import Response as StarletteResponse + response = StarletteResponse( content=body, status_code=response.status_code, diff --git a/hearthnet/ui/tabs/chat.py b/hearthnet/ui/tabs/chat.py index 8503d87eefc80c2325f806ee79977f5ab5857aa7..480d673b69f66ec2eda8f12162806bf039d62fe4 100644 --- a/hearthnet/ui/tabs/chat.py +++ b/hearthnet/ui/tabs/chat.py @@ -69,10 +69,12 @@ even when nodes reconnect after an offline period. for m in msgs: sender = m.get("from", "?") is_mine = sender == node_me - result.append({ - "role": "user" if is_mine else "assistant", - "content": f"{'You' if is_mine else sender}: {m.get('body', '')}", - }) + result.append( + { + "role": "user" if is_mine else "assistant", + "content": f"{'You' if is_mine else sender}: {m.get('body', '')}", + } + ) return result except Exception as e: return [{"role": "assistant", "content": f"Error loading history: {e}"}] @@ -83,7 +85,11 @@ even when nodes reconnect after an offline period. history = history or [] if bus is None: return ( - [*history, {"role": "user", "content": msg}, {"role": "assistant", "content": "⚠️ Bus not connected"}], + [ + *history, + {"role": "user", "content": msg}, + {"role": "assistant", "content": "⚠️ Bus not connected"}, + ], "", gr.update(visible=False), ) @@ -99,16 +105,23 @@ even when nodes reconnect after an offline period. results = [] for p in all_peers: try: - r = await bus.call("chat.send", (1, 0), {"input": {"recipient": p, "body": msg}}) + r = await bus.call( + "chat.send", (1, 0), {"input": {"recipient": p, "body": msg}} + ) results.append(r.get("output", {}).get("delivered", "queued")) except Exception: results.append("error") - history = [*history, {"role": "user", "content": f"[broadcast to {len(all_peers)} peers] {msg}"}] + history = [ + *history, + {"role": "user", "content": f"[broadcast to {len(all_peers)} peers] {msg}"}, + ] note = f"✓ Broadcast sent to {len(all_peers)} peer(s): {results}" return history, "", gr.update(visible=True, value=note) try: - r = await bus.call("chat.send", (1, 0), {"input": {"recipient": recipient, "body": msg}}) + r = await bus.call( + "chat.send", (1, 0), {"input": {"recipient": recipient, "body": msg}} + ) status = r.get("output", {}).get("delivered", "queued") history = [*history, {"role": "user", "content": msg}] if status == "direct": @@ -117,7 +130,11 @@ even when nodes reconnect after an offline period. note = f"✓ {status} → `{recipient}`" return history, "", gr.update(visible=True, value=note) except Exception as e: - history = [*history, {"role": "user", "content": msg}, {"role": "assistant", "content": f"Error: {e}"}] + history = [ + *history, + {"role": "user", "content": msg}, + {"role": "assistant", "content": f"Error: {e}"}, + ] return history, "", gr.update(visible=False) history_btn.click(load_history, inputs=peer_id, outputs=chat_out) diff --git a/hearthnet/ui/tabs/files.py b/hearthnet/ui/tabs/files.py index c357f78bf536492b682137f02321ce63e04715a0..85698efb3bbe4e34302caf90315503c52ee27538 100644 --- a/hearthnet/ui/tabs/files.py +++ b/hearthnet/ui/tabs/files.py @@ -31,7 +31,9 @@ The same file uploaded on two different nodes gets the same CID — deduplicatio gr.Markdown("#### Download File by CID") with gr.Row(): - cid_input = gr.Textbox(label="CID (paste from list above)", placeholder="blake3:...", scale=4) + cid_input = gr.Textbox( + label="CID (paste from list above)", placeholder="blake3:...", scale=4 + ) download_btn = gr.Button("⬇ Download", scale=1) download_file = gr.File(label="Download", visible=False) download_err = gr.Markdown(visible=False) diff --git a/hearthnet/ui/tabs/getting_started.py b/hearthnet/ui/tabs/getting_started.py index 3896d9f04de9b6e7008099c3c230a7e535fd16fa..861d7a14274dfed0da036dfd33e55a8a68b7168a 100644 --- a/hearthnet/ui/tabs/getting_started.py +++ b/hearthnet/ui/tabs/getting_started.py @@ -410,4 +410,3 @@ python scripts/connect_to_hf.py - Emergency alerts propagate to both the Space and your local node - Marketplace posts replicate between your node and the Space """) - diff --git a/hearthnet/ui/tabs/nemotron.py b/hearthnet/ui/tabs/nemotron.py index ed224630ec438c2b9780546225dd0ed39e478f74..2b2bf6a851a1aa833ac5a20b1a3b7162ee142a16 100644 --- a/hearthnet/ui/tabs/nemotron.py +++ b/hearthnet/ui/tabs/nemotron.py @@ -129,7 +129,7 @@ Works offline with local Nemotron NIM, or online with the NVIDIA API. ingest_status = gr.Textbox(label="Status", lines=2) # ── Status / instructions ────────────────────────────────────────────────── - with gr.Accordion("ℹ️ Setup & Prize Info", open=False): + with gr.Accordion("[i] Setup & Prize Info", open=False): gr.Markdown( """ ### Nemotron Setup diff --git a/hearthnet/ui/tabs/settings.py b/hearthnet/ui/tabs/settings.py index 3a215b855baa70013b4f10b09544d2e319ae0ec9..bdd7e17e349c01fafee77b0a7dd7d9bd0a657dfd 100644 --- a/hearthnet/ui/tabs/settings.py +++ b/hearthnet/ui/tabs/settings.py @@ -235,7 +235,9 @@ and connect them via LAN (Option A). They will see each other's capabilities acr kp = load_or_generate(Path.home() / ".hearthnet" / "keys") # Detect whether we're on HF Space or local - hf_space_host = os.getenv("SPACE_HOST") # e.g. build-small-hackathon-hearthnet.hf.space + hf_space_host = os.getenv( + "SPACE_HOST" + ) # e.g. build-small-hackathon-hearthnet.hf.space if hf_space_host: public_host = hf_space_host public_port = 443 @@ -271,9 +273,9 @@ and connect them via LAN (Option A). They will see each other's capabilities acr note = "" if hf_space_host: - note = f"\n\n> ℹ️ This invite uses the **HF Space URL** (`{public_host}`). Peers outside the Space can use it." + note = f"\n\n> [i] This invite uses the **HF Space URL** (`{public_host}`). Peers outside the Space can use it." else: - note = f"\n\n> ℹ️ Host is `{public_host}:{public_port}`. Make sure this is reachable by the invitee." + note = f"\n\n> [i] Host is `{public_host}:{public_port}`. Make sure this is reachable by the invitee." return _qr_svg(qr_data), link + note except Exception as exc: return f"

Error: {exc}

", f"Error: {exc}" diff --git a/hearthnet/ui/topology.py b/hearthnet/ui/topology.py index 24e24008d5222067f88afaf8fb2afe04f4986179..216fa3ec2ceaa0d9c3e38b7daf77e536c31b48b4 100644 --- a/hearthnet/ui/topology.py +++ b/hearthnet/ui/topology.py @@ -14,6 +14,7 @@ from typing import Any try: import gradio as gr + _HAS_GRADIO = True except ImportError: _HAS_GRADIO = False @@ -100,7 +101,9 @@ class TopologyComponent: dur = t.get("duration_ms", "?") ok = "✓" if t.get("success", True) else "✗" color = "#4ade80" if t.get("success", True) else "#f87171" - trace_rows += f"{ok}{cap}{dur}ms" + trace_rows += ( + f"{ok}{cap}{dur}ms" + ) if not trace_rows: trace_rows = "No calls yet" diff --git a/tests/conftest.py b/tests/conftest.py index 546fff282b3d8f84f6678bd86f19edd9b346b5ad..6a618885f0eea3fc5a7b12a0c20a09c5efdd9d88 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,7 @@ if str(ROOT) not in sys.path: # Needed for Python 3.13 + pytest-asyncio 0.26 where loop teardown is strict. try: import nest_asyncio + nest_asyncio.apply() except ImportError: pass diff --git a/tests/test_behavioral_layer.py b/tests/test_behavioral_layer.py index 300955e17e13a931a994e0bee93f95e71d38a807..41d34269463048ecbefee0783176dda051b9fc3d 100644 --- a/tests/test_behavioral_layer.py +++ b/tests/test_behavioral_layer.py @@ -3,6 +3,7 @@ Behavioral tests - exercising actual algorithm execution paths. Target: LLM chat logic, RAG ranking, marketplace posting, chat routing, bus capability matching. Goal: Push coverage from 50% to 60%+ """ + import pytest import asyncio from unittest.mock import MagicMock, patch, AsyncMock @@ -22,7 +23,7 @@ def _run(coro): class TestLlmChatBehavior: """Test actual LLM chat algorithm behavior.""" - + def test_llm_extracts_last_user_message(self): """Test LLM correctly extracts last user message from history.""" try: @@ -43,7 +44,7 @@ class TestLlmChatBehavior: assert "Second question" in result["output"]["message"]["content"] except Exception: pass - + def test_llm_handles_empty_user_messages(self): """Test LLM handles messages without user content.""" try: @@ -62,20 +63,14 @@ class TestLlmChatBehavior: assert result.get("output") is not None except Exception: pass - + def test_llm_token_counting_accuracy(self): """Test LLM token counting matches word count.""" try: llm = LlmService() req = MagicMock() text = "The quick brown fox jumps over lazy dog" - req.body = { - "input": { - "messages": [ - {"role": "user", "content": text} - ] - } - } + req.body = {"input": {"messages": [{"role": "user", "content": text}]}} result = _run(llm.chat(req)) meta = result.get("meta", {}) # Word count = token count (approximate) @@ -83,18 +78,14 @@ class TestLlmChatBehavior: assert meta.get("tokens_in") > 0 except Exception: pass - + def test_llm_response_attribution(self): """Test LLM includes model name in response.""" try: model_name = "custom-model-v2" llm = LlmService(model=model_name) req = MagicMock() - req.body = { - "input": { - "messages": [{"role": "user", "content": "test"}] - } - } + req.body = {"input": {"messages": [{"role": "user", "content": "test"}]}} result = _run(llm.chat(req)) # Response should mention model meta = result.get("meta", {}) @@ -105,7 +96,7 @@ class TestLlmChatBehavior: class TestRagRankingBehavior: """Test RAG document ranking algorithm.""" - + def test_rag_ranking_by_term_frequency(self): """Test RAG ranks documents by query term frequency.""" try: @@ -116,12 +107,7 @@ class TestRagRankingBehavior: {"id": "3", "title": "Python Advanced", "text": "Advanced Python topics"}, ] req = MagicMock() - req.body = { - "input": { - "query": "Python", - "k": 10 - } - } + req.body = {"input": {"query": "Python", "k": 10}} result = _run(rag.query(req)) chunks = result["output"]["chunks"] # Highest scoring should be first @@ -129,46 +115,31 @@ class TestRagRankingBehavior: assert chunks[0]["score"] >= chunks[1]["score"] except Exception: pass - + def test_rag_respects_k_limit(self): """Test RAG returns at most k results.""" try: rag = RagService() rag.documents = [ - {"id": str(i), "title": f"Doc{i}", "text": "content"} - for i in range(20) + {"id": str(i), "title": f"Doc{i}", "text": "content"} for i in range(20) ] req = MagicMock() - req.body = { - "input": { - "query": "content", - "k": 5 - } - } + req.body = {"input": {"query": "content", "k": 5}} result = _run(rag.query(req)) chunks = result["output"]["chunks"] assert len(chunks) <= 5 except Exception: pass - + def test_rag_metadata_preservation(self): """Test RAG preserves document metadata in results.""" try: rag = RagService() rag.documents = [ - { - "id": "doc-abc", - "title": "Important Doc", - "text": "This is important information" - } + {"id": "doc-abc", "title": "Important Doc", "text": "This is important information"} ] req = MagicMock() - req.body = { - "input": { - "query": "important", - "k": 1 - } - } + req.body = {"input": {"query": "important", "k": 1}} result = _run(rag.query(req)) chunks = result["output"]["chunks"] if chunks: @@ -176,19 +147,14 @@ class TestRagRankingBehavior: assert chunks[0]["metadata"]["chunk_id"] == "doc-abc" except Exception: pass - + def test_rag_ingestion_updates_corpus(self): """Test RAG ingestion actually adds documents.""" try: rag = RagService(corpus="test") initial_count = len(rag.documents) req = MagicMock() - req.body = { - "input": { - "title": "New Document", - "text": "New content here" - } - } + req.body = {"input": {"title": "New Document", "text": "New content here"}} _run(rag.ingest(req)) # Should increase assert len(rag.documents) == initial_count + 1 @@ -198,80 +164,75 @@ class TestRagRankingBehavior: class TestMarketplacePostingBehavior: """Test marketplace posting logic.""" - + def test_marketplace_preserves_caller_identity(self): """Test marketplace attributes posts to caller.""" try: market = MarketplaceService() req = MagicMock() req.caller = "seller-node-123" - req.body = { - "input": { - "title": "Widget", - "price": 10.0 - } - } + req.body = {"input": {"title": "Widget", "price": 10.0}} _run(market.post(req)) # Post should have caller assert market.posts[0]["author"] == "seller-node-123" except Exception: pass - + def test_marketplace_auto_generates_event_id(self): """Test marketplace generates unique event IDs.""" try: market = MarketplaceService() req = MagicMock() req.caller = "seller" - + event_ids = [] for i in range(3): req.body = {"input": {"title": f"Item{i}"}} result = _run(market.post(req)) event_ids.append(result["output"]["event_id"]) - + # All unique assert len(set(event_ids)) == 3 except Exception: pass - + def test_marketplace_lamport_clock_increments(self): """Test marketplace lamport clock increases monotonically.""" try: market = MarketplaceService() req = MagicMock() req.caller = "seller" - + lamports = [] for i in range(5): req.body = {"input": {"title": f"Item{i}"}} result = _run(market.post(req)) lamports.append(result["output"]["lamport"]) - + # Should be strictly increasing for i in range(len(lamports) - 1): assert lamports[i] < lamports[i + 1] except Exception: pass - + def test_marketplace_category_filtering(self): """Test marketplace correctly filters by category.""" try: market = MarketplaceService() req = MagicMock() req.caller = "seller" - + # Post different categories categories = ["electronics", "books", "electronics", "furniture", "books"] for cat in categories: req.body = {"input": {"title": f"Item", "category": cat}} _run(market.post(req)) - + # Filter for electronics req.body = {"input": {"category": "electronics"}} result = _run(market.list_posts(req)) posts = result["output"]["posts"] - + # Should have 2 electronics electronics_count = sum(1 for p in posts if p.get("category") == "electronics") assert electronics_count == 2 @@ -281,43 +242,33 @@ class TestMarketplacePostingBehavior: class TestChatRoutingBehavior: """Test chat message routing logic.""" - + def test_chat_direct_delivery_detection(self): """Test chat detects direct vs queued delivery.""" try: node_id = "alice@mesh" chat = ChatService(node_id=node_id) - + # Direct: self message req = MagicMock() req.caller = "alice" - req.body = { - "input": { - "recipient": node_id, - "body": "Note to self" - } - } + req.body = {"input": {"recipient": node_id, "body": "Note to self"}} result = _run(chat.send(req)) assert result["output"]["delivered"] == "direct" - + # Queued: remote message - req.body = { - "input": { - "recipient": "bob@mesh", - "body": "Message to Bob" - } - } + req.body = {"input": {"recipient": "bob@mesh", "body": "Message to Bob"}} result = _run(chat.send(req)) assert result["output"]["delivered"] == "queued" except Exception: pass - + def test_chat_history_peer_filtering(self): """Test chat history filters correctly by peer.""" try: chat = ChatService(node_id="local") req = MagicMock() - + # Send messages from/to different peers messages_spec = [ ("alice", "bob", "msg1"), @@ -325,27 +276,22 @@ class TestChatRoutingBehavior: ("charlie", "bob", "msg3"), ("alice", "charlie", "msg4"), ] - + for caller, recipient, body in messages_spec: req.caller = caller - req.body = { - "input": { - "recipient": recipient, - "body": body - } - } + req.body = {"input": {"recipient": recipient, "body": body}} _run(chat.send(req)) - + # Query messages with alice req.body = {"input": {"peer": "alice"}} result = _run(chat.history(req)) messages = result["output"]["messages"] - + # Should include messages from/to alice assert len(messages) >= 3 except Exception: pass - + def test_chat_message_attachment_handling(self): """Test chat preserves attachment data.""" try: @@ -356,11 +302,11 @@ class TestChatRoutingBehavior: "input": { "recipient": "bob", "body": "Check these files", - "attachments": ["file1.pdf", "file2.jpg", "file3.zip"] + "attachments": ["file1.pdf", "file2.jpg", "file3.zip"], } } _run(chat.send(req)) - + # Check stored message msg = chat.messages[0] assert len(msg.get("attachments", [])) == 3 @@ -371,104 +317,86 @@ class TestChatRoutingBehavior: class TestBusCapabilityMatching: """Test bus capability matching algorithm.""" - + def test_capability_exact_match(self): """Test bus matches exact capability parameters.""" try: from hearthnet.services.demo import _model_matches - + # Exact match - assert _model_matches( - {"model": "gpt-3.5"}, - {"model": "gpt-3.5"} - ) + assert _model_matches({"model": "gpt-3.5"}, {"model": "gpt-3.5"}) # No match - assert not _model_matches( - {"model": "gpt-3.5"}, - {"model": "gpt-4"} - ) + assert not _model_matches({"model": "gpt-3.5"}, {"model": "gpt-4"}) except Exception: pass - + def test_capability_wildcard_matching(self): """Test bus handles wildcard capability matching.""" try: from hearthnet.services.demo import _model_matches - + # Offered capability without requirement = match assert _model_matches( {"model": "gpt-3.5"}, - {} # No requirement + {}, # No requirement ) # Any offered matches empty requirement - assert _model_matches( - {"model": "any-model"}, - {} - ) + assert _model_matches({"model": "any-model"}, {}) except Exception: pass - + def test_capability_corpus_matching(self): """Test corpus parameter matching.""" try: from hearthnet.services.demo import _corpus_matches - - assert _corpus_matches( - {"corpus": "prod"}, - {"corpus": "prod"} - ) - assert not _corpus_matches( - {"corpus": "prod"}, - {"corpus": "dev"} - ) - assert _corpus_matches( - {"corpus": "prod"}, - {} - ) + + assert _corpus_matches({"corpus": "prod"}, {"corpus": "prod"}) + assert not _corpus_matches({"corpus": "prod"}, {"corpus": "dev"}) + assert _corpus_matches({"corpus": "prod"}, {}) except Exception: pass class TestBlobChunkingAlgorithm: """Test actual chunking algorithm behavior.""" - + def test_chunking_splits_at_boundaries(self): """Test chunking splits exactly at size boundaries.""" try: from hearthnet.blobs.chunker import chunk_blob - + data = b"x" * 2048 manifest, chunks = chunk_blob(data, chunk_size=1024) - + # Should have exactly 2 chunks of 1024 each assert len(chunks) == 2 assert len(chunks[0]) == 1024 assert len(chunks[1]) == 1024 except Exception: pass - + def test_chunking_merkle_root_deterministic(self): """Test chunking produces consistent merkle roots.""" try: from hearthnet.blobs.chunker import chunk_blob - + data = b"test data content here" manifest1, _ = chunk_blob(data, chunk_size=256) manifest2, _ = chunk_blob(data, chunk_size=256) - + # Same data = same merkle root assert manifest1.cid == manifest2.cid except Exception: pass - + def test_chunking_partial_last_chunk(self): """Test chunking handles non-aligned final chunk.""" try: from hearthnet.blobs.chunker import chunk_blob - + data = b"x" * 2567 # Not multiple of 1024 manifest, chunks = chunk_blob(data, chunk_size=1024) - + # 3 chunks: 1024 + 1024 + 519 assert len(chunks) == 3 assert len(chunks[2]) == 519 @@ -479,28 +407,28 @@ class TestBlobChunkingAlgorithm: class TestEventBusRouting: """Test event bus routing logic.""" - + def test_bus_service_registration(self): """Test service registration in bus.""" try: rag = RagService() caps = rag.capabilities() - + # Should have multiple capabilities assert len(caps) >= 2 # Each capability should be a tuple (descriptor, handler, matcher?) assert all(isinstance(c, tuple) for c in caps) except Exception: pass - + def test_bus_capability_descriptors(self): """Test capability descriptors contain required fields.""" try: from hearthnet.services.demo import LlmService - + llm = LlmService() caps = llm.capabilities() - + # First cap should be llm.chat descriptor = caps[0][0] assert descriptor.name == "llm.chat" @@ -512,48 +440,43 @@ class TestEventBusRouting: class TestDataPreservation: """Test data preservation across operations.""" - + def test_chat_message_preservation(self): """Test chat messages are preserved in order.""" try: chat = ChatService(node_id="node1") req = MagicMock() req.caller = "user" - + bodies = ["First", "Second", "Third"] for body in bodies: - req.body = { - "input": { - "recipient": "other", - "body": body - } - } + req.body = {"input": {"recipient": "other", "body": body}} _run(chat.send(req)) - + # Verify order assert chat.messages[0]["body"] == "First" assert chat.messages[1]["body"] == "Second" assert chat.messages[2]["body"] == "Third" except Exception: pass - + def test_marketplace_post_preservation(self): """Test marketplace posts are preserved with all fields.""" try: market = MarketplaceService() req = MagicMock() req.caller = "seller" - + req.body = { "input": { "title": "Laptop", "price": 999.99, "category": "electronics", - "condition": "new" + "condition": "new", } } _run(market.post(req)) - + # All fields should be present post = market.posts[0] assert post["title"] == "Laptop" @@ -562,32 +485,22 @@ class TestDataPreservation: assert post["condition"] == "new" except Exception: pass - + def test_rag_document_persistence(self): """Test RAG documents persist across queries.""" try: rag = RagService() - + # Ingest req = MagicMock() - req.body = { - "input": { - "title": "Doc1", - "text": "Content1" - } - } + req.body = {"input": {"title": "Doc1", "text": "Content1"}} _run(rag.ingest(req)) - + # Query should find it - req.body = { - "input": { - "query": "Content1", - "k": 10 - } - } + req.body = {"input": {"query": "Content1", "k": 10}} result = _run(rag.query(req)) chunks = result["output"]["chunks"] - + # Document should be in results assert any("Content1" in c["text"] for c in chunks) except Exception: diff --git a/tests/test_capability_contract.py b/tests/test_capability_contract.py index 5594b80c5cb0c3a7e359f2c18d2c471d80fcb03b..63e7d1d1d7eaa52a7453fb0c1318754b8bb775db 100644 --- a/tests/test_capability_contract.py +++ b/tests/test_capability_contract.py @@ -2,65 +2,71 @@ Tests for CAPABILITY_CONTRACT documentation Covers: api_schemas, error_codes, endpoint_contracts """ + import pytest + class TestCAPABILITY_CONTRACTApiSchemas: """Test api schemas.""" + def test_validation(self): try: pass except Exception: pass - + def test_consistency(self): try: pass except Exception: pass - + def test_completeness(self): try: pass except Exception: pass + class TestCAPABILITY_CONTRACTErrorCodes: """Test error codes.""" + def test_validation(self): try: pass except Exception: pass - + def test_consistency(self): try: pass except Exception: pass - + def test_completeness(self): try: pass except Exception: pass + class TestCAPABILITY_CONTRACTEndpointContracts: """Test endpoint contracts.""" + def test_validation(self): try: pass except Exception: pass - + def test_consistency(self): try: pass except Exception: pass - + def test_completeness(self): try: pass except Exception: pass - diff --git a/tests/test_complexity.py b/tests/test_complexity.py index a64c3ddfbd8c908b2558de42e05a9424ffabcd60..02a2387567abea2b73dfb8cee7f05d0dac568043 100644 --- a/tests/test_complexity.py +++ b/tests/test_complexity.py @@ -3,6 +3,7 @@ Tests edge cases, large datasets, and system limits. Validates backend input validation and sanitization. """ + from __future__ import annotations import asyncio @@ -26,12 +27,7 @@ class TestInputValidation: result = await node.bus.call( "chat.send", (1, 0), - { - "input": { - "recipient": "", - "body": "test message" - } - }, + {"input": {"recipient": "", "body": "test message"}}, ) # Should return error assert "error" in result, "Should reject empty recipient" @@ -49,12 +45,7 @@ class TestInputValidation: result = await node.bus.call( "chat.send", (1, 0), - { - "input": { - "recipient": "val-self", - "body": "test" - } - }, + {"input": {"recipient": "val-self", "body": "test"}}, ) # Should return error assert "error" in result, "Should reject self-send" @@ -68,7 +59,7 @@ class TestInputValidation: from hearthnet.constants import EMBED_MAX_TEXTS svc = EmbeddingService() - + # Try to embed too many texts too_many = ["text"] * (EMBED_MAX_TEXTS + 10) req = RouteRequest( @@ -79,7 +70,7 @@ class TestInputValidation: trace_id="t1", ) result = await svc.handle_embed(req) - + if "error" in result: print(f"\n Max texts enforced: {result.get('error')}") msg = str(result.get("message", result.get("error", ""))).lower() @@ -93,7 +84,7 @@ class TestInputValidation: from hearthnet.constants import EMBED_MAX_CHARS svc = EmbeddingService() - + # Text that's too long too_long_text = "x" * (EMBED_MAX_CHARS + 100) req = RouteRequest( @@ -104,7 +95,7 @@ class TestInputValidation: trace_id="t1", ) result = await svc.handle_embed(req) - + if "error" in result: print(f"\n Max chars enforced: {result.get('error')}") msg = str(result.get("message", result.get("error", ""))).lower() @@ -125,7 +116,7 @@ class TestInputValidation: trace_id="t1", ) result = await svc.handle_put(req) - + assert result.get("error") is not None, "Should reject invalid base64" print(f"\n Invalid base64 rejected: {result.get('error')}") @@ -144,7 +135,7 @@ class TestInputValidation: trace_id="t1", ) result = await svc.handle_get(req) - + assert result.get("error") is not None, "Should reject missing CID" print(f"\n Missing CID returns error: {result.get('error')}") @@ -182,7 +173,9 @@ class TestStressConditions: (1, 0), {"input": {"limit": 100}}, ) - listings = list_result.get("output", {}).get("posts", list_result.get("output", {}).get("listings", [])) + listings = list_result.get("output", {}).get( + "posts", list_result.get("output", {}).get("listings", []) + ) print(f"\n Posted {len(listings)} marketplace listings") assert len(listings) >= 10, f"Expected >= 10 listings, got {len(listings)}" @@ -192,9 +185,9 @@ class TestStressConditions: # 5MB blob large_data = b"x" * (5 * 1024 * 1024) - + manifest, chunks = chunk_blob(large_data) - + # Verify integrity reassembled = b"".join(chunks) assert reassembled == large_data, "Reassembled data should match original" @@ -209,7 +202,7 @@ class TestStressConditions: td = tempfile.mkdtemp() try: log = EventLog(Path(td) / "stress.db", "stress-community") - + # Add many events for i in range(50): log.append_local( @@ -229,6 +222,7 @@ class TestStressConditions: gc.collect() finally: import shutil + try: shutil.rmtree(td, ignore_errors=True) except Exception: @@ -304,7 +298,7 @@ class TestComplexityEdgeCases: td = tempfile.mkdtemp() try: log = EventLog(Path(td) / "edge.db", "edge-community") - + # Try to handle edge case events try: log.append_local("edge.event", "", {"data": None}) @@ -318,6 +312,7 @@ class TestComplexityEdgeCases: gc.collect() finally: import shutil + try: shutil.rmtree(td, ignore_errors=True) except Exception: diff --git a/tests/test_components_real.py b/tests/test_components_real.py index 8a7fb980182959bdfb3f8e423e63177c276b7245..fc900fd2b7519b2bfa6899d9abe517fcba1b690b 100644 --- a/tests/test_components_real.py +++ b/tests/test_components_real.py @@ -5,6 +5,7 @@ These tests use the demo backends (fast, deterministic) but assert on the actual values returned through the full bus → service → response path. No mocks. No echo-and-forget. Every test checks a meaningful result. """ + from __future__ import annotations import asyncio @@ -15,6 +16,7 @@ import pytest # Helpers # ───────────────────────────────────────────────────────────────────────────── + def _run(coro): return asyncio.run(coro) @@ -23,12 +25,14 @@ def _run(coro): # 1. RAG: documents are indexed and retrieved by relevance # ───────────────────────────────────────────────────────────────────────────── + class TestRagRetrieval: """rag.query returns the most relevant chunks from the indexed corpus.""" @pytest.fixture def node_with_rag(self): from hearthnet.node import InMemoryNetwork + net = InMemoryNetwork() node = net.add_node("rag-test", "RAG Test Node", "ed25519:test") node.install_demo_services(corpus="health") @@ -38,38 +42,57 @@ class TestRagRetrieval: await node.bus.call( "rag.ingest", (1, 0), - {"params": {"corpus": "health"}, "input": { - "doc_cid": "water.001", - "title": "Water Safety", - "text": "Boil water for one minute to make it safe to drink.", - }}, + { + "params": {"corpus": "health"}, + "input": { + "doc_cid": "water.001", + "title": "Water Safety", + "text": "Boil water for one minute to make it safe to drink.", + }, + }, ) await node.bus.call( "rag.ingest", (1, 0), - {"params": {"corpus": "health"}, "input": { - "doc_cid": "cpr.001", - "title": "CPR Basics", - "text": "Perform 30 chest compressions then 2 rescue breaths.", - }}, + { + "params": {"corpus": "health"}, + "input": { + "doc_cid": "cpr.001", + "title": "CPR Basics", + "text": "Perform 30 chest compressions then 2 rescue breaths.", + }, + }, ) + _run(_ingest()) return node def test_rag_returns_chunks(self, node_with_rag): - result = _run(node_with_rag.bus.call( - "rag.query", (1, 0), - {"params": {"corpus": "health"}, "input": {"query": "boil water safe drink", "k": 3}}, - )) + result = _run( + node_with_rag.bus.call( + "rag.query", + (1, 0), + { + "params": {"corpus": "health"}, + "input": {"query": "boil water safe drink", "k": 3}, + }, + ) + ) chunks = result["output"]["chunks"] assert len(chunks) > 0, "RAG must return at least one chunk" def test_rag_ranks_by_relevance(self, node_with_rag): """The most relevant chunk is ranked first.""" - result = _run(node_with_rag.bus.call( - "rag.query", (1, 0), - {"params": {"corpus": "health"}, "input": {"query": "boil water safe drink", "k": 3}}, - )) + result = _run( + node_with_rag.bus.call( + "rag.query", + (1, 0), + { + "params": {"corpus": "health"}, + "input": {"query": "boil water safe drink", "k": 3}, + }, + ) + ) top = result["output"]["chunks"][0] assert "water" in top["text"].lower() or "boil" in top["text"].lower(), ( f"Top chunk should mention water/boil, got: {top['text']!r}" @@ -78,18 +101,27 @@ class TestRagRetrieval: def test_rag_ingest_increases_corpus(self, node_with_rag): """After ingest, a new document is retrievable.""" - _run(node_with_rag.bus.call( - "rag.ingest", (1, 0), - {"params": {"corpus": "health"}, "input": { - "doc_cid": "fire.001", - "title": "Fire Safety", - "text": "If fire spreads evacuate immediately via the nearest exit.", - }}, - )) - result = _run(node_with_rag.bus.call( - "rag.query", (1, 0), - {"params": {"corpus": "health"}, "input": {"query": "fire evacuate exit", "k": 3}}, - )) + _run( + node_with_rag.bus.call( + "rag.ingest", + (1, 0), + { + "params": {"corpus": "health"}, + "input": { + "doc_cid": "fire.001", + "title": "Fire Safety", + "text": "If fire spreads evacuate immediately via the nearest exit.", + }, + }, + ) + ) + result = _run( + node_with_rag.bus.call( + "rag.query", + (1, 0), + {"params": {"corpus": "health"}, "input": {"query": "fire evacuate exit", "k": 3}}, + ) + ) texts = [c["text"] for c in result["output"]["chunks"]] assert any("fire" in t.lower() or "evacuate" in t.lower() for t in texts), ( f"Newly ingested fire doc should appear in results; got: {texts}" @@ -98,48 +130,68 @@ class TestRagRetrieval: def test_rag_corpus_isolation(self): """Two nodes with different corpora do not share documents.""" from hearthnet.node import InMemoryNetwork + net = InMemoryNetwork() alpha = net.add_node("alpha", "Alpha", "ed25519:test") beta = net.add_node("beta", "Beta", "ed25519:test") alpha.install_demo_services(corpus="alpha-corpus") beta.install_demo_services(corpus="beta-corpus") - _run(alpha.bus.call( - "rag.ingest", (1, 0), - {"params": {"corpus": "alpha-corpus"}, "input": {"doc_cid": "a1", "title": "Alpha Only", "text": "alpha secret document"}}, - )) + _run( + alpha.bus.call( + "rag.ingest", + (1, 0), + { + "params": {"corpus": "alpha-corpus"}, + "input": { + "doc_cid": "a1", + "title": "Alpha Only", + "text": "alpha secret document", + }, + }, + ) + ) # Beta's bus knows nothing about alpha-corpus - result = _run(beta.bus.call( - "rag.query", (1, 0), - {"params": {"corpus": "beta-corpus"}, "input": {"query": "alpha secret", "k": 3}}, - )) - texts = " ".join(c["text"] for c in result["output"]["chunks"]) - assert "alpha secret" not in texts, ( - "Beta's rag.query must NOT return alpha's documents" + result = _run( + beta.bus.call( + "rag.query", + (1, 0), + {"params": {"corpus": "beta-corpus"}, "input": {"query": "alpha secret", "k": 3}}, + ) ) + texts = " ".join(c["text"] for c in result["output"]["chunks"]) + assert "alpha secret" not in texts, "Beta's rag.query must NOT return alpha's documents" # ───────────────────────────────────────────────────────────────────────────── # 2. LLM: bus.call returns a response with content # ───────────────────────────────────────────────────────────────────────────── + class TestLlmService: """llm.chat routes through the bus and returns a non-empty assistant message.""" @pytest.fixture def node(self): from hearthnet.node import InMemoryNetwork + net = InMemoryNetwork() n = net.add_node("llm-test", "LLM Node", "ed25519:test") n.install_demo_services(corpus="test") return n def test_llm_returns_assistant_message(self, node): - result = _run(node.bus.call( - "llm.chat", (1, 0), - {"params": {"model": "demo-local"}, "input": {"messages": [{"role": "user", "content": "Hello from test"}]}}, - )) + result = _run( + node.bus.call( + "llm.chat", + (1, 0), + { + "params": {"model": "demo-local"}, + "input": {"messages": [{"role": "user", "content": "Hello from test"}]}, + }, + ) + ) output = result.get("output", {}) msg = output.get("message", {}) assert msg.get("role") == "assistant", f"Expected role=assistant, got: {msg}" @@ -148,20 +200,32 @@ class TestLlmService: def test_llm_echoes_input_in_demo_backend(self, node): """Demo backend echoes the user's last message — proves routing reached the service.""" - result = _run(node.bus.call( - "llm.chat", (1, 0), - {"params": {"model": "demo-local"}, "input": {"messages": [{"role": "user", "content": "unique-query-xyz"}]}}, - )) + result = _run( + node.bus.call( + "llm.chat", + (1, 0), + { + "params": {"model": "demo-local"}, + "input": {"messages": [{"role": "user", "content": "unique-query-xyz"}]}, + }, + ) + ) content = result["output"]["message"]["content"] assert "unique-query-xyz" in content, ( f"Demo LLM must echo input so we know the bus reached the service; got: {content!r}" ) def test_llm_meta_has_tokens(self, node): - result = _run(node.bus.call( - "llm.chat", (1, 0), - {"params": {"model": "demo-local"}, "input": {"messages": [{"role": "user", "content": "token count test"}]}}, - )) + result = _run( + node.bus.call( + "llm.chat", + (1, 0), + { + "params": {"model": "demo-local"}, + "input": {"messages": [{"role": "user", "content": "token count test"}]}, + }, + ) + ) meta = result.get("meta", {}) assert "tokens_in" in meta, f"LLM response meta must include tokens_in; got: {meta}" assert meta["tokens_in"] > 0, "tokens_in must be positive" @@ -169,25 +233,34 @@ class TestLlmService: def test_llm_not_available_without_model(self): """When no backend is registered, bus raises an error — not silently ignored.""" from hearthnet.node import HearthNode + bare = HearthNode("bare", "Bare Node", "ed25519:bare") # No services installed — bus.call must raise, not return empty dict with pytest.raises(Exception, match="not_found|not_implemented|no provider"): # BusError - _run(bare.bus.call( - "llm.chat", (1, 0), - {"params": {"model": "demo-local"}, "input": {"messages": [{"role": "user", "content": "test"}]}}, - )) + _run( + bare.bus.call( + "llm.chat", + (1, 0), + { + "params": {"model": "demo-local"}, + "input": {"messages": [{"role": "user", "content": "test"}]}, + }, + ) + ) # ───────────────────────────────────────────────────────────────────────────── # 3. Chat: messages delivered between nodes via bus # ───────────────────────────────────────────────────────────────────────────── + class TestChatService: """chat.send routes to the bus and returns a delivery receipt.""" @pytest.fixture def two_nodes(self): from hearthnet.node import InMemoryNetwork + net = InMemoryNetwork() alice = net.add_node("alice", "Alice", "ed25519:test") bob = net.add_node("bob", "Bob", "ed25519:test") @@ -198,10 +271,13 @@ class TestChatService: def test_chat_send_returns_receipt(self, two_nodes): alice, bob = two_nodes - result = _run(alice.bus.call( - "chat.send", (1, 0), - {"input": {"to": "bob", "text": "Hello Bob from test"}}, - )) + result = _run( + alice.bus.call( + "chat.send", + (1, 0), + {"input": {"to": "bob", "text": "Hello Bob from test"}}, + ) + ) assert "output" in result, f"chat.send must return output; got: {result}" status = result["output"].get("status", result["output"].get("delivered")) assert status is not None, f"chat.send output must contain status; got: {result['output']}" @@ -209,27 +285,34 @@ class TestChatService: def test_chat_send_content_reaches_service(self, two_nodes): """The message text is preserved in the receipt / event.""" alice, _ = two_nodes - result = _run(alice.bus.call( - "chat.send", (1, 0), - {"input": {"to": "bob", "text": "specific-message-content"}}, - )) + result = _run( + alice.bus.call( + "chat.send", + (1, 0), + {"input": {"to": "bob", "text": "specific-message-content"}}, + ) + ) # Either the result echoes the text or the delivery status is present result_str = str(result) - assert "specific-message-content" in result_str or "delivered" in result_str or "queued" in result_str, ( - f"chat.send result must reflect the message was handled; got: {result}" - ) + assert ( + "specific-message-content" in result_str + or "delivered" in result_str + or "queued" in result_str + ), f"chat.send result must reflect the message was handled; got: {result}" # ───────────────────────────────────────────────────────────────────────────── # 4. Router: capabilities route to the correct node # ───────────────────────────────────────────────────────────────────────────── + class TestBusRouting: """Capability bus routes calls to the node that has the matching service.""" @pytest.fixture def mesh(self): from hearthnet.node import InMemoryNetwork + net = InMemoryNetwork() alice = net.add_node("alice", "Alice", "ed25519:test") bob = net.add_node("bob", "Bob", "ed25519:test") @@ -241,27 +324,47 @@ class TestBusRouting: def test_local_capability_preferred_over_remote(self, mesh): """Alice's LLM query is answered by Alice, not Bob.""" alice, _ = mesh - result = _run(alice.bus.call( - "llm.chat", (1, 0), - {"params": {"model": "demo-local"}, "input": {"messages": [{"role": "user", "content": "who are you"}]}}, - )) + result = _run( + alice.bus.call( + "llm.chat", + (1, 0), + { + "params": {"model": "demo-local"}, + "input": {"messages": [{"role": "user", "content": "who are you"}]}, + }, + ) + ) content = result["output"]["message"]["content"] # Demo backend response includes its model name and the echoed message - assert "demo-local" in content, ( - f"Local capability must be preferred; got: {content!r}" - ) + assert "demo-local" in content, f"Local capability must be preferred; got: {content!r}" def test_rag_routes_by_corpus(self, mesh): """alice-docs corpus is served by Alice's RAG, not Bob's.""" alice, bob = mesh - _run(alice.bus.call( - "rag.ingest", (1, 0), - {"params": {"corpus": "alice-docs"}, "input": {"doc_cid": "a1", "title": "Alice Doc", "text": "alice exclusive knowledge"}}, - )) - result = _run(alice.bus.call( - "rag.query", (1, 0), - {"params": {"corpus": "alice-docs"}, "input": {"query": "alice exclusive knowledge", "k": 3}}, - )) + _run( + alice.bus.call( + "rag.ingest", + (1, 0), + { + "params": {"corpus": "alice-docs"}, + "input": { + "doc_cid": "a1", + "title": "Alice Doc", + "text": "alice exclusive knowledge", + }, + }, + ) + ) + result = _run( + alice.bus.call( + "rag.query", + (1, 0), + { + "params": {"corpus": "alice-docs"}, + "input": {"query": "alice exclusive knowledge", "k": 3}, + }, + ) + ) chunks = result["output"]["chunks"] assert len(chunks) > 0 top_text = chunks[0]["text"] @@ -272,15 +375,31 @@ class TestBusRouting: def test_bob_rag_answers_bob_corpus(self, mesh): """bob-docs corpus is served by Bob's RAG, even when called from Alice.""" alice, bob = mesh - _run(bob.bus.call( - "rag.ingest", (1, 0), - {"params": {"corpus": "bob-docs"}, "input": {"doc_cid": "b1", "title": "Bob Doc", "text": "bob exclusive knowledge"}}, - )) + _run( + bob.bus.call( + "rag.ingest", + (1, 0), + { + "params": {"corpus": "bob-docs"}, + "input": { + "doc_cid": "b1", + "title": "Bob Doc", + "text": "bob exclusive knowledge", + }, + }, + ) + ) # Alice calls for bob-docs — bus must route to Bob - result = _run(alice.bus.call( - "rag.query", (1, 0), - {"params": {"corpus": "bob-docs"}, "input": {"query": "bob exclusive knowledge", "k": 3}}, - )) + result = _run( + alice.bus.call( + "rag.query", + (1, 0), + { + "params": {"corpus": "bob-docs"}, + "input": {"query": "bob exclusive knowledge", "k": 3}, + }, + ) + ) chunks = result["output"]["chunks"] assert len(chunks) > 0, "Alice must be able to get Bob's rag.query result" top_text = chunks[0]["text"] @@ -291,22 +410,34 @@ class TestBusRouting: def test_unknown_capability_raises(self, mesh): """Calling a capability no node provides raises, not silently fails.""" alice, _ = mesh - with pytest.raises(Exception, match="not_found|not_implemented|partition|no provider"): # BusError - _run(alice.bus.call( - "nonexistent.capability", (1, 0), {}, - )) + with pytest.raises( + Exception, match="not_found|not_implemented|partition|no provider" + ): # BusError + _run( + alice.bus.call( + "nonexistent.capability", + (1, 0), + {}, + ) + ) def test_marketplace_post_and_list(self, mesh): """market.post stores a post; market.list returns it.""" alice, _ = mesh - _run(alice.bus.call( - "market.post", (1, 0), - {"input": {"title": "Test offer", "category": "tools", "text": "A working wrench"}}, - )) - result = _run(alice.bus.call( - "market.list", (1, 0), - {"input": {"category": "tools"}}, - )) + _run( + alice.bus.call( + "market.post", + (1, 0), + {"input": {"title": "Test offer", "category": "tools", "text": "A working wrench"}}, + ) + ) + result = _run( + alice.bus.call( + "market.list", + (1, 0), + {"input": {"category": "tools"}}, + ) + ) posts = result["output"]["posts"] assert any("wrench" in p.get("text", "") for p in posts), ( f"Marketplace must return the posted item; got posts: {posts}" @@ -317,16 +448,16 @@ class TestBusRouting: # 5. HF Spaces compatibility: @spaces.GPU requirement # ───────────────────────────────────────────────────────────────────────────── + class TestHfSpacesCompatibility: """Ensure app.py satisfies HF ZeroGPU constraints when spaces is present.""" def test_app_imports_without_error(self): """app.py must be importable — any startup error breaks the Space.""" import importlib + # Re-import to catch any regression (already imported, but verifies no side effects) - spec = importlib.util.spec_from_file_location( - "app_smoke", "app.py" - ) + spec = importlib.util.spec_from_file_location("app_smoke", "app.py") mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) # type: ignore[union-attr] assert hasattr(mod, "demo"), "app.py must define a module-level 'demo' variable" @@ -335,9 +466,8 @@ class TestHfSpacesCompatibility: """demo must be a gr.Blocks instance — what HF Spaces expects.""" import app import gradio as gr - assert isinstance(app.demo, gr.Blocks), ( - f"app.demo must be gr.Blocks, got {type(app.demo)}" - ) + + assert isinstance(app.demo, gr.Blocks), f"app.demo must be gr.Blocks, got {type(app.demo)}" def test_hf_spaces_gpu_wrapper_present_when_spaces_available(self, monkeypatch, tmp_path): """When `spaces` package is importable, a @spaces.GPU function must be registered. @@ -353,6 +483,7 @@ class TestHfSpacesCompatibility: class FakeGPU: def __init__(self, duration=60): self.duration = duration + def __call__(self, fn): gpu_calls.append(fn.__name__) return fn diff --git a/tests/test_coverage_boost.py b/tests/test_coverage_boost.py index 9a289773084c022a3715bfac3d001ee610607d6b..1398aae0073fc4372f41503b664fe2bc79c5f54f 100644 --- a/tests/test_coverage_boost.py +++ b/tests/test_coverage_boost.py @@ -30,6 +30,7 @@ def _run(coro): # Config Module Tests # ───────────────────────────────────────────────────────────────────────────── + class TestConfigModule: """Configuration module coverage.""" @@ -51,6 +52,7 @@ class TestConfigModule: # Bus Error Handling Tests # ───────────────────────────────────────────────────────────────────────────── + class TestBusErrors: """Bus error handling.""" @@ -70,7 +72,13 @@ class TestBusErrors: def test_version_not_found(self, node): """Bus raises BusError for wrong versions.""" with pytest.raises(BusError) as exc: - _run(node.bus.call("chat.send", (99, 0), {"input": {"recipient": "bob", "body": "hi"}, "params": {}})) + _run( + node.bus.call( + "chat.send", + (99, 0), + {"input": {"recipient": "bob", "body": "hi"}, "params": {}}, + ) + ) assert exc.value.code == "not_found" @@ -78,6 +86,7 @@ class TestBusErrors: # Event Log Tests # ───────────────────────────────────────────────────────────────────────────── + class TestEventLog: """Event log operations.""" @@ -95,8 +104,8 @@ class TestEventLog: try: log = EventLog() # Just verify the object exists and is usable - assert hasattr(log, 'iterate') - assert hasattr(log, 'head') + assert hasattr(log, "iterate") + assert hasattr(log, "head") except Exception: # EventLog structure may vary - that's OK for infrastructure test pass @@ -106,6 +115,7 @@ class TestEventLog: # Service Integration Tests # ───────────────────────────────────────────────────────────────────────────── + class TestServiceIntegration: """Cross-service integration.""" @@ -118,11 +128,13 @@ class TestServiceIntegration: def test_chat_send_integration(self, node): """Chat service through bus.""" - result = _run(node.bus.call( - "chat.send", - (1, 0), - {"input": {"recipient": "bob", "body": "Test message"}, "params": {}} - )) + result = _run( + node.bus.call( + "chat.send", + (1, 0), + {"input": {"recipient": "bob", "body": "Test message"}, "params": {}}, + ) + ) assert result is not None def test_file_storage_integration(self, node): @@ -130,15 +142,16 @@ class TestServiceIntegration: # Simplified: just verify the call works without checking output try: data = base64.b64encode(b"test content").decode() - result = _run(node.bus.call( - "files.store", - (1, 0), - {"params": {}, "input": { - "filename": "test.txt", - "base64_data": data, - "cid": "test-cid" - }} - )) + result = _run( + node.bus.call( + "files.store", + (1, 0), + { + "params": {}, + "input": {"filename": "test.txt", "base64_data": data, "cid": "test-cid"}, + }, + ) + ) assert result is not None except Exception: # If service not available, that's OK for this infrastructure test @@ -147,11 +160,13 @@ class TestServiceIntegration: def test_embedding_integration(self, node): """Embedding service through bus.""" try: - result = _run(node.bus.call( - "embedding.embed", - (1, 0), - {"params": {}, "input": {"texts": ["hello", "world"]}} - )) + result = _run( + node.bus.call( + "embedding.embed", + (1, 0), + {"params": {}, "input": {"texts": ["hello", "world"]}}, + ) + ) assert result is not None except Exception: # Embedding service may not be registered - that's OK for infrastructure test @@ -159,25 +174,26 @@ class TestServiceIntegration: def test_rag_ingest_integration(self, node): """RAG ingest through bus.""" - result = _run(node.bus.call( - "rag.ingest", - (1, 0), - {"params": {"corpus": "test"}, "input": { - "doc_cid": "doc-1", - "title": "Test", - "text": "Test content" - }} - )) + result = _run( + node.bus.call( + "rag.ingest", + (1, 0), + { + "params": {"corpus": "test"}, + "input": {"doc_cid": "doc-1", "title": "Test", "text": "Test content"}, + }, + ) + ) assert result is not None def test_rag_query_integration(self, node): """RAG query through bus.""" try: - result = _run(node.bus.call( - "rag.query", - (1, 0), - {"params": {"corpus": "test"}, "input": {"query": "test"}} - )) + result = _run( + node.bus.call( + "rag.query", (1, 0), {"params": {"corpus": "test"}, "input": {"query": "test"}} + ) + ) assert result is not None except Exception: # RAG may not have corpus - that's OK for infrastructure test @@ -188,6 +204,7 @@ class TestServiceIntegration: # Concurrent Operations Tests # ───────────────────────────────────────────────────────────────────────────── + class TestConcurrentOperations: """Concurrent bus operations.""" @@ -200,11 +217,12 @@ class TestConcurrentOperations: def test_concurrent_chats(self, node): """Concurrent chat sends.""" + async def task(idx: int): return await node.bus.call( "chat.send", (1, 0), - {"input": {"recipient": f"user-{idx}", "body": f"message {idx}"}, "params": {}} + {"input": {"recipient": f"user-{idx}", "body": f"message {idx}"}, "params": {}}, ) async def _all(): @@ -215,12 +233,13 @@ class TestConcurrentOperations: def test_concurrent_embeddings(self, node): """Concurrent embedding calls.""" + async def task(idx: int): try: return await node.bus.call( "embedding.embed", (1, 0), - {"params": {}, "input": {"texts": [f"text {idx}-1", f"text {idx}-2"]}} + {"params": {}, "input": {"texts": [f"text {idx}-1", f"text {idx}-2"]}}, ) except Exception: return {"skipped": True} @@ -233,15 +252,19 @@ class TestConcurrentOperations: def test_concurrent_rag_operations(self, node): """Concurrent RAG operations.""" + async def task(idx: int): return await node.bus.call( "rag.ingest", (1, 0), - {"params": {"corpus": "concurrent"}, "input": { - "doc_cid": f"doc-{idx}", - "title": f"Title {idx}", - "text": f"Content {idx}" - }} + { + "params": {"corpus": "concurrent"}, + "input": { + "doc_cid": f"doc-{idx}", + "title": f"Title {idx}", + "text": f"Content {idx}", + }, + }, ) async def _all(): @@ -255,6 +278,7 @@ class TestConcurrentOperations: # Blob Handling Tests # ───────────────────────────────────────────────────────────────────────────── + class TestBlobOperations: """Blob chunking and operations.""" @@ -262,6 +286,7 @@ class TestBlobOperations: """Blob chunker module exists.""" try: from hearthnet.blobs.chunker import BlobChunker + assert BlobChunker is not None except ImportError: # Module structure may vary - that's OK @@ -271,6 +296,7 @@ class TestBlobOperations: """Blob operations don't crash.""" try: from hearthnet.blobs.chunker import BlobChunker + chunker = BlobChunker() # Just verify object creation works assert chunker is not None @@ -283,6 +309,7 @@ class TestBlobOperations: # Error Recovery Tests # ───────────────────────────────────────────────────────────────────────────── + class TestErrorRecovery: """Error recovery and resilience.""" @@ -300,24 +327,23 @@ class TestErrorRecovery: _run(node.bus.call("invalid.service", (1, 0), {"input": {}, "params": {}})) except BusError: pass - + # Second call succeeds - result = _run(node.bus.call( - "chat.send", - (1, 0), - {"input": {"recipient": "bob", "body": "after error"}, "params": {}} - )) + result = _run( + node.bus.call( + "chat.send", + (1, 0), + {"input": {"recipient": "bob", "body": "after error"}, "params": {}}, + ) + ) assert result is not None def test_concurrent_error_handling(self, node): """Handle concurrent errors.""" + async def task(idx: int): try: - return await node.bus.call( - f"invalid.{idx}", - (1, 0), - {"input": {}, "params": {}} - ) + return await node.bus.call(f"invalid.{idx}", (1, 0), {"input": {}, "params": {}}) except BusError: return {"error": f"expected {idx}"} @@ -332,6 +358,7 @@ class TestErrorRecovery: # Large Data Tests # ───────────────────────────────────────────────────────────────────────────── + class TestLargeData: """Large message and file handling.""" @@ -345,12 +372,14 @@ class TestLargeData: def test_large_message(self, node): """Handle large chat messages.""" large_text = "x" * (10 * 1024) # 10KB - - result = _run(node.bus.call( - "chat.send", - (1, 0), - {"input": {"recipient": "bob", "body": large_text}, "params": {}} - )) + + result = _run( + node.bus.call( + "chat.send", + (1, 0), + {"input": {"recipient": "bob", "body": large_text}, "params": {}}, + ) + ) assert result is not None def test_large_file(self, node): @@ -358,16 +387,21 @@ class TestLargeData: try: data = b"x" * (100 * 1024) # 100KB b64_data = base64.b64encode(data).decode() - - result = _run(node.bus.call( - "files.store", - (1, 0), - {"params": {}, "input": { - "filename": "large.bin", - "base64_data": b64_data, - "cid": "large-cid" - }} - )) + + result = _run( + node.bus.call( + "files.store", + (1, 0), + { + "params": {}, + "input": { + "filename": "large.bin", + "base64_data": b64_data, + "cid": "large-cid", + }, + }, + ) + ) assert result is not None except Exception: # File service may not be available - that's OK for infrastructure test @@ -378,6 +412,7 @@ class TestLargeData: # Multi-Node Tests # ───────────────────────────────────────────────────────────────────────────── + class TestMultiNode: """Multi-node operations.""" @@ -386,7 +421,7 @@ class TestMultiNode: net = InMemoryNetwork() alice = net.add_node("alice", "Alice", "ed25519:alice") bob = net.add_node("bob", "Bob", "ed25519:bob") - + assert alice is not None assert bob is not None # Nodes have identifiers @@ -397,10 +432,10 @@ class TestMultiNode: net = InMemoryNetwork() node1 = net.add_node("node1", "Node 1", "ed25519:node1") node2 = net.add_node("node2", "Node 2", "ed25519:node2") - + node1.install_demo_services() node2.install_demo_services() - + # Both nodes should be ready assert node1.bus is not None assert node2.bus is not None @@ -410,6 +445,7 @@ class TestMultiNode: # Edge Cases # ───────────────────────────────────────────────────────────────────────────── + class TestEdgeCases: """Edge case handling.""" @@ -423,11 +459,9 @@ class TestEdgeCases: def test_empty_inputs(self, node): """Handle empty inputs gracefully.""" try: - result = _run(node.bus.call( - "embedding.embed", - (1, 0), - {"params": {}, "input": {"texts": []}} - )) + result = _run( + node.bus.call("embedding.embed", (1, 0), {"params": {}, "input": {"texts": []}}) + ) assert result is not None except Exception: # Empty inputs may not be supported - that's OK @@ -436,23 +470,27 @@ class TestEdgeCases: def test_unicode_content(self, node): """Handle unicode content.""" unicode_text = "Hello 🌍 مرحبا 你好" - - result = _run(node.bus.call( - "chat.send", - (1, 0), - {"input": {"recipient": "bob", "body": unicode_text}, "params": {}} - )) + + result = _run( + node.bus.call( + "chat.send", + (1, 0), + {"input": {"recipient": "bob", "body": unicode_text}, "params": {}}, + ) + ) assert result is not None def test_special_characters(self, node): """Handle special characters.""" special_text = "!@#$%^&*()[]{}|;:',.<>?/\\\"`~" - - result = _run(node.bus.call( - "chat.send", - (1, 0), - {"input": {"recipient": "bob", "body": special_text}, "params": {}} - )) + + result = _run( + node.bus.call( + "chat.send", + (1, 0), + {"input": {"recipient": "bob", "body": special_text}, "params": {}}, + ) + ) assert result is not None diff --git a/tests/test_e2e_multinode.py b/tests/test_e2e_multinode.py index 680a2da77adddff5490ad3822f0152af3a7eaf10..36ba67990d94429539022405a6329268a2474ae2 100644 --- a/tests/test_e2e_multinode.py +++ b/tests/test_e2e_multinode.py @@ -11,6 +11,7 @@ two separate users on different devices. Requires: playwright, gradio Run: python -m pytest tests/test_e2e_multinode.py -v """ + from __future__ import annotations import socket @@ -255,7 +256,10 @@ class TestCrossNodeBus: url = f"http://127.0.0.1:{node_a_port}/health" try: with urllib.request.urlopen(url, timeout=5) as resp: # nosec B310 - assert resp.status in (200, 404) # 404 = Gradio doesn't have /health but node started + assert resp.status in ( + 200, + 404, + ) # 404 = Gradio doesn't have /health but node started except urllib.error.HTTPError as e: # Gradio returns 404 for /health — that's fine, node is running assert e.code == 404 diff --git a/tests/test_e2e_playwright.py b/tests/test_e2e_playwright.py index 89d7743b813c42ceb4dd103975fb38449d038108..4cae4922dcd48a3eacdeb97f95fe632ad435fe74 100644 --- a/tests/test_e2e_playwright.py +++ b/tests/test_e2e_playwright.py @@ -6,6 +6,7 @@ drive a real browser and validate user-facing flows with real data. Requires: playwright, gradio, and the hearthnet package installed. Install browsers once with: playwright install chromium """ + from __future__ import annotations import asyncio @@ -25,6 +26,7 @@ import pytest def _find_free_port() -> int: import socket + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", 0)) return s.getsockname()[1] @@ -50,10 +52,10 @@ def app_port() -> Generator[int, None, None]: community_id="test-community", ) ctrl = HearthNetController(node=hn_node) - bus = ctrl.node.bus # HearthNode exposes .bus + bus = ctrl.node.bus # HearthNode exposes .bus ui_app = build_ui(bus=bus) - gradio_blocks = ui_app.build() # UiApp.build() → gr.Blocks + gradio_blocks = ui_app.build() # UiApp.build() → gr.Blocks if hasattr(gradio_blocks, "launch"): gradio_blocks.launch( server_name="127.0.0.1", @@ -67,6 +69,7 @@ def app_port() -> Generator[int, None, None]: # Wait for Gradio to be ready (up to 30s) import urllib.request + deadline = time.time() + 30 ready = False while time.time() < deadline: @@ -173,7 +176,11 @@ class TestAskTab: page.wait_for_timeout(3000) content = page.content() # Some response should have appeared — either real LLM or fallback - assert page.locator(".message").count() > 0 or "HearthNet" in content or "hello" in content.lower() + assert ( + page.locator(".message").count() > 0 + or "HearthNet" in content + or "hello" in content.lower() + ) class TestMarketplaceTab: @@ -198,7 +205,9 @@ class TestEmergencyTab: _wait_tab(page, "Emergency") content = page.content() - assert any(kw in content.lower() for kw in ["emergency", "connectivity", "status", "internet"]) + assert any( + kw in content.lower() for kw in ["emergency", "connectivity", "status", "internet"] + ) class TestChatTab: @@ -231,6 +240,7 @@ class TestApiEndpoints: def test_health_endpoint(self, app_port): """The Gradio app itself exposes a health-check path.""" import urllib.request + url = f"http://127.0.0.1:{app_port}/" with urllib.request.urlopen(url, timeout=5) as resp: # nosec B310 assert resp.status == 200 @@ -238,6 +248,7 @@ class TestApiEndpoints: def test_gradio_api_info(self, app_port): """Gradio exposes /info endpoint for API discovery.""" import urllib.request + try: with urllib.request.urlopen( # nosec B310 f"http://127.0.0.1:{app_port}/info", timeout=5 diff --git a/tests/test_e2e_user_stories.py b/tests/test_e2e_user_stories.py index 6bbe3843b0ce46fb3d6d0cfd09db339f75d01dbe..353a71a333cbef518cc375a79482df9a67d3a365 100644 --- a/tests/test_e2e_user_stories.py +++ b/tests/test_e2e_user_stories.py @@ -23,6 +23,7 @@ Run: pytest tests/test_e2e_user_stories.py -v # Screenshots: docs/screenshots/stories/*.png """ + from __future__ import annotations import socket @@ -177,21 +178,27 @@ class TestUS01AskLlm: """ page, ctx = _alice_page(pw_browser, two_node_mesh) try: - _ss(page, "US01-02-ask-empty", "Ask tab before sending — shows corpus selector, model selector, chat area") + _ss( + page, + "US01-02-ask-empty", + "Ask tab before sending — shows corpus selector, model selector, chat area", + ) page.locator("textarea").first.fill("What is HearthNet?") page.get_by_role("button", name="Send").first.click() page.wait_for_timeout(4000) content = page.content() - _ss(page, "US01-03-ask-response", "Ask tab after sending — LLM response appears in chat, routing trace shown below") + _ss( + page, + "US01-03-ask-response", + "Ask tab after sending — LLM response appears in chat, routing trace shown below", + ) # Response must exist — no fabricated fallback - assert ( - "HearthNet" in content - or "demo-local" in content - or "mesh" in content.lower() - ), "Expected LLM response content" + assert "HearthNet" in content or "demo-local" in content or "mesh" in content.lower(), ( + "Expected LLM response content" + ) finally: ctx.close() @@ -207,7 +214,11 @@ class TestUS01AskLlm: page.wait_for_timeout(4000) content = page.content() - _ss(page, "US01-04-routing-trace", "Routing trace JSON — shows capability, routed_via node ID") + _ss( + page, + "US01-04-routing-trace", + "Routing trace JSON — shows capability, routed_via node ID", + ) # Routing trace panel should have appeared (contains routing keys) assert any(kw in content for kw in ["llm.chat", "routed_via", "capability", "rag"]) finally: @@ -240,9 +251,15 @@ class TestUS02AskRag: page.get_by_role("button", name="Send").first.click() page.wait_for_timeout(5000) - _ss(page, "US02-01-ask-with-rag", "Ask tab with RAG corpus — sources panel shows retrieved chunks") + _ss( + page, + "US02-01-ask-with-rag", + "Ask tab with RAG corpus — sources panel shows retrieved chunks", + ) content = page.content() - assert "water" in content.lower() or "filter" in content.lower() or "demo-local" in content + assert ( + "water" in content.lower() or "filter" in content.lower() or "demo-local" in content + ) finally: ctx.close() @@ -259,7 +276,11 @@ class TestUS03Chat: page, ctx = _alice_page(pw_browser, two_node_mesh) try: _tab(page, "Chat") - _ss(page, "US03-01-chat-tab", "Chat tab — shows recipient field, history area, message input") + _ss( + page, + "US03-01-chat-tab", + "Chat tab — shows recipient field, history area, message input", + ) content = page.content() assert any(kw in content.lower() for kw in ["message", "recipient", "chat", "send"]) finally: @@ -279,7 +300,11 @@ class TestUS03Chat: page.get_by_role("button", name="Send").click() page.wait_for_timeout(2000) - _ss(page, "US03-02-chat-sent", "Chat tab after sending — delivery status (queued/direct) shown") + _ss( + page, + "US03-02-chat-sent", + "Chat tab after sending — delivery status (queued/direct) shown", + ) content = page.content() assert any(kw in content for kw in ["delivered", "queued", "direct", "Error"]) finally: @@ -298,7 +323,11 @@ class TestUS04Mesh: page, ctx = _alice_page(pw_browser, two_node_mesh) try: _tab(page, "Mesh") - _ss(page, "US04-01-mesh-tab-initial", "Mesh tab before refresh — shows 'Click Refresh' placeholder") + _ss( + page, + "US04-01-mesh-tab-initial", + "Mesh tab before refresh — shows 'Click Refresh' placeholder", + ) content = page.content() assert any(kw in content.lower() for kw in ["mesh", "network", "peer", "refresh"]) finally: @@ -315,7 +344,11 @@ class TestUS04Mesh: page.get_by_role("button", name="Refresh Mesh").click() page.wait_for_timeout(3000) - _ss(page, "US04-02-mesh-live-topology", "Mesh tab after refresh — SVG graph shows Alice (green) + Bob (blue), statistics panel") + _ss( + page, + "US04-02-mesh-live-topology", + "Mesh tab after refresh — SVG graph shows Alice (green) + Bob (blue), statistics panel", + ) content = page.content() # Bob should appear in stats or SVG assert "bob" in content.lower() or "peer" in content.lower() @@ -333,9 +366,15 @@ class TestUS04Mesh: page.get_by_role("button", name="Refresh Mesh").click() page.wait_for_timeout(3000) - _ss(page, "US04-03-mesh-capability-matrix", "Mesh tab — capability matrix showing llm.chat, rag.query etc per node") + _ss( + page, + "US04-03-mesh-capability-matrix", + "Mesh tab — capability matrix showing llm.chat, rag.query etc per node", + ) content = page.content() - assert any(kw in content for kw in ["llm.chat", "rag.query", "chat.send", "market.post"]) + assert any( + kw in content for kw in ["llm.chat", "rag.query", "chat.send", "market.post"] + ) finally: ctx.close() @@ -353,7 +392,11 @@ class TestUS05Settings: page, ctx = _alice_page(pw_browser, two_node_mesh) try: _tab(page, "Settings") - _ss(page, "US05-01-settings-identity", "Settings tab — Node Identity section with node ID, profile, community") + _ss( + page, + "US05-01-settings-identity", + "Settings tab — Node Identity section with node ID, profile, community", + ) content = page.content() # Node ID starts with alice or contains an ed25519-style key assert "alice" in content.lower() or "node" in content.lower() @@ -371,7 +414,11 @@ class TestUS05Settings: page.get_by_role("button", name="Refresh Peers").click() page.wait_for_timeout(2000) - _ss(page, "US05-02-settings-peers", "Settings — Peers panel after refresh: shows Bob with capability count (10+ caps)") + _ss( + page, + "US05-02-settings-peers", + "Settings — Peers panel after refresh: shows Bob with capability count (10+ caps)", + ) content = page.content() assert "bob" in content.lower() or "capability" in content.lower() finally: @@ -382,7 +429,11 @@ class TestUS05Settings: page, ctx = _alice_page(pw_browser, two_node_mesh) try: _tab(page, "Settings") - _ss(page, "US05-03-settings-join-mesh", "Settings — Join This Mesh section with QR code generation and 3 join methods") + _ss( + page, + "US05-03-settings-join-mesh", + "Settings — Join This Mesh section with QR code generation and 3 join methods", + ) content = page.content() assert any(kw in content.lower() for kw in ["join", "invite", "qr", "scan", "mesh"]) finally: @@ -393,9 +444,15 @@ class TestUS05Settings: page, ctx = _alice_page(pw_browser, two_node_mesh) try: _tab(page, "Settings") - _ss(page, "US05-04-settings-specialized-nodes", "Settings — Specialized Nodes section with OCR, Medical RAG, thin client examples") + _ss( + page, + "US05-04-settings-specialized-nodes", + "Settings — Specialized Nodes section with OCR, Medical RAG, thin client examples", + ) content = page.content() - assert any(kw in content.lower() for kw in ["ocr", "specialized", "thin client", "routing"]) + assert any( + kw in content.lower() for kw in ["ocr", "specialized", "thin client", "routing"] + ) finally: ctx.close() @@ -404,7 +461,11 @@ class TestUS05Settings: page, ctx = _alice_page(pw_browser, two_node_mesh) try: _tab(page, "Settings") - _ss(page, "US05-05-settings-impl-status", "Settings — Implementation status table covering M01–M31 and X01–X07") + _ss( + page, + "US05-05-settings-impl-status", + "Settings — Implementation status table covering M01–M31 and X01–X07", + ) content = page.content() assert "M01" in content and "M05" in content finally: @@ -445,7 +506,11 @@ class TestUS06Marketplace: page.get_by_role("button", name="Refresh").click() page.wait_for_timeout(1500) - _ss(page, "US06-02-marketplace-after-post", "Marketplace tab after posting — 'Spare router' offer appears in the list") + _ss( + page, + "US06-02-marketplace-after-post", + "Marketplace tab after posting — 'Spare router' offer appears in the list", + ) content = page.content() assert "router" in content.lower() or "post" in content.lower() finally: @@ -464,7 +529,11 @@ class TestUS07Files: page, ctx = _alice_page(pw_browser, two_node_mesh) try: _tab(page, "Files") - _ss(page, "US07-01-files-tab", "Files tab — BLAKE3 content-addressed blob store, upload and list") + _ss( + page, + "US07-01-files-tab", + "Files tab — BLAKE3 content-addressed blob store, upload and list", + ) content = page.content() assert any(kw in content.lower() for kw in ["file", "blob", "upload", "store"]) finally: @@ -483,9 +552,16 @@ class TestUS08Emergency: page, ctx = _alice_page(pw_browser, two_node_mesh) try: _tab(page, "Emergency") - _ss(page, "US08-01-emergency-tab", "Emergency tab — shows current connectivity mode (normal/degraded/offline)") + _ss( + page, + "US08-01-emergency-tab", + "Emergency tab — shows current connectivity mode (normal/degraded/offline)", + ) content = page.content() - assert any(kw in content.lower() for kw in ["emergency", "mode", "connectivity", "normal", "offline"]) + assert any( + kw in content.lower() + for kw in ["emergency", "mode", "connectivity", "normal", "offline"] + ) finally: ctx.close() @@ -525,14 +601,17 @@ class TestUS11ApiCoverage: result = single_node_api.predict(api_name="/refresh_corpora") choices = result.get("choices", []) if isinstance(result, dict) else [] choice_values = [c[0] if isinstance(c, list) else c for c in choices] - assert any("alice-docs" in v or "community" in v or v not in ("(none)", "") for v in choice_values), ( - f"Expected corpus name in choices, got: {choice_values}" - ) + assert any( + "alice-docs" in v or "community" in v or v not in ("(none)", "") for v in choice_values + ), f"Expected corpus name in choices, got: {choice_values}" def test_US11_2_llm_error_surfaces_not_silent(self, single_node_api): """When LLM is unavailable, the error is shown in the chat, not 'No response'.""" result = single_node_api.predict( - "What is HearthNet?", [], "(none)", "auto", + "What is HearthNet?", + [], + "(none)", + "auto", api_name="/handle_send", ) history = result[0] if result else [] @@ -561,19 +640,26 @@ class TestUS11ApiCoverage: corpus = non_none[0] result = single_node_api.predict( - "Tell me about the mesh", [], corpus, "auto", + "Tell me about the mesh", + [], + corpus, + "auto", api_name="/handle_send", ) trace = result[3] if len(result) > 3 else {} trace_val = trace.get("value", {}) if isinstance(trace, dict) else {} rag_section = (trace_val or {}).get("rag") or {} - assert rag_section.get("capability") == "rag.query", f"Expected rag.query in trace, got: {trace_val}" + assert rag_section.get("capability") == "rag.query", ( + f"Expected rag.query in trace, got: {trace_val}" + ) assert "corpus" in rag_section, f"No corpus in RAG trace: {rag_section}" def test_US11_4_chat_send_returns_status(self, single_node_api): """Chat send returns a delivery status (queued/direct), not blank.""" result = single_node_api.predict( - "alice", "Test message", [], + "alice", + "Test message", + [], api_name="/send_msg", ) status = result[2] if len(result) > 2 else {} @@ -585,7 +671,9 @@ class TestUS11ApiCoverage: def test_US11_5_chat_broadcast_star(self, single_node_api): """Chat send with '*' as recipient attempts broadcast.""" result = single_node_api.predict( - "*", "Broadcast test", [], + "*", + "Broadcast test", + [], api_name="/send_msg", ) # Should not raise; status should indicate broadcast @@ -594,7 +682,8 @@ class TestUS11ApiCoverage: def test_US11_6_invite_uses_local_host(self, single_node_api): """Invite generation returns a link with host (not empty).""" result = single_node_api.predict( - "", "member", + "", + "member", api_name="/gen_invite", ) # result[0] = QR HTML, result[1] = invite link @@ -624,11 +713,21 @@ class TestUS12MeshConnection: try: _tab(page, "Settings") content = page.content() - _ss(page, "US12-01-settings-mesh-connect", "Settings — three mesh connection methods: mDNS, invite QR, relay") + _ss( + page, + "US12-01-settings-mesh-connect", + "Settings — three mesh connection methods: mDNS, invite QR, relay", + ) # All three options must be mentioned - assert any(kw in content.lower() for kw in ["mdns", "mDNS", "same", "local", "lan"]), "Option A (mDNS) missing" - assert any(kw in content.lower() for kw in ["invite", "qr", "scan"]), "Option B (invite) missing" - assert any(kw in content.lower() for kw in ["relay", "remote", "internet"]), "Option C (relay) missing" + assert any(kw in content.lower() for kw in ["mdns", "mDNS", "same", "local", "lan"]), ( + "Option A (mDNS) missing" + ) + assert any(kw in content.lower() for kw in ["invite", "qr", "scan"]), ( + "Option B (invite) missing" + ) + assert any(kw in content.lower() for kw in ["relay", "remote", "internet"]), ( + "Option C (relay) missing" + ) finally: ctx.close() @@ -645,7 +744,6 @@ class TestUS12MeshConnection: ) - class TestUS09BobRemoteRouting: """ User story: Bob opens his HearthNet node. His LLM query is answered @@ -655,7 +753,11 @@ class TestUS09BobRemoteRouting: def test_bob_home_shows_node_id(self, pw_browser, two_node_mesh): page, ctx = _bob_page(pw_browser, two_node_mesh) try: - _ss(page, "US09-01-bob-home", "Bob's HearthNet node — header shows Bob's node ID and community") + _ss( + page, + "US09-01-bob-home", + "Bob's HearthNet node — header shows Bob's node ID and community", + ) content = page.content() assert "bob" in content.lower() or "HearthNet" in content finally: @@ -669,7 +771,11 @@ class TestUS09BobRemoteRouting: page.get_by_role("button", name="Send").first.click() page.wait_for_timeout(4000) - _ss(page, "US09-02-bob-ask-response", "Bob's Ask tab — LLM responds to Bob's question (local demo-remote model)") + _ss( + page, + "US09-02-bob-ask-response", + "Bob's Ask tab — LLM responds to Bob's question (local demo-remote model)", + ) content = page.content() assert any(kw in content for kw in ["Bob", "demo-remote", "bob", "hello", "Hello"]) finally: @@ -683,7 +789,11 @@ class TestUS09BobRemoteRouting: page.get_by_role("button", name="Refresh Mesh").click() page.wait_for_timeout(3000) - _ss(page, "US09-03-bob-mesh-sees-alice", "Bob's Mesh tab — SVG graph shows Bob (green) + Alice (blue)") + _ss( + page, + "US09-03-bob-mesh-sees-alice", + "Bob's Mesh tab — SVG graph shows Bob (green) + Alice (blue)", + ) content = page.content() assert "alice" in content.lower() or "peer" in content.lower() finally: @@ -697,7 +807,11 @@ class TestUS09BobRemoteRouting: page.get_by_role("button", name="Refresh Peers").click() page.wait_for_timeout(2000) - _ss(page, "US09-04-bob-settings-peers", "Bob's Settings — Peers panel showing Alice's node ID and capabilities") + _ss( + page, + "US09-04-bob-settings-peers", + "Bob's Settings — Peers panel showing Alice's node ID and capabilities", + ) content = page.content() assert "alice" in content.lower() or "capability" in content.lower() finally: @@ -720,7 +834,9 @@ class TestUS10AllTabs: content = page.content() for tab in self.ALL_TABS: assert page.get_by_role("tab", name=tab).count() > 0, f"Tab '{tab}' missing" - _ss(page, "US10-01-all-tabs-overview", f"All 7 tabs visible: {', '.join(self.ALL_TABS)}") + _ss( + page, "US10-01-all-tabs-overview", f"All 7 tabs visible: {', '.join(self.ALL_TABS)}" + ) finally: ctx.close() diff --git a/tests/test_easter_egg.py b/tests/test_easter_egg.py index 369950d1b691a15c3605ebba0c8794435757f2d8..8719e55d34c6db149b91e5eb782392667367aae8 100644 --- a/tests/test_easter_egg.py +++ b/tests/test_easter_egg.py @@ -1,4 +1,4 @@ -"""Test easter egg - Gradio 6 html_template/js_on_load API.""" +"""Test easter egg - Gradio 6 html_template/js_on_load API.""" from __future__ import annotations @@ -16,8 +16,8 @@ def test_easter_egg_implementation(): assert "hn-ticker" in _EGG_HTML assert "hn-modal" in _EGG_HTML assert "hn-iframe" in _EGG_HTML - assert "/webagent/index.html" in _EGG_HTML # served via FastAPI StaticFiles - assert "file=" not in _EGG_HTML # NOT using file= (blocked by HF proxy) + assert "/webagent/index.html" in _EGG_HTML # served via FastAPI StaticFiles + assert "file=" not in _EGG_HTML # NOT using file= (blocked by HF proxy) assert "