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
@@ -0,0 +1,130 @@
1
+ """Adapters: optional Python plugins that add an application's domain to the graph and lineage (design §12.2).
2
+
3
+ An adapter is a class with any of these methods; all are optional:
4
+
5
+ class MyAdapter(Adapter):
6
+ name = "my-app"
7
+ def views(self, ctx) -> list[View]: ... # named states: stages, steps, tabs
8
+ def extend(self, ctx, graph) -> None: ... # add nodes and edges
9
+ def lineage(self, ctx, selection, layers) -> None: ... # add or change lineage rows
10
+ def checks(self, ctx, graph) -> list[Finding]: ... # extra findings
11
+
12
+ Load with `[adapters] use = ["package.module:MyAdapter"]` or the `stackthread.adapters` entry-point group.
13
+ Adapters run in-process with the user's permissions: they are the user's own code.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import importlib
19
+ import sys
20
+ from dataclasses import dataclass
21
+ from importlib.metadata import entry_points
22
+ from typing import TYPE_CHECKING, Any
23
+
24
+ from stackthread import logs
25
+ from stackthread.config import Config, View
26
+ from stackthread.model import Finding, Graph
27
+
28
+ if TYPE_CHECKING:
29
+ from stackthread.project import Project
30
+
31
+ API_VERSION = 1
32
+ _log = logs.get("adapters")
33
+
34
+
35
+ @dataclass
36
+ class Context:
37
+ config: Config
38
+ project: Project
39
+
40
+
41
+ class Adapter:
42
+ name = "adapter"
43
+ api = API_VERSION
44
+
45
+ def views(self, ctx: Context) -> list[View]:
46
+ return []
47
+
48
+ def extend(self, ctx: Context, graph: Graph) -> None:
49
+ return None
50
+
51
+ def lineage(self, ctx: Context, selection: dict[str, Any], layers: list[dict[str, Any]]) -> None:
52
+ return None
53
+
54
+ def checks(self, ctx: Context, graph: Graph) -> list[Finding]:
55
+ return []
56
+
57
+
58
+ _loaded: dict[tuple[str, tuple[str, ...]], list[Adapter]] = {}
59
+
60
+
61
+ def load(config: Config) -> list[Adapter]:
62
+ key = (str(config.root), tuple(config.adapters)) # not id(): ids are reused once an object is freed
63
+ if key in _loaded:
64
+ return _loaded[key]
65
+ out: list[Adapter] = []
66
+ root = str(config.root)
67
+ if root not in sys.path:
68
+ sys.path.insert(0, root) # adapters usually live in the project itself
69
+ for spec in config.adapters:
70
+ module_name, _, attr = spec.partition(":")
71
+ try:
72
+ obj = getattr(importlib.import_module(module_name), attr or "Adapter")
73
+ out.append(obj() if isinstance(obj, type) else obj)
74
+ _log.info("loaded adapter %s", spec, extra=logs.ctx(adapter=spec))
75
+ except Exception as e: # report and carry on; a broken adapter must not stop indexing
76
+ _log.error("adapter %s failed to load: %s", spec, e, exc_info=True, extra=logs.ctx(adapter=spec))
77
+ out.append(_Broken(spec, e))
78
+ for ep in entry_points(group="stackthread.adapters"):
79
+ try:
80
+ obj = ep.load()
81
+ out.append(obj() if isinstance(obj, type) else obj)
82
+ _log.info("loaded adapter %s from an installed package", ep.name, extra=logs.ctx(adapter=ep.name))
83
+ except Exception as e:
84
+ _log.error("adapter %s failed to load: %s", ep.name, e, exc_info=True, extra=logs.ctx(adapter=ep.name))
85
+ out.append(_Broken(ep.name, e))
86
+ _loaded[key] = out
87
+ return out
88
+
89
+
90
+ class _Broken(Adapter):
91
+ def __init__(self, spec: str, error: Exception) -> None:
92
+ self.name = spec
93
+ self.error = error
94
+
95
+ def checks(self, ctx: Context, graph: Graph) -> list[Finding]:
96
+ return [Finding("adapter", "error", f"adapter {self.name} failed to load: {self.error}")]
97
+
98
+
99
+ def extend_all(project: Project, graph: Graph) -> None:
100
+ ctx = Context(project.config, project)
101
+ for adapter in load(project.config):
102
+ try:
103
+ adapter.extend(ctx, graph)
104
+ for finding in adapter.checks(ctx, graph):
105
+ graph.finding(finding)
106
+ except Exception as e:
107
+ _log.error("adapter %s failed while indexing: %s", adapter.name, e, exc_info=True)
108
+ graph.finding(Finding("adapter", "error", f"adapter {adapter.name} failed: {e}"))
109
+
110
+
111
+ def views(project: Project) -> list[View]:
112
+ out = list(project.config.views)
113
+ ctx = Context(project.config, project)
114
+ for adapter in load(project.config):
115
+ try:
116
+ for v in adapter.views(ctx):
117
+ v.source = adapter.name
118
+ out.append(v)
119
+ except Exception as e:
120
+ _log.error("adapter %s failed to list views: %s", adapter.name, e, exc_info=True)
121
+ return out
122
+
123
+
124
+ def lineage(project: Project, selection: dict[str, Any], layers: list[dict[str, Any]]) -> None:
125
+ ctx = Context(project.config, project)
126
+ for adapter in load(project.config):
127
+ try:
128
+ adapter.lineage(ctx, selection, layers)
129
+ except Exception as e:
130
+ _log.error("adapter %s failed while explaining: %s", adapter.name, e, exc_info=True)
stackthread/cli.py ADDED
@@ -0,0 +1,342 @@
1
+ """Command line: init, serve, scan, explain, check, doctor, logs, report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from stackthread import __version__, detect, logs
14
+ from stackthread.config import CONFIG_FILE, Config, ConfigError, find_config, load
15
+
16
+ _log = logs.get("cli")
17
+
18
+
19
+ def _out(text: str = "") -> None:
20
+ sys.stdout.write(text + "\n")
21
+
22
+
23
+ def _err(text: str) -> None:
24
+ sys.stderr.write(text + "\n")
25
+
26
+
27
+ def _config(args: argparse.Namespace) -> Config:
28
+ cfg = _load_config(args)
29
+ path = logs.setup(cfg.state_dir, args.console_level, cfg.log.level, cfg.log.size_kb, cfg.log.keep)
30
+ _log.info(
31
+ "stackthread %s: %s",
32
+ __version__,
33
+ args.command,
34
+ extra=logs.ctx(**logs.environment(), config=str(cfg.path) if cfg.path else "detected", log=str(path)),
35
+ )
36
+ return cfg
37
+
38
+
39
+ def _load_config(args: argparse.Namespace) -> Config:
40
+ if args.config:
41
+ path = Path(args.config).resolve()
42
+ if not path.is_file():
43
+ raise ConfigError([f"no config file at {path}"])
44
+ return load(path)
45
+ found = find_config(Path.cwd())
46
+ if found is not None:
47
+ return load(found)
48
+ cfg = detect.config_from(Path.cwd())
49
+ if not cfg.sources:
50
+ raise ConfigError([f"no {CONFIG_FILE} here and nothing to detect in {Path.cwd()}; run `stackthread init`"])
51
+ _err(f"no {CONFIG_FILE}; using detected sources: " + ", ".join(f"{s.name} ({s.root})" for s in cfg.sources))
52
+ return cfg
53
+
54
+
55
+ def _project(args: argparse.Namespace) -> Any:
56
+ from stackthread.project import Project
57
+
58
+ return Project(_config(args))
59
+
60
+
61
+ # ---------------------------------------------------------------- commands
62
+
63
+
64
+ def cmd_init(args: argparse.Namespace) -> int:
65
+ root = Path.cwd()
66
+ target = root / CONFIG_FILE
67
+ if target.exists() and not args.force:
68
+ _err(f"{CONFIG_FILE} already exists; use --force to replace it")
69
+ return 1
70
+ found = detect.detect(root)
71
+ if not found.sources:
72
+ _err("found no React-on-Vite app and no Django or FastAPI backend under this folder")
73
+ return 1
74
+ target.write_text(detect.render_toml(root, found), encoding="utf-8")
75
+ _out(f"wrote {target}")
76
+ for note in found.notes:
77
+ _out(f" found {note}")
78
+ frontends = [s for s in found.sources if s.role == "frontend"]
79
+ if frontends:
80
+ _out("\nnext, in each frontend:")
81
+ for s in frontends:
82
+ _out(f" cd {s.root} && npm install --save-dev @stackthread/vite")
83
+ _out(" then add the plugin in vite.config: plugins: [react(), stackthread()]")
84
+ _out(" (import stackthread from '@stackthread/vite')")
85
+ _out("\nthen run: stackthread serve")
86
+ return 0
87
+
88
+
89
+ def cmd_serve(args: argparse.Namespace) -> int:
90
+ from stackthread.server import serve
91
+
92
+ project = _project(args)
93
+ if args.read_only:
94
+ project.config.server.edit = False
95
+ serve(project, port=args.port, open_browser=not args.no_open, watch=not args.no_watch)
96
+ return 0
97
+
98
+
99
+ def cmd_scan(args: argparse.Namespace) -> int:
100
+ project = _project(args)
101
+ started = time.perf_counter()
102
+ snap = project.scan(use_cache=not args.no_cache)
103
+ took = (time.perf_counter() - started) * 1000
104
+ project.close()
105
+ g = snap.graph
106
+ if args.json:
107
+ data = {"rev": snap.rev, **g.to_json()}
108
+ if args.json == "-":
109
+ _out(json.dumps(data, indent=1))
110
+ else:
111
+ Path(args.json).write_text(json.dumps(data), encoding="utf-8")
112
+ _err(f"wrote {args.json}")
113
+ return 0
114
+ kinds: dict[str, int] = {}
115
+ for n in g.nodes.values():
116
+ kinds[n.kind] = kinds.get(n.kind, 0) + 1
117
+ edges: dict[str, int] = {}
118
+ for e in g.edges:
119
+ edges[e.kind] = edges.get(e.kind, 0) + 1
120
+ _out(f"{project.config.name}: {sum(len(f) for f in snap.facts.values())} files in {took:.0f} ms")
121
+ for s in project.config.sources:
122
+ _out(f" {s.name:<12} {s.lang:<10} {s.role:<8} {s.root:<20} {len(snap.facts.get(s.name, {}))} files")
123
+ _out(" nodes " + ", ".join(f"{k} {v}" for k, v in sorted(kinds.items(), key=lambda kv: -kv[1])))
124
+ _out(" edges " + ", ".join(f"{k} {v}" for k, v in sorted(edges.items(), key=lambda kv: -kv[1])))
125
+ requests = [e for e in g.edges if e.kind == "http"]
126
+ unmatched = [f for f in g.findings if f.rule == "unmatched-request"]
127
+ _out(f" requests linked to endpoints: {len(requests)}; unmatched: {len(unmatched)}")
128
+ for w in snap.warnings:
129
+ _out(f" warning: {w}")
130
+ return 0
131
+
132
+
133
+ def cmd_explain(args: argparse.Namespace) -> int:
134
+ from stackthread import lineage
135
+
136
+ project = _project(args)
137
+ snap = project.scan()
138
+ project.close()
139
+ result = lineage.for_location(project, snap, args.location.replace("\\", "/"))
140
+ if args.json:
141
+ _out(json.dumps(result, indent=1))
142
+ return 0
143
+ target = result["target"]
144
+ if not target.get("known"):
145
+ _err(f"nothing indexed at {args.location}; give a path relative to the project root, like web/src/App.tsx:12")
146
+ return 1
147
+ _out(f"{target.get('label') or target.get('element') or ''} {target.get('file')}:{target.get('line')}")
148
+ for layer in result["layers"]:
149
+ _out(f"\n{layer['title']}")
150
+ for row in layer["rows"][: args.limit]:
151
+ where = f"{row.get('file', '')}:{row.get('line', '')}" if row.get("file") else ""
152
+ mark = "" if row.get("confidence", "exact") == "exact" else f" [{row['confidence']}]"
153
+ _out(f" {' ' * min(row.get('depth', 0), 6)}{row['label']} {where} {row.get('reason', '')}{mark}")
154
+ hidden = len(layer["rows"]) - args.limit
155
+ if hidden > 0:
156
+ _out(f" … {hidden} more (use --limit)")
157
+ return 0
158
+
159
+
160
+ def cmd_check(args: argparse.Namespace) -> int:
161
+ project = _project(args)
162
+ snap = project.scan()
163
+ project.close()
164
+ order = {"info": 0, "warning": 1, "error": 2}
165
+ findings = sorted(snap.graph.findings, key=lambda f: (-order[f.severity], f.file, f.line))
166
+ if args.json:
167
+ _out(json.dumps([f.to_json() for f in findings], indent=1))
168
+ else:
169
+ for f in findings:
170
+ where = f"{f.file}:{f.line}" if f.file else ""
171
+ _out(f"{f.severity:<7} {f.rule:<18} {where} {f.message}")
172
+ _out(f"{len(findings)} finding(s)")
173
+ threshold = order[args.fail_on]
174
+ return 1 if any(order[f.severity] >= threshold for f in findings) else 0
175
+
176
+
177
+ def cmd_doctor(args: argparse.Namespace) -> int:
178
+ from stackthread import report
179
+
180
+ try:
181
+ cfg: Config | None = _config(args)
182
+ problem = ""
183
+ except ConfigError as e:
184
+ cfg, problem = None, str(e)
185
+ ok, lines = report.doctor(cfg, problem)
186
+ for line in lines:
187
+ _out(line)
188
+ return 0 if ok else 1
189
+
190
+
191
+ def _matches(entry: dict[str, Any], level: str, area: str | None) -> bool:
192
+ return logs.level_of(str(entry.get("level")), 20) >= logs.level_of(level, 10) and (
193
+ area is None or entry.get("area") == area
194
+ )
195
+
196
+
197
+ def cmd_logs(args: argparse.Namespace) -> int:
198
+ try:
199
+ state = _load_config(args).state_dir
200
+ except ConfigError:
201
+ state = Path.cwd() / ".stackthread"
202
+ if not logs.files(state):
203
+ _err(f"no logs yet in {logs.log_dir(state)}; they appear once stackthread has run here")
204
+ return 1
205
+
206
+ def show(entry: dict[str, Any]) -> None:
207
+ _out(json.dumps(entry, ensure_ascii=False) if args.json else logs.render(entry))
208
+
209
+ for entry in logs.recent(state, args.level, args.lines, args.area):
210
+ show(entry)
211
+ if not args.follow:
212
+ return 0
213
+ seen = sum(1 for _ in logs.entries(state))
214
+ try:
215
+ while True:
216
+ time.sleep(0.5)
217
+ every = list(logs.entries(state))
218
+ if len(every) < seen: # the file was rotated
219
+ seen = 0
220
+ for entry in every[seen:]:
221
+ if _matches(entry, args.level, args.area):
222
+ show(entry)
223
+ seen = len(every)
224
+ except KeyboardInterrupt:
225
+ return 0
226
+
227
+
228
+ def cmd_report(args: argparse.Namespace) -> int:
229
+ from stackthread import report
230
+
231
+ cfg: Config | None = None
232
+ problem = ""
233
+ snapshot = None
234
+ try:
235
+ cfg = _config(args)
236
+ except ConfigError as e:
237
+ problem = f"configuration problem: {e}"
238
+ if cfg is not None and not args.no_scan:
239
+ from stackthread.project import Project
240
+
241
+ project = Project(cfg)
242
+ try:
243
+ snapshot = project.scan()
244
+ except Exception as e: # the report must be made even when indexing fails
245
+ _log.exception("indexing failed while making a report")
246
+ problem = f"indexing failed: {type(e).__name__}: {e}"
247
+ finally:
248
+ project.close()
249
+ data, names = report.build(cfg, snapshot, args.note or "", None, problem)
250
+ path = report.save(cfg, data, Path(args.out) if args.out else None)
251
+ _log.info("made a problem report: %s", path.name, extra=logs.ctx(path=str(path), size=len(data)))
252
+ _out(f"wrote {path} ({len(data) / 1024:.0f} KB)")
253
+ for name in names:
254
+ _out(f" {name}")
255
+ _out("")
256
+ _out("It holds logs and settings, never source code. Send it to whoever looks after stackthread for you.")
257
+ return 0
258
+
259
+
260
+ # ---------------------------------------------------------------- entry point
261
+
262
+
263
+ def parser() -> argparse.ArgumentParser:
264
+ p = argparse.ArgumentParser(
265
+ prog="stackthread",
266
+ description="Click any element in your running web app and follow it through your code.",
267
+ )
268
+ p.add_argument("--version", action="version", version=f"stackthread {__version__}")
269
+ p.add_argument("--config", help=f"path to {CONFIG_FILE} or pyproject.toml (default: nearest one, else detect)")
270
+ p.add_argument(
271
+ "--log-level",
272
+ choices=("debug", "info", "warning", "error"),
273
+ help="what to print on the console (default: info for serve, warning otherwise; env STACKTHREAD_LOG_LEVEL)",
274
+ )
275
+ p.add_argument("-v", "--verbose", action="store_true", help="print everything, like --log-level debug")
276
+ sub = p.add_subparsers(dest="command", required=True, metavar="command")
277
+
278
+ s = sub.add_parser("init", help=f"detect sources and write {CONFIG_FILE}")
279
+ s.add_argument("--force", action="store_true", help="replace an existing config")
280
+ s.set_defaults(fn=cmd_init)
281
+
282
+ s = sub.add_parser("serve", help="index, watch and serve the UI and API on 127.0.0.1")
283
+ s.add_argument("--port", type=int, help="first port to try (default from config, 7341)")
284
+ s.add_argument("--no-open", action="store_true", help="do not open a browser")
285
+ s.add_argument("--no-watch", action="store_true", help="index once; do not follow changes")
286
+ s.add_argument("--read-only", action="store_true", help="never write files")
287
+ s.set_defaults(fn=cmd_serve)
288
+
289
+ s = sub.add_parser("scan", help="index once and print a summary, or the graph as JSON")
290
+ s.add_argument("--json", metavar="FILE", help="write the graph as JSON to FILE, or - for stdout")
291
+ s.add_argument("--no-cache", action="store_true", help="ignore and rebuild the facts cache")
292
+ s.set_defaults(fn=cmd_scan)
293
+
294
+ s = sub.add_parser("explain", help="lineage of a code location, such as web/src/App.tsx:12")
295
+ s.add_argument("location", help="file:line or file:line:col, relative to the project root")
296
+ s.add_argument("--json", action="store_true", help="print JSON")
297
+ s.add_argument("--limit", type=int, default=25, help="rows per layer (default 25)")
298
+ s.set_defaults(fn=cmd_explain)
299
+
300
+ s = sub.add_parser("check", help="report findings; non-zero exit for CI")
301
+ s.add_argument("--fail-on", choices=("error", "warning", "info"), default="error")
302
+ s.add_argument("--json", action="store_true", help="print JSON")
303
+ s.set_defaults(fn=cmd_check)
304
+
305
+ s = sub.add_parser("doctor", help="check node, the bundled extractor, the UI and the config")
306
+ s.set_defaults(fn=cmd_doctor)
307
+
308
+ s = sub.add_parser("logs", help="show recent log entries from .stackthread/logs")
309
+ s.add_argument("--level", choices=("debug", "info", "warning", "error"), default="info", help="lowest level shown")
310
+ s.add_argument("-n", "--lines", type=int, default=50, help="how many entries (default 50; 0 for all)")
311
+ s.add_argument("--area", help="only one part: index, worker, server, http, edit, adapters, client, cli")
312
+ s.add_argument("-f", "--follow", action="store_true", help="keep printing new entries")
313
+ s.add_argument("--json", action="store_true", help="print the raw JSON lines")
314
+ s.set_defaults(fn=cmd_logs)
315
+
316
+ s = sub.add_parser("report", help="make a zip with logs and diagnostics to send when something goes wrong")
317
+ s.add_argument("--note", help="what you were doing and what went wrong")
318
+ s.add_argument("--out", help="where to write the zip (default: .stackthread/reports/)")
319
+ s.add_argument("--no-scan", action="store_true", help="do not index the project for the summary")
320
+ s.set_defaults(fn=cmd_report)
321
+ return p
322
+
323
+
324
+ def main(argv: list[str] | None = None) -> int:
325
+ args = parser().parse_args(argv)
326
+ default = "info" if args.command == "serve" else "warning"
327
+ args.console_level = (
328
+ "debug" if args.verbose else args.log_level or os.environ.get("STACKTHREAD_LOG_LEVEL") or default
329
+ )
330
+ logs.setup(None, args.console_level)
331
+ try:
332
+ return int(args.fn(args))
333
+ except ConfigError as e:
334
+ _log.debug("configuration problem: %s", e)
335
+ _err(f"stackthread: config error {e}")
336
+ return 2
337
+ except KeyboardInterrupt:
338
+ return 130
339
+ except Exception:
340
+ _log.critical("stackthread stopped because of an unexpected error", exc_info=True)
341
+ _err("Run `stackthread report` to make a file with the details for whoever looks after stackthread.")
342
+ return 70