stackthread 0.1.0.dev2__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.
Potentially problematic release.
This version of stackthread might be problematic. Click here for more details.
- stackthread/__init__.py +6 -0
- stackthread/__main__.py +3 -0
- stackthread/_vendor/THIRD_PARTY_NOTICES.txt +23 -0
- stackthread/_vendor/extract.mjs +15 -0
- stackthread/adapters.py +130 -0
- stackthread/cli.py +342 -0
- stackthread/config.py +409 -0
- stackthread/detect.py +150 -0
- stackthread/lang/__init__.py +1 -0
- stackthread/lang/js/__init__.py +1 -0
- stackthread/lang/js/aliases.py +133 -0
- stackthread/lang/js/build.py +455 -0
- stackthread/lang/js/worker.py +186 -0
- stackthread/lang/python/__init__.py +1 -0
- stackthread/lang/python/build.py +249 -0
- stackthread/lang/python/extract.py +494 -0
- stackthread/lang/python/frameworks.py +700 -0
- stackthread/lang/python/orm.py +300 -0
- stackthread/lang/python/resolve.py +448 -0
- stackthread/lineage.py +965 -0
- stackthread/link.py +130 -0
- stackthread/logs.py +314 -0
- stackthread/model.py +161 -0
- stackthread/paths.py +173 -0
- stackthread/project.py +414 -0
- stackthread/report.py +203 -0
- stackthread/rules.py +48 -0
- stackthread/server/__init__.py +5 -0
- stackthread/server/app.py +696 -0
- stackthread/server/edit.py +108 -0
- stackthread/server/security.py +73 -0
- stackthread/static/THIRD_PARTY_NOTICES.txt +379 -0
- stackthread/static/app.css +1 -0
- stackthread/static/app.js +29 -0
- stackthread/static/index.html +16 -0
- stackthread-0.1.0.dev2.dist-info/METADATA +51 -0
- stackthread-0.1.0.dev2.dist-info/RECORD +40 -0
- stackthread-0.1.0.dev2.dist-info/WHEEL +4 -0
- stackthread-0.1.0.dev2.dist-info/entry_points.txt +2 -0
- stackthread-0.1.0.dev2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
"""HTTP server on the stdlib: routes, JSON API, server-sent events, static UI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import collections
|
|
6
|
+
import json
|
|
7
|
+
import mimetypes
|
|
8
|
+
import queue
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import webbrowser
|
|
14
|
+
from http import HTTPStatus
|
|
15
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
from urllib.parse import parse_qs, urlsplit
|
|
19
|
+
|
|
20
|
+
from stackthread import SCHEMA_VERSION, __version__, adapters, lineage, logs, report
|
|
21
|
+
from stackthread.project import Poller, Project, Snapshot
|
|
22
|
+
from stackthread.server import edit, security
|
|
23
|
+
|
|
24
|
+
PREFIX = "/__stackthread"
|
|
25
|
+
_log = logs.get("server")
|
|
26
|
+
_http = logs.get("http")
|
|
27
|
+
_edit = logs.get("edit")
|
|
28
|
+
_client = logs.get("client")
|
|
29
|
+
CLIENT_SOURCES = ("ui", "overlay", "vite")
|
|
30
|
+
CLIENT_LEVELS = {"error": 40, "warning": 30, "info": 20}
|
|
31
|
+
CLIENT_PER_MINUTE = 60
|
|
32
|
+
SLOW_LINEAGE_MS = 1000
|
|
33
|
+
STATIC = Path(__file__).resolve().parents[1] / "static"
|
|
34
|
+
MAX_BODY = 4 * 1024 * 1024
|
|
35
|
+
EDITOR_URLS = {
|
|
36
|
+
"vscode": "vscode://file/{path}:{line}:{col}",
|
|
37
|
+
"cursor": "cursor://file/{path}:{line}:{col}",
|
|
38
|
+
"windsurf": "windsurf://file/{path}:{line}:{col}",
|
|
39
|
+
"zed": "zed://file/{path}:{line}:{col}",
|
|
40
|
+
"idea": "idea://open?file={path}&line={line}",
|
|
41
|
+
"pycharm": "pycharm://open?file={path}&line={line}",
|
|
42
|
+
"webstorm": "webstorm://open?file={path}&line={line}",
|
|
43
|
+
"none": "",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Events:
|
|
48
|
+
"""Fan-out of server-sent events to every open UI."""
|
|
49
|
+
|
|
50
|
+
def __init__(self) -> None:
|
|
51
|
+
self._clients: list[queue.Queue[str]] = []
|
|
52
|
+
self._lock = threading.Lock()
|
|
53
|
+
|
|
54
|
+
def publish(self, kind: str, data: dict[str, Any]) -> None:
|
|
55
|
+
message = f"event: {kind}\ndata: {json.dumps(data)}\n\n"
|
|
56
|
+
with self._lock:
|
|
57
|
+
for q in self._clients:
|
|
58
|
+
q.put(message)
|
|
59
|
+
|
|
60
|
+
def connect(self) -> queue.Queue[str]:
|
|
61
|
+
q: queue.Queue[str] = queue.Queue(maxsize=200)
|
|
62
|
+
with self._lock:
|
|
63
|
+
self._clients.append(q)
|
|
64
|
+
return q
|
|
65
|
+
|
|
66
|
+
def disconnect(self, q: queue.Queue[str]) -> None:
|
|
67
|
+
with self._lock:
|
|
68
|
+
if q in self._clients:
|
|
69
|
+
self._clients.remove(q)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ClientLimiter:
|
|
73
|
+
"""At most CLIENT_PER_MINUTE messages a minute from each browser source; the rest are counted, then reported."""
|
|
74
|
+
|
|
75
|
+
def __init__(self) -> None:
|
|
76
|
+
self._sent: dict[str, collections.deque[float]] = collections.defaultdict(collections.deque)
|
|
77
|
+
self._dropped: dict[str, int] = collections.defaultdict(int)
|
|
78
|
+
self._lock = threading.Lock()
|
|
79
|
+
|
|
80
|
+
def allow(self, source: str) -> tuple[bool, int]:
|
|
81
|
+
"""Whether to log this one, and how many were dropped since the last one that was."""
|
|
82
|
+
now = time.monotonic()
|
|
83
|
+
with self._lock:
|
|
84
|
+
sent = self._sent[source]
|
|
85
|
+
while sent and now - sent[0] > 60:
|
|
86
|
+
sent.popleft()
|
|
87
|
+
if len(sent) >= CLIENT_PER_MINUTE:
|
|
88
|
+
self._dropped[source] += 1
|
|
89
|
+
return False, 0
|
|
90
|
+
sent.append(now)
|
|
91
|
+
dropped, self._dropped[source] = self._dropped[source], 0
|
|
92
|
+
return True, dropped
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class App:
|
|
96
|
+
def __init__(self, project: Project, port: int, token: str) -> None:
|
|
97
|
+
self.project = project
|
|
98
|
+
self.port = port
|
|
99
|
+
self.token = token
|
|
100
|
+
self.events = Events()
|
|
101
|
+
self.dev_urls: list[str] = []
|
|
102
|
+
self.limiter = ClientLimiter()
|
|
103
|
+
self._last_progress = 0.0
|
|
104
|
+
logs.hide(token)
|
|
105
|
+
project.subscribe(self._on_snapshot)
|
|
106
|
+
project.on_activity(self._on_activity)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def snapshot(self) -> Snapshot:
|
|
110
|
+
if self.project.snapshot is None:
|
|
111
|
+
raise edit.EditError(503, "stackthread is still reading the code; try again in a moment")
|
|
112
|
+
return self.project.snapshot
|
|
113
|
+
|
|
114
|
+
def _on_activity(self, activity: dict[str, Any]) -> None:
|
|
115
|
+
# progress while indexing is throttled to ten a second; every change of state goes out at once
|
|
116
|
+
now = time.monotonic()
|
|
117
|
+
in_progress = activity.get("state") == "indexing" and 0 < activity.get("done", 0) < activity.get("total", 0)
|
|
118
|
+
if in_progress and now - self._last_progress < 0.1:
|
|
119
|
+
return
|
|
120
|
+
self._last_progress = now
|
|
121
|
+
self.events.publish("activity", activity)
|
|
122
|
+
|
|
123
|
+
def _on_snapshot(self, snap: Snapshot) -> None:
|
|
124
|
+
self.events.publish(
|
|
125
|
+
"rev",
|
|
126
|
+
{
|
|
127
|
+
"rev": snap.rev,
|
|
128
|
+
"changed": snap.changed[:50],
|
|
129
|
+
"ms": round(snap.built_ms),
|
|
130
|
+
"findings": len(snap.graph.findings),
|
|
131
|
+
},
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# ---- API ----
|
|
135
|
+
|
|
136
|
+
def meta(self) -> dict[str, Any]:
|
|
137
|
+
cfg, snap = self.project.config, self.project.snapshot
|
|
138
|
+
if snap is None:
|
|
139
|
+
return {
|
|
140
|
+
"name": cfg.name,
|
|
141
|
+
"version": __version__,
|
|
142
|
+
"schema": SCHEMA_VERSION,
|
|
143
|
+
"ready": False,
|
|
144
|
+
"activity": self.project.activity,
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
"name": cfg.name,
|
|
148
|
+
"version": __version__,
|
|
149
|
+
"schema": SCHEMA_VERSION,
|
|
150
|
+
"ready": True,
|
|
151
|
+
"activity": self.project.activity,
|
|
152
|
+
"rev": snap.rev,
|
|
153
|
+
"root": str(cfg.root),
|
|
154
|
+
"platform": sys.platform,
|
|
155
|
+
"editor": {"name": cfg.server.editor, "url": EDITOR_URLS.get(cfg.server.editor, "")},
|
|
156
|
+
"edit": cfg.server.edit,
|
|
157
|
+
"detected": cfg.detected,
|
|
158
|
+
"config": str(cfg.path) if cfg.path else None,
|
|
159
|
+
"sources": [
|
|
160
|
+
{
|
|
161
|
+
"name": s.name,
|
|
162
|
+
"lang": s.lang,
|
|
163
|
+
"role": s.role,
|
|
164
|
+
"root": s.root,
|
|
165
|
+
"files": len(snap.facts.get(s.name, {})),
|
|
166
|
+
}
|
|
167
|
+
for s in cfg.sources
|
|
168
|
+
],
|
|
169
|
+
"warnings": snap.warnings,
|
|
170
|
+
"devUrls": self.dev_urls,
|
|
171
|
+
"counts": {
|
|
172
|
+
k: sum(1 for n in snap.graph.nodes.values() if n.kind == k)
|
|
173
|
+
for k in ("page", "component", "function", "hook", "endpoint", "model", "class", "method")
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
def node(self, node_id: str) -> dict[str, Any]:
|
|
178
|
+
g = self.snapshot.graph
|
|
179
|
+
node = g.nodes.get(node_id)
|
|
180
|
+
if node is None:
|
|
181
|
+
raise edit.EditError(404, "no such node")
|
|
182
|
+
|
|
183
|
+
def side(e: Any, other: str) -> dict[str, Any]:
|
|
184
|
+
n = g.nodes[other]
|
|
185
|
+
data = {
|
|
186
|
+
"id": n.id,
|
|
187
|
+
"kind": n.kind,
|
|
188
|
+
"name": n.name,
|
|
189
|
+
"qualname": n.qualname,
|
|
190
|
+
"file": n.file,
|
|
191
|
+
"line": n.line,
|
|
192
|
+
"layer": n.layer,
|
|
193
|
+
"edge": e.kind,
|
|
194
|
+
"confidence": e.confidence,
|
|
195
|
+
"label": e.label,
|
|
196
|
+
}
|
|
197
|
+
if e.site:
|
|
198
|
+
data["site"] = f"{e.site.file}:{e.site.line}"
|
|
199
|
+
return data
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
"node": node.to_json(),
|
|
203
|
+
"out": [side(e, e.dst) for e in g.out(node_id) if e.kind != "contains"],
|
|
204
|
+
"in": [side(e, e.src) for e in g.into(node_id) if e.kind != "contains"],
|
|
205
|
+
"children": [side(e, e.dst) for e in g.out(node_id, "contains")],
|
|
206
|
+
"parent": next((side(e, e.src) for e in g.into(node_id, "contains")), None),
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
def search(self, q: str, limit: int) -> list[dict[str, Any]]:
|
|
210
|
+
q = q.strip().lower()
|
|
211
|
+
if not q:
|
|
212
|
+
return []
|
|
213
|
+
scored = []
|
|
214
|
+
for n in self.snapshot.graph.nodes.values():
|
|
215
|
+
name, qual, file = n.name.lower(), (n.qualname or "").lower(), n.file.lower()
|
|
216
|
+
if name == q:
|
|
217
|
+
score = 0
|
|
218
|
+
elif name.startswith(q):
|
|
219
|
+
score = 1
|
|
220
|
+
elif q in name:
|
|
221
|
+
score = 2
|
|
222
|
+
elif q in qual:
|
|
223
|
+
score = 3
|
|
224
|
+
elif q in file:
|
|
225
|
+
score = 4
|
|
226
|
+
else:
|
|
227
|
+
continue
|
|
228
|
+
if "test" in n.tags:
|
|
229
|
+
score += 3
|
|
230
|
+
scored.append((score, len(n.name), n))
|
|
231
|
+
scored.sort(key=lambda x: (x[0], x[1]))
|
|
232
|
+
return [
|
|
233
|
+
{
|
|
234
|
+
"id": n.id,
|
|
235
|
+
"kind": n.kind,
|
|
236
|
+
"name": n.name,
|
|
237
|
+
"qualname": n.qualname,
|
|
238
|
+
"file": n.file,
|
|
239
|
+
"line": n.line,
|
|
240
|
+
"layer": n.layer,
|
|
241
|
+
"source": n.source,
|
|
242
|
+
}
|
|
243
|
+
for _, _, n in scored[:limit]
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
def pages(self) -> dict[str, Any]:
|
|
247
|
+
g = self.snapshot.graph
|
|
248
|
+
pages = []
|
|
249
|
+
for p in sorted(g.of_kind("page"), key=lambda n: n.attrs.get("path", "")):
|
|
250
|
+
comp = g.nodes.get(p.attrs.get("component", ""))
|
|
251
|
+
pages.append(
|
|
252
|
+
{
|
|
253
|
+
"id": p.id,
|
|
254
|
+
"path": p.attrs.get("path"),
|
|
255
|
+
"source": p.source,
|
|
256
|
+
"file": p.file,
|
|
257
|
+
"line": p.line,
|
|
258
|
+
"component": comp.name if comp else None,
|
|
259
|
+
"componentId": comp.id if comp else None,
|
|
260
|
+
}
|
|
261
|
+
)
|
|
262
|
+
views = [v.to_json() for v in adapters.views(self.project)]
|
|
263
|
+
return {"pages": pages, "views": views, "params": self.project.config.page_params}
|
|
264
|
+
|
|
265
|
+
def findings(self) -> list[dict[str, Any]]:
|
|
266
|
+
order = {"error": 0, "warning": 1, "info": 2}
|
|
267
|
+
return [f.to_json() for f in sorted(self.snapshot.graph.findings, key=lambda f: (order[f.severity], f.file))]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _json(handler: BaseHTTPRequestHandler, data: Any, status: int = 200) -> None:
|
|
271
|
+
body = json.dumps(data, separators=(",", ":")).encode("utf-8")
|
|
272
|
+
handler.send_response(status)
|
|
273
|
+
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
|
274
|
+
handler.send_header("Content-Length", str(len(body)))
|
|
275
|
+
handler.send_header("Cache-Control", "no-store")
|
|
276
|
+
handler.end_headers()
|
|
277
|
+
handler.wfile.write(body)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def make_handler(app: App) -> type[BaseHTTPRequestHandler]:
|
|
281
|
+
class Handler(BaseHTTPRequestHandler):
|
|
282
|
+
server_version = f"stackthread/{__version__}"
|
|
283
|
+
protocol_version = "HTTP/1.1"
|
|
284
|
+
|
|
285
|
+
def parse_request(self) -> bool:
|
|
286
|
+
self._started = time.perf_counter()
|
|
287
|
+
return super().parse_request()
|
|
288
|
+
|
|
289
|
+
def log_request(self, code: int | str = "-", size: int | str = "-") -> None:
|
|
290
|
+
status = int(code) if str(code).isdigit() else 0
|
|
291
|
+
path = re.sub(r"([?&]token=)[^&]*", r"\1[hidden]", getattr(self, "path", ""))
|
|
292
|
+
ms = (time.perf_counter() - getattr(self, "_started", time.perf_counter())) * 1000
|
|
293
|
+
level = 40 if status >= 500 else 10
|
|
294
|
+
_http.log(
|
|
295
|
+
level,
|
|
296
|
+
"%s %s %s %.0f ms",
|
|
297
|
+
self.command,
|
|
298
|
+
path,
|
|
299
|
+
status,
|
|
300
|
+
ms,
|
|
301
|
+
extra=logs.ctx(method=self.command, path=path, status=status, ms=round(ms, 1)),
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
def log_error(self, format: str, *args: Any) -> None:
|
|
305
|
+
_http.debug(format, *args)
|
|
306
|
+
|
|
307
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
308
|
+
_http.debug(format, *args)
|
|
309
|
+
|
|
310
|
+
def _security_headers(self) -> None:
|
|
311
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
312
|
+
self.send_header("Referrer-Policy", "no-referrer")
|
|
313
|
+
|
|
314
|
+
def end_headers(self) -> None:
|
|
315
|
+
self._security_headers()
|
|
316
|
+
super().end_headers()
|
|
317
|
+
|
|
318
|
+
def _fail(self, status: int, message: str) -> None:
|
|
319
|
+
if self.command in ("POST", "PUT"):
|
|
320
|
+
self.close_connection = True # an unread body must not be parsed as the next request
|
|
321
|
+
_json(self, {"error": message}, status)
|
|
322
|
+
|
|
323
|
+
def _refuse(self, status: int, reason: str, **fields: Any) -> bool:
|
|
324
|
+
_http.warning(
|
|
325
|
+
"refused %s %s: %s",
|
|
326
|
+
self.command,
|
|
327
|
+
urlsplit(self.path).path,
|
|
328
|
+
reason,
|
|
329
|
+
extra=logs.ctx(status=status, **fields),
|
|
330
|
+
)
|
|
331
|
+
self._fail(status, reason)
|
|
332
|
+
return False
|
|
333
|
+
|
|
334
|
+
def _guard(self, needs_token: bool, query: dict[str, list[str]]) -> bool:
|
|
335
|
+
if not security.host_allowed(self.headers.get("Host"), app.port):
|
|
336
|
+
return self._refuse(HTTPStatus.MISDIRECTED_REQUEST, "bad Host header", host=self.headers.get("Host"))
|
|
337
|
+
if not security.origin_allowed(self.headers.get("Origin")):
|
|
338
|
+
return self._refuse(
|
|
339
|
+
HTTPStatus.FORBIDDEN, "cross-origin requests are not allowed", origin=self.headers.get("Origin")
|
|
340
|
+
)
|
|
341
|
+
if needs_token:
|
|
342
|
+
given = self.headers.get(security.TOKEN_HEADER) or query.get("token", [""])[0]
|
|
343
|
+
if not security.token_ok(app.token, given):
|
|
344
|
+
return self._refuse(HTTPStatus.UNAUTHORIZED, "missing or wrong session token")
|
|
345
|
+
return True
|
|
346
|
+
|
|
347
|
+
def _body(self) -> Any:
|
|
348
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
349
|
+
if length > MAX_BODY:
|
|
350
|
+
raise edit.EditError(413, "request body too large")
|
|
351
|
+
if not (self.headers.get("Content-Type") or "").startswith("application/json"):
|
|
352
|
+
raise edit.EditError(415, "send application/json")
|
|
353
|
+
return json.loads(self.rfile.read(length) or b"{}")
|
|
354
|
+
|
|
355
|
+
# ---- routing ----
|
|
356
|
+
|
|
357
|
+
def do_GET(self) -> None:
|
|
358
|
+
url = urlsplit(self.path)
|
|
359
|
+
query = parse_qs(url.query)
|
|
360
|
+
path = url.path
|
|
361
|
+
if path in ("/", ""):
|
|
362
|
+
if not self._guard(False, query):
|
|
363
|
+
return
|
|
364
|
+
self.send_response(302)
|
|
365
|
+
self.send_header("Location", f"{PREFIX}/")
|
|
366
|
+
self.send_header("Content-Length", "0")
|
|
367
|
+
self.end_headers()
|
|
368
|
+
return
|
|
369
|
+
if not path.startswith(PREFIX):
|
|
370
|
+
self._fail(404, "not found")
|
|
371
|
+
return
|
|
372
|
+
sub = path[len(PREFIX) :] or "/"
|
|
373
|
+
if sub.startswith("/api/"):
|
|
374
|
+
if not self._guard(True, query):
|
|
375
|
+
return
|
|
376
|
+
try:
|
|
377
|
+
self._api_get(sub[5:], query)
|
|
378
|
+
except edit.EditError as e:
|
|
379
|
+
if e.status not in (404, 503):
|
|
380
|
+
_http.warning("GET %s: %s", sub, e.message, extra=logs.ctx(status=e.status))
|
|
381
|
+
self._fail(e.status, e.message)
|
|
382
|
+
except Exception as e: # report instead of dropping the connection
|
|
383
|
+
_http.exception("GET %s failed", sub)
|
|
384
|
+
self._fail(500, f"{type(e).__name__}: {e}")
|
|
385
|
+
return
|
|
386
|
+
if not self._guard(False, query):
|
|
387
|
+
return
|
|
388
|
+
self._static(sub)
|
|
389
|
+
|
|
390
|
+
def do_POST(self) -> None:
|
|
391
|
+
self._mutate("POST")
|
|
392
|
+
|
|
393
|
+
def do_PUT(self) -> None:
|
|
394
|
+
self._mutate("PUT")
|
|
395
|
+
|
|
396
|
+
def _mutate(self, method: str) -> None:
|
|
397
|
+
url = urlsplit(self.path)
|
|
398
|
+
query = parse_qs(url.query)
|
|
399
|
+
sub = url.path[len(PREFIX) :] if url.path.startswith(PREFIX) else ""
|
|
400
|
+
if not sub.startswith("/api/"):
|
|
401
|
+
self._fail(404, "not found")
|
|
402
|
+
return
|
|
403
|
+
if not self._guard(True, query):
|
|
404
|
+
return
|
|
405
|
+
try:
|
|
406
|
+
body = self._body()
|
|
407
|
+
name = sub[5:]
|
|
408
|
+
if method == "POST" and name == "lineage":
|
|
409
|
+
started = time.perf_counter()
|
|
410
|
+
result = lineage.for_selection(app.project, app.snapshot, body)
|
|
411
|
+
self._log_lineage(result, started)
|
|
412
|
+
_json(self, result)
|
|
413
|
+
elif method == "PUT" and name == "source":
|
|
414
|
+
file = str(body.get("file", ""))
|
|
415
|
+
try:
|
|
416
|
+
written = edit.write(
|
|
417
|
+
app.project.config, file, str(body.get("hash", "")), str(body.get("text", ""))
|
|
418
|
+
)
|
|
419
|
+
except edit.EditError as e:
|
|
420
|
+
_edit.warning(
|
|
421
|
+
"did not save %s: %s", file, e.message, extra=logs.ctx(file=file, status=e.status)
|
|
422
|
+
)
|
|
423
|
+
raise
|
|
424
|
+
_edit.info(
|
|
425
|
+
"saved %s%s",
|
|
426
|
+
file,
|
|
427
|
+
" and formatted it" if written.formatted else "",
|
|
428
|
+
extra=logs.ctx(file=file, formatted=written.formatted),
|
|
429
|
+
)
|
|
430
|
+
if written.output and not written.formatted:
|
|
431
|
+
_edit.warning("the formatter reported a problem with %s: %s", file, written.output[-500:])
|
|
432
|
+
app.project.invalidate([file])
|
|
433
|
+
threading.Thread(target=app.project.poll, name="stackthread-after-save", daemon=True).start()
|
|
434
|
+
_json(self, {"hash": written.hash, "formatted": written.formatted, "output": written.output})
|
|
435
|
+
elif method == "POST" and name == "hello":
|
|
436
|
+
dev = str(body.get("devUrl", ""))
|
|
437
|
+
if (
|
|
438
|
+
re.match(r"^https?://(localhost|127\.0\.0\.1|\[::1\])(:\d+)?/?$", dev)
|
|
439
|
+
and dev.rstrip("/") not in app.dev_urls
|
|
440
|
+
):
|
|
441
|
+
app.dev_urls.append(dev.rstrip("/"))
|
|
442
|
+
_log.info("the Vite dev server at %s connected", dev, extra=logs.ctx(dev_url=dev))
|
|
443
|
+
_json(self, {"ok": True, "name": app.project.config.name})
|
|
444
|
+
elif method == "POST" and name == "log":
|
|
445
|
+
self._client_log(body)
|
|
446
|
+
elif method == "POST" and name == "report":
|
|
447
|
+
self._report(body)
|
|
448
|
+
else:
|
|
449
|
+
self._fail(404, "not found")
|
|
450
|
+
except edit.EditError as e:
|
|
451
|
+
self._fail(e.status, e.message)
|
|
452
|
+
except json.JSONDecodeError:
|
|
453
|
+
self._fail(400, "body is not valid JSON")
|
|
454
|
+
except Exception as e:
|
|
455
|
+
_http.exception("%s %s failed", method, sub)
|
|
456
|
+
self._fail(500, f"{type(e).__name__}: {e}")
|
|
457
|
+
|
|
458
|
+
def _log_lineage(self, result: dict[str, Any], started: float) -> None:
|
|
459
|
+
ms = (time.perf_counter() - started) * 1000
|
|
460
|
+
target = result.get("target", {})
|
|
461
|
+
places = sum(len(layer.get("rows", [])) for layer in result.get("layers", []))
|
|
462
|
+
loc = target.get("loc") or target.get("file", "")
|
|
463
|
+
_log.log(
|
|
464
|
+
30 if ms > SLOW_LINEAGE_MS else 10,
|
|
465
|
+
"%s %s in %.0f ms (%d places)",
|
|
466
|
+
"slow explanation of" if ms > SLOW_LINEAGE_MS else "explained",
|
|
467
|
+
loc,
|
|
468
|
+
ms,
|
|
469
|
+
places,
|
|
470
|
+
extra=logs.ctx(loc=loc, ms=round(ms, 1), places=places, known=target.get("known")),
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
def _client_log(self, body: Any) -> None:
|
|
474
|
+
"""Errors the browser reports: the UI, the in-app overlay, the Vite plugin. Size-capped and rate-limited."""
|
|
475
|
+
if not isinstance(body, dict):
|
|
476
|
+
raise edit.EditError(400, "send a JSON object")
|
|
477
|
+
source = str(body.get("source", ""))
|
|
478
|
+
level = str(body.get("level", "error"))
|
|
479
|
+
message = str(body.get("message", "")).strip()
|
|
480
|
+
if source not in CLIENT_SOURCES or level not in CLIENT_LEVELS or not message:
|
|
481
|
+
raise edit.EditError(400, f"need source ({', '.join(CLIENT_SOURCES)}), level and message")
|
|
482
|
+
allowed, dropped = app.limiter.allow(source)
|
|
483
|
+
if dropped:
|
|
484
|
+
_client.warning("dropped %d messages from the %s because it sent too many", dropped, source)
|
|
485
|
+
if allowed:
|
|
486
|
+
fields: dict[str, Any] = {"source": source}
|
|
487
|
+
for key, limit in (("url", 500), ("stack", 8000), ("where", 300)):
|
|
488
|
+
if body.get(key):
|
|
489
|
+
fields[key] = str(body[key])[:limit]
|
|
490
|
+
detail = body.get("detail")
|
|
491
|
+
if isinstance(detail, dict):
|
|
492
|
+
text = json.dumps(detail, default=str)
|
|
493
|
+
fields["detail"] = detail if len(text) <= 2000 else text[:2000]
|
|
494
|
+
_client.log(CLIENT_LEVELS[level], "[%s] %s", source, message[:2000], extra=logs.ctx(**fields))
|
|
495
|
+
_json(self, {"ok": True, "logged": allowed})
|
|
496
|
+
|
|
497
|
+
def _report(self, body: Any) -> None:
|
|
498
|
+
note = str(body.get("note", "")) if isinstance(body, dict) else ""
|
|
499
|
+
data, names = report.build(app.project.config, app.project.snapshot, note, app.project.activity)
|
|
500
|
+
path = report.save(app.project.config, data)
|
|
501
|
+
_log.info("made a problem report: %s", path.name, extra=logs.ctx(path=str(path), size=len(data)))
|
|
502
|
+
self.send_response(200)
|
|
503
|
+
self.send_header("Content-Type", "application/zip")
|
|
504
|
+
self.send_header("Content-Length", str(len(data)))
|
|
505
|
+
self.send_header("Content-Disposition", f'attachment; filename="{path.name}"')
|
|
506
|
+
self.send_header("X-Report-Path", str(path.relative_to(app.project.config.root)).replace("\\", "/"))
|
|
507
|
+
self.send_header("X-Report-Files", ",".join(names))
|
|
508
|
+
self.send_header("Cache-Control", "no-store")
|
|
509
|
+
self.end_headers()
|
|
510
|
+
self.wfile.write(data)
|
|
511
|
+
|
|
512
|
+
def _api_get(self, name: str, query: dict[str, list[str]]) -> None:
|
|
513
|
+
q = {k: v[0] for k, v in query.items()}
|
|
514
|
+
if name == "meta":
|
|
515
|
+
_json(self, app.meta())
|
|
516
|
+
elif name == "graph":
|
|
517
|
+
etag = f'"{app.snapshot.rev}"'
|
|
518
|
+
if self.headers.get("If-None-Match") == etag:
|
|
519
|
+
self.send_response(304)
|
|
520
|
+
self.send_header("ETag", etag)
|
|
521
|
+
self.send_header("Content-Length", "0")
|
|
522
|
+
self.end_headers()
|
|
523
|
+
return
|
|
524
|
+
body = json.dumps(
|
|
525
|
+
{"rev": app.snapshot.rev, **app.snapshot.graph.to_json()}, separators=(",", ":")
|
|
526
|
+
).encode()
|
|
527
|
+
self.send_response(200)
|
|
528
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
529
|
+
self.send_header("Content-Length", str(len(body)))
|
|
530
|
+
self.send_header("ETag", etag)
|
|
531
|
+
self.send_header("Cache-Control", "no-cache")
|
|
532
|
+
self.end_headers()
|
|
533
|
+
self.wfile.write(body)
|
|
534
|
+
elif name == "node":
|
|
535
|
+
_json(self, app.node(q.get("id", "")))
|
|
536
|
+
elif name == "source":
|
|
537
|
+
text, digest = edit.read(app.project.config, q.get("file", ""))
|
|
538
|
+
_json(self, {"file": q.get("file"), "text": text, "hash": digest})
|
|
539
|
+
elif name == "explain":
|
|
540
|
+
started = time.perf_counter()
|
|
541
|
+
result = lineage.for_location(app.project, app.snapshot, q.get("loc", ""))
|
|
542
|
+
self._log_lineage(result, started)
|
|
543
|
+
_json(self, result)
|
|
544
|
+
elif name == "logs":
|
|
545
|
+
limit = min(int(q.get("n", "100") or 100), 1000)
|
|
546
|
+
_json(
|
|
547
|
+
self,
|
|
548
|
+
logs.recent(app.project.config.state_dir, q.get("level", "info"), limit, q.get("area") or None),
|
|
549
|
+
)
|
|
550
|
+
elif name == "pages":
|
|
551
|
+
_json(self, app.pages())
|
|
552
|
+
elif name == "search":
|
|
553
|
+
_json(self, app.search(q.get("q", ""), min(int(q.get("limit", "30") or 30), 200)))
|
|
554
|
+
elif name == "findings":
|
|
555
|
+
_json(self, app.findings())
|
|
556
|
+
elif name == "events":
|
|
557
|
+
self._events()
|
|
558
|
+
else:
|
|
559
|
+
self._fail(404, "not found")
|
|
560
|
+
|
|
561
|
+
def _events(self) -> None:
|
|
562
|
+
self.send_response(200)
|
|
563
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
564
|
+
self.send_header("Cache-Control", "no-store")
|
|
565
|
+
self.send_header("Connection", "keep-alive")
|
|
566
|
+
self.end_headers()
|
|
567
|
+
q = app.events.connect()
|
|
568
|
+
_log.debug("a browser window connected for live updates")
|
|
569
|
+
try:
|
|
570
|
+
snap = app.project.snapshot
|
|
571
|
+
hello = {"rev": snap.rev if snap else 0, "activity": app.project.activity}
|
|
572
|
+
self.wfile.write(f"event: hello\ndata: {json.dumps(hello, default=str)}\n\n".encode())
|
|
573
|
+
self.wfile.flush()
|
|
574
|
+
while True:
|
|
575
|
+
try:
|
|
576
|
+
message = q.get(timeout=15)
|
|
577
|
+
except queue.Empty:
|
|
578
|
+
message = ": ping\n\n"
|
|
579
|
+
self.wfile.write(message.encode())
|
|
580
|
+
self.wfile.flush()
|
|
581
|
+
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError):
|
|
582
|
+
pass
|
|
583
|
+
finally:
|
|
584
|
+
app.events.disconnect(q)
|
|
585
|
+
_log.debug("a browser window disconnected")
|
|
586
|
+
self.close_connection = True
|
|
587
|
+
|
|
588
|
+
def _static(self, sub: str) -> None:
|
|
589
|
+
name = sub.strip("/") or "index.html"
|
|
590
|
+
if "/" in name or "." not in name: # client-side routes all serve the app shell
|
|
591
|
+
name = "index.html"
|
|
592
|
+
path = (STATIC / name).resolve()
|
|
593
|
+
if not path.is_relative_to(STATIC.resolve()):
|
|
594
|
+
self._fail(404, "not found")
|
|
595
|
+
return
|
|
596
|
+
if not path.is_file() and name != "index.html":
|
|
597
|
+
self._fail(404, "not found")
|
|
598
|
+
return
|
|
599
|
+
if not path.is_file():
|
|
600
|
+
body = _missing_ui().encode()
|
|
601
|
+
ctype = "text/html; charset=utf-8"
|
|
602
|
+
else:
|
|
603
|
+
body = path.read_bytes()
|
|
604
|
+
ctype = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
|
605
|
+
if path.name == "index.html":
|
|
606
|
+
boot = json.dumps({"token": app.token, "base": PREFIX, "version": __version__})
|
|
607
|
+
body = body.replace(
|
|
608
|
+
b"<!--stackthread-boot-->",
|
|
609
|
+
f'<script id="stackthread-boot" type="application/json">{boot}</script>'.encode(),
|
|
610
|
+
)
|
|
611
|
+
ctype = "text/html; charset=utf-8"
|
|
612
|
+
self.send_response(200)
|
|
613
|
+
self.send_header("Content-Type", ctype)
|
|
614
|
+
self.send_header("Content-Length", str(len(body)))
|
|
615
|
+
self.send_header("Cache-Control", "no-cache" if path.name == "index.html" else "max-age=60")
|
|
616
|
+
if path.name == "index.html" or not path.is_file():
|
|
617
|
+
self.send_header(
|
|
618
|
+
"Content-Security-Policy",
|
|
619
|
+
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
|
|
620
|
+
"img-src 'self' data:; connect-src 'self'; frame-src 'self' http://localhost:* "
|
|
621
|
+
"http://127.0.0.1:*; frame-ancestors 'self' http://localhost:* http://127.0.0.1:*",
|
|
622
|
+
)
|
|
623
|
+
self.end_headers()
|
|
624
|
+
self.wfile.write(body)
|
|
625
|
+
|
|
626
|
+
return Handler
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _missing_ui() -> str:
|
|
630
|
+
return (
|
|
631
|
+
"<!doctype html><meta charset=utf-8><title>stackthread</title><body style='font:15px system-ui;padding:2rem'>"
|
|
632
|
+
"<h1>stackthread is running</h1><p>The browser UI is not built in this checkout. Run <code>npm run build</code>"
|
|
633
|
+
" in <code>js/</code>, or install stackthread from PyPI. The API is available under "
|
|
634
|
+
"<code>/__stackthread/api/</code>.</p>"
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
class Server(ThreadingHTTPServer):
|
|
639
|
+
daemon_threads = True
|
|
640
|
+
allow_reuse_address = False
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def bind(port: int, attempts: int = 10) -> tuple[Server, int]:
|
|
644
|
+
"""Bind to 127.0.0.1 on the first free port from `port`. Never binds other interfaces."""
|
|
645
|
+
last: OSError | None = None
|
|
646
|
+
for candidate in range(port, port + attempts):
|
|
647
|
+
try:
|
|
648
|
+
return Server(("127.0.0.1", candidate), BaseHTTPRequestHandler), candidate
|
|
649
|
+
except OSError as e:
|
|
650
|
+
last = e
|
|
651
|
+
raise OSError(f"no free port from {port} to {port + attempts - 1}: {last}")
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _first_index(project: Project, poller: Poller | None) -> None:
|
|
655
|
+
"""The first full read of the code, while the server already answers. Then start watching for changes."""
|
|
656
|
+
try:
|
|
657
|
+
project.scan()
|
|
658
|
+
except Exception as e:
|
|
659
|
+
_log.exception("the first indexing run failed")
|
|
660
|
+
project._set_activity("failed", error=f"{type(e).__name__}: {e}")
|
|
661
|
+
if poller is not None:
|
|
662
|
+
poller.start()
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def serve(project: Project, port: int | None = None, open_browser: bool = True, watch: bool = True) -> None:
|
|
666
|
+
config = project.config
|
|
667
|
+
server, actual = bind(port or config.server.port)
|
|
668
|
+
token = security.new_token()
|
|
669
|
+
app = App(project, actual, token)
|
|
670
|
+
server.RequestHandlerClass = make_handler(app)
|
|
671
|
+
session = security.write_session(config.state_dir, actual, token, config.root)
|
|
672
|
+
url = f"http://127.0.0.1:{actual}{PREFIX}/"
|
|
673
|
+
poller = Poller(project) if watch else None
|
|
674
|
+
_log.info(
|
|
675
|
+
"stackthread %s serving %s at %s",
|
|
676
|
+
__version__,
|
|
677
|
+
config.name,
|
|
678
|
+
url,
|
|
679
|
+
extra=logs.ctx(port=actual, edit=config.server.edit, watch=watch, session=str(session)),
|
|
680
|
+
)
|
|
681
|
+
_log.info("with the Vite plugin, open http://localhost:<vite port>/__stackthread/ for the live inspector")
|
|
682
|
+
_log.info("logs are kept in %s", logs.log_dir(config.state_dir))
|
|
683
|
+
threading.Thread(target=_first_index, args=(project, poller), name="stackthread-index", daemon=True).start()
|
|
684
|
+
if open_browser and config.server.open_browser:
|
|
685
|
+
threading.Timer(0.4, lambda: webbrowser.open(url)).start()
|
|
686
|
+
try:
|
|
687
|
+
server.serve_forever(poll_interval=0.5)
|
|
688
|
+
except KeyboardInterrupt:
|
|
689
|
+
_log.info("stopping")
|
|
690
|
+
finally:
|
|
691
|
+
if poller is not None:
|
|
692
|
+
poller.stop()
|
|
693
|
+
server.server_close()
|
|
694
|
+
security.clear_session(config.state_dir, token)
|
|
695
|
+
project.close()
|
|
696
|
+
_log.info("stopped")
|