windhover 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
windhover/tracer.py ADDED
@@ -0,0 +1,325 @@
1
+ """Windhover tracer — one LangChain callback, two sinks.
2
+
3
+ `SpanBuilder` turns LangGraph/LangChain callbacks into a run + span tree and hands
4
+ each event to a `sink`. In-process runs use a DB sink; external apps use
5
+ `WindhoverTracer` (HTTP sink) — identical span logic, so the trace looks the same
6
+ wherever it came from.
7
+
8
+ Callback realities handled:
9
+ * LangGraph exposes the node name only on `on_chain_start` (metadata.langgraph_node),
10
+ never on end → we map langchain run_id -> our span.
11
+ * Token usage lives in either `llm_output.token_usage` (OpenAI-style) or a
12
+ generation's `usage_metadata` (input_tokens/output_tokens) — we read both.
13
+ * Parent linkage uses LangChain's parent_run_id.
14
+ Best-effort: a sink error never propagates into the user's graph.
15
+ """
16
+ from __future__ import annotations
17
+ import json, time, uuid
18
+ from pathlib import Path
19
+ from typing import Any, Callable, Optional
20
+
21
+ try:
22
+ from langchain_core.callbacks import BaseCallbackHandler
23
+ except Exception: # pragma: no cover - allows import without langchain
24
+ BaseCallbackHandler = object # type: ignore
25
+
26
+ _PRICING: Optional[dict] = None
27
+
28
+
29
+ def _load_pricing() -> dict:
30
+ global _PRICING
31
+ if _PRICING is None:
32
+ try:
33
+ p = Path(__file__).parent / "pricing.json"
34
+ _PRICING = {k: v for k, v in json.loads(p.read_text()).items()
35
+ if not k.startswith("_")}
36
+ except Exception:
37
+ _PRICING = {}
38
+ return _PRICING
39
+
40
+
41
+ def cost_of(model: Optional[str], pt: Optional[int], ct: Optional[int]) -> Optional[float]:
42
+ if not model or (pt is None and ct is None):
43
+ return None
44
+ table = _load_pricing()
45
+ best = max((k for k in table if model.startswith(k)), key=len, default=None)
46
+ if not best:
47
+ return None
48
+ rate = table[best]
49
+ return round((pt or 0) / 1e6 * rate["input"] + (ct or 0) / 1e6 * rate["output"], 6)
50
+
51
+
52
+ def _err_text(error: Any) -> str:
53
+ """Full traceback when we have one — the UI maps 'File …, line N' frames
54
+ onto node source so you can see exactly where a run broke."""
55
+ try:
56
+ if isinstance(error, BaseException) and error.__traceback__ is not None:
57
+ import traceback
58
+ return "".join(traceback.format_exception(
59
+ type(error), error, error.__traceback__))[-4000:]
60
+ except Exception:
61
+ pass
62
+ return str(error)
63
+
64
+
65
+ def _trunc(v: Any, n: int = 4000) -> Any:
66
+ s = json.dumps(v, default=str)
67
+ return json.loads(s) if len(s) <= n else s[:n] + "…"
68
+
69
+
70
+ def _model_name(serialized: dict, kw: dict) -> str:
71
+ inv = kw.get("invocation_params") or {}
72
+ for src in (inv, (serialized or {}).get("kwargs", {}), serialized or {}):
73
+ for key in ("model", "model_name", "model_id", "deployment_name"):
74
+ if src.get(key):
75
+ return str(src[key])
76
+ ids = (serialized or {}).get("id") or []
77
+ return ids[-1] if ids else "llm"
78
+
79
+
80
+ def _usage(response: Any) -> tuple[Optional[int], Optional[int]]:
81
+ # OpenAI-style aggregate
82
+ out = getattr(response, "llm_output", None) or {}
83
+ for key in ("token_usage", "usage"):
84
+ u = out.get(key) if isinstance(out, dict) else None
85
+ if u:
86
+ return (u.get("prompt_tokens") or u.get("input_tokens"),
87
+ u.get("completion_tokens") or u.get("output_tokens"))
88
+ # per-generation usage_metadata (chat models)
89
+ try:
90
+ for gens in response.generations:
91
+ for g in gens:
92
+ um = getattr(getattr(g, "message", None), "usage_metadata", None)
93
+ if um:
94
+ return um.get("input_tokens"), um.get("output_tokens")
95
+ except Exception:
96
+ pass
97
+ return None, None
98
+
99
+
100
+ def _gen_text(response: Any) -> str:
101
+ try:
102
+ parts = []
103
+ for gens in response.generations:
104
+ for g in gens:
105
+ msg = getattr(g, "message", None)
106
+ text = getattr(g, "text", "") or str(getattr(msg, "content", "") or "")
107
+ if not text and msg is not None:
108
+ # structured-output / function-calling: content is empty,
109
+ # the payload lives in the tool calls
110
+ tc = (getattr(msg, "tool_calls", None) or
111
+ (getattr(msg, "additional_kwargs", None) or {}).get("tool_calls"))
112
+ if tc:
113
+ text = json.dumps(tc, default=str)
114
+ parts.append(text)
115
+ return "\n".join(p for p in parts if p)
116
+ except Exception:
117
+ return ""
118
+
119
+
120
+ class SpanBuilder(BaseCallbackHandler):
121
+ def __init__(self, sink: Callable[[dict], None], run_name: str = "external",
122
+ session: Optional[str] = None, tags: Optional[list] = None):
123
+ self.sink = sink
124
+ self.run_name = run_name
125
+ self.session = session
126
+ self.tags = tags
127
+ self.run_id = uuid.uuid4().hex[:12]
128
+ self._root = None # langchain run_id of the graph root
129
+ self._t0: Optional[float] = None
130
+ self._open: dict[str, dict] = {} # langchain run_id -> pending span
131
+ self._span_of: dict[str, str] = {} # langchain run_id -> our span id (for parent links)
132
+ self._seq = 0
133
+
134
+ # -- infra --
135
+ def _emit(self, ev: dict) -> None:
136
+ try:
137
+ self.sink(ev)
138
+ except Exception:
139
+ pass # observability must never break the graph
140
+
141
+ def _rel(self, t: float) -> int:
142
+ return int((t - (self._t0 or t)) * 1000)
143
+
144
+ def _finish_span(self, info: dict, *, output=None, status="ok", error=None,
145
+ model=None, pt=None, ct=None):
146
+ now = time.time()
147
+ sid = info["span_id"]
148
+ self._emit({"kind": "span", "id": sid, "run_id": self.run_id,
149
+ "parent_id": self._span_of.get(info.get("parent")),
150
+ "seq": self._seq, "type": info["type"], "name": info["name"],
151
+ "status": status, "started_ms": int(info["t"] * 1000),
152
+ "ended_ms": int(now * 1000), "offset_ms": self._rel(info["t"]),
153
+ "dur_ms": int((now - info["t"]) * 1000),
154
+ "input": info.get("input"), "output": output, "model": model,
155
+ "prompt_tokens": pt, "completion_tokens": ct,
156
+ "cost_usd": cost_of(model, pt, ct), "error": error})
157
+ self._seq += 1
158
+
159
+ # -- chains / nodes --
160
+ _INTERNAL_TAG_PREFIXES = ("graph:", "langsmith:", "seq:", "langgraph_")
161
+
162
+ def on_chain_start(self, serialized, inputs, *, run_id=None, parent_run_id=None,
163
+ metadata=None, tags=None, **kw):
164
+ node = (metadata or {}).get("langgraph_node")
165
+ if parent_run_id is None and self._root is None:
166
+ self._root = run_id
167
+ self._t0 = time.time()
168
+ # standard LangChain idioms work from ANY app, no constructor needed:
169
+ # config={"metadata": {"windhover_session": ..., "windhover_tags": [...]},
170
+ # "tags": [...]}
171
+ md = metadata or {}
172
+ if md.get("windhover_session"):
173
+ self.session = str(md["windhover_session"])
174
+ user_tags = [t for t in (tags or [])
175
+ if not str(t).startswith(self._INTERNAL_TAG_PREFIXES)]
176
+ extra = md.get("windhover_tags") or []
177
+ merged = list(dict.fromkeys(
178
+ [*(self.tags or []), *map(str, extra), *map(str, user_tags)]))
179
+ self.tags = merged or None
180
+ self._emit({"kind": "run_open", "run_id": self.run_id, "graph": self.run_name,
181
+ "input": _trunc(inputs), "started_ms": int(self._t0 * 1000),
182
+ "session": self.session, "tags": self.tags})
183
+ if node:
184
+ sid = uuid.uuid4().hex[:12]
185
+ self._span_of[run_id] = sid
186
+ self._open[run_id] = {"span_id": sid, "type": "node", "name": node,
187
+ "t": time.time(), "parent": parent_run_id,
188
+ "input": None}
189
+
190
+ def on_chain_end(self, outputs, *, run_id=None, **kw):
191
+ info = self._open.pop(run_id, None)
192
+ if info:
193
+ self._finish_span(info, output=_trunc(outputs))
194
+ elif run_id == self._root:
195
+ # LangGraph human-in-the-loop: a paused graph surfaces __interrupt__
196
+ # in its final output — that's a pause awaiting Command(resume=...),
197
+ # not a completion.
198
+ interrupted = isinstance(outputs, dict) and "__interrupt__" in outputs
199
+ if interrupted:
200
+ self._finish_span(
201
+ {"span_id": uuid.uuid4().hex[:12], "type": "interrupt",
202
+ "name": "interrupt", "t": time.time(), "parent": None,
203
+ "input": None},
204
+ output=_trunc(outputs.get("__interrupt__")))
205
+ self._emit({"kind": "run_close", "run_id": self.run_id,
206
+ "status": "interrupted" if interrupted else "done",
207
+ "ended_ms": int(time.time() * 1000)})
208
+
209
+ def on_chain_error(self, error, *, run_id=None, **kw):
210
+ info = self._open.pop(run_id, None)
211
+ err = _err_text(error)
212
+ if info:
213
+ self._finish_span(info, status="error", error=err)
214
+ if run_id == self._root:
215
+ self._emit({"kind": "run_close", "run_id": self.run_id, "status": "error",
216
+ "ended_ms": int(time.time() * 1000), "error": err})
217
+
218
+ # -- LLMs --
219
+ def _llm_start(self, serialized, prompt, run_id, parent_run_id, kw):
220
+ sid = uuid.uuid4().hex[:12]
221
+ self._span_of[run_id] = sid
222
+ self._open[run_id] = {"span_id": sid, "type": "llm",
223
+ "name": _model_name(serialized, kw), "t": time.time(),
224
+ "parent": parent_run_id, "input": _trunc(prompt)}
225
+
226
+ def on_llm_start(self, serialized, prompts, *, run_id=None, parent_run_id=None, **kw):
227
+ self._llm_start(serialized, prompts, run_id, parent_run_id, kw)
228
+
229
+ def on_chat_model_start(self, serialized, messages, *, run_id=None, parent_run_id=None, **kw):
230
+ flat = [[getattr(m, "content", str(m)) for m in conv] for conv in messages]
231
+ self._llm_start(serialized, flat, run_id, parent_run_id, kw)
232
+
233
+ def on_llm_end(self, response, *, run_id=None, **kw):
234
+ info = self._open.pop(run_id, None)
235
+ if not info:
236
+ return
237
+ pt, ct = _usage(response)
238
+ self._finish_span(info, output=_gen_text(response)[:4000],
239
+ model=info["name"], pt=pt, ct=ct)
240
+
241
+ def on_llm_error(self, error, *, run_id=None, **kw):
242
+ info = self._open.pop(run_id, None)
243
+ if info:
244
+ self._finish_span(info, status="error", error=_err_text(error), model=info["name"])
245
+
246
+ # -- retrievers (LangChain RAG) --
247
+ def on_retriever_start(self, serialized, query, *, run_id=None, parent_run_id=None, **kw):
248
+ sid = uuid.uuid4().hex[:12]
249
+ self._span_of[run_id] = sid
250
+ self._open[run_id] = {"span_id": sid, "type": "retriever",
251
+ "name": (serialized or {}).get("name") or "retriever",
252
+ "t": time.time(), "parent": parent_run_id,
253
+ "input": _trunc(query)}
254
+
255
+ def on_retriever_end(self, documents, *, run_id=None, **kw):
256
+ info = self._open.pop(run_id, None)
257
+ if not info:
258
+ return
259
+ docs = list(documents or [])
260
+ preview = [{"content": str(getattr(d, "page_content", d))[:300],
261
+ "metadata": _trunc(getattr(d, "metadata", None), 500)}
262
+ for d in docs[:8]]
263
+ self._finish_span(info, output=_trunc({"count": len(docs), "documents": preview}))
264
+
265
+ def on_retriever_error(self, error, *, run_id=None, **kw):
266
+ info = self._open.pop(run_id, None)
267
+ if info:
268
+ self._finish_span(info, status="error", error=_err_text(error))
269
+
270
+ # -- tools --
271
+ def on_tool_start(self, serialized, input_str, *, run_id=None, parent_run_id=None, **kw):
272
+ sid = uuid.uuid4().hex[:12]
273
+ self._span_of[run_id] = sid
274
+ self._open[run_id] = {"span_id": sid, "type": "tool",
275
+ "name": (serialized or {}).get("name", "tool"),
276
+ "t": time.time(), "parent": parent_run_id,
277
+ "input": _trunc(input_str)}
278
+
279
+ def on_tool_end(self, output, *, run_id=None, **kw):
280
+ info = self._open.pop(run_id, None)
281
+ if info:
282
+ self._finish_span(info, output=_trunc(str(output)[:4000]))
283
+
284
+ def on_tool_error(self, error, *, run_id=None, **kw):
285
+ info = self._open.pop(run_id, None)
286
+ if info:
287
+ self._finish_span(info, status="error", error=_err_text(error))
288
+
289
+
290
+ def apply_to_store(store, ev: dict, source: str = "ui") -> None:
291
+ """Route one tracer event into the store. Shared by the local sink and the
292
+ HTTP ingest endpoint so both persist identically. (Events carry `run_id`;
293
+ the runs table keys on `id` — translate here.)"""
294
+ kind = ev.get("kind")
295
+ if kind == "run_open":
296
+ store.open_run({"id": ev["run_id"], "graph": ev.get("graph"), "source": source,
297
+ "session": ev.get("session"), "tags": ev.get("tags"),
298
+ "input": ev.get("input"), "started_ms": ev["started_ms"]})
299
+ elif kind == "span":
300
+ store.add_span(ev)
301
+ elif kind == "run_close":
302
+ store.close_run(ev["run_id"], ev.get("status", "done"),
303
+ ev.get("ended_ms"), ev.get("error"))
304
+
305
+
306
+ def db_sink(store) -> Callable[[dict], None]:
307
+ return lambda ev: apply_to_store(store, ev, source="ui")
308
+
309
+
310
+ def http_sink(base_url: str) -> Callable[[dict], None]:
311
+ import urllib.request
312
+ url = base_url.rstrip("/") + "/api/ingest"
313
+
314
+ def sink(ev: dict) -> None:
315
+ req = urllib.request.Request(url, data=json.dumps(ev).encode(),
316
+ headers={"Content-Type": "application/json"})
317
+ urllib.request.urlopen(req, timeout=3)
318
+ return sink
319
+
320
+
321
+ class WindhoverTracer(SpanBuilder):
322
+ """Drop into any app: config={"callbacks": [WindhoverTracer("http://host:8090")]}."""
323
+ def __init__(self, base_url: str, name: str = "external",
324
+ session: Optional[str] = None, tags: Optional[list] = None):
325
+ super().__init__(http_sink(base_url), run_name=name, session=session, tags=tags)
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: windhover
3
+ Version: 0.7.0
4
+ Summary: Self-hosted, mobile-friendly observability for LangGraph — traces, run history, cost, and a living graph view.
5
+ Project-URL: Repository, https://github.com/justfeltlikerunning/windhover
6
+ Author: justfeltlikerunning
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: agents,langchain,langgraph,llm,observability,tracing
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: fastapi>=0.110
12
+ Requires-Dist: uvicorn>=0.29
13
+ Provides-Extra: demo
14
+ Requires-Dist: langgraph>=0.2; extra == 'demo'
15
+ Provides-Extra: dev
16
+ Requires-Dist: langgraph>=0.2; extra == 'dev'
17
+ Requires-Dist: pytest; extra == 'dev'
18
+ Description-Content-Type: text/markdown
19
+
20
+ <p align="center"><img src="docs/logo.svg" width="112" alt="Windhover — a hovering kestrel"></p>
21
+ <h1 align="center">Windhover</h1>
22
+
23
+ > *Windhover* — the old poetic name for the kestrel, the falcon that hangs motionless
24
+ > in the wind, watching everything below. This tool does the same for your agent graphs.
25
+
26
+ **Self-hosted, mobile-friendly observability for [LangGraph](https://github.com/langchain-ai/langgraph).**
27
+ Trace depth like LangSmith (LLM prompts, tokens, cost, latency — plus retrievers and
28
+ human-in-the-loop interrupts), run history, a timing waterfall, per-node stats, error
29
+ forensics down to the throwing source line — and a **living graph view** that auto-updates
30
+ when your code's topology changes. Point it at any compiled graph, or trace runs in from
31
+ your own app. No LangSmith account, no cloud tunnel, no fragile websocket. HTTP + SSE, MIT.
32
+
33
+ > Nothing about your graph's domain is baked in. Topology, the input form, and run
34
+ > outputs all come from the graph itself. Windhover observes — it never edits your graph.
35
+
36
+ | Living graph (parallel fan-out) | Trace drawer — retrievers, LLM calls, cost, state |
37
+ |---|---|
38
+ | ![Graph view](docs/graph.png) | ![Trace drawer](docs/trace.png) |
39
+
40
+ | Runs — search, tags, sessions, interrupts | Dashboards — per-day, per-model |
41
+ |---|---|
42
+ | ![Runs table](docs/runs.png) | ![Stats](docs/stats.png) |
43
+
44
+ ## Quick start
45
+ ```bash
46
+ pip install fastapi uvicorn langgraph
47
+ WINDHOVER_GRAPH=windhover.demo_graph:graph python -m windhover.server # -> :8090
48
+ ```
49
+ Open `http://<host>:8090`. **New run** (input pre-filled from the graph's schema) →
50
+ watch it execute → **Runs** for history, span trees, and replay → **Stats** for cost/latency.
51
+ Edit the graph file while it runs and the canvas updates itself.
52
+
53
+ Your own graph: `WINDHOVER_GRAPH="myapp.graphs:g" WINDHOVER_GRAPH_DIR=/path python -m windhover.server`
54
+
55
+ ## Trace runs from any app
56
+ ```python
57
+ from windhover import WindhoverTracer
58
+ graph.invoke(input, config={"callbacks": [WindhoverTracer("http://HOST:8090")]})
59
+ ```
60
+ Node spans, LLM calls (model/prompt/response/tokens/cost), and tools show up in **Runs** —
61
+ wherever your app runs. Non-blocking, best-effort; never raises into your graph.
62
+
63
+ Sessions and tags use standard LangChain config — no Windhover imports needed beyond the tracer:
64
+ ```python
65
+ graph.invoke(input, config={
66
+ "callbacks": [WindhoverTracer("http://HOST:8090")],
67
+ "metadata": {"windhover_session": "chat-42", "windhover_tags": ["prod"]},
68
+ "tags": ["also-captured"], # langgraph-internal tags are filtered out
69
+ })
70
+ ```
71
+
72
+ ## Features
73
+ - **Any graph** — topology from `graph.get_graph()`; input form from its state schema.
74
+ - **Full trace tree** — nodes → nested LLM / tool / **retriever** spans: prompts, responses,
75
+ tokens, cost, latency, retrieved documents with their metadata.
76
+ - **Clickable graph** — tap a node for health, latency, wiring, its **source code**, and recent executions with payloads.
77
+ - **Error forensics** — failed runs show the full traceback; the failing node turns red on the
78
+ graph, and the node's source renders with the **throwing line highlighted**.
79
+ - **Human-in-the-loop aware** — a graph paused on `interrupt()` shows an amber **interrupted**
80
+ status plus the payload it's asking a human about.
81
+ - **State evolution** — every trace shows which state keys each node wrote, in order.
82
+ - **X-ray** — graphs with subgraphs get a canvas toggle that expands composite nodes
83
+ (`get_graph(xray=True)`).
84
+ - **Search & filters** — full-text over prompts/payloads/errors (FTS5, LIKE fallback),
85
+ status/tag/session filters, bookmarks, pagination, CSV/JSON export.
86
+ - **Sessions** — group runs into threads/batches; roll-up tokens, cost, errors.
87
+ - **Scores** — attach numeric evals to runs (API or UI): eval harnesses, LLM-as-judge, human review.
88
+ - **Run history + replay** — SQLite; runs persist even if the browser closes (worker thread).
89
+ - **Living graph** — file watcher re-extracts topology in a subprocess and pushes it to the UI.
90
+ - **Dashboards** — runs/tokens per day, per-model usage and latency, per-node latency, error rate.
91
+ - **Mobile-first PWA**, light/dark. Fully local (FastAPI + Cytoscape.js).
92
+
93
+ ## Scores API
94
+ ```bash
95
+ curl -X POST :8090/api/runs/RUN_ID/scores -H 'Content-Type: application/json' \
96
+ -d '{"name": "accuracy", "value": 0.92, "comment": "vs golden set"}'
97
+ ```
98
+
99
+ ## Config (env)
100
+ `WINDHOVER_GRAPH` (module:attr; unset = ingest-only) · `WINDHOVER_GRAPH_DIR` · `WINDHOVER_DB`
101
+ · `WINDHOVER_HOST`/`WINDHOVER_PORT` (0.0.0.0/8090) · `WINDHOVER_WATCH` (1) · `WINDHOVER_PRICING`
102
+ · `WINDHOVER_RETENTION_DAYS` (0 = keep forever; else prune older runs on startup + every 6h).
103
+ Edit `windhover/pricing.json` for your models' $/1M rates (unknown model → cost null).
104
+
105
+ ## Notes
106
+ Runs use the imported graph (restart to run new code); the *view* always reflects
107
+ current-on-disk topology. All frontend assets are vendored — no CDN, works fully offline.
108
+ Deep links: `#runs`, `#sessions`, `#stats`, `#run=<id>`.
109
+
110
+ ## License
111
+ MIT.
@@ -0,0 +1,22 @@
1
+ windhover/__init__.py,sha256=U2jUQF-jNb8VlWsEufEv6msDC1JgIpB0DM8ei5q1cqA,189
2
+ windhover/config.py,sha256=q77ZzFfzVwHA0vzMcM_pMQtGC3Ry4JYzRvTeb8RLYjA,1288
3
+ windhover/demo_graph.py,sha256=QGzuC4-FnvH7ySC0UplyKQVnieYTg-CJmn1GU44_rFU,1784
4
+ windhover/extract.py,sha256=PNSmccsvKWgS1DaGwVAk6G1_kgNMBRNNc4GGv3dBFKw,3572
5
+ windhover/pricing.json,sha256=PFVIaMKOV_m6WwUFIbATiG6ezqK4aFHetWLHqbjYqMU,737
6
+ windhover/server.py,sha256=Ycjm88jbqIBCuZTBJvvnKi4zT9PhBYYF3vqAu8EFRzU,11520
7
+ windhover/store.py,sha256=ftBBIehrcPFmGwBmnOjjBD1Z4ylS9M50TaYwK0laebc,18485
8
+ windhover/tracer.py,sha256=HIN44_lYdjqYSolZmQ7Ga4ViuwQ4xOWzVez1G18HXos,14359
9
+ windhover/static/icon.svg,sha256=bk8o_MuQUDktZr7gkDJcjBXYua4MGWsqLpk4X80h09M,5223
10
+ windhover/static/index.html,sha256=8646IwKv-39OQZRlyL-EkFRjDy2SxJwXA8amTr4geeg,70319
11
+ windhover/static/manifest.json,sha256=7iI_nFz2vDlUXEu0-yM48VxTgheIZ-IyILsBQeRzqx4,478
12
+ windhover/static/vendor/cytoscape-dagre.js,sha256=v3D-QCmR3L_zPgWn5KUnHHgCC7dehdHICrdTjkFXES4,12665
13
+ windhover/static/vendor/cytoscape.min.js,sha256=g-jFSmvsZVv9gd8H32BWScJor2muymel6i2lTqQtrIE,373304
14
+ windhover/static/vendor/dagre.min.js,sha256=YuuXh8z9vfQUjU2Z0x2_nuR3Dq_ugeY311m1KqwizVE,283803
15
+ windhover/static/vendor/fonts/fraunces-500.woff2,sha256=eXXB73yvidBgbdmF01q-MaYZwZJizxP1oFk4zGj7J7I,18000
16
+ windhover/static/vendor/fonts/fraunces-600-italic.woff2,sha256=I3EtthHQeZ34YFBPDmYmreIFiPqyuPSILQ5xGjMNobc,23032
17
+ windhover/static/vendor/fonts/fraunces-600.woff2,sha256=Oh3ncR0Ue61EIoJQRfh1l_13zKcufJbTsKgXNdAN2oI,18096
18
+ windhover-0.7.0.dist-info/METADATA,sha256=1Lazx7ccwNzmghG7y03A1UkbMqqhZNTdX6jLHIPPgEs,5716
19
+ windhover-0.7.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
20
+ windhover-0.7.0.dist-info/entry_points.txt,sha256=fS7ufKlG8-6PsU_-S2NTLVT-gP5RC3j7YlAeJt_PIZM,52
21
+ windhover-0.7.0.dist-info/licenses/LICENSE,sha256=VTp3f8eoENYAaleKt6xdLDOtIvUFCZJxTXt6G4FqsT0,1076
22
+ windhover-0.7.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ windhover = windhover.server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 justfeltlikerunning
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.