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.

Files changed (40) hide show
  1. stackthread/__init__.py +6 -0
  2. stackthread/__main__.py +3 -0
  3. stackthread/_vendor/THIRD_PARTY_NOTICES.txt +23 -0
  4. stackthread/_vendor/extract.mjs +15 -0
  5. stackthread/adapters.py +130 -0
  6. stackthread/cli.py +342 -0
  7. stackthread/config.py +409 -0
  8. stackthread/detect.py +150 -0
  9. stackthread/lang/__init__.py +1 -0
  10. stackthread/lang/js/__init__.py +1 -0
  11. stackthread/lang/js/aliases.py +133 -0
  12. stackthread/lang/js/build.py +455 -0
  13. stackthread/lang/js/worker.py +186 -0
  14. stackthread/lang/python/__init__.py +1 -0
  15. stackthread/lang/python/build.py +249 -0
  16. stackthread/lang/python/extract.py +494 -0
  17. stackthread/lang/python/frameworks.py +700 -0
  18. stackthread/lang/python/orm.py +300 -0
  19. stackthread/lang/python/resolve.py +448 -0
  20. stackthread/lineage.py +965 -0
  21. stackthread/link.py +130 -0
  22. stackthread/logs.py +314 -0
  23. stackthread/model.py +161 -0
  24. stackthread/paths.py +173 -0
  25. stackthread/project.py +414 -0
  26. stackthread/report.py +203 -0
  27. stackthread/rules.py +48 -0
  28. stackthread/server/__init__.py +5 -0
  29. stackthread/server/app.py +696 -0
  30. stackthread/server/edit.py +108 -0
  31. stackthread/server/security.py +73 -0
  32. stackthread/static/THIRD_PARTY_NOTICES.txt +379 -0
  33. stackthread/static/app.css +1 -0
  34. stackthread/static/app.js +29 -0
  35. stackthread/static/index.html +16 -0
  36. stackthread-0.1.0.dev2.dist-info/METADATA +51 -0
  37. stackthread-0.1.0.dev2.dist-info/RECORD +40 -0
  38. stackthread-0.1.0.dev2.dist-info/WHEEL +4 -0
  39. stackthread-0.1.0.dev2.dist-info/entry_points.txt +2 -0
  40. stackthread-0.1.0.dev2.dist-info/licenses/LICENSE +21 -0
