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
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}")
|