stackthread/paths.py ADDED
@@ -0,0 +1,173 @@
1
+ """Glob matching, .gitignore rules and file discovery. Paths in the model are POSIX and relative to the project root."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from collections.abc import Iterator
8
+ from dataclasses import dataclass
9
+ from functools import lru_cache
10
+ from pathlib import Path
11
+
12
+ #: Folders never worth reading, whatever the config says.
13
+ ALWAYS_SKIP = frozenset(
14
+ {
15
+ ".git",
16
+ ".hg",
17
+ ".svn",
18
+ "node_modules",
19
+ ".venv",
20
+ "venv",
21
+ "env",
22
+ "__pycache__",
23
+ "dist",
24
+ "build",
25
+ ".next",
26
+ ".nuxt",
27
+ ".output",
28
+ "coverage",
29
+ "htmlcov",
30
+ ".mypy_cache",
31
+ ".ruff_cache",
32
+ ".pytest_cache",
33
+ ".tox",
34
+ ".stackthread",
35
+ ".turbo",
36
+ ".cache",
37
+ ".idea",
38
+ ".vscode",
39
+ "site-packages",
40
+ ".eggs",
41
+ }
42
+ )
43
+
44
+ PYTHON_EXTS = (".py",)
45
+ SCRIPT_EXTS = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts")
46
+
47
+
48
+ @lru_cache(maxsize=1024)
49
+ def glob_regex(pattern: str) -> re.Pattern[str]:
50
+ """Compile a glob with `**` (any depth), `*` (within a segment), `?` and `[...]` to a regex."""
51
+ i, out = 0, []
52
+ while i < len(pattern):
53
+ c = pattern[i]
54
+ if c == "*":
55
+ if pattern[i : i + 2] == "**":
56
+ i += 2
57
+ if pattern[i : i + 1] == "/":
58
+ i += 1
59
+ out.append("(?:.*/)?")
60
+ else:
61
+ out.append(".*")
62
+ continue
63
+ out.append("[^/]*")
64
+ elif c == "?":
65
+ out.append("[^/]")
66
+ elif c == "[":
67
+ end = pattern.find("]", i + 1)
68
+ if end == -1:
69
+ out.append(re.escape(c))
70
+ else:
71
+ body = pattern[i + 1 : end].replace("\\", "\\\\")
72
+ if body.startswith("!"):
73
+ body = "^" + body[1:]
74
+ out.append(f"[{body}]")
75
+ i = end
76
+ else:
77
+ out.append(re.escape(c))
78
+ i += 1
79
+ return re.compile("".join(out) + r"\Z")
80
+
81
+
82
+ def matches(path: str, patterns: list[str] | tuple[str, ...]) -> bool:
83
+ return any(glob_regex(p).match(path) for p in patterns)
84
+
85
+
86
+ @dataclass(frozen=True, slots=True)
87
+ class _Rule:
88
+ regex: re.Pattern[str]
89
+ negate: bool
90
+ dir_only: bool
91
+
92
+
93
+ class GitIgnore:
94
+ """The subset of .gitignore that matters here: comments, negation, anchoring, directory-only rules, `**`."""
95
+
96
+ def __init__(self, rules: list[tuple[str, _Rule]]) -> None:
97
+ self._rules = rules # (base dir relative to project root, rule)
98
+
99
+ @classmethod
100
+ def load(cls, root: Path, dirs: list[Path]) -> GitIgnore:
101
+ rules: list[tuple[str, _Rule]] = []
102
+ for d in dirs:
103
+ file = d / ".gitignore"
104
+ if not file.is_file():
105
+ continue
106
+ base = "" if d == root else d.relative_to(root).as_posix()
107
+ for raw in file.read_text(encoding="utf-8", errors="replace").splitlines():
108
+ line = raw.strip()
109
+ if not line or line.startswith("#"):
110
+ continue
111
+ negate = line.startswith("!")
112
+ line = line[1:] if negate else line
113
+ dir_only = line.endswith("/")
114
+ line = line.rstrip("/")
115
+ anchored = "/" in line
116
+ line = line.lstrip("/")
117
+ pattern = line if anchored else f"**/{line}"
118
+ rules.append((base, _Rule(glob_regex(pattern), negate, dir_only)))
119
+ return cls(rules)
120
+
121
+ def ignored(self, rel: str, is_dir: bool) -> bool:
122
+ result = False
123
+ for base, rule in self._rules:
124
+ if base:
125
+ if not rel.startswith(base + "/"):
126
+ continue
127
+ sub = rel[len(base) + 1 :]
128
+ else:
129
+ sub = rel
130
+ if rule.dir_only and not is_dir:
131
+ continue
132
+ if rule.regex.match(sub):
133
+ result = not rule.negate
134
+ return result
135
+
136
+
137
+ def walk(root: Path, base: Path, include: list[str], exclude: list[str], exts: tuple[str, ...]) -> Iterator[Path]:
138
+ """Files under `base` with one of `exts`, matching include and not exclude (globs relative to `base`),
139
+ skipping ALWAYS_SKIP folders and anything the project's .gitignore files ignore."""
140
+ chain = [base]
141
+ while chain[-1] != root and root in chain[-1].parents:
142
+ chain.append(chain[-1].parent)
143
+ gitignore = GitIgnore.load(root, list(reversed(chain))) # root first, so deeper files take precedence
144
+ base_resolved = base.resolve()
145
+ for dirpath, dirnames, filenames in os.walk(base):
146
+ current = Path(dirpath)
147
+ rel_dir = current.relative_to(root).as_posix() if current != root else ""
148
+ keep = []
149
+ for d in dirnames:
150
+ if d in ALWAYS_SKIP or d.endswith(".egg-info"):
151
+ continue
152
+ rel = f"{rel_dir}/{d}" if rel_dir else d
153
+ if gitignore.ignored(rel, True):
154
+ continue
155
+ keep.append(d)
156
+ dirnames[:] = sorted(keep)
157
+ for name in sorted(filenames):
158
+ if not name.endswith(exts) or name.endswith((".d.ts", ".d.mts", ".d.cts")):
159
+ continue
160
+ path = current / name
161
+ rel_root = path.relative_to(root).as_posix()
162
+ if gitignore.ignored(rel_root, False):
163
+ continue
164
+ rel_base = path.resolve().relative_to(base_resolved).as_posix()
165
+ if include and not matches(rel_base, include):
166
+ continue
167
+ if exclude and matches(rel_base, exclude):
168
+ continue
169
+ yield path
170
+
171
+
172
+ def posix_rel(path: Path, root: Path) -> str:
173
+ return path.resolve().relative_to(root.resolve()).as_posix()
stackthread/project.py ADDED
@@ -0,0 +1,414 @@
1
+ """A project: discovery, extraction with a cache, graph builds, revisions and change polling (design §5, §15)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import threading
7
+ import time
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from stackthread import SCHEMA_VERSION, __version__, logs
14
+ from stackthread.config import Config, SourceConfig
15
+ from stackthread.lang.js import aliases as js_aliases
16
+ from stackthread.lang.js.build import JsBuild
17
+ from stackthread.lang.js.build import build as build_js
18
+ from stackthread.lang.js.worker import JsWorker, NodeUnavailable
19
+ from stackthread.lang.python.build import PyBuild
20
+ from stackthread.lang.python.build import build as build_py
21
+ from stackthread.lang.python.extract import extract as extract_py
22
+ from stackthread.link import link_requests
23
+ from stackthread.model import Finding, Graph
24
+ from stackthread.paths import PYTHON_EXTS, SCRIPT_EXTS, walk
25
+
26
+ Stat = tuple[int, int]
27
+ JS_BATCH = 40 # files per request to the Node worker, so progress moves while a large frontend is read
28
+ _log = logs.get("index")
29
+
30
+
31
+ def _fingerprint() -> str:
32
+ """Changes whenever either extractor changes, so facts from an older extractor are never reused."""
33
+ import hashlib
34
+
35
+ from stackthread.lang.js.worker import extractor_path
36
+ from stackthread.lang.python import extract as py_extract
37
+
38
+ h = hashlib.sha256(__version__.encode())
39
+ h.update(Path(py_extract.__file__).read_bytes())
40
+ script = extractor_path()
41
+ if script is not None and script.is_file():
42
+ h.update(script.read_bytes())
43
+ return h.hexdigest()[:16]
44
+
45
+
46
+ FINGERPRINT = _fingerprint()
47
+
48
+
49
+ @dataclass
50
+ class Snapshot:
51
+ """One immutable revision of everything the server shows. Swapped atomically on rebuild."""
52
+
53
+ rev: int
54
+ graph: Graph
55
+ py: dict[str, PyBuild]
56
+ js: dict[str, JsBuild]
57
+ facts: dict[str, dict[str, dict[str, Any]]]
58
+ built_ms: float
59
+ changed: list[str] = field(default_factory=list)
60
+ warnings: list[str] = field(default_factory=list)
61
+
62
+ def element(self, loc: str) -> tuple[JsBuild, dict[str, Any]] | None:
63
+ for build in self.js.values():
64
+ el = build.elements.get(loc)
65
+ if el is not None:
66
+ return build, el
67
+ return None
68
+
69
+ def source_of_file(self, file: str) -> str | None:
70
+ for source, files in self.facts.items():
71
+ if file in files:
72
+ return source
73
+ return None
74
+
75
+
76
+ def default_includes(source: SourceConfig, root: Path) -> list[str]:
77
+ if source.include:
78
+ return source.include
79
+ if not source.is_python and (root / "src").is_dir():
80
+ return ["src/**/*"]
81
+ return []
82
+
83
+
84
+ class Project:
85
+ def __init__(self, config: Config, log: Callable[[str], None] | None = None) -> None:
86
+ self.config = config
87
+ self.log = log or _log.warning # for adapters and older callers; everything else logs directly
88
+ self.stats: dict[str, dict[str, Stat]] = {}
89
+ self.facts: dict[str, dict[str, dict[str, Any]]] = {}
90
+ self.worker = JsWorker()
91
+ self.snapshot: Snapshot | None = None
92
+ self.activity: dict[str, Any] = {"state": "starting"}
93
+ self._rev = 0
94
+ self._lock = threading.RLock()
95
+ self._listeners: list[Callable[[Snapshot], None]] = []
96
+ self._activity_listeners: list[Callable[[dict[str, Any]], None]] = []
97
+ self._cache_file = config.state_dir / "cache" / "facts.json"
98
+ self._node_problem: str | None = None
99
+
100
+ # ---- listeners ----
101
+
102
+ def subscribe(self, fn: Callable[[Snapshot], None]) -> None:
103
+ self._listeners.append(fn)
104
+
105
+ def unsubscribe(self, fn: Callable[[Snapshot], None]) -> None:
106
+ if fn in self._listeners:
107
+ self._listeners.remove(fn)
108
+
109
+ def on_activity(self, fn: Callable[[dict[str, Any]], None]) -> None:
110
+ self._activity_listeners.append(fn)
111
+
112
+ def _set_activity(self, state: str, **data: Any) -> None:
113
+ """What the project is doing: starting, indexing (done of total), updating (files), idle, or failed."""
114
+ self.activity = {"state": state, "at": time.time(), **data}
115
+ for listener in list(self._activity_listeners):
116
+ try:
117
+ listener(self.activity)
118
+ except Exception: # a listener must never break indexing
119
+ _log.exception("activity listener failed")
120
+
121
+ # ---- discovery ----
122
+
123
+ def _discover(self, source: SourceConfig) -> dict[str, tuple[Path, Stat]]:
124
+ base = self.config.source_root(source)
125
+ exts = PYTHON_EXTS if source.is_python else SCRIPT_EXTS
126
+ out: dict[str, tuple[Path, Stat]] = {}
127
+ for path in walk(self.config.root, base, default_includes(source, base), source.exclude, exts):
128
+ try:
129
+ st = path.stat()
130
+ except OSError:
131
+ continue
132
+ out[path.relative_to(self.config.root).as_posix()] = (path, (st.st_mtime_ns, st.st_size))
133
+ return out
134
+
135
+ # ---- extraction ----
136
+
137
+ def _extract(
138
+ self, source: SourceConfig, items: dict[str, Path], progress: Callable[[int], None] | None = None
139
+ ) -> dict[str, dict[str, Any]]:
140
+ if not items:
141
+ return {}
142
+ started = time.perf_counter()
143
+ out: dict[str, dict[str, Any]] = {}
144
+ if source.is_python:
145
+ base = self.config.source_root(source)
146
+ for n, (file, path) in enumerate(items.items(), 1):
147
+ try:
148
+ text = path.read_text(encoding="utf-8")
149
+ except (OSError, UnicodeDecodeError) as e:
150
+ text = ""
151
+ _log.warning("cannot read %s: %s", file, e, extra=logs.ctx(file=file))
152
+ out[file] = extract_py(text, path.resolve().relative_to(base).as_posix(), file)
153
+ if progress and n % 20 == 0:
154
+ progress(20)
155
+ if progress:
156
+ progress(len(items) % 20)
157
+ else:
158
+ pairs = [(p, f) for f, p in items.items()]
159
+ try:
160
+ for i in range(0, len(pairs), JS_BATCH):
161
+ batch = pairs[i : i + JS_BATCH]
162
+ out.update(self.worker.extract(batch, {"clients": self.config.http.clients}))
163
+ if progress:
164
+ progress(len(batch))
165
+ self._node_problem = None
166
+ except NodeUnavailable as e:
167
+ if str(e) != self._node_problem:
168
+ _log.error("cannot read the %s frontend: %s", source.name, e, extra=logs.ctx(source=source.name))
169
+ self._node_problem = str(e)
170
+ return out
171
+ _log.debug(
172
+ "read %d %s files in %.0f ms",
173
+ len(items),
174
+ source.name,
175
+ (time.perf_counter() - started) * 1000,
176
+ extra=logs.ctx(source=source.name, files=len(items)),
177
+ )
178
+ return out
179
+
180
+ # ---- cache ----
181
+
182
+ def _load_cache(self) -> dict[str, dict[str, Any]]:
183
+ try:
184
+ data = json.loads(self._cache_file.read_text(encoding="utf-8"))
185
+ except FileNotFoundError:
186
+ return {}
187
+ except (OSError, ValueError) as e:
188
+ _log.warning("ignoring an unreadable facts cache: %s", e)
189
+ return {}
190
+ if data.get("schema") != SCHEMA_VERSION or data.get("extractors") != FINGERPRINT:
191
+ _log.info("the facts cache is from another version of stackthread; reading every file again")
192
+ return {}
193
+ sources: dict[str, dict[str, Any]] = data.get("sources", {})
194
+ return sources
195
+
196
+ def _save_cache(self) -> None:
197
+ data = {
198
+ "schema": SCHEMA_VERSION,
199
+ "version": __version__,
200
+ "extractors": FINGERPRINT,
201
+ "sources": {
202
+ name: {
203
+ file: {"stat": list(self.stats[name][file]), "facts": facts}
204
+ for file, facts in files.items()
205
+ if file in self.stats.get(name, {})
206
+ }
207
+ for name, files in self.facts.items()
208
+ },
209
+ }
210
+ try:
211
+ self._cache_file.parent.mkdir(parents=True, exist_ok=True)
212
+ state = self.config.state_dir
213
+ if state in self._cache_file.parents and not (state / ".gitignore").exists():
214
+ (state / ".gitignore").write_text("*\n", encoding="utf-8")
215
+ tmp = self._cache_file.with_suffix(".tmp")
216
+ tmp.write_text(json.dumps(data, separators=(",", ":")), encoding="utf-8")
217
+ tmp.replace(self._cache_file)
218
+ except OSError as e:
219
+ _log.warning("cannot write the facts cache: %s", e)
220
+
221
+ # ---- scanning ----
222
+
223
+ def scan(self, use_cache: bool = True) -> Snapshot:
224
+ """Index everything. Files whose size and mtime match the cache are not read again."""
225
+ with self._lock:
226
+ started = time.perf_counter()
227
+ cached = self._load_cache() if use_cache else {}
228
+ plan: list[tuple[SourceConfig, dict[str, tuple[Path, Stat]], dict[str, Any], dict[str, Path]]] = []
229
+ for source in self.config.sources:
230
+ found = self._discover(source)
231
+ previous = cached.get(source.name, {})
232
+ keep = {
233
+ f: entry["facts"]
234
+ for f, entry in previous.items()
235
+ if f in found and tuple(entry["stat"]) == found[f][1]
236
+ }
237
+ todo = {f: p for f, (p, _) in found.items() if f not in keep}
238
+ plan.append((source, found, keep, todo))
239
+ _log.debug(
240
+ "%s: %d files, %d from the cache, %d to read",
241
+ source.name,
242
+ len(found),
243
+ len(keep),
244
+ len(todo),
245
+ extra=logs.ctx(source=source.name, found=len(found), cached=len(keep), read=len(todo)),
246
+ )
247
+ total = sum(len(todo) for *_, todo in plan)
248
+ done = 0
249
+ self._set_activity("indexing", done=0, total=total)
250
+
251
+ def advance(n: int) -> None:
252
+ nonlocal done
253
+ done += n
254
+ self._set_activity("indexing", done=min(done, total), total=total)
255
+
256
+ for source, found, keep, todo in plan:
257
+ fresh = self._extract(source, todo, advance)
258
+ self.facts[source.name] = {**keep, **fresh}
259
+ self.stats[source.name] = {f: s for f, (_, s) in found.items() if f in self.facts[source.name]}
260
+ snapshot = self._build(changed=[])
261
+ self._save_cache()
262
+ files = sum(len(f) for f in self.facts.values())
263
+ ms = (time.perf_counter() - started) * 1000
264
+ _log.info(
265
+ "indexed %d files in %.0f ms (%d read, %d from the cache); revision %d",
266
+ files,
267
+ ms,
268
+ total,
269
+ files - total,
270
+ snapshot.rev,
271
+ extra=logs.ctx(
272
+ files=files,
273
+ read=total,
274
+ ms=round(ms),
275
+ rev=snapshot.rev,
276
+ nodes=len(snapshot.graph.nodes),
277
+ edges=len(snapshot.graph.edges),
278
+ ),
279
+ )
280
+ self._set_activity("idle", rev=snapshot.rev, ms=round(ms), files=files)
281
+ return snapshot
282
+
283
+ def poll(self) -> Snapshot | None:
284
+ """Re-index what changed on disk since the last scan. Returns the new snapshot, or None if nothing did."""
285
+ with self._lock:
286
+ started = time.perf_counter()
287
+ work: list[tuple[SourceConfig, dict[str, tuple[Path, Stat]], list[str], dict[str, Path]]] = []
288
+ for source in self.config.sources:
289
+ found = self._discover(source)
290
+ known = self.stats.setdefault(source.name, {})
291
+ removed = [f for f in known if f not in found]
292
+ todo = {f: p for f, (p, s) in found.items() if known.get(f) != s}
293
+ if removed or todo:
294
+ work.append((source, found, removed, todo))
295
+ if not work:
296
+ return None
297
+ changed = sorted(f for _, _, removed, todo in work for f in [*removed, *todo])
298
+ self._set_activity("updating", files=changed[:20], count=len(changed))
299
+ for source, found, removed, todo in work:
300
+ known = self.stats[source.name]
301
+ files = self.facts.setdefault(source.name, {})
302
+ for f in removed:
303
+ known.pop(f, None)
304
+ files.pop(f, None)
305
+ if todo:
306
+ files.update(self._extract(source, todo))
307
+ for f in todo:
308
+ known[f] = found[f][1]
309
+ snapshot = self._build(changed=changed)
310
+ threading.Thread(target=self._save_cache, name="stackthread-cache", daemon=True).start()
311
+ ms = (time.perf_counter() - started) * 1000
312
+ _log.info(
313
+ "%s changed; updated in %.0f ms; revision %d",
314
+ _short(changed),
315
+ ms,
316
+ snapshot.rev,
317
+ extra=logs.ctx(changed=changed[:50], count=len(changed), ms=round(ms), rev=snapshot.rev),
318
+ )
319
+ self._set_activity("idle", rev=snapshot.rev, ms=round(ms), files=changed[:20])
320
+ return snapshot
321
+
322
+ def invalidate(self, files: list[str]) -> None:
323
+ """Forget files so the next poll reads them again (after an edit through the server)."""
324
+ with self._lock:
325
+ for source in self.stats.values():
326
+ for f in files:
327
+ source.pop(f, None)
328
+
329
+ # ---- building ----
330
+
331
+ def _build(self, changed: list[str]) -> Snapshot:
332
+ started = time.perf_counter()
333
+ graph = Graph()
334
+ py: dict[str, PyBuild] = {}
335
+ js: dict[str, JsBuild] = {}
336
+ warnings: list[str] = []
337
+ for source in self.config.sources:
338
+ facts = self.facts.get(source.name, {})
339
+ if source.is_python:
340
+ py[source.name] = build_py(graph, source, list(facts.values()))
341
+ for source in self.config.sources:
342
+ if not source.is_python:
343
+ js[source.name] = build_js(graph, source, self.config.root, self.facts.get(source.name, {}))
344
+ if self._node_problem and any(not s.is_python for s in self.config.sources):
345
+ warnings.append(self._node_problem)
346
+ graph.finding(Finding("node-unavailable", "error", self._node_problem))
347
+ proxies = {
348
+ name: js_aliases.proxy_prefixes(self.config.root / self.config.source(name).root) # type: ignore[union-attr]
349
+ for name in js
350
+ }
351
+ link_requests(graph, self.config, [r for b in js.values() for r in b.requests], proxies)
352
+ from stackthread import adapters, rules
353
+
354
+ rules.run(graph, self.config)
355
+ adapters.extend_all(self, graph)
356
+ unreadable = sorted({f.file for f in graph.findings if f.rule == "parse-error" and f.file})
357
+ if unreadable:
358
+ _log.warning(
359
+ "%d file(s) could not be parsed: %s",
360
+ len(unreadable),
361
+ _short(unreadable),
362
+ extra=logs.ctx(files=unreadable[:50]),
363
+ )
364
+ self._rev += 1
365
+ snapshot = Snapshot(
366
+ self._rev,
367
+ graph,
368
+ py,
369
+ js,
370
+ {k: dict(v) for k, v in self.facts.items()},
371
+ (time.perf_counter() - started) * 1000,
372
+ changed,
373
+ warnings,
374
+ )
375
+ self.snapshot = snapshot
376
+ for listener in list(self._listeners):
377
+ try:
378
+ listener(snapshot)
379
+ except Exception: # a listener must never break indexing
380
+ _log.exception("snapshot listener failed")
381
+ return snapshot
382
+
383
+ def close(self) -> None:
384
+ self.worker.close()
385
+
386
+
387
+ def _short(files: list[str], n: int = 3) -> str:
388
+ names = [f.rsplit("/", 1)[-1] for f in files[:n]]
389
+ more = len(files) - n
390
+ return ", ".join(names) + (f" and {more} more" if more > 0 else "")
391
+
392
+
393
+ class Poller:
394
+ """Polls the project for changes in a background thread (stdlib only; about one second of latency)."""
395
+
396
+ def __init__(self, project: Project, interval: float = 0.75) -> None:
397
+ self.project = project
398
+ self.interval = interval
399
+ self._stop = threading.Event()
400
+ self._thread = threading.Thread(target=self._run, name="stackthread-poll", daemon=True)
401
+
402
+ def start(self) -> None:
403
+ self._thread.start()
404
+
405
+ def stop(self) -> None:
406
+ self._stop.set()
407
+
408
+ def _run(self) -> None:
409
+ while not self._stop.wait(self.interval):
410
+ try:
411
+ self.project.poll()
412
+ except Exception as e: # keep watching whatever happens in one rebuild
413
+ _log.exception("rebuild failed: %s", e)
414
+ self.project._set_activity("failed", error=f"{type(e).__name__}: {e}")